The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.
Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.
- extract toIso() from adminUsers.js into utils/dateNormalize.js
(contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
uploaded_at/captured_at; api-tokens list normalizes all four
timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
serves ISO strings
activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.
Co-authored-by: Paul Nothaft <[email protected]>
* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)
Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.
- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
visibility is computed at REQUEST time (reveal_at <= now opens the
gate exactly on schedule); the minutely scheduler only stamps
revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
shell with photos: [] + hidden_until_reveal for plain guests;
image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
sequential — listing-only gating would be probeable); feedback-summary
gated too. Slideshow tokens (surprise beamer), client access and the
admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
upload settings, status line and "Reveal now" button on the overview;
re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens
* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)
- "Reveal now" also clears a pending reveal_at: the schedule is
consumed, so the full-form admin save can't accidentally re-hide a
revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
(gallery.uploadPhotos never existed; the button showed EN everywhere)
* fix(gallery): close reveal bypasses from review round 1 (#838)
- the hero-derivative route and the secure-images token-mint +
secure-download routes are now reveal-gated: hero serves a 1920px
derivative of ANY sequential photo id and secure tokens fetch
originals — both were open bypasses while hidden. blockHiddenGallery
moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
reveal mode — they are the host/customer, not a guest, and were
getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)
* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)
- legacy /api/images router reveal-gated (view, secure-token + signed-url
minting), and the signed-URL SERVE path re-checks hidden state via a
backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
at serve time — a re-hide kills in-flight guest tokens within the
request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
/my-feedback returns the empty back-compat shape (rows leak filename +
storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
pass the reveal timestamp so a re-hidden gallery's second reveal
fires workflows again instead of deduping into silence
* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)
- the scheduler now consumes reveal_at when stamping (matching "Reveal
now"), and re-arming via a partial API update clears a stale PAST
schedule — previously {reveal_mode:true} without reveal_at could
instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
poll while the mode is on — a re-hide now propagates to open clients
in both directions, not just hidden→visible
Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.
---------
Co-authored-by: Paul Nothaft <[email protected]>
* feat(feedback): emoji reactions on photos (#839)
Per-photo emoji reactions from a fixed curated set (❤️😂😍👏🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).
- migration 164: allow_reactions toggle (default on, still gated by the
opt-in feedback_enabled master switch), photo_feedback.reaction value
column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
(shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
admin: allow_reactions toggle next to likes, analytics tile,
create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests
* fix(feedback): reach reactions without comments; numeric analytics totals (#839)
- the lightbox feedback-panel toggle was gated on allow_comments only —
with comments off the new reaction bar was unreachable; the gate now
opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
total_feedback concatenated instead of adding ("00006")
* fix(feedback): harden reactions from review round 1 (#839)
- per-emoji tallies are gated on show_feedback_to_guests — with sharing
off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
duplicated by the (like-parity) check-then-insert race collapse on the
next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
stored rows predating the reaction key otherwise dropped it to the
generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
the onError closure sees the post-optimistic render, so the old
revert froze the wrong state on failed toggles
* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)
- summary.reaction_count is gated on show_feedback_to_guests like the
per-emoji map, keeping the "no aggregates while sharing is off"
promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
and the type filter gains a Reactions option (7 locales; es has no
types block and falls back to EN defaults)
* fix(feedback): register reaction activity types with translated labels (#839)
photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.
* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)
- guest CRM: per-guest reaction counts in the list aggregation and a
Reacted tab (photo grid with emoji badges) + stats card in the guest
detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
were otherwise unreachable in this layout since it bypasses the
shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
i18n keys for the 7 locales that carry the guests block
* fix(feedback): portal the premium reaction bar to document.body (#839)
Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.
---------
Co-authored-by: Paul Nothaft <[email protected]>
* feat(auth): OIDC role mapping + login policy — phase 2 (#798)
Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles,
Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping
table validated against the roles table, re-evaluated on every SSO login with
highest-priority-wins on multiple matches. The last active super_admin is
never demoted. Optional require-mapped-role policy refuses logins whose token
maps to no role (sso_error=no_role).
Login policy: oidc_disable_local_login makes the API refuse password logins
(403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective
while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens
local login. Public settings expose the EFFECTIVE flag only.
Settings UI: Role-mapping card (claim path, mapping rows editor, strict
toggle) and Login-policy card with break-glass hint, EN+DE.
14 new integration tests over the mock IdP.
* fix(auth): harden phase-2 review findings (#798)
- memoize the scrypt-derived OIDC key and serve /public/settings from a
10s-TTL flag cache — the unauthenticated endpoint no longer pays a
13-key config read + blocking scryptSync per request (login route
still checks uncached)
- make the last-super-admin demotion guard atomic (FOR UPDATE on the
active super rows) — concurrent mapped callbacks could previously
both count 2 and demote both supers
- own-property lookup in role mapping: IdP values like `constructor`
now count as unmapped instead of corrupting the roles query
- SsoTab clears oidc_disable_local_login in the same save that turns
SSO off — the full-form payload otherwise hit the server-side 400
* fix(auth): guarantee break-glass reachability for SSO-only mode (#798)
- wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start
docker-compose.yml env allowlist (production compose already passes
.env via env_file) and document both in .env.example
- refuse enabling oidc_disable_local_login unless an active
local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the
password route, which OIDC-owned accounts can never use, and
settings.edit is super_admin-only — an all-OIDC instance would be
unrecoverable during an IdP outage
* fix(auth): close SSO-only lockout gaps from review round 3 (#798)
- role sync never demotes the last active LOCAL-password super_admin
(an OIDC-owned super does not count as break-glass), and
isLocalLoginDisabled() disarms itself when no such account remains —
self-healing against manual demotion/deactivation/deletion paths
- the local-super save-time check now validates the MERGED state, so
re-enabling SSO with a stored disable flag is checked too
- ALL oidc_* keys are reserved from the generic settings upserts/reads
(prefix match) — policy and mapping invariants can only go through
the validated PUT /sso
- /admin/login/mfa re-checks the policy so an mfa_pending token minted
before the flip cannot complete into a local session
---------
Co-authored-by: Paul Nothaft <[email protected]>
* fix(crm): pass trx to logActivity inside transactions — audit rows were silently lost on SQLite
createContract, updateContract, createStorno and reissueInvoice called
logActivity() (contract paths also adminActor()) from inside a knex
transaction without the trx executor — the pattern db.js:648's comment
explicitly warns about. On single-connection SQLite the audit insert
waits on a second pool connection while the trx holds the only one:
a 60s acquire-timeout stall per call, then logActivity's catch swallows
the failure and the audit row is silently lost. Postgres unaffected.
Fix mirrors the one call site that already did it right
(contract_created_from_quote, conversions.js): resolve the audit actor
before the transaction opens and pass trx as logActivity's executor so
the insert rides the transaction's connection.
Verified NOT affected (logActivity outside any trx, unchanged):
cancelContract, contract_converted_to_event, contract_signed_by_customer,
contract_sent, invoice_sent/_cancelled(draft)/_released/monthly_bill.
Found by the #587 integration-test work (PR #850, which shrank the pool
acquire timeout to tolerate the stall — that workaround can be dropped
once both land).
* fix(crm): run reissueInvoice's createInvoice without a wrapping transaction (codex review of #851)
The round-1 fix passed trx to the reissue audit call — but that point
was never reached on single-connection SQLite: createInvoice internally
reads via the global connection (businessProfileService.getProfile,
getAppSetting, bank-account resolution), so the outer trx deadlocked
first and aborted the replacement AFTER the Storno had already
committed and been emailed.
createInvoice's five other callers all run it without a trx; reissue
now does the same and backlinks afterwards. Trade-off documented in
code: replacement + backlink are no longer atomic — a crash between
them leaves a visible draft without replaces_invoice_id, which beats
the guaranteed stall. New regression test drives a full cancel+reissue
on the SQLite harness and pins the invoice_reissued audit row.
* fix(crm): restore the reissue transaction by routing createInvoice's reads through trx (codex review of #851, round 2)
Round 2 was right that dropping the wrapping transaction traded the
deadlock for orphan drafts: createInvoice inserts the invoice row and
claims a sequence number BEFORE line-item validation can throw, so a
failed reissue would persist partial state after the Storno committed.
Proper fix: the transaction is back, and every read inside createInvoice
now rides it — getProfile and resolveBankAccountForCurrency gained an
optional conn param (default db, all other callers unchanged),
getAppSetting calls pass trx (crm_invoice_round_total + the
resolveNetDays default the regression test flushed out), and the
invoice_created audit uses the trx executor. The reissue regression test
now proves a full cancel+reissue commits atomically on single-connection
SQLite.
* feat(notifications): surface guest activity in the admin bell (#746)
Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:
- gallery_opened on the guest photo-list route, debounced in-memory to
one notification per event per 6h (the endpoint fires per page load;
per-hit notifications would spam the bell). Slideshow traffic stays
excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
photoFavorite, which previously fell through to the generic 'system
activity' line) in all 8 languages — resolved via the existing smart
camelCase fallback, no switch cases needed. Distinct bell icons per
type.
* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)
- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
access_logs — the most common download path never reached the bell.
Now emits gallery_downloaded with scope 'single', debounced to one
notification per event per hour: a guest saving 30 photos is one
signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
<Bell> for every row. Added an icon map so gallery opens (Eye),
downloads (Download), favorites (Heart) and the pre-existing style
names render their intended icons.
* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)
- Single-photo notification now fires on res 'finish' with status < 400:
emitting up-front logged downloads that then 404ed/failed AND burned
the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
(grep-verified) — settings/user/mail/etc. styles render their declared
icons instead of falling back to Bell.
Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.
* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)
- gallery_opened/gallery_downloaded now carry the real actor: client
sessions (accessLevel 'client') are recorded as 'customer' instead of
being mislabeled 'guest' — #746 explicitly covers client activity, so
they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
single-photo path — piping is not delivery. The presigned-redirect
and on-the-fly-archiver paths keep their existing timing (redirect
handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
no longer fall back to Bell — the grep that built the map missed the
digit in the name).
* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)
- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
admin.activities.<type> keys that didn't exist, rendering raw
identifiers — added gallery_opened/gallery_downloaded entries in all
8 locales.
- Customer-portal opens already log customer_event_access at the
access-token mint; the ensuing /photos call no longer double-notifies
(client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
notify on res 'finish' < 400 — archive.finalize() ends Archiver's
input, not the HTTP transfer.
* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)
The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.
verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
* feat(slideshow): guest-scannable share-link QR overlay (#837)
- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
the 3s projector poll never re-encodes), so the kiosk needs no QR lib
and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).
* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)
- OverviewTab never passed event.show_qr into the settings card (and the
Event type lacked the field), so a stored true/false override always
displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
empty or category-filtered live gallery showed only 'Waiting for
photos', exactly when 'scan to add the first photos' matters most.
Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
tokens and past events no longer accumulate base64 PNGs forever.
* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)
With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.
* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)
req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.
* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)
- A loopback window.location.origin from the kiosk is no more
guest-reachable than the loopback base it would replace — rejected;
when no reachable URL remains the overlay is suppressed entirely (no
QR beats a QR that sends phones to their own localhost). New test
pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
the origin is caller-influenced when the base is loopback, so
URL-keyed caching let a slideshow-link holder force a fresh
QRCode.toDataURL per request via unique origins — a cheap CPU
exhaustion path. Encode rate is now bounded per event regardless of
input. QR margin also raised to the 4-module spec quiet zone,
matching #847.
* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)
- A slideshow-token holder could poison the projector's QR: an
attacker-origin entry cached per event was served to the legitimate
kiosk for the rest of the throttle window. A cached artifact is now
only served when its URL matches the request; mismatches inside the
window suppress the overlay briefly instead of showing foreign
content.
- Cold-cache stampede closed: concurrent polls share one in-flight
encode promise instead of each scheduling a 512px render.
Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
* feat(events): gallery QR code + printable table-card/poster PDFs (#836)
- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
A4 poster with event name, QR, localized caption (8 locales; Cyrillic
falls back to English — built-in Helvetica has no Cyrillic glyphs) and
the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).
* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)
- QR URLs: prefer the configured public base, but fall back to the admin
browser's origin (passed as ?origin=, validated) when the base is
missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
instead of WinAnsi-only Helvetica: Cyrillic event names no longer
silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
and all positions derive from constants, so long event names can't
push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
after unmount/event-switch is revoked instead of leaking and
overwriting the newer event's QR.
* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)
Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/[email protected].
Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.
Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.
* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)
- The QR section keys off share-link availability instead of a loaded
preview: a transient failure of the preview request no longer hides
every download button until reload; a placeholder tile renders in
place of the image.
- Preview + actions stack on phone widths and the button grid drops to
one column below sm, so 'Tischkarte (A6)'-length labels don't
overflow.
* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)
- The QR target is now the STORED share_link — exactly what the card
displays and the admin copies. Rebuilding from current slug/token/
short-URL setting could diverge for legacy absolute links or events
created under a different short-URL setting; a printed QR encoding a
different URL than the card is a permanent mistake. Rebuild remains
only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
assets — the spec's quiet zone; margin 2 risks scan failures when the
printout sits against colored surroundings.
* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)
Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.
* test(events): 30s timeout for the print-PDF cases (CI fix)
The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
* fix(file-watcher): bound concurrent photo processing
chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.
Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.
Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.
* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)
The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
* fix(uploads): keep videos when thumbnail generation fails
processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both
pipeline paths, while the image branch next to each already survives its
thumbnail failures:
- processUploadedPhotos (sync): the throw failed the whole upload — the
video was lost.
- processPhoto (async worker, the path real uploads take): the throw
marked the row 'failed', and the guest gallery only lists 'complete' —
the video became permanently invisible despite being fully uploaded.
Both call sites now fall back to extractVideoMetadata() alone and keep
the video without a preview; if even the probe fails, the video is kept
with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented
for both paths + regression test.
* fix(uploads): placeholder thumbnail for rescued videos (codex review of #845)
A completed video with a NULL thumbnail made the gallery grid fetch the
ORIGINAL video file as an <img> blob (thumbnail_url || url) — a
potentially multi-GB download for a broken tile. Both fallback paths now
generate the existing sharp-rendered play-button placeholder
(generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real
tile. Test asserts the placeholder key lands in thumbnail_path.
* fix(security): read the password-complexity key the settings UI writes
The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).
* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)
On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
Follow-ups from the codex review of #834:
- .gitignore: backend/storage/ is runtime-generated (media, previews,
thumbnails, business docs) and was only partially ignored — E2E runs
left it dangling as untracked, which is how ~12 MB of artifacts nearly
landed in a commit. Ignore the whole directory (nothing under it is
tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
storage/previews, so locally generated previews were copied into
production images. Exclude storage entirely — the Dockerfile creates
the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
repo, and its private extension whitelist silently drifted from the
real validation paths (see #834), which is exactly the trap dead
security code sets.
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
client-supplied original filename as the RAW output basename, but thumbnails/
heroes/previews are global keys — two galleries uploading IMG_0001.dng would
overwrite each other's derivative. Use the unique stored newFilename instead.
(processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
RAW, so it fell back to the original bytes and recorded the copy as watermarked.
Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
fail it with ENOENT.
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.
Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an <img>, so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).
EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
Two findings from the Codex review:
- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
(offset 4) magic number (the check is .every, so alternatives can't be
separate entries).
- Changing the shared upload.fileRequirements string to interpolate {{formats}}
left the admin PhotoUpload caller passing only { limit }, rendering the
placeholder literally (it was also already dropping {{sizeLimit}} from #823).
The admin caller now passes formats + sizeLimit + limit, from the admin
settings it already loads.
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.
- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
with sharp) + withProcessableImage() which is a pass-through for ordinary
images and swaps in the extracted JPEG for RAW. Wired into ingest
(photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
Preview). generateHeroImage/generatePreviewImage gained outputBasename so
RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
security file-validator.
Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.
Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.
Companion to the HEIC/dynamic-hint PR; targets main only.
Two of the three things from #821:
- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
but a genuine .heic upload is now handled when it arrives.)
- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
extensionsToLabel() renders the actually-configured, supported formats (e.g.
"JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
across all 8 locales. Unsupported extensions are dropped from the label so it
never advertises a format the backend would reject.
DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.
Adds vitest coverage for extensionsToLabel + the HEIC mapping.
Three follow-ups from the Codex review of #823:
1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
but it's a real type gap — the #614 count field is declared, this one wasn't.
Added the optional numeric field.
2. The general-settings update endpoint validated general_max_files_per_upload
but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
could persist. publicSettings then advertised the raw value while
getMaxFileSizeMb() normalised it — the guest UI would reject files the
backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).
3. The update route cleared the file-count cache but not the new file-size
cache, so for up to 60s the public endpoint could advertise a new limit
while multer still enforced the old one. Now clears both under the same
uploadLimitTouched guard.
Follow-up on the merged #823 (main-only), so this targets main only.
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.
- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
null stores NULL (inherit) rather than being coerced to 0/false by
formatBoolean on SQLite. The update handler already did `=== null ? null`.
Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.
Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
- never pulled/recreated their real containers (stayed on the old version,
e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
- started the dev-only mailhog service that docker-compose.yml defines
(reported restart-looping).
The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.
Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.
Adds unit tests for the production-vs-default command generation.
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.
- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
and passes it to the requirements hint. The "max 50MB" literal in
upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
locales; adds upload.fileTooLarge (en/de; others fall back to en).
Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.
Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
- GET /api/events → every gallery's bcrypt password_hash, share_token, and
client name/email (the list handler selects * and mapEventForApi keeps
those columns),
- PUT /api/events/:id → reset any gallery's password (full takeover),
- DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.
Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).
Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).
1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
customer/event ids, so ANY pre-restore JWT can rebind to a different restored
principal. Revoking just the importing token wasn't enough. importFromPicpeak
now stamps a unix-second cutoff in app_settings after the restore commits, and
adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
whose iat predates it (cached 30s → one in-memory compare on the hot path).
The operator's forced re-login mints a token past the cutoff, so it passes.
2. Role preservation across an RBAC replace (captureOperatorRole /
preserveOperatorRole). The operator's role + granted permission NAMES are
captured before the wipe; after roles/role_permissions are replaced the role
is resolved by NAME against the restored data, and re-created with its grants
if the backup omits it — so a crafted or cross-instance backup can't silently
downgrade or lock out the operator. reinjectCurrentAdmin now returns the
operator's id so the row can be re-pointed at the resolved role.
3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
explicit ids without advancing the sequences, so the next natural insert into
any restored table collided on the PK. Runs AFTER commit (setval isn't
transactional) and guards every table with a column-existence check —
pg_get_serial_sequence RAISES on id-less tables like role_permissions.
No-op on SQLite.
Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.
Stacks on #811 (shares the reinject hardening); merge after it.
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):
- MFA hijack: reinject wrote back only password_hash/is_active/
must_change_password, leaving a crafted backup's two_factor_* on the
operator's row — it could strip or replace their second factor. The email-
matched row is now updated with the operator's full AUTH set (login identity,
password, and all two_factor_* columns). Relationship/audit FKs (role_id,
created_by) are deliberately NOT forced from the snapshot: on a cross-instance
restore those pre-restore ids may be absent from the backup and would dangle
the FK (SQLite rolls back at commit); the restored row keeps its own valid
values.
- Cross-instance restore rollback / FK safety: reinject matched only by email,
so a backup shipping a different admin with the default `admin` username hit
UNIQUE(username) and rolled the whole restore back; email and username could
even collide on two different rows. Reconciliation is now non-destructive:
the email-matching row is updated in place (id preserved → restored FKs like
events.created_by stay valid); any different row holding the operator's
username is RENAMED, not deleted (deletion would fire ON DELETE actions /
dangle references); only when no row has the operator's email is a fresh row
inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
left the Postgres identity sequence unadvanced, so a sequence-based insert
could collide).
- Stale session after restore: admin_users ids shift on restore, but the
operator's live JWT is bound only to decoded.id (IP logged not enforced; the
backup controls password_changed_at). The route now revokes the token (result
checked and logged) and clears the admin cookie; the client redirects to a
fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
guarantee.
Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.
Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.
Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.
Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.
Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.
Adds route regression test covering the bypass, the public path, and bad tokens.
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.
Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):
- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
were returned in PLAINTEXT by GET /admin/backup/config and by the
generic settings reads (GET /admin/settings and /admin/settings/:type
— which mask the recaptcha/umami/rybbit keys but not these). All
three now mask with the established bullet sentinel, and
PUT /admin/backup/config skips the sentinel on write so the edit form
round-trips without clobbering stored credentials (same pattern as
the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
allowlist: the first login step passed, the second factor got a 503 —
any MFA-enrolled admin was locked out exactly while maintenance mode
was on
Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
- OIDC-owned accounts can never authenticate locally: the password
login rejects auth_provider='oidc' rows outright (generic 401), and
the super-admin password reset refuses them with a clear message —
previously a reset would have minted a local password bypassing the
IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
AdminAuthContext hydrates user state from it: an SSO redirect
establishes the session without any login JSON, which left the header
identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
(same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
(leftJoin, prefixed columns, aliases) — the route change made the old
mock throw, which read as "table missing, trust token"
Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
guarantees sub uniqueness within an issuer, so a sub-only lookup let a
newly configured IdP's user inherit an old IdP's admin account on
subject collision; migration 162 gains external_issuer + composite
unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
email — spec-compliant providers may serve email/profile claims only
there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
generic settings reads (GET / and GET /:type)
Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
state cookie lives); final redirects absolute to the frontend base;
login button builds its URL via buildResourceUrl — split-origin
deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
blank issuer/client while enabled=true survives; enabling requires a
derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
secret rotation)
- email→admin linking claims the row atomically (conditional update on
external_subject IS NULL) — concurrent first-time callbacks with the
same verified email but different subjects can't both authenticate
Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
Split out of #801 so the public-API behavior change gets its own review:
- v1 POST /events validates event_type against the live event_types
catalog instead of the hardcoded whitelist — custom types created in
Settings → Event Types were rejected with 400. BREAKING for the
never-seeded 'family' slug, which the old whitelist silently accepted
and wrote as a dangling reference; create a matching event type to
keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
event_type: 'wedding' — it resolves via crm_default_event_type, then
the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
for shared use (no behavior change)
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
Three review rounds on PR #801; fixes in response:
- isValidEventType: live catalog is authoritative when it has rows — a
deleted or deactivated slug no longer validates via the legacy
fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
updateEventType: refuse deactivating the last active type (unknown
slugs are rejected since the validator change, so an empty active
catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
(a portable-backup restore can leave the key absent) and a normal
admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
stripped from ALL generic settings upserts (/general, /security,
/analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
and a genuinely failed system-type deletion reloads the list and
stays on the step instead of advancing past the only window in which
it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
discover valid slugs; OpenAPI enum replaced with the live-catalog
description
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.
- New wizard step between features and config: edit name/URL prefix,
remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
an admin already exists, false on fresh installs; POST /api/setup/
complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
in-use check extended to quotes; per-type reminder template
(event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
removed from the catalog
- v1 API event creation validates event_type against the live catalog
instead of a hardcoded whitelist (custom types were rejected; the
never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
crm_default_event_type / resolveDefaultEventType instead of
hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
to eventTypeService for reuse)
Two invoice-PDF changes from #794.
1. VAT / free-text note (Benedikt's request, placement A). A new
`crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
free-text line directly under the MwSt. row on every invoice. Data-driven:
the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
totals-block reserve grows by the measured note height so a long note can't
push the grand total into the footer. Read in invoice/render.js, threaded
through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
unaffected.
2. Multi-page footer overlap. On a full continuation page the line-item table
filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
marginBottom-12 — INSIDE that fill zone — so items overlapped the page
number. Move the stamp into the bottom margin (below the content edge),
zeroing that page's bottom margin during the write so it can't trigger
PDFKit's auto-page-break. Verified: on a full page the lowest item text is
at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.
Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
inline check (super_admin bypasses; others limited to owned/ownerless events).
New test covers a settings.edit-holding non-super_admin blocked (403) on all
three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.