4 Commits

Author SHA1 Message Date
Paddy b4c8c91795 Scope step 5 of the calendar migration, and gate it on step 4 being live
Step 5 removes the legacy path, so it removes the fallback step 4 still leans
on: the join that renders the author of every pre-cutover event, and the
routes an old cached bundle talks to. Building it before step 4 has been
deployed and watched turns a recoverable deploy into an unrecoverable one, so
this records the shape rather than implementing it.

Two decisions worth having in the runbook rather than in someone's memory.
The legacy calendar user module gets deleted outright rather than unmounted -
a survey confirmed nothing outside that directory imports it, and it is the
API's last unauthenticated account-creation and mail-sending endpoint. The
users and sessions tables get renamed aside rather than dropped: the display
names are already snapshotted so nothing visible depends on those rows, but
they still hold e-mail addresses and password hashes, and a rename makes them
unreachable without destroying anything.

Also notes the two consequences worth accepting deliberately: activation links
already in inboxes become 404s, and createdById leaves the wire format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:01:44 +02:00
Paddy d960ac8e24 Fold the pre-deploy review findings into the calendar cutover
A fresh-context review before deploying found two things that would have
broken production, both in the runbook rather than the code.

The deploy order named only migration 003. Production has none of the three -
001 and 002 were only ever applied to the dev database - and the new API reads
the columns they add on every request, so following it literally would have
500'd every calendar call including the anonymous feed the public website
uses. Step 4 now carries a numbered checklist with a verification query.

APP_ORIGINS replaces the code's default list rather than adding to it, so
naming calendar.nachklang.art in DEFAULT_APP_ORIGINS is not enough if that
variable is set on the vhost - and its failure mode is the quiet one the
config already warns about, where everything works except sign-out. Added to
the same checklist.

Also from the review:

The two operands of the read guard on /json/next and /ical were swapped so the
password check short-circuits first. They are side-effect free, so the order
was free - but the old one put an admin-database query in front of the public
feed for any caller holding a .nachklang.art cookie, which is a dependency
that feed has never had. Two tests now assert the admin database is not
consulted at all.

/:calendar/json/next had no route-level test, despite being the endpoint the
public website actually calls and the property named as load-bearing. Covered
now, along with the rest of its credential matrix.

Migrations 001 and 002 gained IF NOT EXISTS. They are applied by hand with no
tracking table, so a partial re-run should be a no-op rather than an error
that aborts the rest of the paste. Verified by applying all three twice to a
throwaway container and diffing against the dev schema.

Swagger: two descriptions still claimed authentication was required where the
public calendar needs none, the calendar enum omitted `birthdays`, and a
`createdBy` request-body field was documented and read but never persisted -
misleading in a way that suggests a client can set authorship. Removed. The
CORS comment describing the calendar's query-parameter sessions is no longer
true and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:41:49 +02:00
Paddy b848d6eab9 Move the calendar onto the shared session cookie
Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.

The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.

The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.

That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.

credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.

Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.

/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:23:36 +02:00
Paddy 61d3883479 Read calendar event creators from the admin module, and archive the old ones
Steps 1 and 3 of docs/calendar-auth-migration.md. The calendar is the last
module still authenticating against its own users/sessions tables; this is
the groundwork that lets step 4 swap it for the shared admin identity.

An event now 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. The two live in different databases, so there is no
foreign key and no join - a cross-schema reference would tie the schemas'
lifecycles together, and the name is instead resolved through one lookup per
result set against the admin database.

The creator is only ever rendered as a name; nothing authorises on it. That
is what makes the planned account backfill unnecessary - dropped by decision -
and what makes the read degrade rather than fail: an admin id that no longer
resolves falls back, and an unreachable admin database costs a name rather
than the response. The public calendar is read anonymously by nachklang.art
and has never depended on the admin database being up.

Since there is no backfill, step 5 dropping the legacy users table would have
erased the authorship of every pre-cutover event. Migration 002 brings that
part of step 5 forward and snapshots the names onto the events themselves, so
the data is safe well before the table holding it goes away.

