8 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
Paddy 3c892d02ed Put the feedback and tickets admin areas behind the shared identity (#13)
Jenkins Production Deployment
Reviewed-on: #13
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 19:06:30 +00:00
Paddy bf7be65b03 Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
2026-09-06 10:41:54 +00:00
Paddy ce9b173c71 Merge pull request 'Document dotenv 16 quoting rule for .env values' (#11) from feature/api-esm-prep into master
Reviewed-on: #11
2026-09-05 14:13:29 +00:00
Paddy 10c459db0f Merge pull request 'Migrate the API to native ESM and vitest; pin Node 26' (#10) from feature/api-esm-prep into master
Jenkins Production Deployment
Reviewed-on: #10
2026-09-05 14:05:13 +00:00
34 changed files with 1780 additions and 769 deletions
+19 -6
View File
@@ -5,15 +5,28 @@ These items were identified during a security review on 2026-05-02 and conscious
--- ---
## 1. Session credentials in URL query parameters (logged-in users) ## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers **Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers. `sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
server access logs, browser history, proxy logs, and `Referer` headers.
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update. **Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
closed the item outright rather than moving the credential somewhere safer.
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup. Two things this did *not* change, both deliberate:
- The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
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.
--- ---
@@ -24,9 +37,9 @@ These items were identified during a security review on 2026-05-02 and conscious
- `PUT /move/:eventId` (move) - `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete) - `DELETE /:eventId` (delete)
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins. Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check. **Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation`createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
--- ---
+27 -9
View File
@@ -1,5 +1,8 @@
-- Local dev only. Real schema, provided directly by the repo owner -- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess. -- (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.
USE nachklang_calendar; USE nachklang_calendar;
CREATE TABLE `calendars` ( CREATE TABLE `calendars` (
@@ -38,10 +41,16 @@ CREATE TABLE `events` (
`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(),
`created_by_id` int(11) NOT NULL, -- 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.
`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_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_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_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;
@@ -58,11 +67,16 @@ CREATE TABLE `event_versions` (
`location` text DEFAULT NULL, `location` text DEFAULT NULL,
`url` text DEFAULT NULL, `url` text DEFAULT NULL,
`version_created_by_id` int(11) 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.
`version_created_by_name` varchar(255) DEFAULT NULL,
`status` text DEFAULT NULL, `status` text DEFAULT NULL,
`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_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_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_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;
@@ -78,12 +92,16 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
INSERT INTO users (email, password_hash, full_name, is_active) VALUES INSERT INTO users (email, password_hash, full_name, is_active) VALUES
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1); ('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES -- Two rows are left on the legacy path and one carries an admin user id, so
(1, UUID(), 1), -- dev exercises both branches of the step 3 dual-read rather than only the
(1, UUID(), 1), -- happy one. It is deliberately a PUBLIC event, so the anonymous listing the
(1, UUID(), 1); -- 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) VALUES 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), (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), (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); (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');
+225 -34
View File
@@ -1,8 +1,18 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **not started.** Written 2026-09-05 alongside the admin module (step 2 of Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
`docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
calendar alone. left to write.
> 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.
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
are now live, so the calendar is the last module still on the legacy query-parameter
sessions.
## Why the calendar was left out ## Why the calendar was left out
@@ -38,37 +48,218 @@ permissions can be granted before anything else moves.
Each step is meant to leave production working on its own. Each step is meant to leave production working on its own.
1. **Add a bridging column.** `ALTER TABLE events ADD COLUMN created_by_user_id 1. **Add a bridging column.** ~~`ALTER TABLE events ADD COLUMN created_by_user_id
VARCHAR(36) NULL`, indexed. Nothing reads it yet. VARCHAR(36) NULL`, indexed. Nothing reads it yet.~~ **Done 2026-09-06**, as
2. **Map the accounts.** For every legacy `users` row that should survive, invite the `sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the
person through the admin UI. On acceptance, backfill `events.created_by_user_id` from calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both
`events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps `events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries
working on the legacy path until step 4. no foreign key (see "What the code actually looks like" below). The dev seed leaves two
3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back events on the legacy path and gives one an admin id, so step 3's dual-read has both cases
to `created_by_id`. Writes fill both. This is the only step that is temporary code, and to exercise. Verified by applying the pre-migration schema and then the migration to a
it should carry a removal note pointing at step 5. throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev
4. **Switch the routes.** Replace the query-parameter session checks in schema: identical. Applied to the running dev database on the same day; a dev container
`events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change created before then needs it applied, or recreating.
the Angular frontend to `withCredentials: true` against the same origin list. Deploy the 2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the
API first; the calendar frontend is broken between the two deploys, so pick a quiet creator is only ever a display name (see below), old events keep resolving through the
time. This closes `DEFERRED_SECURITY.md` item 1. legacy join until step 5 and then simply lose the name. Re-inviting the people who
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions` actually still need calendar access remains an operational task, but it is no longer a
table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays migration step and nothing is blocked on it.
only if something still calls it - otherwise delete it too. `X-Session-Id` / 3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back
`X-Session-Key` can then come out of the CORS `allowedHeaders` list in to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads
`src/app.factory.ts`. both columns and prefers the admin one, resolving the name through a single
`findDisplayNames` lookup against the admin database per result set (added to
`users.admin.service.ts` for this). Four copies of the same SELECT and four copies of the
row mapper were collapsed into one of each first - the dual read would otherwise have had
to be written four times.
A name now has three possible sources, tried weakest first: the legacy join, then the
`created_by_name` snapshot from migration 002, then the live admin lookup - which wins
because it is the only one that follows an account being renamed. An admin id that no
longer resolves falls back rather than blanking, and a failure to reach the admin database
is caught and logged rather than propagated, so an anonymous read of the public calendar
never depends on the admin database being up. Covered by
`test/calendar/events.service.test.ts`.
**Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
no route reads `sessionId`/`sessionKey` any more.
How it came out, route by route:
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
anything created between the two migrations. Applying it early is safe - widening a
column to accept NULL cannot break the running pre-cutover build.
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
### Deploy checklist
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
every read, so deploying it against a database missing them fails every calendar request
including the anonymous public feed the website uses. In order:
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
three are re-runnable, so applying one that is already applied is a no-op. Verify
before continuing:
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
- four rows across the two tables, and `created_by_id` nullable.
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
default list, but the environment variable *replaces* that list rather than adding to
it - so if it is set at all (the tickets/feedback cutover may have set it), append
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
else works. That is the failure mode the comment in `admin.config.ts` warns about.
3. **Deploy the API.**
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
their author at step 5.
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
does nothing.
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
the user then sees is an empty event table and saves that silently do nothing. It looks
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
or take the frontend offline for it.
**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.
**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.
Code, in one branch:
- **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.
Database, as `sql/calendar/004_*.sql`:
- 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).
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.
Two consequences to accept explicitly rather than discover:
- 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.
## What the code actually looks like (surveyed 2026-09-06)
Four things found while doing step 1 that change how the later steps should be built:
- **`created_by_id` is display-only.** Nothing authorises on it. `events.router.ts` gates
PUT, POST, DELETE and `/move` on `user?.isActive` alone - there is no "only the creator may
edit" rule anywhere - and the column is read back solely to render `created_by_name` and
`last_modified_by_name`. That de-risks steps 2, 3 and 5 considerably: an event whose
creator never gets re-invited loses a name in the UI, it does not become uneditable or
invisible. It also means the step 2 backfill is best-effort, not a precondition.
- **The two schemas are separate databases.** `nachklang_calendar` and `nachklang_admin`
have their own connection pools (`Calendar.db.ts` vs the admin module's Kysely instance).
So the bridging columns get no foreign key, and - the part the original sequence missed -
**the `LEFT OUTER JOIN users` that produces the creator's name cannot simply be repointed**.
It would have to become a cross-schema join, which hardcodes the admin database name into
calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for
step 3: drop the join for the new path and resolve names in the service layer instead -
collect the distinct ids from the result set and do one lookup against the admin users
service. One extra query per listing, no coupling, and it keeps working if the admin
database ever moves.
- **`events.created_by_id` is `NOT NULL`.** Step 5 cannot simply stop writing it; that step
has to drop the column (and its FK to `users`) in the same migration that stops the writes,
or make it nullable first.
- **Every calendar read already hits the session table.** `/:calendar/json` calls
`UserService.checkSession` before falling back to `credentials.service.ts`, so the shared
credentials are the *fallback*, not the primary path. Step 4 replaces the first half of
that with `requireAppAccess('calendar')` and has to decide what happens to the second half
- which is the first open question below.
## Open questions to settle before starting ## Open questions to settle before starting
- **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a **Settled 2026-09-06:**
separate mechanism (they serve people with no account at all, and iCal clients that
cannot send headers), or do read-only accounts replace them? This is a product decision, - **The shared calendar credentials keep working, but only for iCal.** The web app goes
not a technical one, and it decides how much of `credentials.service.ts` survives. cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on
- **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query `GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely
string on purpose, because iCal clients cannot send headers. Cookie sessions do not help cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5.
here; this endpoint likely keeps its own scheme. `public` stays anonymous everywhere - see the note under step 4.
- **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no - **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared
longer need access. credentials survive at all rather than an exception to their removal.
- **`event_versions.version_created_by_id`.** The same INT reference again, joined in - **No account backfill.** See step 2 above.
`events.service.ts` for the "last modified by" name. It has to move with `events`, and it - **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and
is the reason step 1's bridging column needs a sibling on `event_versions`. `event_versions.version_created_by_name`, backfilled once by migration 002 and never
written again. This was originally listed as a step 5 question; it was brought forward so
the data is safe well before the table that holds it is dropped.
**Nothing is open.** The last one - ~~`event_versions.version_created_by_id`~~, the same INT
reference on the version rows - was handled in passing: step 1 gave it a sibling bridging
column, step 2 a sibling snapshot, and step 3 reads it exactly like `events`.
@@ -0,0 +1,38 @@
-- Nachklang e.V. Calendar module — step 1 of docs/calendar-auth-migration.md.
-- Adds the bridging columns that let an event record who created it as an
-- *admin* user id (VARCHAR(36)) alongside the legacy calendar users.user_id
-- (INT). Apply manually against the CALENDAR_DB database:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 001_add_admin_user_bridge.sql
--
-- Numbered 001 because this is the first migration this repo owns for the
-- calendar schema: the tables themselves predate it and were provided by the
-- repo owner (mirrored for dev in docker/init/01-calendar-schema-dev.sql).
--
-- Nothing reads these columns yet — step 3 introduces the dual-read. Adding
-- them first means the backfill in step 2 has somewhere to write, and this
-- migration can be applied to production on its own without any code change.
--
-- No foreign key, on purpose. The admin `user` table lives in a *different*
-- database (nachklang_admin) behind a different connection pool, and a
-- cross-schema FK would tie the two schemas' lifecycles together: you could no
-- longer dump, restore or move one without the other. The reference is
-- enforced in application code, which is also where the legacy/new fallback
-- lives.
--
-- The collation is pinned to the admin database's (utf8mb4_unicode_ci) rather
-- than inherited from the calendar tables' utf8mb4_general_ci. These columns
-- hold ids that only ever compare against nachklang_admin.user.id, and a
-- mismatched collation makes any such comparison fail at runtime with
-- "Illegal mix of collations" instead of at review time.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `created_by_id`,
ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `version_created_by_id`,
ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
@@ -0,0 +1,42 @@
-- Nachklang e.V. Calendar module — step 5 preparation, brought forward.
-- Apply manually against the CALENDAR_DB database, after 001:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 002_snapshot_legacy_creator_names.sql
--
-- Snapshots the creator's and last editor's *name* onto the event itself.
--
-- Why: the creator is only ever rendered as a name (nothing authorises on it),
-- and today that name comes from joining the calendar's own `users` table.
-- Step 5 drops that table, which would silently erase the authorship of every
-- event created before the cutover. There is no account backfill to save them
-- either - that was dropped deliberately, see docs/calendar-auth-migration.md.
-- One text column per reference keeps the history at no ongoing cost.
--
-- These columns are an archive, not a source of truth. Nothing writes them
-- after this backfill: events created from the cutover onwards carry an admin
-- user id, whose name is resolved live so that renaming an account updates
-- everywhere. The read path prefers the live admin name, falls back to this
-- snapshot, and falls back again to the join until step 5 removes it.
--
-- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
-- only touches rows with no snapshot yet. Step 4's migration re-runs the
-- backfill, to catch anything created between this migration and the cutover.
--
-- No charset clause: unlike 001's id columns these hold display text that is
-- only ever compared against other calendar data, so they inherit the tables'
-- utf8mb4_general_ci like the columns they are copied from.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
@@ -0,0 +1,32 @@
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
-- and BEFORE deploying the API build that goes with it:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
--
-- From the cutover on, an event's creator is an admin-module user id. There is
-- no legacy calendar user id to write any more, and `events.created_by_id` is
-- NOT NULL - so without this the very first event created after the deploy
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
--
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
-- until step 5 drops the column and the table together.
--
-- Applying this early is harmless. Widening a column to accept NULL cannot
-- break the running pre-cutover build, which always supplies a value, so this
-- can go out ahead of the deploy rather than during it.
ALTER TABLE `events`
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
-- Re-run of 002's backfill, to catch anything created between the two
-- migrations while the legacy path was still writing events. Idempotent by
-- construction: it only touches rows that have no snapshot yet.
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
+8 -2
View File
@@ -63,8 +63,14 @@ 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({
// X-Session-* stay allowed until the calendar module is migrated off the // X-Session-* are no longer read by anything on this side, and no longer
// legacy header sessions (see docs/calendar-auth-migration.md). // 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'], 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.
+5 -1
View File
@@ -33,7 +33,11 @@ const localhostOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001', 'http://localhost:3001',
'http://localhost:3002', 'http://localhost:3002',
'http://localhost:3003' 'http://localhost:3003',
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
// this list, sign-out from the calendar answers 403 in dev only, which is a
// confusing thing to debug against a production config that is fine.
'http://localhost:4200'
]; ];
const trustedOrigins = isProd const trustedOrigins = isProd
+23 -2
View File
@@ -73,8 +73,29 @@ const parseList = (value: string | undefined, fallback: string[]): string[] => {
return parsed.length > 0 ? parsed : fallback; return parsed.length > 0 ? parsed : fallback;
}; };
// The apps whose frontends may talk to /admin/* with credentials. /**
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, '')); * The apps whose frontends may talk to /admin/* with credentials.
*
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and
* feedback admin areas call /admin/auth/sign-out from their own origin. That
* became load-bearing with the step 4 cutover: before it, the only browser
* origin that ever reached /admin/auth was the admin app itself. The calendar
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
*
* Hence the production default rather than an empty list. An origin missing
* here fails in a way that is easy to misread - sign-in works, the app works,
* and only sign-out returns an origin error - so the two frontends we know
* about are named here and APP_ORIGINS overrides them for a staging host.
* Dev adds the localhost ports separately (see admin.auth.ts).
*/
const DEFAULT_APP_ORIGINS = [
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
];
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
.map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin // Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports. // app itself always counts, and dev adds the local ports.
@@ -437,3 +437,30 @@ export const findUserByEmail = async (email: string): Promise<{id: string; email
return row ?? null; return row ?? null;
}; };
/**
* Display names for a set of user ids, as an id -> name map. Ids that no
* longer exist are simply absent from the map rather than mapping to a
* placeholder, so callers can distinguish "deleted account" from "never had
* one" and choose their own fallback.
*
* This exists for the calendar migration (docs/calendar-auth-migration.md
* step 3): the calendar lives in a different database, so it cannot join
* against `user` to render "created by". One lookup per result set keeps that
* cheap without coupling the two schemas.
*/
export const findDisplayNames = async (ids: readonly string[]): Promise<Map<string, string>> => {
const distinct = Array.from(new Set(ids.filter(id => id)));
if (distinct.length === 0) {
// Kysely renders `in ()` for an empty list, which MariaDB rejects.
return new Map();
}
const rows = await db
.selectFrom('user')
.select(['id', 'name'])
.where('id', 'in', distinct)
.execute();
return new Map(rows.map(row => [row.id, row.name]));
};
@@ -1,73 +1,55 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service.js';
dotenv.config(); dotenv.config();
/** /**
* Checks if the password gives admin privileges (view / create / edit / delete) * The shared calendar passwords, and nothing else.
* @param password *
* Before the step 4 cutover each function here also took a sessionId/sessionKey
* pair and checked it against the calendar's own sessions table, so "is this a
* signed-in user?" and "did they send the right shared password?" were tangled
* together in five places. Signed-in access is now decided by
* requireAppAccess('calendar') before the handler runs; what is left is the
* fallback for people who have no account at all.
*
* That fallback survives on purpose, for one reason: an iCal client subscribing
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
* through the session cookie instead. See docs/calendar-auth-migration.md.
*
* `public` is deliberately open to everyone with no credential of any kind -
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
* test/calendar/credentials.service.test.ts.
*/ */
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return false;
}
/** const credentialFor = (calendarName: string): string | undefined => {
* Checks if the password gives member view privileges
* @param password
*/
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MEMBER_CREDENTIAL;
}
/**
* Checks if the password gives choir view privileges
* @param password
*/
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.CHOIR_CREDENTIAL;
}
/**
* Checks if the password gives management view privileges
* @param password
*/
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MANAGEMENT_CREDENTIAL;
}
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
switch (calendarName) { switch (calendarName) {
case 'public':
return true;
case 'members': case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip); return process.env.MEMBER_CREDENTIAL;
case 'choir': case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip); case 'birthdays':
return process.env.CHOIR_CREDENTIAL;
case 'management': case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip); return process.env.MANAGEMENT_CREDENTIAL;
case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
default: default:
return false; return undefined;
} }
} };
/**
* Whether the given shared password opens the given calendar. Answers false
* for an unknown calendar, and - importantly - for a calendar whose credential
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
* "everyone with an empty password gets in".
*/
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
if (calendarName === 'public') {
return true;
}
const expected = credentialFor(calendarName);
if (!expected) {
return false;
}
return password === expected;
};
+28 -4
View File
@@ -67,16 +67,35 @@
* example: "John Doe" * example: "John Doe"
* createdById: * createdById:
* type: integer * type: integer
* description: The ID of the user who created the event * 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 * example: 456
* createdByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the creator, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* lastModifiedBy: * lastModifiedBy:
* 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: * lastModifiedById:
* type: integer * type: integer
* description: The ID of the user who last modified the event * deprecated: true
* nullable: true
* description: >
* The legacy calendar user id of the last editor. Being replaced
* by lastModifiedByUserId.
* example: 456 * example: 456
* lastModifiedByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the last editor, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* url: * url:
* type: string * type: string
* description: A URL with more information about the event * description: A URL with more information about the event
@@ -102,10 +121,15 @@ export interface Event {
createdDate: Date; createdDate: Date;
lastModifiedDate?: Date; lastModifiedDate?: Date;
location: string; location: string;
/** Display name of the creator, from whichever id below resolved. */
createdBy?: string; createdBy?: string;
createdById: number; 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; lastModifiedBy?: string;
lastModifiedById?: number; lastModifiedById?: number | null;
lastModifiedByUserId?: string | null;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
repeatFrequency: string; repeatFrequency: string;
+201 -190
View File
@@ -7,7 +7,7 @@ import {Event} from './event.interface.js';
import * as EventService from './events.service.js'; import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js'; import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js'; import * as CredentialService from './credentials.service.js';
import * as UserService from '../users/users.service.js'; import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
@@ -29,6 +29,44 @@ export const calendarNames = new Map<string, any>([
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}] ['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
]); ]);
/**
* The gate on everything that writes. Step 4 of
* docs/calendar-auth-migration.md replaced a sessionId/sessionKey pair in the
* query string (DEFERRED_SECURITY.md item 1) with the same session cookie the
* other three apps use, and "any activated @nachklang.art account" with an
* explicit per-user calendar permission.
*/
const requireCalendarAccess = requireAppAccess('calendar');
/** Set by requireCalendarAccess; the writer's admin identity. */
const adminOf = (res: Response): AdminAccess => res.locals.admin as AdminAccess;
/**
* Resolves a signed-in calendar user for the *read* routes, or null.
*
* Reads cannot use the middleware: the same URL serves an anonymous visitor
* (the public calendar the website polls), someone holding a shared password
* (an iCal subscription), and a signed-in editor who should see drafts. So it
* answers "who is this, if anyone?" instead of refusing the request, and each
* handler decides what that means.
*
* A failure to reach the admin database is swallowed for the same reason the
* name lookup in events.service.ts swallows one: it must not be able to take
* the anonymous public calendar down.
*/
const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
try {
const access = await resolveAccess(req);
if (!access || access.disabled || !access.apps.includes('calendar')) {
return null;
}
return access;
} catch (e: any) {
logger.warn('Calendar: could not resolve the session, continuing as anonymous: ' + e.message);
return null;
}
};
/** /**
* Controller Definitions * Controller Definitions
@@ -39,7 +77,10 @@ export const calendarNames = new Map<string, any>([
* /calendar/events/{calendar}/json: * /calendar/events/{calendar}/json:
* get: * get:
* summary: Get all events from a specific calendar in JSON format * summary: Get all events from a specific calendar in JSON format
* description: Returns all events from the specified calendar in JSON format. Authentication required. * description: >
* Returns the calendar's events. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission - which
* also unlocks drafts - or the calendar's shared password.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -48,23 +89,13 @@ export const calendarNames = new Map<string, any>([
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -109,10 +140,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -126,23 +154,19 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
let user = await UserService.checkSession(sessionId, sessionKey, ip); const editor = await signedInEditor(req);
// If no user was found, check if the password gives access to the calendar // Not signed in: fall back to the shared password for this calendar.
if(user === null || !user.isActive) { if (!editor && ! await CredentialService.hasAccess(calendarName, password)) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { res.status(403).send({'message': 'You do not have access to the specified calendar.'});
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); return;
return;
}
} }
let events: Event[]; // Editors get the admin view (drafts included, calendar includes ignored);
// everyone else gets published events only.
if(user?.isActive) { let events: Event[] = editor
events = await EventService.getAllEventsAdmin(calendarId); ? await EventService.getAllEventsAdmin(calendarId)
} else { : await EventService.getAllEvents(calendarId);
events = await EventService.getAllEvents(calendarId);
}
// Send the events back // Send the events back
res.status(200).send(events); res.status(200).send(events);
@@ -158,7 +182,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next: * /calendar/events/{calendar}/json/next:
* get: * get:
* summary: Get the next upcoming event from a calendar * summary: Get the next upcoming event from a calendar
* description: Returns the next upcoming event from the specified calendar. Authentication required. * description: >
* The next upcoming event. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission or the
* calendar's shared password.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -167,23 +194,13 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get the next event from * description: The name of the calendar to get the next event from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -242,10 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -259,7 +273,19 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { // Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
//
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -290,7 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical: * /calendar/events/{calendar}/ical:
* get: * get:
* summary: Get all events from a specific calendar in iCal format * summary: Get all events from a specific calendar in iCal format
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required. * description: >
* The calendar in iCal format. The public calendar is open to everyone; the
* others take the calendar's shared password in the query string, which is
* why that mechanism survives - an iCal client cannot send a cookie.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -299,23 +328,13 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true * required: true
* schema: * schema:
* type: string * type: string
* enum: [public, members, choir, management] * enum: [public, members, choir, management, birthdays]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success - returns iCal file * description: Success - returns iCal file
@@ -365,10 +384,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -382,7 +398,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { // Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
//
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -413,22 +441,11 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* /calendar/events: * /calendar/events:
* post: * post:
* summary: Create a new event * summary: Create a new event
* description: Creates a new event in the specified calendar. Authentication required. * description: Creates a new event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* parameters: * security:
* - in: query * - AdminSessionCookie: []
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -495,16 +512,32 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to create events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -522,19 +555,9 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.post('/', async (req: Request, res: Response) => { eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.body.calendarId === undefined || req.body.calendarId === undefined ||
@@ -556,7 +579,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -585,9 +610,11 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* put: * put:
* summary: Update an existing event * summary: Update an existing event
* description: Updates an existing event with the provided data. Authentication required. * description: Updates an existing event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -595,18 +622,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to update * description: The ID of the event to update
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -639,9 +654,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -673,16 +685,32 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to update events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -700,19 +728,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -735,8 +753,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', // LEGACY createdById is deliberately not set: there is no calendar
createdById: user.userId ?? -1, // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -768,9 +787,11 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* /calendar/events/move/{eventId}: * /calendar/events/move/{eventId}:
* put: * put:
* summary: Move an event to a different calendar * summary: Move an event to a different calendar
* description: Moves an existing event to a different calendar. Authentication required. * description: Moves an event to a different calendar. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -778,18 +799,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to move * description: The ID of the event to move
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -820,9 +829,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* location: * location:
* type: string * type: string
* example: "Musikhochschule, Karlsruhe" * example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url: * url:
* type: string * type: string
* example: "https://www.nachklang.art/events/concert" * example: "https://www.nachklang.art/events/concert"
@@ -854,16 +860,32 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to move events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -881,19 +903,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -913,8 +925,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', // LEGACY createdById is deliberately not set: there is no calendar
createdById: user.userId ?? -1, // user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -944,9 +957,11 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* delete: * delete:
* summary: Delete an event * summary: Delete an event
* description: Deletes an existing event. Authentication required. * description: Deletes an event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -954,18 +969,6 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to delete * description: The ID of the event to delete
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* responses: * responses:
* 200: * 200:
* description: Event deleted successfully * description: Event deleted successfully
@@ -987,16 +990,32 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to delete events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -1014,19 +1033,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => { eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined req.params.eventId === undefined
@@ -1046,7 +1055,9 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(), createdDate: new Date(),
location: '', location: '',
createdBy: '', createdBy: '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: '', url: '',
wholeDay: false, wholeDay: false,
repeatFrequency: '', repeatFrequency: '',
+182 -157
View File
@@ -2,28 +2,56 @@ import * as dotenv from 'dotenv';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import {Event} from './event.interface.js'; import {Event} from './event.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js'; import {NachklangCalendarDB} from '../Calendar.db.js';
import * as AdminUsersService from '../../admin/users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
dotenv.config(); dotenv.config();
/** /**
* Returns all events for the given calendar * Step 3 of docs/calendar-auth-migration.md: the dual read.
* @param calendarId The calendar Id *
* 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 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.
*
* That name has three possible sources, and they are tried weakest first:
*
* 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.
*/ */
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const eventsQuery = ` /**
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e * 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.
*
* `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 = `
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
@@ -33,34 +61,124 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
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 u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
for (let row of eventsRes) { /**
eventRows.push({ * Maps a result row to an Event. `status` is included only where it always
eventId: row.event_id, * was: the admin views and the by-id lookup return it, the two public listings
calendarId: row.calendar_id, * do not.
uuid: row.uuid, */
name: row.name, const toEvent = (row: any, includeStatus: boolean): Event => {
description: row.description, const event: Event = {
startDateTime: row.start_datetime, eventId: row.event_id,
endDateTime: row.end_datetime, calendarId: row.calendar_id,
createdDate: row.created_date, uuid: row.uuid,
lastModifiedDate: row.version_created_at, name: row.name,
location: row.location, description: row.description,
createdBy: row.created_by_name, startDateTime: row.start_datetime,
createdById: row.created_by_id, endDateTime: row.end_datetime,
lastModifiedBy: row.last_modified_by_name, createdDate: row.created_date,
lastModifiedById: row.version_created_by_id, lastModifiedDate: row.version_created_at,
url: row.url, location: row.location,
wholeDay: row.whole_day, // Name resolution, weakest first: the LEGACY join against the calendar
repeatFrequency: row.repeat_frequency // 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,
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,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
};
if (includeStatus) {
event.status = row.status;
}
return event;
};
/**
* Fills in creator/editor names for rows that carry an admin user id, by way
* of a single lookup against the admin database. The calendar cannot join
* against `user` - it is a different schema behind a different pool - and
* making it one would tie the two schemas together as tightly as a foreign key
* would.
*
* 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
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/
const resolveAdminNames = async (events: Event[]): Promise<void> => {
const ids = events
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
.filter((id): id is string => Boolean(id));
if (ids.length === 0) {
return;
}
let names: Map<string, string>;
try {
names = await AdminUsersService.findDisplayNames(ids);
} catch (e: any) {
logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message);
return;
}
for (const event of events) {
const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined;
if (createdBy) {
event.createdBy = createdBy;
} }
return eventRows; const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined;
if (lastModifiedBy) {
event.lastModifiedBy = lastModifiedBy;
}
}
};
/**
* The calendars a listing has to cover: the requested one plus whatever it
* declares in `includes_calendars`.
*/
const calendarsToFetch = async (conn: any, calendarId: number): Promise<number[]> => {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendars: number[] = [calendarId];
for (let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendars = [...calendars, ...includes];
}
return calendars;
};
/**
* Returns all events for the given calendar
* @param calendarId The calendar Id
*/
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const calendars = await calendarsToFetch(conn, calendarId);
const eventsQuery = `${EVENT_SELECT}
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendars]);
const events = eventsRes.map((row: any) => toEvent(row, false));
await resolveAdminNames(events);
return events;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -76,48 +194,16 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
*/ */
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => { export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try { try {
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) 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
WHERE e.calendar_id = ? WHERE e.calendar_id = ?
ORDER BY e.event_id`; ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId); const eventsRes = await conn.query(eventsQuery, calendarId);
for (let row of eventsRes) { const events = eventsRes.map((row: any) => toEvent(row, true));
eventRows.push({ await resolveAdminNames(events);
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
});
}
return eventRows; return events;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -136,18 +222,7 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
export const getEventById = async (eventId: number): Promise<Event | null> => { export const getEventById = async (eventId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) 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
WHERE e.event_id = ?`; WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId); const eventsRes = await conn.query(eventsQuery, eventId);
@@ -155,27 +230,10 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
return null; return null;
} }
const row = eventsRes[0]; const event = toEvent(eventsRes[0], true);
return { await resolveAdminNames([event]);
eventId: row.event_id,
calendarId: row.calendar_id, return event;
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -193,11 +251,11 @@ export const createEvent = async (event: Event): Promise<number> => {
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
let eventUUID = Guid.create().toString(); let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id'; const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]); const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -218,8 +276,8 @@ export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -240,8 +298,8 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -283,56 +341,23 @@ export const moveEvent = async (event: Event): Promise<boolean> => {
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => { export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?'; const calendars = await calendarsToFetch(conn, calendarId);
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const now = new Date(); const now = new Date();
const eventsQuery = ` const eventsQuery = `${EVENT_SELECT}
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) 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
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ? WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
ORDER BY v.start_datetime ASC ORDER BY v.start_datetime ASC
LIMIT 1`; LIMIT 1`;
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]); const eventsRes = await conn.query(eventsQuery, [calendars, now]);
if (eventsRes.length === 0) { if (eventsRes.length === 0) {
return null; return null;
} }
const row = eventsRes[0]; const event = toEvent(eventsRes[0], false);
return { await resolveAdminNames([event]);
eventId: row.event_id,
calendarId: row.calendar_id, return event;
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
} as Event;
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
@@ -1,19 +1,6 @@
/** /**
* @swagger * @swagger
* components: * components:
* parameters:
* SessionIdHeader:
* in: header
* name: X-Session-Id
* required: true
* schema:
* type: string
* SessionKeyHeader:
* in: header
* name: X-Session-Key
* required: true
* schema:
* type: string
* schemas: * schemas:
* EventAdminSummary: * EventAdminSummary:
* type: object * type: object
+8 -5
View File
@@ -26,9 +26,8 @@ adminRouter.use(requireAdminAuth);
* summary: Validate the current admin session * summary: Validate the current admin session
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity. * description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -43,6 +42,8 @@ adminRouter.use(requireAdminAuth);
* type: string * type: string
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
@@ -55,9 +56,9 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* summary: Delete a single submission * summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool. * description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: submissionId * name: submissionId
* required: true * required: true
@@ -70,6 +71,8 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* description: Unknown submission * description: Unknown submission
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => { adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try { try {
@@ -18,9 +18,8 @@ export const eventsAdminRouter = express.Router();
* summary: List all events (admin) * summary: List all events (admin)
* description: All events, published or not, past or future, with submission counts. * description: All events, published or not, past or future, with submission counts.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -32,13 +31,14 @@ export const eventsAdminRouter = express.Router();
* $ref: '#/components/schemas/EventAdminSummary' * $ref: '#/components/schemas/EventAdminSummary'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Create an event * summary: Create an event
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied. * description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -68,6 +68,8 @@ export const eventsAdminRouter = express.Router();
* description: Missing required fields * description: Missing required fields
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/', async (req: Request, res: Response) => { eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -101,9 +103,9 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Get one event (admin) * summary: Get one event (admin)
* description: Full event detail including setlist and assigned questions. * description: Full event detail including setlist and assigned questions.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -120,12 +122,14 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put: * put:
* summary: Update an event * summary: Update an event
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -138,13 +142,15 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Delete an event * summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed. * description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -163,6 +169,8 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Submissions exist and force was not set * description: Submissions exist and force was not set
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
try { try {
@@ -214,9 +222,9 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* get: * get:
* summary: Get an event's setlist * summary: Get an event's setlist
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -227,12 +235,14 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Add a song to an event's setlist * summary: Add a song to an event's setlist
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -257,6 +267,8 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Missing title * description: Missing title
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
try { try {
@@ -292,9 +304,9 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* summary: Bulk reorder an event's setlist * summary: Bulk reorder an event's setlist
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction. * description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -317,6 +329,8 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* description: Reordered * description: Reordered
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => { eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
try { try {
@@ -334,9 +348,9 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* get: * get:
* summary: Get an event's assigned questions * summary: Get an event's assigned questions
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -347,13 +361,15 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put: * put:
* summary: Bulk-set an event's assigned questions * summary: Bulk-set an event's assigned questions
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form. * description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -383,6 +399,8 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Saved * description: Saved
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
try { try {
@@ -16,9 +16,9 @@ export const questionsAdminRouter = express.Router();
* get: * get:
* summary: List the question library * summary: List the question library
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: includeArchived * name: includeArchived
* schema: * schema:
@@ -34,12 +34,13 @@ export const questionsAdminRouter = express.Router();
* $ref: '#/components/schemas/AdminQuestion' * $ref: '#/components/schemas/AdminQuestion'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post: * post:
* summary: Create a question * summary: Create a question
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -61,6 +62,8 @@ export const questionsAdminRouter = express.Router();
* description: Missing or invalid fields * description: Missing or invalid fields
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
questionsAdminRouter.get('/', async (req: Request, res: Response) => { questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -94,9 +97,9 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Edit a question's label/help text * summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead. * description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: questionId * name: questionId
* required: true * required: true
@@ -123,13 +126,15 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question * description: Unknown question
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Archive (or hard-delete) a question * summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event. * description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: questionId * name: questionId
* required: true * required: true
@@ -142,6 +147,8 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question * description: Unknown question
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => { questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try { try {
@@ -19,9 +19,9 @@ export const reportsAdminRouter = express.Router();
* summary: Aggregated feedback report for one event * summary: Aggregated feedback report for one event
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts. * description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -34,6 +34,8 @@ export const reportsAdminRouter = express.Router();
* description: Unknown event * description: Unknown event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
try { try {
@@ -55,9 +57,9 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* summary: Guest Book entries for one event * summary: Guest Book entries for one event
* description: Newest first, paginated. * description: Newest first, paginated.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -81,6 +83,8 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
try { try {
@@ -101,9 +105,9 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* summary: Newsletter signups for one event * summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated. * description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -127,6 +131,8 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try { try {
@@ -147,9 +153,9 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* summary: CSV export of all answers for one event * summary: CSV export of all answers for one event
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard. * description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -162,6 +168,8 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* text/csv: {} * text/csv: {}
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
try { try {
@@ -187,9 +195,9 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* get: * get:
* summary: CSV export of Guest Book entries for one event * summary: CSV export of Guest Book entries for one event
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -202,6 +210,8 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* text/csv: {} * text/csv: {}
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
try { try {
@@ -16,9 +16,9 @@ export const songsAdminRouter = express.Router();
* put: * put:
* summary: Edit a song's title/composer * summary: Edit a song's title/composer
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: songId * name: songId
* required: true * required: true
@@ -45,13 +45,15 @@ export const songsAdminRouter = express.Router();
* description: Unknown song * description: Unknown song
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete: * delete:
* summary: Remove a song * summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed. * description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin] * tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: songId * name: songId
* required: true * required: true
@@ -64,6 +66,8 @@ export const songsAdminRouter = express.Router();
* description: Unknown song * description: Unknown song
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => { songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
try { try {
+24 -64
View File
@@ -1,78 +1,38 @@
import express from 'express'; import {requireAppAccess} from '../admin/admin.middleware.js';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './feedback.errors.js';
/** /**
* This file is the ONLY place in the feedback module that knows how admin * This file is the ONLY place in the feedback module that knows how admin
* authentication works today. No route handler and no service outside this * authentication works. No route handler and no service outside this file may
* file may import users.service, read session headers, or touch bcrypt. * read session headers or resolve a user itself.
* *
* Today: reuses the existing Calendar users/sessions mechanism. Any * Today: the shared admin identity in `src/models/admin/`. A session cookie
* activated @nachklang.art account may administer feedback — no roles. * set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator * the account. Both are re-checked on every request, so disabling a user or
* below and changing the one `activeAuthenticator` binding (plus the * taking their feedback permission away takes effect immediately.
* frontend's login route handler) — nothing else in the feedback module
* needs to change.
* *
* Explicitly forbidden: accepting sessionId/sessionKey from query * Before 2026-09-06 this was a header session against the calendar users
* parameters, even "temporarily". That is the exact mistake documented in * table, and any activated @nachklang.art account could administer feedback.
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials * That is why the swap is a one-line binding: everything downstream only ever
* end up in access logs, browser history, proxy logs, and Referer headers. * saw `requireAdminAuth` and `res.locals.admin`, and both still mean what
* Headers only. * they meant. What changed is that access is now granted per user rather than
* implied by having an account.
*
* Explicitly forbidden: accepting session credentials from query parameters,
* even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials end
* up in access logs, browser history, proxy logs, and Referer headers.
*/ */
// The only thing the rest of the feedback module knows about an admin. // The only thing the rest of the feedback module knows about an admin. The
// shared middleware puts a superset of this on res.locals.admin.
export interface AdminIdentity { export interface AdminIdentity {
id: string; id: string;
email: string; email: string;
displayName: string; displayName: string;
} }
// Pluggable strategy: extract + verify credentials from a request.
// Returns the identity, or null if unauthenticated. Throws only on
// infrastructure errors (e.g. the DB being unreachable).
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// Current implementation: reads X-Session-Id / X-Session-Key headers,
// delegates to the existing calendar UserService.checkSession(...).
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
// Mirrors the Calendar domain's own convention: a valid session on an
// inactive (not yet activated) account is not sufficient.
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
// Swap point: change this one binding to migrate to Keycloak.
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
// Express middleware used by every admin route. On success: // Express middleware used by every admin route. On success:
// res.locals.admin = AdminIdentity, calls next(). On failure: 401. // res.locals.admin = AdminAccess (an AdminIdentity plus permissions), calls
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => { // next(). On failure: 401 when not signed in, 403 when signed in without the
try { // feedback permission.
const identity = await activeAuthenticator(req); export const requireAdminAuth = requireAppAccess('feedback');
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+4 -3
View File
@@ -16,14 +16,15 @@ adminRouter.use(requireAdminAuth);
* get: * get:
* summary: Validate the current admin session * summary: Validate the current admin session
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
+20 -12
View File
@@ -11,14 +11,15 @@ export const eventsAdminRouter = express.Router();
* summary: List concerts for the admin event picker * summary: List concerts for the admin event picker
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events). * description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/', async (req: Request, res: Response) => { eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -35,14 +36,15 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: List public-calendar events not yet added to the ticket shop * summary: List public-calendar events not yet added to the ticket shop
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added. * description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/available', async (req: Request, res: Response) => { eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
try { try {
@@ -58,9 +60,9 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* get: * get:
* summary: Get a concert's voucher/capacity stats * summary: Get a concert's voucher/capacity stats
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -75,6 +77,8 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* $ref: '#/components/schemas/EventStats' * $ref: '#/components/schemas/EventStats'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => { eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
try { try {
@@ -91,9 +95,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* summary: Set a concert's voucher settings * summary: Set a concert's voucher settings
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address. * description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -123,6 +127,8 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* description: Saved * description: Saved
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => { eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try { try {
@@ -149,9 +155,9 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* summary: Remove an event from the ticket shop * summary: Remove an event from the ticket shop
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way. * description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: eventId * name: eventId
* required: true * required: true
@@ -164,6 +170,8 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* description: Vouchers already reference this event * description: Vouchers already reference this event
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => { eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
try { try {
@@ -11,9 +11,9 @@ export const redemptionsAdminRouter = express.Router();
* summary: List redemptions (admin) * summary: List redemptions (admin)
* description: Filterable by event and status (ACTIVE/UNDONE). * description: Filterable by event and status (ACTIVE/UNDONE).
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: eventId * name: eventId
* schema: * schema:
@@ -33,6 +33,8 @@ export const redemptionsAdminRouter = express.Router();
* $ref: '#/components/schemas/RedemptionSummary' * $ref: '#/components/schemas/RedemptionSummary'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => { redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -50,9 +52,9 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* get: * get:
* summary: Get a single redemption (admin) * summary: Get a single redemption (admin)
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -65,13 +67,15 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Unknown redemption * description: Unknown redemption
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* patch: * patch:
* summary: Edit a redemption's contact info and/or guest list * summary: Edit a redemption's contact info and/or guest list
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason. * description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -105,6 +109,8 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Not active, exceeds max guests, or exceeds remaining capacity * description: Not active, exceeds max guests, or exceeds remaining capacity
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => { redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
try { try {
@@ -161,9 +167,9 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* summary: Undo a redemption * summary: Undo a redemption
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record. * description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -186,6 +192,8 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* description: Redemption is not active * description: Redemption is not active
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => { redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
try { try {
@@ -211,9 +219,9 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
* summary: Resend the redemption confirmation email * summary: Resend the redemption confirmation email
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed. * description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: redemptionId * name: redemptionId
* required: true * required: true
@@ -230,6 +238,8 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
* description: The email relay rejected the send * description: The email relay rejected the send
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => { redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
try { try {
@@ -259,9 +269,9 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
* get: * get:
* summary: Get a voucher's admin-action audit trail * summary: Get a voucher's admin-action audit trail
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -278,6 +288,8 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
* $ref: '#/components/schemas/AuditLogEntry' * $ref: '#/components/schemas/AuditLogEntry'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
export const voucherHistoryRouter = express.Router(); export const voucherHistoryRouter = express.Router();
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => { voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
@@ -11,9 +11,9 @@ export const vouchersAdminRouter = express.Router();
* summary: List vouchers (admin) * summary: List vouchers (admin)
* description: Filterable by event and status. * description: Filterable by event and status.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query * - in: query
* name: eventId * name: eventId
* schema: * schema:
@@ -33,6 +33,8 @@ export const vouchersAdminRouter = express.Router();
* $ref: '#/components/schemas/VoucherCode' * $ref: '#/components/schemas/VoucherCode'
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.get('/', async (req: Request, res: Response) => { vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
try { try {
@@ -51,9 +53,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: Batch-generate wildcard codes * summary: Batch-generate wildcard codes
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId. * description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -87,6 +88,8 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Invalid input * description: Invalid input
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => { vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
try { try {
@@ -112,9 +115,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* summary: Bulk-create personalized codes * summary: Bulk-create personalized codes
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId. * description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
* tags: [tickets-admin] * tags: [tickets-admin]
* parameters: * security:
* - $ref: '#/components/parameters/SessionIdHeader' * - AdminSessionCookie: []
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -147,6 +149,8 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* description: Invalid input * description: Invalid input
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => { vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
try { try {
@@ -169,9 +173,9 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* get: * get:
* summary: Get a single voucher (admin) * summary: Get a single voucher (admin)
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -184,6 +188,8 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* description: Unknown code * description: Unknown code
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => { vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
try { try {
@@ -205,9 +211,9 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* summary: Void an unredeemed code * summary: Void an unredeemed code
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail. * description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
* tags: [tickets-admin] * tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path * - in: path
* name: code * name: code
* required: true * required: true
@@ -230,6 +236,8 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* description: Code is not in UNUSED status * description: Code is not in UNUSED status
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/ */
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => { vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
try { try {
+17 -49
View File
@@ -1,20 +1,23 @@
import express from 'express'; import {requireAppAccess} from '../admin/admin.middleware.js';
import * as UserService from '../calendar/users/users.service.js';
import {sendServerError} from './tickets.errors.js';
/** /**
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in * Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
* the tickets module that knows how admin authentication works. No route * the tickets module that knows how admin authentication works. No route
* handler and no service outside this file may import users.service, read * handler and no service outside this file may read session headers or
* session headers, or touch bcrypt. * resolve a user itself.
* *
* Today: reuses the existing Calendar users/sessions mechanism. Any * Today: the shared admin identity in `src/models/admin/`. A session cookie
* activated @nachklang.art account may administer vouchers - no roles, same * set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated * the account. Both are re-checked on every request, so disabling a user or
* roles/permissions model is explicitly out of scope for v1. * taking their tickets permission away takes effect immediately.
* *
* Explicitly forbidden: accepting sessionId/sessionKey from query * Before 2026-09-06 this was a header session against the calendar users
* parameters - headers only (see DEFERRED_SECURITY.md item 1). * table, and any activated @nachklang.art account could administer vouchers
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
* v1). It is in scope now, and lives in the admin module rather than here.
*
* Explicitly forbidden: accepting session credentials from query parameters -
* see DEFERRED_SECURITY.md item 1.
*/ */
export interface AdminIdentity { export interface AdminIdentity {
@@ -23,41 +26,6 @@ export interface AdminIdentity {
displayName: string; displayName: string;
} }
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>; // On failure: 401 when not signed in, 403 when signed in without the tickets
// permission.
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => { export const requireAdminAuth = requireAppAccess('tickets');
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+35
View File
@@ -78,6 +78,41 @@ describe('CLIENT_IP_HEADERS', () => {
}); });
}); });
describe('APP_ORIGINS', () => {
beforeEach(() => {
delete process.env.APP_ORIGINS;
});
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
// tickets and feedback origins load-bearing: without them their sign-out
// call is rejected while everything else still works.
it('defaults to the three production frontends', async () => {
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]);
});
it('is overridden wholesale by the environment, for a staging host', async () => {
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.staging.example',
// Trailing slash stripped: an origin with one never matches.
'https://feedback.staging.example'
]);
});
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
const config = await loadConfig();
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
});
});
describe('isProd', () => { describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => { it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
+118
View File
@@ -0,0 +1,118 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express, {Request, Response} from 'express';
/**
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
* tickets.auth.ts used to carry their own header-session authenticator against
* 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.
*/
export interface BindingMocks {
/** auth.api.getSession from the mocked admin.auth.js */
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);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const userWith = (...apps: string[]) => ({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'Anna Admin',
disabled: false,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
export const describeAdminBinding = (
app: string,
otherApp: string,
middleware: express.RequestHandler,
mocks: () => BindingMocks
): void => {
describe(`${app} requireAdminAuth`, () => {
let m: BindingMocks;
const run = async () => {
const res = makeRes();
const next = vi.fn();
await middleware(makeReq(), res, next);
return {res, next};
};
beforeEach(() => {
m = mocks();
m.getSession.mockReset();
m.loadAccess.mockReset();
m.checkSession.mockReset();
});
it('responds 401 and does not call next() without a session', async () => {
m.getSession.mockResolvedValue(null);
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(otherApp));
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('responds 403 for a disabled user who still holds the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() with the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(app));
const {res, next} = await run();
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();
});
});
};
+49
View File
@@ -0,0 +1,49 @@
import {describe, expect, it, beforeEach} from 'vitest';
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
/**
* The public calendar is read anonymously by nachklang.art to show the next
* upcoming event. That is a load-bearing property, not an accident: the step 4
* cutover moved every signed-in path onto session cookies and left these shared
* passwords behind only for iCal subscriptions, and the failure mode of getting
* it wrong is the public website silently losing its events feed.
*
* So this pins both halves: public needs nothing, and the restricted calendars
* still need something.
*/
describe('hasAccess', () => {
beforeEach(() => {
process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir-secret';
process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
});
it('lets anyone read the public calendar with no password at all', async () => {
await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
});
it.each([
['members', 'member-secret'],
['choir', 'choir-secret'],
['management', 'management-secret'],
['birthdays', 'choir-secret']
])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
});
it('refuses an unknown calendar outright', async () => {
await expect(CredentialService.hasAccess('nope', 'member-secret')).resolves.toBe(false);
});
it('refuses a calendar whose credential is not configured', async () => {
// An unset MEMBER_CREDENTIAL must not become "any password works", and in
// particular must not become "an absent password works".
delete process.env.MEMBER_CREDENTIAL;
await expect(CredentialService.hasAccess('members', '')).resolves.toBe(false);
await expect(CredentialService.hasAccess('members', undefined as any)).resolves.toBe(false);
});
});
+224
View File
@@ -0,0 +1,224 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
getAllEvents: vi.fn(),
getAllEventsAdmin: vi.fn(),
getEventById: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
moveEvent: vi.fn(),
getNextUpcomingEvent: vi.fn()
}));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import * as EventService from '../../src/models/calendar/events/events.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
/**
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
* on credentials.service covers the password table; this covers the thing that
* table is wired into, which is where the interesting mistakes live:
*
* - the public calendar has to stay readable with no session and no password,
* - the shared password has to keep working for the restricted calendars,
* because iCal clients cannot send a cookie,
* - and every write has to be behind the session cookie *and* an explicit
* calendar permission, not merely behind "is signed in".
*/
const app = express();
app.use(express.json());
app.use('/calendar/events', eventsRouter);
const signedInAs = (apps: string[], disabled = false) => {
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
(UsersService.loadAccess as any).mockResolvedValue({
id: 'admin-1',
email: 'a@nachklang.art',
displayName: 'A',
disabled,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
};
const signedOut = () => {
(auth.api.getSession as any).mockResolvedValue(null);
};
const validEvent = {
calendarId: 1,
name: 'Konzert',
startDateTime: '2026-04-18T19:00:00Z',
endDateTime: '2026-04-18T21:00:00Z'
};
beforeEach(() => {
vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
(EventService.createEvent as any).mockResolvedValue(1);
(EventService.updateEvent as any).mockResolvedValue(1);
(EventService.moveEvent as any).mockResolvedValue(true);
(EventService.deleteEvent as any).mockResolvedValue(true);
signedOut();
});
describe('reading', () => {
it('serves the public calendar anonymously', async () => {
// The property nachklang.art depends on. No cookie, no password.
await request(app).get('/calendar/events/public/json').expect(200);
// And as the non-admin view: an anonymous caller must not see drafts.
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('refuses a restricted calendar with neither session nor password', async () => {
await request(app).get('/calendar/events/members/json').expect(403);
});
it('serves a restricted calendar to a shared password, without drafts', async () => {
await request(app)
.get('/calendar/events/members/json')
.query({password: 'member-secret'})
.expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('gives a signed-in editor the admin view instead', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json').expect(200);
expect(EventService.getAllEventsAdmin).toHaveBeenCalled();
expect(EventService.getAllEvents).not.toHaveBeenCalled();
});
it('treats a signed-in user without the calendar permission as anonymous', async () => {
// Not a 403: they may still read the public calendar like anyone else.
signedInAs(['tickets']);
await request(app).get('/calendar/events/public/json').expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
await request(app).get('/calendar/events/members/json').expect(403);
});
it('still serves the public calendar when the admin database is down', async () => {
(auth.api.getSession as any).mockRejectedValue(new Error('ECONNREFUSED'));
await request(app).get('/calendar/events/public/json').expect(200);
});
// The endpoint www.nachklang.art actually calls for its next-event teaser.
// Tested separately from /json because it takes a different code path - it
// has no admin view and no editor branch - so covering /json proves nothing
// about it, and its failure is invisible until someone notices the website
// has gone quiet.
it('serves the next upcoming event anonymously on the public calendar', async () => {
await request(app).get('/calendar/events/public/json/next').expect(200);
// And without asking the admin database who the caller is: the public
// feed must not acquire a dependency it has never had.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
await request(app).get('/calendar/events/members/json/next').expect(403);
});
it('serves the next upcoming event to a shared password', async () => {
await request(app)
.get('/calendar/events/members/json/next')
.query({password: 'member-secret'})
.expect(200);
});
it('serves the next upcoming event to a signed-in editor', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json/next').expect(200);
});
it('does not consult the admin database for the anonymous public iCal export', async () => {
await request(app).get('/calendar/events/public/ical').expect(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]);
await request(app).get('/calendar/events/public/ical').expect(200);
await request(app).get('/calendar/events/members/ical').expect(403);
await request(app)
.get('/calendar/events/members/ical')
.query({password: 'member-secret'})
.expect(200);
});
});
describe('writing', () => {
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 401 when signed out', async (method, path) => {
await (request(app) as any)[method](path).send(validEvent).expect(401);
});
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 403 without the calendar permission', async (method, path) => {
signedInAs(['tickets', 'feedback', 'admin']);
await (request(app) as any)[method](path).send(validEvent).expect(403);
});
it('answers 403 for a disabled account that still holds the permission', async () => {
signedInAs(['calendar'], true);
await request(app).post('/calendar/events').send(validEvent).expect(403);
});
it('records the writer as an admin user id, never a legacy one', async () => {
signedInAs(['calendar']);
await request(app).post('/calendar/events').send(validEvent).expect(201);
const written = (EventService.createEvent as any).mock.calls[0][0];
expect(written.createdByUserId).toBe('admin-1');
// Migration 003 made the legacy column nullable precisely so this can be unset.
expect(written.createdById).toBeUndefined();
});
it('refuses the shared password as a way to write', async () => {
// The passwords are a read fallback for clients that cannot hold a
// session. They must never become an editing credential.
await request(app)
.post('/calendar/events')
.query({password: 'member-secret'})
.send(validEvent)
.expect(401);
});
});
+191
View File
@@ -0,0 +1,191 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
const connection = {
query: vi.fn(),
execute: vi.fn(),
beginTransaction: vi.fn(),
commit: vi.fn(),
rollback: vi.fn(),
end: vi.fn()
};
vi.mock('../../src/models/calendar/Calendar.db.js', () => ({
NachklangCalendarDB: {getConnection: vi.fn(async () => connection)}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
findDisplayNames: vi.fn()
}));
import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js';
import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js';
import * as EventService from '../../src/models/calendar/events/events.service.js';
/**
* 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.
*/
// One row of the shape the shared SELECT produces.
const row = (over: Record<string, unknown> = {}) => ({
event_id: 1,
calendar_id: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
start_datetime: new Date('2026-04-18T19:00:00Z'),
end_datetime: new Date('2026-04-18T21:00:00Z'),
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,
version_created_by_user_id: null,
version_created_by_name: null,
legacy_last_modified_by_name: 'Legacy Person',
url: '',
whole_day: 0,
repeat_frequency: '',
status: 'PUBLIC',
...over
});
/** getAllEvents runs the calendars lookup first, then the events query. */
const givenEvents = (...rows: unknown[]) => {
connection.query.mockReset();
connection.query
.mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}])
.mockResolvedValueOnce(rows);
};
beforeEach(() => {
vi.clearAllMocks();
connection.end.mockResolvedValue(undefined);
(NachklangCalendarDB.getConnection as any).mockResolvedValue(connection);
});
describe('creator names', () => {
it('uses the legacy join 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].createdByUserId).toBeNull();
// Nothing to resolve, so the admin database is not touched at all.
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
});
it('prefers the admin name when the row carries an admin id', async () => {
givenEvents(row({
created_by_user_id: 'admin-1',
version_created_by_user_id: 'admin-2'
}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(
new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']])
);
const events = await EventService.getAllEvents(1);
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.
givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
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 () => {
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');
});
it('resolves a mixed result set in a single lookup', async () => {
givenEvents(
row({event_id: 1}),
row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}),
row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'})
);
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']);
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
});
it('still returns the events when the admin database is unreachable', async () => {
givenEvents(row({created_by_user_id: 'admin-1'}));
(AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED'));
const events = await EventService.getAllEvents(1);
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');
});
});
describe('status', () => {
it('is omitted from the public listing and present in the admin one', async () => {
givenEvents(row());
const publicEvents = await EventService.getAllEvents(1);
expect(publicEvents[0].status).toBeUndefined();
connection.query.mockReset();
connection.query.mockResolvedValueOnce([row()]);
const adminEvents = await EventService.getAllEventsAdmin(1);
expect(adminEvents[0].status).toBe('PUBLIC');
});
});
+16 -81
View File
@@ -1,88 +1,23 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest'; import {vi, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({ vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn() checkSession: vi.fn()
})); }));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
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 * as UserService from '../../src/models/calendar/users/users.service.js';
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth.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';
import {describeAdminBinding} from '../admin/auth-binding.js';
const mockCheckSession = UserService.checkSession as Mock; describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock,
const makeReq = (headers: Record<string, string>): Request => { loadAccess: UsersService.loadAccess as Mock,
return { checkSession: UserService.checkSession as Mock
header: (name: string) => headers[name], }));
ip: '203.0.113.42'
} as unknown as Request;
};
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
describe('sessionHeaderAuthenticator', () => {
beforeEach(() => mockCheckSession.mockReset());
it('returns null when headers are missing', async () => {
const identity = await sessionHeaderAuthenticator(makeReq({}));
expect(identity).toBeNull();
expect(mockCheckSession).not.toHaveBeenCalled();
});
it('returns null when checkSession finds no user', async () => {
mockCheckSession.mockResolvedValue(null);
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns null for a valid session on an inactive account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toBeNull();
});
it('returns the identity for a valid session on an active account', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
it('passes the session id and key from headers through to checkSession, never from query params', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true});
await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'}));
expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42');
});
});
describe('requireAdminAuth', () => {
beforeEach(() => mockCheckSession.mockReset());
it('responds 401 and does not call next() when unauthenticated', async () => {
mockCheckSession.mockResolvedValue(null);
const req = makeReq({});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() when authenticated', async () => {
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'});
const res = makeRes();
const next = vi.fn();
await requireAdminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
});
+24 -8
View File
@@ -221,16 +221,32 @@ describe('requireAppAccess', () => {
expect(Array.isArray(res.body)).toBe(true); expect(Array.isArray(res.body)).toBe(true);
}); });
// Step 2 deliberately does NOT swap the feedback and tickets authenticators: // The step 4 cutover (2026-09-06): the feedback and tickets admin areas now
// they still authenticate against the legacy calendar sessions, so an admin // sit behind this same gate, so one sign-in reaches every app the user has a
// cookie means nothing to them yet. This asserts that boundary rather than // permission for - and reaches no further. Until step 4 these two returned
// the end state - when step 4 lands, these two expectations become 200/403 // 401 for an admin cookie, because each module still ran its own header
// and this comment goes away. // session against the calendar users table.
it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => { it('lets an admin cookie into the feedback and tickets admin areas', async () => {
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']); const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(401); expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(401); expect((await user.agent.get('/tickets/admin/me')).status).toBe(200);
});
it('403s each app separately for a user who only holds the other one', async () => {
const user = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['feedback']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(200);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(403);
});
it('401s the feedback and tickets admin areas for a legacy header session', async () => {
const res = await request(app)
.get('/feedback/admin/me')
.set('X-Session-Id', '1')
.set('X-Session-Key', 'whatever');
expect(res.status).toBe(401);
}); });
}); });
+23
View File
@@ -0,0 +1,23 @@
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()}}
}));
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';
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
}));