diff --git a/CLAUDE.md b/CLAUDE.md index a6ef8f0..7945ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,15 +33,15 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca | Layer | Location | |---|---| -| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` | -| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | +| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` | +| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | | DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) | -**Auth model:** Two of them, on purpose. +**Auth model:** One, since the calendar migration completed. -*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 +*Admin module (`src/models/admin/`)* — used by every app: calendar, feedback, tickets and the +admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to `.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public @@ -59,11 +59,24 @@ two and the last-admin guard stops guarding; and both write endpoints accept same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL` makes sure someone can always get in on a fresh database. -*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation -get a session token (30-day window, hash + IP stored in the DB), passed as query -parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`. -Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, -`MANAGEMENT_CREDENTIAL`) come from `.env`. +*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session +token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records +the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads +resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal +subscription and a signed-in editor. + +Two calendar-specific things survive that migration and are easy to break: + +- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to + show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and + `test/calendar/events.router.test.ts`. +- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`, + from `.env`) still open the restricted calendars for reading**, because an iCal client + cannot send a cookie. They can never write. + +An event's creator is a display name and nothing else - nothing authorises on it. It resolves +from the admin module's `user.name` when the row carries an admin id, and otherwise from +`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside. **Admin database driver:** the admin pool is the **callback-style** `mysql2`, never `mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`; diff --git a/DEFERRED_SECURITY.md b/DEFERRED_SECURITY.md index 49383aa..140e611 100644 --- a/DEFERRED_SECURITY.md +++ b/DEFERRED_SECURITY.md @@ -24,9 +24,9 @@ Two things this did *not* change, both deliberate: this is the one caller that genuinely needs a credential in the URL. It grants read access to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can never be used to write. -- The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a - legacy session they mint no longer opens anything, but they are still live - password-accepting endpoints. Step 5 removes them. +- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06, + along with the `users` and `sessions` tables they used - renamed aside rather than dropped, + so nothing was destroyed. --- @@ -43,37 +43,23 @@ Currently any account holding the `calendar` permission can edit, move, or delet --- -## 3. Activation token has no expiry +## 3. Activation token has no expiry — CLOSED 2026-09-06 -> **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`~~ — deleted. -**File:** `src/models/calendar/users/users.service.ts` — `createUser` / `activateUser` +The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by +removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the +calendar's own account system. Accounts now come only from the admin module's `invitations`, +whose tokens expire after 7 days and are stored as a SHA-256 hash. -The email activation link is valid indefinitely. Acceptable for a small, trusted userbase. - -**Fix:** -1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`). -2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`. -3. Check `activation_expires > NOW()` in `activateUser` before accepting the token. +Any activation link still sitting in an inbox now 404s. It only ever activated a legacy +account, which no longer opens anything. --- -## 4. Password reset token has no expiry +## 4. Password reset token has no expiry — CLOSED 2026-09-06 -> **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`~~ — deleted. -**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. - -**Fix:** -1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`). -2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`. -3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token. +Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer +exists. Password resets go through better-auth, whose reset tokens expire after one hour. diff --git a/docker/init/01-calendar-schema-dev.sql b/docker/init/01-calendar-schema-dev.sql index 06e8c6c..ebd451a 100644 --- a/docker/init/01-calendar-schema-dev.sql +++ b/docker/init/01-calendar-schema-dev.sql @@ -1,8 +1,13 @@ --- Local dev only. Real schema, provided directly by the repo owner --- (calendars, events, event_versions, sessions, users) - not a guess. --- Columns added by this repo's own migrations under sql/calendar/ are folded --- in here rather than appended, so a fresh dev container matches production --- after every migration has been applied. Keep the two in step. +-- Local dev only. Derived from the real schema, which was provided directly by +-- the repo owner (calendars, events, event_versions, and the sessions/users +-- tables that step 5 of docs/calendar-auth-migration.md renamed aside). +-- +-- There is no users or sessions table here: a fresh dev database has no legacy +-- accounts to archive, so it starts where production ends up. +-- +-- Changes made by this repo's own migrations under sql/calendar/ are folded in +-- here rather than appended, so a fresh dev container matches production once +-- every migration has been applied. Keep the two in step. USE nachklang_calendar; CREATE TABLE `calendars` ( @@ -12,47 +17,20 @@ CREATE TABLE `calendars` ( PRIMARY KEY (`calendar_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -CREATE TABLE `users` ( - `user_id` int(11) NOT NULL AUTO_INCREMENT, - `full_name` text NOT NULL, - `password_hash` text DEFAULT NULL, - `email` text NOT NULL, - `is_active` tinyint(1) DEFAULT 0, - `pw_reset_token_hash` text DEFAULT NULL, - `activation_token` text DEFAULT NULL, - PRIMARY KEY (`user_id`), - UNIQUE KEY `email` (`email`) USING HASH -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - -CREATE TABLE `sessions` ( - `session_id` int(11) NOT NULL AUTO_INCREMENT, - `user_id` int(11) NOT NULL, - `session_key_hash` text DEFAULT NULL, - `created_date` datetime DEFAULT current_timestamp(), - `valid_until` datetime DEFAULT (current_timestamp() + interval 30 day), - `last_ip` text DEFAULT NULL, - PRIMARY KEY (`session_id`), - KEY `sessions_users_user_id_fk` (`user_id`), - CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - CREATE TABLE `events` ( `event_id` int(11) NOT NULL AUTO_INCREMENT, `calendar_id` int(11) NOT NULL, `uuid` text NOT NULL, `created_date` datetime DEFAULT current_timestamp(), - -- Nullable since the cutover; see sql/calendar/003_allow_null_legacy_creator.sql. - `created_by_id` int(11) DEFAULT NULL, -- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql. `created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, - -- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql. + -- Creator name, archived before the legacy users table went away; + -- see sql/calendar/002_snapshot_legacy_creator_names.sql. `created_by_name` varchar(255) DEFAULT NULL, PRIMARY KEY (`event_id`), KEY `events_calendars_calendar_id_fk` (`calendar_id`), - KEY `events_users_user_id_fk` (`created_by_id`), KEY `events_created_by_user_idx` (`created_by_user_id`), - CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`), - CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`) + CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE `event_versions` ( @@ -66,7 +44,6 @@ CREATE TABLE `event_versions` ( `repeat_frequency` text DEFAULT NULL, `location` text DEFAULT NULL, `url` text DEFAULT NULL, - `version_created_by_id` int(11) DEFAULT NULL, -- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql. `version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, -- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql. @@ -75,10 +52,8 @@ CREATE TABLE `event_versions` ( `version_created_at` datetime DEFAULT current_timestamp(), PRIMARY KEY (`event_version_id`), KEY `event_versions_events_event_id_fk` (`event_id`), - KEY `event_versions_users_user_id_fk` (`version_created_by_id`), KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`), - CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`) + CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES @@ -88,20 +63,17 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES (4, 'choir', '[]'), (5, 'birthdays', '[]'); --- Dev admin, password: devpassword -INSERT INTO users (email, password_hash, full_name, is_active) VALUES - ('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1); +-- Two events carry only an archived creator name, as every pre-cutover event +-- does, and one carries an admin user id whose name is resolved live. Dev +-- therefore exercises both name sources rather than only one. The one with an +-- id is deliberately a PUBLIC event, so the anonymous listing the website uses +-- covers both. The id is the dev admin from 04-admin-schema.sql. +INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES + (1, UUID(), NULL, 'Dev Admin'), + (1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL), + (1, UUID(), NULL, 'Dev Admin'); --- Two rows are left on the legacy path and one carries an admin user id, so --- dev exercises both branches of the step 3 dual-read rather than only the --- happy one. It is deliberately a PUBLIC event, so the anonymous listing the --- website uses covers both. The id is the dev admin from 04-admin-schema.sql. -INSERT INTO events (calendar_id, uuid, created_by_id, created_by_user_id, created_by_name) VALUES - (1, UUID(), 1, NULL, 'Dev Admin'), - (1, UUID(), 1, 'dev-user-0000-0000-0000-000000000001', NULL), - (1, UUID(), 1, NULL, 'Dev Admin'); - -INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id, version_created_by_user_id, version_created_by_name) VALUES - (1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1, NULL, 'Dev Admin'), - (2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1, 'dev-user-0000-0000-0000-000000000001', NULL), - (3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1, NULL, 'Dev Admin'); +INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES + (1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', NULL, 'Dev Admin'), + (2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 'dev-user-0000-0000-0000-000000000001', NULL), + (3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', NULL, 'Dev Admin'); diff --git a/docs/calendar-auth-migration.md b/docs/calendar-auth-migration.md index 080e2ce..e1810aa 100644 --- a/docs/calendar-auth-migration.md +++ b/docs/calendar-auth-migration.md @@ -1,13 +1,13 @@ # Migrating the Calendar domain onto the admin identity module -Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by -decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is -left to write. +Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on +2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is +implemented and awaiting deploy - see its own checklist below, whose ordering is the +**opposite** of step 4's. -> Read the deploy checklist under step 4 before applying anything. "Done" below means the -> code exists on a branch, **not** that production has it - and in particular production has -> none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be -> built on top of a step 4 that has not been deployed and watched. +Verified live after step 4: the public calendar still answers anonymously, all 23 public +events kept a resolvable author, restricted calendars still refuse without a credential, +legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out. Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan @@ -157,62 +157,57 @@ Each step is meant to leave production working on its own. **One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather than `?password=` stops working permanently. The shared-password URLs are unaffected. -5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been - deployed and watched, because it removes the fallback step 4 still leans on. Scoped and - decided 2026-09-06; what follows is the agreed shape, not a suggestion. +5. **Drop the legacy path.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`; + not yet deployed. Gated on step 4 being live, which it now is. - **Prerequisite: step 4 live in production and behaving.** Until then the legacy join is - what renders the author of every pre-cutover event, and the legacy routes are what an old - cached bundle talks to. Doing this first turns a recoverable deploy into an unrecoverable - one. + What went: - Code, in one branch: + - `src/models/calendar/users/` in its entirety - registration, login, activation, both + password-reset routes, and the session checking the feedback and tickets admin areas + used to authenticate against - plus its mount in `Calendar.router.ts`. That was the + API's last unauthenticated account-creation and mail-sending endpoint. + - The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*` + aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the + row mapper and the `Event` interface. One name source remains besides the live admin + lookup: the snapshot, which is what made this safe. + - `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them + since the tickets and feedback cutover, or sent them since this one. + - The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a + calendar header session. There is nothing left to fall back to. - - **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`, - `session.interface.ts`, `user.interface.ts` - and the `calendarRouter.use('/users', ...)` - line in `Calendar.router.ts`. *(Decided: delete outright rather than unmount.)* This - removes the last unauthenticated account-creation and mail-sending endpoint in the API. - A survey on 2026-09-06 confirmed nothing outside that directory imports it, and nothing - outside it touches the `users`/`sessions` tables except the two joins below. - - **Drop the legacy half of the read** in `events.service.ts`: the two - `LEFT OUTER JOIN users` clauses, the `legacy_*` aliases, and `created_by_id` / - `version_created_by_id` from the SELECT and the row mapper. The snapshot fallback stays - - it is what makes this safe. Remove `createdById` / `lastModifiedById` from - `event.interface.ts` and their (already deprecated) swagger properties. - - **Remove `X-Session-Id` / `X-Session-Key`** from the CORS `allowedHeaders` in - `src/app.factory.ts`. Nothing has sent them since the tickets and feedback frontends were - redeployed. - - **Drop the obsolete test mocks**: `test/feedback/feedback.auth.test.ts`, - `test/tickets/tickets.auth.test.ts` and `test/admin/auth-binding.ts` each mock - `calendar/users/users.service.js` and assert `checkSession` is never called. That - tripwire is meaningless once the module does not exist; remove the mock and the - assertion, keep the rest. + `DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring) + close with it - not by adding expiries but by deleting the code that issued them. - Database, as `sql/calendar/004_*.sql`: + ### Deploy checklist — note the order is REVERSED from step 4 - - Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`, - then the `created_by_id` and `version_created_by_id` columns. - - **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename - rather than drop.)* The reasoning: the display names are already snapshotted so nothing - visible depends on these rows, but they still hold the old e-mail addresses and password - hashes, and a rename makes the tables unreachable without destroying anything. Dropping - them later is one statement, at a moment when nobody is mid-deploy. - - Mirror all of it in `docker/init/01-calendar-schema-dev.sql` (the archive tables need no - mirror - a fresh dev database has nothing to archive). + Step 4's migration only added columns, so it went first. `004` *removes* columns and a + table that the currently running build still selects and joins, so running it first fails + every calendar read including the public feed. The step 5 build references none of them and + runs happily against the old schema. Therefore: - Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and - **4** (password reset token has no expiry) close outright - both describe code that ceases - to exist. Item 2 (no event ownership check) stays open. + 1. **Confirm the snapshot is complete.** Both must return 0: + ```sql + SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events; + SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions; + ``` + A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first. + 2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read + `createdById` (its `Event` model has only the name), and nothing else is known to. + 3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save. + At this point the old columns and tables still exist, unused, so this step is fully + reversible by redeploying the previous build. - Two consequences to accept explicitly rather than discover: + Note that `tsc` does not remove output for deleted sources, so a build over an existing + `dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the + routes 404, but the deployed artifact still contains the code - clear `dist/` in the + pipeline if you want the artifact to match the source. + 4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for + the columns; the accounts themselves are only renamed aside. + 5. Optionally, later and at a quiet moment: + `DROP TABLE sessions_legacy_archive, users_legacy_archive;` - - Any activation or password-reset e-mail already sent points at - `api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever - valid for legacy accounts, which no longer open anything. - - `Event.createdById` disappears from the API response. The Angular frontend never read it - (its `Event` model has only `createdBy`, the name), so this is not a breaking change for - the only known consumer - but it is a wire-format removal, so check anything else that - reads `/calendar/events/*/json` first. + **One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check + anything reading `/calendar/events/*/json` that is not the calendar frontend. ## What the code actually looks like (surveyed 2026-09-06) diff --git a/sql/calendar/004_drop_legacy_auth.sql b/sql/calendar/004_drop_legacy_auth.sql new file mode 100644 index 0000000..5c9e30c --- /dev/null +++ b/sql/calendar/004_drop_legacy_auth.sql @@ -0,0 +1,60 @@ +-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md. +-- Apply manually against the CALENDAR_DB database, after 003: +-- mysql -h -u -p < 004_drop_legacy_auth.sql +-- +-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. *** +-- +-- This is the opposite order from the step 4 cutover, and getting it wrong by +-- analogy is the obvious mistake. Step 4's migration only *added* things, so it +-- was safe ahead of the deploy. This one removes columns and a table that the +-- currently running build still selects and joins - applying it first fails +-- every calendar read, including the anonymous public feed the website uses. +-- The step 5 build touches none of them, so it runs happily against the old +-- schema; deploy it, confirm the calendar works, then run this. +-- +-- Nothing here loses information that is still reachable: the creators' display +-- names were snapshotted into events.created_by_name and +-- event_versions.version_created_by_name by migration 002, and the step 4 +-- runbook re-ran that backfill after the deploy. Verify before running: +-- +-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events; +-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions; +-- +-- Both must be 0. A non-zero count is an event whose author this migration +-- would erase; re-run 002's backfill first. + +-- The foreign keys have to go before the columns they are declared on. +-- IF EXISTS so that a re-run after a partial failure gets past them. +ALTER TABLE `events` + DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`; + +ALTER TABLE `event_versions` + DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`; + +ALTER TABLE `events` + DROP INDEX IF EXISTS `events_users_user_id_fk`, + DROP COLUMN IF EXISTS `created_by_id`; + +ALTER TABLE `event_versions` + DROP INDEX IF EXISTS `event_versions_users_user_id_fk`, + DROP COLUMN IF EXISTS `version_created_by_id`; + +-- The accounts themselves are renamed aside rather than dropped. +-- +-- Nothing visible depends on them any more - the names are snapshotted, and no +-- code has referenced these tables since the step 4 cutover. But they still +-- hold e-mail addresses and password hashes, and a rename makes them +-- unreachable without destroying anything. +-- +-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name +-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the +-- pair stays internally consistent whichever order they are renamed in. +-- Verified on MariaDB 11. +-- +-- Unlike the statements above this is not re-runnable, and that is the safe +-- behaviour: a second run fails on a missing `sessions` rather than doing +-- anything. Drop them for real whenever you like, at a moment when nobody is +-- mid-deploy: +-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`; +RENAME TABLE `sessions` TO `sessions_legacy_archive`; +RENAME TABLE `users` TO `users_legacy_archive`; diff --git a/src/app.factory.ts b/src/app.factory.ts index 3328f90..2d981c0 100644 --- a/src/app.factory.ts +++ b/src/app.factory.ts @@ -63,15 +63,12 @@ export const createApp = (): express.Application => { // 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-* are no longer read by anything on this side, and no longer - // sent by anything either: the tickets and feedback cutover took the last - // two readers off them, and the calendar cutover removed the last legacy - // credential path in the API (its session used to travel in query - // parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed - // only so a browser still running a pre-cutover tickets or feedback bundle - // gets a clean 401 rather than a CORS preflight failure. Drop them once - // those have aged out - see docs/calendar-auth-migration.md step 5. - allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], + // Content-Type alone. X-Session-Id / X-Session-Key were allowed here + // through the two cutovers so that a browser still holding a pre-cutover + // bundle got a clean 401 rather than a confusing CORS preflight failure. + // Nothing has read them since the first cutover and nothing has sent them + // since the second, so they came out with the rest of the legacy path. + allowedHeaders: ['Content-Type'], // The admin session lives in a cookie, so browsers must be allowed to send // it cross-origin - this is what makes credentials: 'include' work. credentials: true, diff --git a/src/models/calendar/Calendar.router.ts b/src/models/calendar/Calendar.router.ts index e07cdf5..1c76fd1 100644 --- a/src/models/calendar/Calendar.router.ts +++ b/src/models/calendar/Calendar.router.ts @@ -5,7 +5,6 @@ import express, {Request, Response} from 'express'; import {Guid} from 'guid-typescript'; import logger from '../../middleware/logger.js'; import {eventsRouter} from './events/events.router.js'; -import {usersRouter} from './users/users.router.js'; /** * Router Definition @@ -13,7 +12,13 @@ import {usersRouter} from './users/users.router.js'; export const calendarRouter = express.Router(); calendarRouter.use('/events', eventsRouter); -calendarRouter.use('/users', usersRouter); + +/* + * There is no /calendar/users any more. It held this module's own accounts - + * registration, login, activation, password reset, and the session table the + * feedback and tickets admin areas used to authenticate against - and every one + * of those moved to the admin module. See docs/calendar-auth-migration.md. + */ /** diff --git a/src/models/calendar/events/event.interface.ts b/src/models/calendar/events/event.interface.ts index 0caf877..3997dc8 100644 --- a/src/models/calendar/events/event.interface.ts +++ b/src/models/calendar/events/event.interface.ts @@ -14,7 +14,6 @@ * - endDateTime * - createdDate * - location - * - createdById * - url * - wholeDay * properties: @@ -65,15 +64,6 @@ * type: string * description: The name of the user who created the event * example: "John Doe" - * createdById: - * type: integer - * deprecated: true - * description: > - * The legacy calendar user id of the creator. Being replaced by - * createdByUserId; see docs/calendar-auth-migration.md. Null on - * events created after the cutover. - * nullable: true - * example: 456 * createdByUserId: * type: string * nullable: true @@ -83,14 +73,6 @@ * type: string * description: The name of the user who last modified the event * example: "John Doe" - * lastModifiedById: - * type: integer - * deprecated: true - * nullable: true - * description: > - * The legacy calendar user id of the last editor. Being replaced - * by lastModifiedByUserId. - * example: 456 * lastModifiedByUserId: * type: string * nullable: true @@ -121,14 +103,12 @@ export interface Event { createdDate: Date; lastModifiedDate?: Date; location: string; - /** Display name of the creator, from whichever id below resolved. */ + /** Display name of the creator: the live admin name when the id below + * resolves, otherwise the name archived before the legacy users table was + * removed. See docs/calendar-auth-migration.md. */ createdBy?: string; - createdById?: number | null; - /** Set once the event's creator exists in the admin module. Preferred over - * createdById when both are present; see docs/calendar-auth-migration.md. */ createdByUserId?: string | null; lastModifiedBy?: string; - lastModifiedById?: number | null; lastModifiedByUserId?: string | null; url: string; wholeDay: boolean; diff --git a/src/models/calendar/events/events.service.ts b/src/models/calendar/events/events.service.ts index 0f29f03..710a836 100644 --- a/src/models/calendar/events/events.service.ts +++ b/src/models/calendar/events/events.service.ts @@ -9,49 +9,35 @@ import logger from '../../../middleware/logger.js'; dotenv.config(); /** - * Step 3 of docs/calendar-auth-migration.md: the dual read. + * How an event's creator is resolved, after step 5 of + * docs/calendar-auth-migration.md removed the legacy path. * - * An event records its creator twice - `created_by_id`, the legacy INT into - * the calendar database's own `users` table, and `created_by_user_id`, the - * admin module's VARCHAR(36) id. Old rows have only the first, rows written - * after the step 4 cutover will have only the second, and the two live in - * different databases, so this file has to read both and prefer the new one. + * The creator is only ever rendered as a name - nothing authorises on it, there + * is no "only the creator may edit" rule anywhere - which is why an unresolvable + * one degrades to blank rather than to an error. * - * The one thing the creator is used for is a display name. Nothing authorises - * on it - there is no "only the creator may edit" rule anywhere - which is why - * a name that cannot be resolved degrades to blank instead of to an error. + * Two sources remain, weaker first: * - * That name has three possible sources, and they are tried weakest first: + * 1. `created_by_name`, a snapshot of the name as it stood when the calendar + * had its own `users` table. Migration 002 took it, migration 004 dropped + * the table it was taken from, and nothing has written it since. It exists + * so the authorship of pre-cutover events survived that removal. + * 2. The admin module's `user.name`, looked up live for rows carrying an admin + * id. It wins, because it is the only one that follows a rename. * - * 1. LEGACY - joining the calendar's own `users` table on `created_by_id`. - * 2. `created_by_name`, the snapshot migration 002 took of exactly that join, - * so the authorship of pre-cutover events survives step 5 dropping the - * table. An archive: nothing writes it after the backfill. - * 3. The admin module's `user.name`, looked up live for rows that carry an - * admin id. It wins because it is the only one that follows a rename. - * - * Writes only ever set the admin id: since the step 4 cutover there is no - * calendar user id to write, which is why migration 003 made `created_by_id` - * nullable. The reads below still handle rows that predate that. - * - * Removal note: everything marked LEGACY below comes out in step 5, together - * with the `users`/`sessions` tables and the `created_by_id` columns. The - * snapshot stays - it is the reason step 5 can drop them. + * The third source - joining the calendar's own `users` table on + * `created_by_id` - is gone with the table and the column. */ /** - * The one SELECT the four read paths share. It was copied out four times - * before, which is precisely why the dual read had to be added in four - * places; callers append their own WHERE and ORDER BY. + * The one SELECT the four read paths share; callers append their own WHERE and + * ORDER BY. `v.*` carries the version row's own creator columns, so only the + * `events` columns need naming. * - * `v.*` carries `version_created_by_user_id` and `version_created_by_name` - * along with the rest of the version row, so only the `events` columns need - * naming. The two joined names are aliased `legacy_*` because the unprefixed - * names are now real columns. + * There are no joins to a users table any more. There is no users table. */ const EVENT_SELECT = ` - SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name, - u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e + SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e INNER JOIN ( SELECT event_id, MAX(event_version_id) AS latest_version FROM event_versions @@ -59,9 +45,7 @@ const EVENT_SELECT = ` ) latest_versions ON e.event_id = latest_versions.event_id INNER JOIN event_versions v - ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version - LEFT OUTER JOIN users u ON u.user_id = e.created_by_id - LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`; + ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`; /** * Maps a result row to an Event. `status` is included only where it always @@ -80,15 +64,11 @@ const toEvent = (row: any, includeStatus: boolean): Event => { createdDate: row.created_date, lastModifiedDate: row.version_created_at, location: row.location, - // Name resolution, weakest first: the LEGACY join against the calendar - // users table, then the snapshot taken in migration 002, then - in - // resolveAdminNames below - the live admin name, which wins because it - // is the only one that follows an account being renamed. - createdBy: row.created_by_name ?? row.legacy_created_by_name, - createdById: row.created_by_id, + // The archived name. resolveAdminNames below overwrites it for rows that + // carry an admin id, which is the only source that follows a rename. + createdBy: row.created_by_name, createdByUserId: row.created_by_user_id ?? null, - lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name, - lastModifiedById: row.version_created_by_id, + lastModifiedBy: row.version_created_by_name, lastModifiedByUserId: row.version_created_by_user_id ?? null, url: row.url, wholeDay: row.whole_day, @@ -112,7 +92,7 @@ const toEvent = (row: any, includeStatus: boolean): Event => { * A failure here is swallowed on purpose. These endpoints include the public * calendar the website reads anonymously, and a name is decoration: if the * admin database is unreachable, an event should still render with whatever - * the legacy join produced rather than 500 the whole listing. The alternative + * the snapshot holds rather than 500 the whole listing. The alternative * would widen the public calendar's blast radius to include the admin * database, which it has never depended on before. */ diff --git a/src/models/calendar/users/session.interface.ts b/src/models/calendar/users/session.interface.ts deleted file mode 100644 index cf90a6a..0000000 --- a/src/models/calendar/users/session.interface.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @swagger - * components: - * schemas: - * Session: - * type: object - * required: - * - sessionId - * - userId - * - sessionKey - * - sessionKeyHash - * - lastIP - * properties: - * sessionId: - * type: integer - * description: The unique identifier for the session - * example: 789 - * userId: - * type: integer - * description: The ID of the user this session belongs to - * example: 456 - * sessionKey: - * type: string - * description: The session key used for authentication - * example: "abc123def456" - * sessionKeyHash: - * type: string - * description: The hashed session key (not returned in API responses) - * example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG" - * createdDate: - * type: string - * format: date-time - * description: The date and time when the session was created - * example: "2023-05-01T10:00:00.000Z" - * validUntil: - * type: string - * format: date-time - * description: The date and time until when the session is valid - * example: "2023-05-08T10:00:00.000Z" - * lastIP: - * type: string - * description: The last IP address used with this session - * example: "192.168.1.1" - */ -export interface Session { - sessionId: number; - userId: number; - sessionKey: string; - sessionKeyHash: string; - createdDate?: Date; - validUntil?: Date; - lastIP: string; -} diff --git a/src/models/calendar/users/user.interface.ts b/src/models/calendar/users/user.interface.ts deleted file mode 100644 index a74a20c..0000000 --- a/src/models/calendar/users/user.interface.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @swagger - * components: - * schemas: - * User: - * type: object - * required: - * - userId - * - fullName - * - passwordHash - * - email - * - isActive - * properties: - * userId: - * type: integer - * description: The unique identifier for the user - * example: 456 - * fullName: - * type: string - * description: The full name of the user - * example: "John Doe" - * passwordHash: - * type: string - * description: The hashed password of the user (not returned in API responses) - * example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG" - * email: - * type: string - * format: email - * description: The email address of the user - * example: "john.doe@nachklang.art" - * isActive: - * type: boolean - * description: Whether the user account is active - * example: true - */ -export interface User { - userId: number; - fullName: string; - passwordHash: string; - email: string; - isActive: boolean; -} diff --git a/src/models/calendar/users/users.router.ts b/src/models/calendar/users/users.router.ts deleted file mode 100644 index dbd932e..0000000 --- a/src/models/calendar/users/users.router.ts +++ /dev/null @@ -1,671 +0,0 @@ -/** - * Required External Modules and Interfaces - */ - -import express, {Request, Response} from 'express'; -import * as UserService from './users.service.js'; -import {Session} from './session.interface.js'; -import {User} from './user.interface.js'; -import {Guid} from 'guid-typescript'; -import logger from '../../../middleware/logger.js'; - -/** - * Router Definition - */ - -export const usersRouter = express.Router(); - - -/** - * Controller Definitions - */ - -/** - * @swagger - * /calendar/users/register: - * post: - * summary: Register a new user - * description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses. - * tags: - * - calendar - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - email - * - password - * - fullName - * properties: - * email: - * type: string - * format: email - * example: john.doe@nachklang.art - * description: Must be an official Nachklang email address - * password: - * type: string - * format: password - * example: securePassword123 - * fullName: - * type: string - * example: John Doe - * responses: - * 201: - * description: User registered successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * sessionId: - * type: integer - * example: 123 - * sessionKey: - * type: string - * example: abc123def456 - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: Missing parameters - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: PROCESSING_ERROR - * message: - * type: string - * example: Internal Server Error. Try again later. - * reference: - * type: string - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - */ -// POST users/register -usersRouter.post('/register', async (req: Request, res: Response) => { - try { - const password: string = req.body.password; - const email: string = req.body.email; - const fullName: string = req.body.fullName; - const ip: string = req.socket.remoteAddress ?? ''; - - if (!password || !email || !fullName) { - // Missing - res.status(400).send(JSON.stringify({message: 'Missing parameters'})); - return; - } - - const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/; - - if(!emailRegex.test(email)) { - res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'})); - return; - } - - // Create the user and a session - const session: Session = await UserService.createUser(email, password, fullName, ip); - - // Send the session details back to the user - res.status(201).send({ - sessionId: session.sessionId, - sessionKey: session.sessionKey - }); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); - -/** - * @swagger - * /calendar/users/activate: - * get: - * summary: Activate a user account - * description: Activates a user account using the provided user ID and activation token. - * tags: - * - calendar - * parameters: - * - in: query - * name: id - * required: true - * schema: - * type: integer - * description: The ID of the user to activate - * - in: query - * name: token - * required: true - * schema: - * type: string - * description: The activation token sent to the user's email - * responses: - * 200: - * description: User activated successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: OK - * message: - * type: string - * example: User activated - * 400: - * description: Bad request - missing parameters or activation failed - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: PROCESSING_ERROR - * message: - * type: string - * example: Error activating user. Please contact your administrator. - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: PROCESSING_ERROR - * message: - * type: string - * example: Internal Server Error. Try again later. - * reference: - * type: string - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - */ -// GET /users/activate -usersRouter.get('/activate', async (req: Request, res: Response) => { - try { - const userId: number = parseInt(req.query.id as string ?? '-1', 10); - const token: string = req.query.token as string ?? ''; - - if (!userId || !token) { - // Missing - res.status(400).send(JSON.stringify({message: 'Missing parameters'})); - return; - } - - // Create the user and a session - const success: boolean = await UserService.activateUser(userId, token); - - // Send the session details back to the user - if(success) { - res.status(200).send({ - 'status': 'OK', - 'message': 'User activated' - }); - return; - } - res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'}); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); - -/** - * @swagger - * /calendar/users/login: - * post: - * summary: Login a user - * description: Authenticates a user with the provided email and password and returns a session. - * tags: - * - calendar - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - email - * - password - * properties: - * email: - * type: string - * format: email - * example: john.doe@nachklang.art - * password: - * type: string - * format: password - * example: securePassword123 - * responses: - * 200: - * description: Login successful - * content: - * application/json: - * schema: - * type: object - * properties: - * sessionId: - * type: integer - * example: 123 - * sessionKey: - * type: string - * example: abc123def456 - * 400: - * description: Bad request - missing parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: Missing parameters - * 401: - * description: Unauthorized - invalid credentials - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: Wrong username and / or password - * sessionId: - * type: integer - * example: -1 - * sessionKey: - * type: string - * example: "" - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: PROCESSING_ERROR - * message: - * type: string - * example: Internal Server Error. Try again later. - * reference: - * type: string - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - */ -// POST users/login -usersRouter.post('/login', async (req: Request, res: Response) => { - try { - const password: string = req.body.password; - const email: string = req.body.email; - const ip: string = req.socket.remoteAddress ?? ''; - - if (!password || !email) { - // Missing - res.status(400).send(JSON.stringify({message: 'Missing parameters'})); - return; - } - - // Create a session - const session: Session | null = await UserService.login(email, password, ip); - - if (!session || !session.sessionId) { - // Error logging in, probably wrong username / password - res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''})); - return; - } - - // Send the session details back to the user - res.status(200).send({ - sessionId: session.sessionId, - sessionKey: session.sessionKey - }); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); - -/** - * @swagger - * /calendar/users/checkSessionValid: - * post: - * summary: Check if a session is valid - * description: Checks if the provided session is valid and returns the user information if it is. - * tags: - * - calendar - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - sessionId - * - sessionKey - * properties: - * sessionId: - * type: integer - * example: 123 - * sessionKey: - * type: string - * example: abc123def456 - * responses: - * 200: - * description: Session is valid - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/User' - * 401: - * description: Unauthorized - invalid session - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: ["Invalid session"] - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: PROCESSING_ERROR - * message: - * type: string - * example: Internal Server Error. Try again later. - * reference: - * type: string - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - */ -// POST users/checkSessionValid -usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => { - try { - const ip: string = req.socket.remoteAddress ?? ''; - const session_id = req.body.sessionId; - const session_key = req.body.sessionKey; - - if (!session_id || !session_key) { - // Error logging in, probably wrong username / password - res.status(401).send(JSON.stringify({messages: ['No session detected']})); - return; - } - - const user: User | null = await UserService.checkSession(session_id, session_key, ip); - - if (!user || !user.userId) { - // Error logging in, probably wrong username / password - res.status(401).send(JSON.stringify({messages: ['Invalid session']})); - return; - } - - res.status(200).send(user); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); - -/** - * @swagger - * /calendar/users/initiatePasswordReset: - * post: - * summary: Initiates a password reset - * description: Checks if the user exists and if so, initiates a password reset by sending an email to the user. - * tags: - * - calendar - * responses: - * 200: - * description: Success - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Success - * description: A list of status messages - * 400: - * description: Problem with the request. Please consider the returned detailed error. - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Missing parameters - * description: A list of error messages - * 401: - * description: Problem with authorizing the user. Please check the provided credentials. - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Invalid session - * description: A list of error messages - * 500: - * description: A server error occurred. Please try again. If this issue persists, contact the admin. - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * description: The response status - * example: PROCESSING_ERROR - * message: - * type: string - * description: The detailed error message - * example: Internal Server Error. Try again later. - * reference: - * type: string - * description: An error reference for getting support concerning this error. - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * example: patrick@nachklang.art - */ -usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => { - try { - const username = req.body.username; - - if (!username) { - // Error logging in, probably wrong username / password - res.status(400).send(JSON.stringify({messages: ['No username given']})); - return; - } - - const success: boolean = await UserService.initiatePasswordReset(username); - - if (!success) { - // Error logging in, probably wrong username / password - res.status(401).send(JSON.stringify({messages: ['Error']})); - return; - } - - res.status(200).send(JSON.stringify({messages: ['Success']})); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); - -/** - * @swagger - * /calendar/users/finalizePasswordReset: - * post: - * summary: Finalizes the password reset - * description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password. - * tags: - * - calendar - * responses: - * 200: - * description: Success - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Success - * description: A list of status messages - * 400: - * description: Problem with the request. Please consider the returned detailed error. - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Missing parameters - * description: A list of error messages - * 401: - * description: Problem with authorizing the user. Please check the provided credentials. - * content: - * application/json: - * schema: - * type: object - * properties: - * messages: - * type: array - * items: - * type: string - * example: Invalid session - * description: A list of error messages - * 500: - * description: A server error occurred. Please try again. If this issue persists, contact the admin. - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * description: The response status - * example: PROCESSING_ERROR - * message: - * type: string - * description: The detailed error message - * example: Internal Server Error. Try again later. - * reference: - * type: string - * description: An error reference for getting support concerning this error. - * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * example: patrick@nachklang.art - * token: - * type: string - * example: 3ccd147f-720b-4e29-a8b7-46b63de31555 - * password: - * type: string - * example: ExtremelyBadPassword - */ -usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => { - try { - const username = req.body.username; - const token = req.body.token; - const newPassword = req.body.password; - - if (!username) { - // Error logging in, probably wrong username / password - res.status(400).send(JSON.stringify({messages: ['No username, token or password given']})); - return; - } - - const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword); - - if (!success) { - // Error logging in, probably wrong username / password - res.status(401).send(JSON.stringify({messages: ['Error']})); - return; - } - - res.status(200).send(JSON.stringify({messages: ['Success']})); - } catch (e: any) { - let errorGuid = Guid.create().toString(); - logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); - res.status(500).send({ - 'status': 'PROCESSING_ERROR', - 'message': 'Internal Server Error. Try again later.', - 'reference': errorGuid - }); - } -}); diff --git a/src/models/calendar/users/users.service.ts b/src/models/calendar/users/users.service.ts deleted file mode 100644 index 222dcf4..0000000 --- a/src/models/calendar/users/users.service.ts +++ /dev/null @@ -1,296 +0,0 @@ -import * as dotenv from 'dotenv'; -import bcrypt from 'bcrypt'; -import {Guid} from 'guid-typescript'; -import {User} from './user.interface.js'; -import {Session} from './session.interface.js'; -import {NachklangCalendarDB} from '../Calendar.db.js'; -import {MailService} from '../../../common/common.mail.js'; - -dotenv.config(); - -/** - * Data Model Interfaces - */ - - -/** - * Service Methods - */ - -/** - * Creates a user record in the database, also creates a session. Returns the session if successful. - */ -export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - // Hash password and generate + hash session key - const pwHash = bcrypt.hashSync(password, 10); - const sessionKey = Guid.create().toString(); - const sessionKeyHash = bcrypt.hashSync(sessionKey, 10); - - const activationToken = Guid.create().toString(); - const activationTokenHash = bcrypt.hashSync(activationToken, 10); - - // Create user entry in SQL - const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id'; - const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]); - - // Get user id of the created user - let userId: number = -1; - for (const row of userIdRes) { - userId = row.user_id; - } - - // Create session - const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id'; - const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]); - await conn.commit(); - - // Get session id of the created session - let sessionId: number = -1; - for (const row of sessionIdRes) { - sessionId = row.session_id; - } - - // Send email with activation link (after commit so we don't block on email - // delivery). sendMail never throws on a delivery failure - it logs and - // returns false - so a mail-server problem here can't roll back the - // already-committed user and leave registration reporting a false error. - await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`); - - return { - sessionId: sessionId, - userId: userId, - sessionKey: sessionKey, - sessionKeyHash: 'HIDDEN', - lastIP: ip - }; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -}; - -export const activateUser = async (userId: number, token: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0'; - const userNameRes = await conn.query(checkTokenQuery, [userId]); - let storedTokenHash = ''; - for (const row of userNameRes) { - storedTokenHash = row.activation_token; - } - if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) { - return false; - } - const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?'; - const activateRes = await conn.execute(activateQuery, [userId]); - await conn.commit(); - return activateRes.affectedRows !== 0; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -} - -/** - * Checks if the given credentials are valid and creates a new session if they are. - * Returns the session information in case of a successful login - */ -export const login = async (email: string, password: string, ip: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - // Get saved password hash - const query = 'SELECT user_id, password_hash FROM users WHERE email = ?'; - const userRows = await conn.query(query, email); - let savedHash = ''; - let userId = -1; - for (const row of userRows) { - savedHash = row.password_hash; - userId = row.user_id; - } - - // Check for correct password - if (!bcrypt.compareSync(password, savedHash)) { - return null; - } - - // Generate + hash session key - const sessionKey = Guid.create().toString(); - const sessionKeyHash = bcrypt.hashSync(sessionKey, 10); - - // Create session - const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id'; - const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]); - await conn.commit(); - - // Get session id of the created session - let sessionId: number = -1; - for (const row of sessionIdRes) { - sessionId = row.session_id; - } - - return { - sessionId: sessionId, - userId: userId, - sessionKey: sessionKey, - sessionKeyHash: 'HIDDEN', - lastIP: ip - }; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -}; - -/** - * Checks if the given session information are valid and returns the user information if they are - */ -export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - // Get saved session key hash - const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?'; - const sessionRows = await conn.query(query, sessionId); - let savedHash = ''; - let userId = -1; - let validUntil = new Date(); - for (const row of sessionRows) { - savedHash = row.session_key_hash; - userId = row.user_id; - validUntil = row.valid_until; - } - - // Check for correct key - if (!bcrypt.compareSync(sessionKey, savedHash)) { - return null; - } - - // Check if the session is still valid - if (validUntil <= new Date()) { - return null; - } - - // Update session entry in SQL - const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?'; - await conn.query(updateSessionsQuery, [ip, sessionId]); - await conn.commit(); - - // Get the other required user information - const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?'; - const userRows = await conn.query(userQuery, userId); - let email = ''; - let fullName = ''; - let is_active = false; - for (const row of userRows) { - email = row.email; - fullName = row.full_name; - is_active = row.is_active; - } - - // Everything is fine, return user information - return { - userId: userId, - email: email, - passwordHash: 'HIDDEN', - fullName: fullName, - isActive: is_active - }; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -}; - -export const initiatePasswordReset = async (email: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?'; - const userNameRes = await conn.query(checkUsernameQuery, [email]); - if (userNameRes.length === 0) { - return false; - } - let userId: number = -1; - let fullName: string = ''; - for(let row of userNameRes) { - userId = row.user_id; - fullName = row.full_Name; - } - - let resetToken = Guid.create().toString(); - let resetTokenHash = bcrypt.hashSync(resetToken, 10); - - const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?'; - const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]); - - if(updateRes.affectedRows === 0) { - return false; - } - await conn.commit(); - - await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`); - return true; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -} - -export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise => { - let conn = await NachklangCalendarDB.getConnection(); - try { - await conn.beginTransaction(); - - const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?'; - const userNameRes = await conn.query(checkTokenQuery, [email]); - if (userNameRes.length === 0) { - return false; - } - let userId: string = ''; - let tokenHash: string = ''; - for(let row of userNameRes) { - userId = row.user_id; - tokenHash = row.pw_reset_token_hash; - } - - if(!bcrypt.compareSync(token, tokenHash)) { - return false; - } - - const pwHash = bcrypt.hashSync(newPassword, 10); - const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?'; - const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]); - - if(updateRes.affectedRows > 0) { - await conn.commit(); - return true; - } - - return false; - } catch (err) { - await conn.rollback(); - throw err; - } finally { - await conn.end(); - } -} diff --git a/test/admin/auth-binding.ts b/test/admin/auth-binding.ts index d50a0c9..aaf737b 100644 --- a/test/admin/auth-binding.ts +++ b/test/admin/auth-binding.ts @@ -7,9 +7,14 @@ import express, {Request, Response} from 'express'; * the calendar users table; both are now one binding to the shared admin gate. * * What is worth asserting is not how that gate works - admin.middleware.test.ts - * owns that - but that each module is bound to *its own* app, and that neither - * consults the calendar users service any more. The mocks live in the calling - * file because vi.mock is per-module-graph; only the assertions are shared. + * owns that - but that each module is bound to *its own* app. The mocks live in + * the calling file because vi.mock is per-module-graph; only the assertions are + * shared. + * + * There used to be a tripwire here asserting neither module fell back to the + * calendar's header sessions. It went with step 5: the calendar users service + * no longer exists, so there is nothing left to fall back to and nothing to + * assert against. */ export interface BindingMocks { @@ -17,8 +22,6 @@ export interface BindingMocks { getSession: Mock; /** loadAccess from the mocked users.admin.service.js */ loadAccess: Mock; - /** checkSession from the mocked calendar users.service.js */ - checkSession: Mock; } const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request); @@ -60,7 +63,6 @@ export const describeAdminBinding = ( m = mocks(); m.getSession.mockReset(); m.loadAccess.mockReset(); - m.checkSession.mockReset(); }); it('responds 401 and does not call next() without a session', async () => { @@ -101,18 +103,5 @@ export const describeAdminBinding = ( expect(next).toHaveBeenCalled(); expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'}); }); - - // Weaker than it looks and kept deliberately: neither module imports - // checkSession any more, so this cannot fail today. It is a tripwire for - // the change that would matter - someone reintroducing a header-session - // fallback "just for the calendar users who have not been invited yet", - // which is exactly the shortcut the cutover exists to close. - it('never falls back to a calendar header session', async () => { - m.getSession.mockResolvedValue(null); - - await run(); - - expect(m.checkSession).not.toHaveBeenCalled(); - }); }); }; diff --git a/test/calendar/events.service.test.ts b/test/calendar/events.service.test.ts index 19489e1..2fa2213 100644 --- a/test/calendar/events.service.test.ts +++ b/test/calendar/events.service.test.ts @@ -23,11 +23,11 @@ import * as EventService from '../../src/models/calendar/events/events.service.j /** * Step 3 of docs/calendar-auth-migration.md. The property under test is that - * an event's creator resolves from whichever of its three possible sources is - * strongest - the live admin name, then the snapshot from migration 002, then - * the legacy join - and that a failure to reach the admin database costs a name - * rather than the whole response: the public calendar is read anonymously by - * the website and has never depended on the admin database being up. + * an event's creator resolves from the stronger of its two remaining sources - + * the live admin name, else the name archived before the legacy users table was + * removed - and that a failure to reach the admin database costs a name rather + * than the whole response: the public calendar is read anonymously by the + * website and has never depended on the admin database being up. */ // One row of the shape the shared SELECT produces. @@ -42,14 +42,10 @@ const row = (over: Record = {}) => ({ created_date: new Date('2026-01-01T00:00:00Z'), version_created_at: new Date('2026-01-02T00:00:00Z'), location: '', - created_by_id: 7, created_by_user_id: null, - created_by_name: null, - legacy_created_by_name: 'Legacy Person', - version_created_by_id: 7, + created_by_name: 'Archived Person', version_created_by_user_id: null, - version_created_by_name: null, - legacy_last_modified_by_name: 'Legacy Person', + version_created_by_name: 'Archived Person', url: '', whole_day: 0, repeat_frequency: '', @@ -72,13 +68,12 @@ beforeEach(() => { }); describe('creator names', () => { - it('uses the legacy join when the row has no admin id', async () => { + it('uses the archived name when the row has no admin id', async () => { givenEvents(row()); const events = await EventService.getAllEvents(1); - expect(events[0].createdBy).toBe('Legacy Person'); - expect(events[0].createdById).toBe(7); + expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].createdByUserId).toBeNull(); // Nothing to resolve, so the admin database is not touched at all. expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled(); @@ -97,23 +92,9 @@ describe('creator names', () => { expect(events[0].createdBy).toBe('Neue Person'); expect(events[0].lastModifiedBy).toBe('Andere Person'); - // The legacy id is still reported during the transition. - expect(events[0].createdById).toBe(7); expect(events[0].createdByUserId).toBe('admin-1'); }); - it('prefers the snapshot over the legacy join', async () => { - givenEvents(row({ - created_by_name: 'Archived Person', - version_created_by_name: 'Archived Person' - })); - - const events = await EventService.getAllEvents(1); - - expect(events[0].createdBy).toBe('Archived Person'); - expect(events[0].lastModifiedBy).toBe('Archived Person'); - }); - it('prefers the live admin name over the snapshot', async () => { // A renamed account has to win over an archive that was correct when it // was taken - otherwise renaming someone would leave stale names behind. @@ -125,29 +106,26 @@ describe('creator names', () => { expect(events[0].createdBy).toBe('Neue Person'); }); - it('keeps the snapshot when step 5 has removed the legacy join', async () => { - // What a post-step-5 row looks like: no legacy id, no join, snapshot only. - givenEvents(row({ - created_by_id: null, - legacy_created_by_name: undefined, - legacy_last_modified_by_name: undefined, - created_by_name: 'Archived Person', - version_created_by_name: 'Archived Person' - })); - - const events = await EventService.getAllEvents(1); - - expect(events[0].createdBy).toBe('Archived Person'); - expect(events[0].lastModifiedBy).toBe('Archived Person'); - }); - - it('falls back to the legacy name when the admin account is gone', async () => { + it('falls back to the archived name when the admin account is gone', async () => { givenEvents(row({created_by_user_id: 'deleted'})); (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map()); const events = await EventService.getAllEvents(1); - expect(events[0].createdBy).toBe('Legacy Person'); + expect(events[0].createdBy).toBe('Archived Person'); + }); + + it('leaves the name blank when a row has neither source', async () => { + // A post-cutover event whose author was later deleted from the admin + // module: no snapshot was ever taken for it, and the id resolves to + // nothing. Blank is the designed outcome - the creator is decoration. + givenEvents(row({created_by_user_id: 'deleted', created_by_name: null})); + (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map()); + + const events = await EventService.getAllEvents(1); + + expect(events[0].createdBy).toBeNull(); + expect(events).toHaveLength(1); }); it('resolves a mixed result set in a single lookup', async () => { @@ -160,7 +138,7 @@ describe('creator names', () => { const events = await EventService.getAllEvents(1); - expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']); + expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']); expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1); }); @@ -172,8 +150,8 @@ describe('creator names', () => { expect(events).toHaveLength(1); expect(events[0].name).toBe('Konzert'); - // Degrades to the legacy name rather than failing the request. - expect(events[0].createdBy).toBe('Legacy Person'); + // Degrades to the archived name rather than failing the request. + expect(events[0].createdBy).toBe('Archived Person'); }); }); diff --git a/test/feedback/feedback.auth.test.ts b/test/feedback/feedback.auth.test.ts index 9b66930..006dfc2 100644 --- a/test/feedback/feedback.auth.test.ts +++ b/test/feedback/feedback.auth.test.ts @@ -1,8 +1,5 @@ import {vi, type Mock} from 'vitest'; -vi.mock('../../src/models/calendar/users/users.service.js', () => ({ - checkSession: vi.fn() -})); vi.mock('../../src/models/admin/admin.auth.js', () => ({ auth: {api: {getSession: vi.fn()}} })); @@ -10,7 +7,6 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({ loadAccess: vi.fn() })); -import * as UserService from '../../src/models/calendar/users/users.service.js'; import {auth} from '../../src/models/admin/admin.auth.js'; import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js'; @@ -18,6 +14,5 @@ import {describeAdminBinding} from '../admin/auth-binding.js'; describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({ getSession: auth.api.getSession as unknown as Mock, - loadAccess: UsersService.loadAccess as Mock, - checkSession: UserService.checkSession as Mock + loadAccess: UsersService.loadAccess as Mock })); diff --git a/test/tickets/tickets.auth.test.ts b/test/tickets/tickets.auth.test.ts index e1f7f0f..00615cd 100644 --- a/test/tickets/tickets.auth.test.ts +++ b/test/tickets/tickets.auth.test.ts @@ -1,8 +1,5 @@ import {vi, type Mock} from 'vitest'; -vi.mock('../../src/models/calendar/users/users.service.js', () => ({ - checkSession: vi.fn() -})); vi.mock('../../src/models/admin/admin.auth.js', () => ({ auth: {api: {getSession: vi.fn()}} })); @@ -10,7 +7,6 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({ loadAccess: vi.fn() })); -import * as UserService from '../../src/models/calendar/users/users.service.js'; import {auth} from '../../src/models/admin/admin.auth.js'; import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js'; @@ -18,6 +14,5 @@ import {describeAdminBinding} from '../admin/auth-binding.js'; describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({ getSession: auth.api.getSession as unknown as Mock, - loadAccess: UsersService.loadAccess as Mock, - checkSession: UserService.checkSession as Mock + loadAccess: UsersService.loadAccess as Mock }));