The same SELECT and row mapper existed in four copies; collapsed to one of
each first, so the dual read is written once rather than four times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:09:21 +02:00
17 changed files with 1370 additions and 271 deletions
+10 -23
View File
@@ -33,15 +33,15 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
| Layer | Location | | Layer | Location |
|---|---| |---|---|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` | | Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` | | Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | 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) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** One, since the calendar migration completed. **Auth model:** Two of them, on purpose.
*Admin module (`src/models/admin/`)*used by every app: calendar, feedback, tickets and the *Admin module (`src/models/admin/`)*the current one, used by feedback, tickets and the
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every 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 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 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 `.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
@@ -59,24 +59,11 @@ two and the last-admin guard stops guarding; and both write endpoints accept
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL` same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
makes sure someone can always get in on a fresh database. makes sure someone can always get in on a fresh database.
*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session *Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records get a session token (30-day window, hash + IP stored in the DB), passed as query
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`.
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`,
subscription and a signed-in editor. `MANAGEMENT_CREDENTIAL`) come from `.env`.
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 **Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`; `mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
+29 -15
View File
@@ -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 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 to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write. never be used to write.
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06, - The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a
along with the `users` and `sessions` tables they used - renamed aside rather than dropped, legacy session they mint no longer opens anything, but they are still live
so nothing was destroyed. password-accepting endpoints. Step 5 removes them.
--- ---
@@ -43,23 +43,37 @@ Currently any account holding the `calendar` permission can edit, move, or delet
--- ---
## 3. Activation token has no expiry — CLOSED 2026-09-06 ## 3. Activation token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted. > **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`.
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by **File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
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.
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
account, which no longer opens anything.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
--- ---
## 4. Password reset token has no expiry — CLOSED 2026-09-06 ## 4. Password reset token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted. > **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.
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer **File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
+55 -27
View File
@@ -1,13 +1,8 @@
-- Local dev only. Derived from the real schema, which was provided directly by -- Local dev only. Real schema, provided directly by the repo owner
-- the repo owner (calendars, events, event_versions, and the sessions/users -- (calendars, events, event_versions, sessions, users) - not a guess.
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside). -- 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
-- There is no users or sessions table here: a fresh dev database has no legacy -- after every migration has been applied. Keep the two in step.
-- 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; USE nachklang_calendar;
CREATE TABLE `calendars` ( CREATE TABLE `calendars` (
@@ -17,20 +12,47 @@ CREATE TABLE `calendars` (
PRIMARY KEY (`calendar_id`) PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) 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` ( CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT, `event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL, `calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL, `uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(), `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. -- 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, `created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Creator name, archived before the legacy users table went away; -- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
`created_by_name` varchar(255) DEFAULT NULL, `created_by_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`event_id`), PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_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`), 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_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` ( CREATE TABLE `event_versions` (
@@ -44,6 +66,7 @@ CREATE TABLE `event_versions` (
`repeat_frequency` text DEFAULT NULL, `repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL, `location` text DEFAULT NULL,
`url` 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. -- 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, `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. -- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
@@ -52,8 +75,10 @@ CREATE TABLE `event_versions` (
`version_created_at` datetime DEFAULT current_timestamp(), `version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`), PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_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`), 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_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
@@ -63,17 +88,20 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(4, 'choir', '[]'), (4, 'choir', '[]'),
(5, 'birthdays', '[]'); (5, 'birthdays', '[]');
-- Two events carry only an archived creator name, as every pre-cutover event -- Dev admin, password: devpassword
-- does, and one carries an admin user id whose name is resolved live. Dev INSERT INTO users (email, password_hash, full_name, is_active) VALUES
-- therefore exercises both name sources rather than only one. The one with an ('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
-- 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');
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 -- Two rows are left on the legacy path and one carries an admin user id, so
(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'), -- dev exercises both branches of the step 3 dual-read rather than only the
(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), -- happy one. It is deliberately a PUBLIC event, so the anonymous listing the
(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'); -- 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');
+55 -68
View File
@@ -1,22 +1,13 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
decision and part of step 5 brought forward; the rest went out as written. decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
left to write.
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit > Read the deploy checklist under step 4 before applying anything. "Done" below means the
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that > code exists on a branch, **not** that production has it - and in particular production has
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and > none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it. > built on top of a step 4 that has not been deployed and watched.
Verified in production after the final deploy: the public calendar answers anonymously on all
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
`created_by_name` snapshot, since the table it was copied from no longer exists under that
name. That last one is the whole reason part of step 5 was brought forward.
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the 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 nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -166,66 +157,62 @@ Each step is meant to leave production working on its own.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather **One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected. than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it 5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been
was. 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.
What went: **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.
- `src/models/calendar/users/` in its entirety - registration, login, activation, both Code, in one branch:
password-reset routes, and the session checking the feedback and tickets admin areas
used to authenticate against - plus its mount in `Calendar.router.ts`. That was the
API's last unauthenticated account-creation and mail-sending endpoint.
- The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*`
aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the
row mapper and the `Event` interface. One name source remains besides the live admin
lookup: the snapshot, which is what made this safe.
- `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them
since the tickets and feedback cutover, or sent them since this one.
- The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a
calendar header session. There is nothing left to fall back to.
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring) - **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`,
close with it - not by adding expiries but by deleting the code that issued them. `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.
### Deploy checklist — kept for the record; the order was REVERSED from step 4 Database, as `sql/calendar/004_*.sql`:
Step 4's migration only added columns, so it went first. `004` *removes* columns and a - Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`,
table that the currently running build still selects and joins, so running it first fails then the `created_by_id` and `version_created_by_id` columns.
every calendar read including the public feed. The step 5 build references none of them and - **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename
runs happily against the old schema. Therefore: 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).
1. **Confirm the snapshot is complete.** Both must return 0: Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and
```sql **4** (password reset token has no expiry) close outright - both describe code that ceases
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events; to exist. Item 2 (no event ownership check) stays open.
SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
```
A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first.
2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read
`createdById` (its `Event` model has only the name), and nothing else is known to.
3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save.
At this point the old columns and tables still exist, unused, so this step is fully
reversible by redeploying the previous build.
Note that `tsc` does not remove output for deleted sources, so a build over an existing Two consequences to accept explicitly rather than discover:
`dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the
routes 404, but the deployed artifact still contains the code - clear `dist/` in the
pipeline if you want the artifact to match the source.
4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for
the columns; the accounts themselves are only renamed aside.
5. Optionally, later and at a quiet moment:
`DROP TABLE sessions_legacy_archive, users_legacy_archive;`
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check - Any activation or password-reset e-mail already sent points at
anything reading `/calendar/events/*/json` that is not the calendar frontend. `api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever
valid for legacy accounts, which no longer open anything.
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover* - `Event.createdById` disappears from the API response. The Angular frontend never read it
Angular bundle looked signed in and showed every event's status as "Error". The old bundle (its `Event` model has only `createdBy`, the name), so this is not a breaking change for
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5, the only known consumer - but it is a wire-format removal, so check anything else that
so it rendered as authenticated - then fetched events with no cookie, got the anonymous reads `/calendar/events/*/json` first.
listing, which omits `status`, and the UI's status switch fell through to its error label.
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
fails honestly instead of faking a session. This is the same "does not look broken" window
the step 4 checklist warns about, seen from the other side.
## What the code actually looks like (surveyed 2026-09-06) ## What the code actually looks like (surveyed 2026-09-06)
-60
View File
@@ -1,60 +0,0 @@
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
-- Apply manually against the CALENDAR_DB database, after 003:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
--
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
--
-- This is the opposite order from the step 4 cutover, and getting it wrong by
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
-- was safe ahead of the deploy. This one removes columns and a table that the
-- currently running build still selects and joins - applying it first fails
-- every calendar read, including the anonymous public feed the website uses.
-- The step 5 build touches none of them, so it runs happily against the old
-- schema; deploy it, confirm the calendar works, then run this.
--
-- Nothing here loses information that is still reachable: the creators' display
-- names were snapshotted into events.created_by_name and
-- event_versions.version_created_by_name by migration 002, and the step 4
-- runbook re-ran that backfill after the deploy. Verify before running:
--
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
--
-- Both must be 0. A non-zero count is an event whose author this migration
-- would erase; re-run 002's backfill first.
-- The foreign keys have to go before the columns they are declared on.
-- IF EXISTS so that a re-run after a partial failure gets past them.
ALTER TABLE `events`
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
ALTER TABLE `event_versions`
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
ALTER TABLE `events`
DROP INDEX IF EXISTS `events_users_user_id_fk`,
DROP COLUMN IF EXISTS `created_by_id`;
ALTER TABLE `event_versions`
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
DROP COLUMN IF EXISTS `version_created_by_id`;
-- The accounts themselves are renamed aside rather than dropped.
--
-- Nothing visible depends on them any more - the names are snapshotted, and no
-- code has referenced these tables since the step 4 cutover. But they still
-- hold e-mail addresses and password hashes, and a rename makes them
-- unreachable without destroying anything.
--
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
-- pair stays internally consistent whichever order they are renamed in.
-- Verified on MariaDB 11.
--
-- Unlike the statements above this is not re-runnable, and that is the safe
-- behaviour: a second run fails on a missing `sessions` rather than doing
-- anything. Drop them for real whenever you like, at a moment when nobody is
-- mid-deploy:
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
RENAME TABLE `users` TO `users_legacy_archive`;
+9 -6
View File
@@ -63,12 +63,15 @@ export const createApp = (): express.Application => {
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above. // 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+$/; 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({ app.use(cors({
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here // X-Session-* are no longer read by anything on this side, and no longer
// through the two cutovers so that a browser still holding a pre-cutover // sent by anything either: the tickets and feedback cutover took the last
// bundle got a clean 401 rather than a confusing CORS preflight failure. // two readers off them, and the calendar cutover removed the last legacy
// Nothing has read them since the first cutover and nothing has sent them // credential path in the API (its session used to travel in query
// since the second, so they came out with the rest of the legacy path. // parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed
allowedHeaders: ['Content-Type'], // 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'],
// The admin session lives in a cookie, so browsers must be allowed to send // The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work. // it cross-origin - this is what makes credentials: 'include' work.
credentials: true, credentials: true,
+2 -7
View File
@@ -5,6 +5,7 @@ import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js'; import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router.js'; import {eventsRouter} from './events/events.router.js';
import {usersRouter} from './users/users.router.js';
/** /**
* Router Definition * Router Definition
@@ -12,13 +13,7 @@ import {eventsRouter} from './events/events.router.js';
export const calendarRouter = express.Router(); export const calendarRouter = express.Router();
calendarRouter.use('/events', eventsRouter); 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.
*/
/** /**
+23 -3
View File
@@ -14,6 +14,7 @@
* - endDateTime * - endDateTime
* - createdDate * - createdDate
* - location * - location
* - createdById
* - url * - url
* - wholeDay * - wholeDay
* properties: * properties:
@@ -64,6 +65,15 @@
* type: string * type: string
* description: The name of the user who created the event * description: The name of the user who created the event
* example: "John Doe" * 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: * createdByUserId:
* type: string * type: string
* nullable: true * nullable: true
@@ -73,6 +83,14 @@
* type: string * type: string
* description: The name of the user who last modified the event * description: The name of the user who last modified the event
* example: "John Doe" * 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: * lastModifiedByUserId:
* type: string * type: string
* nullable: true * nullable: true
@@ -103,12 +121,14 @@ export interface Event {
createdDate: Date; createdDate: Date;
lastModifiedDate?: Date; lastModifiedDate?: Date;
location: string; location: string;
/** Display name of the creator: the live admin name when the id below /** Display name of the creator, from whichever id below resolved. */
* resolves, otherwise the name archived before the legacy users table was
* removed. See docs/calendar-auth-migration.md. */
createdBy?: string; 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; createdByUserId?: string | null;
lastModifiedBy?: string; lastModifiedBy?: string;
lastModifiedById?: number | null;
lastModifiedByUserId?: string | null; lastModifiedByUserId?: string | null;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
+45 -25
View File
@@ -9,35 +9,49 @@ import logger from '../../../middleware/logger.js';
dotenv.config(); dotenv.config();
/** /**
* How an event's creator is resolved, after step 5 of * Step 3 of docs/calendar-auth-migration.md: the dual read.
* docs/calendar-auth-migration.md removed the legacy path.
* *
* The creator is only ever rendered as a name - nothing authorises on it, there * An event records its creator twice - `created_by_id`, the legacy INT into
* is no "only the creator may edit" rule anywhere - which is why an unresolvable * the calendar database's own `users` table, and `created_by_user_id`, the
* one degrades to blank rather than to an error. * 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.
* *
* Two sources remain, weaker first: * 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.
* *
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar * That name has three possible sources, and they are tried weakest first:
* had its own `users` table. Migration 002 took it, migration 004 dropped
* the table it was taken from, and nothing has written it since. It exists
* so the authorship of pre-cutover events survived that removal.
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
* id. It wins, because it is the only one that follows a rename.
* *
* The third source - joining the calendar's own `users` table on * 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
* `created_by_id` - is gone with the table and the column. * 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 one SELECT the four read paths share; callers append their own WHERE and * The one SELECT the four read paths share. It was copied out four times
* ORDER BY. `v.*` carries the version row's own creator columns, so only the * before, which is precisely why the dual read had to be added in four
* `events` columns need naming. * places; callers append their own WHERE and ORDER BY.
* *
* There are no joins to a users table any more. There is no users table. * `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.
*/ */
const EVENT_SELECT = ` const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e 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
INNER JOIN ( INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions FROM event_versions
@@ -45,7 +59,9 @@ const EVENT_SELECT = `
) latest_versions ) latest_versions
ON e.event_id = latest_versions.event_id ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`; 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`;
/** /**
* Maps a result row to an Event. `status` is included only where it always * Maps a result row to an Event. `status` is included only where it always
@@ -64,11 +80,15 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
createdDate: row.created_date, createdDate: row.created_date,
lastModifiedDate: row.version_created_at, lastModifiedDate: row.version_created_at,
location: row.location, location: row.location,
// The archived name. resolveAdminNames below overwrites it for rows that // Name resolution, weakest first: the LEGACY join against the calendar
// carry an admin id, which is the only source that follows a rename. // users table, then the snapshot taken in migration 002, then - in
createdBy: row.created_by_name, // 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,
createdByUserId: row.created_by_user_id ?? null, createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name, lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
lastModifiedById: row.version_created_by_id,
lastModifiedByUserId: row.version_created_by_user_id ?? null, lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url, url: row.url,
wholeDay: row.whole_day, wholeDay: row.whole_day,
@@ -92,7 +112,7 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
* A failure here is swallowed on purpose. These endpoints include the public * A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the * calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever * admin database is unreachable, an event should still render with whatever
* the snapshot holds rather than 500 the whole listing. The alternative * the legacy join produced rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin * would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before. * database, which it has never depended on before.
*/ */
@@ -0,0 +1,53 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session {
sessionId: number;
userId: number;
sessionKey: string;
sessionKeyHash: string;
createdDate?: Date;
validUntil?: Date;
lastIP: string;
}
@@ -0,0 +1,42 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User {
userId: number;
fullName: string;
passwordHash: string;
email: string;
isActive: boolean;
}
+671
View File
@@ -0,0 +1,671 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.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
});
}
});
+296
View File
@@ -0,0 +1,296 @@
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<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email
// delivery). 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<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
+19 -8
View File
@@ -7,14 +7,9 @@ import express, {Request, Response} from 'express';
* the calendar users table; both are now one binding to the shared admin gate. * 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 * What is worth asserting is not how that gate works - admin.middleware.test.ts
* owns that - but that each module is bound to *its own* app. The mocks live in * owns that - but that each module is bound to *its own* app, and that neither
* the calling file because vi.mock is per-module-graph; only the assertions are * consults the calendar users service any more. The mocks live in the calling
* shared. * 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 { export interface BindingMocks {
@@ -22,6 +17,8 @@ export interface BindingMocks {
getSession: Mock; getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */ /** loadAccess from the mocked users.admin.service.js */
loadAccess: Mock; 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); const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
@@ -63,6 +60,7 @@ export const describeAdminBinding = (
m = mocks(); m = mocks();
m.getSession.mockReset(); m.getSession.mockReset();
m.loadAccess.mockReset(); m.loadAccess.mockReset();
m.checkSession.mockReset();
}); });
it('responds 401 and does not call next() without a session', async () => { it('responds 401 and does not call next() without a session', async () => {
@@ -103,5 +101,18 @@ export const describeAdminBinding = (
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'}); 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();
});
}); });
}; };
+49 -27
View File
@@ -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 * Step 3 of docs/calendar-auth-migration.md. The property under test is that
* an event's creator resolves from the stronger of its two remaining sources - * an event's creator resolves from whichever of its three possible sources is
* the live admin name, else the name archived before the legacy users table was * strongest - the live admin name, then the snapshot from migration 002, then
* removed - and that a failure to reach the admin database costs a name rather * the legacy join - and that a failure to reach the admin database costs a name
* than the whole response: the public calendar is read anonymously by the * rather than the whole response: the public calendar is read anonymously by
* website and has never depended on the admin database being up. * the website and has never depended on the admin database being up.
*/ */
// One row of the shape the shared SELECT produces. // One row of the shape the shared SELECT produces.
@@ -42,10 +42,14 @@ const row = (over: Record<string, unknown> = {}) => ({
created_date: new Date('2026-01-01T00:00:00Z'), created_date: new Date('2026-01-01T00:00:00Z'),
version_created_at: new Date('2026-01-02T00:00:00Z'), version_created_at: new Date('2026-01-02T00:00:00Z'),
location: '', location: '',
created_by_id: 7,
created_by_user_id: null, created_by_user_id: null,
created_by_name: 'Archived Person', created_by_name: null,
legacy_created_by_name: 'Legacy Person',
version_created_by_id: 7,
version_created_by_user_id: null, version_created_by_user_id: null,
version_created_by_name: 'Archived Person', version_created_by_name: null,
legacy_last_modified_by_name: 'Legacy Person',
url: '', url: '',
whole_day: 0, whole_day: 0,
repeat_frequency: '', repeat_frequency: '',
@@ -68,12 +72,13 @@ beforeEach(() => {
}); });
describe('creator names', () => { describe('creator names', () => {
it('uses the archived name when the row has no admin id', async () => { it('uses the legacy join when the row has no admin id', async () => {
givenEvents(row()); givenEvents(row());
const events = await EventService.getAllEvents(1); const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].createdBy).toBe('Legacy Person');
expect(events[0].createdById).toBe(7);
expect(events[0].createdByUserId).toBeNull(); expect(events[0].createdByUserId).toBeNull();
// Nothing to resolve, so the admin database is not touched at all. // Nothing to resolve, so the admin database is not touched at all.
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled(); expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
@@ -92,9 +97,23 @@ describe('creator names', () => {
expect(events[0].createdBy).toBe('Neue Person'); expect(events[0].createdBy).toBe('Neue Person');
expect(events[0].lastModifiedBy).toBe('Andere 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'); 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 () => { it('prefers the live admin name over the snapshot', async () => {
// A renamed account has to win over an archive that was correct when it // A renamed account has to win over an archive that was correct when it
// was taken - otherwise renaming someone would leave stale names behind. // was taken - otherwise renaming someone would leave stale names behind.
@@ -106,26 +125,29 @@ describe('creator names', () => {
expect(events[0].createdBy).toBe('Neue Person'); expect(events[0].createdBy).toBe('Neue Person');
}); });
it('falls back to the archived name when the admin account is gone', async () => { 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 () => {
givenEvents(row({created_by_user_id: 'deleted'})); givenEvents(row({created_by_user_id: 'deleted'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map()); (AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1); const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].createdBy).toBe('Legacy 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 () => { it('resolves a mixed result set in a single lookup', async () => {
@@ -138,7 +160,7 @@ describe('creator names', () => {
const events = await EventService.getAllEvents(1); const events = await EventService.getAllEvents(1);
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']); expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']);
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1); expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
}); });
@@ -150,8 +172,8 @@ describe('creator names', () => {
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0].name).toBe('Konzert'); expect(events[0].name).toBe('Konzert');
// Degrades to the archived name rather than failing the request. // Degrades to the legacy name rather than failing the request.
expect(events[0].createdBy).toBe('Archived Person'); expect(events[0].createdBy).toBe('Legacy Person');
}); });
}); });
+6 -1
View File
@@ -1,5 +1,8 @@
import {vi, type Mock} from 'vitest'; 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', () => ({ vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}} auth: {api: {getSession: vi.fn()}}
})); }));
@@ -7,6 +10,7 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn() loadAccess: vi.fn()
})); }));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {auth} from '../../src/models/admin/admin.auth.js'; import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js'; import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
@@ -14,5 +18,6 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({ describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock, getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock loadAccess: UsersService.loadAccess as Mock,
checkSession: UserService.checkSession as Mock
})); }));
+6 -1
View File
@@ -1,5 +1,8 @@
import {vi, type Mock} from 'vitest'; 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', () => ({ vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}} auth: {api: {getSession: vi.fn()}}
})); }));
@@ -7,6 +10,7 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn() loadAccess: vi.fn()
})); }));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {auth} from '../../src/models/admin/admin.auth.js'; import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js'; import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js';
@@ -14,5 +18,6 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({ describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock, getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock loadAccess: UsersService.loadAccess as Mock,
checkSession: UserService.checkSession as Mock
})); }));