Post-merge cleanups after #403 (customer portal):
- Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's
090_add_customer_accounts ... 095_add_customer_portal_flag chain.
- customerAccountsService.js: TODO note on must_change_password
documenting that the column is decorative until an admin
pre-loaded-password flow ships (mirrors what adminAuth does for
must_change_password today).
- customerAuth.js: doc-comment on the /login route explaining why the
customerPortal feature flag deliberately doesn't gate it (toggle off
hides UI, doesn't revoke existing-customer access; deactivate
individual accounts to lock out).
- 095_add_customer_portal_flag.js: header comment said "Migration 094"
(copy-paste from 094) — now matches the filename.
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.
- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
(skips videos, skips S3 deployments — those need the writer fix
alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
'inside' (only kicks in when the seed setting is missing — existing
installs keep their saved value). Add UI tooltip recommending
'inside' for masonry/mosaic/justified, 'cover' for uniform grids.
i18n covers all six locales.
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.
* Backend: restored GET/PUT /admin/settings/customer-surface
endpoints, whitelisted only to the two branding keys
(customer_show_logo, customer_show_company_name). The
calendar/quotes/bills feature globals that used to live on this
endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
app_settings again so /api/customer/auth/session honours the
toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
flow — separate from the main BrandingPage payload so flipping a
toggle doesn't replay the full branding mutation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The route was registered in upstream/beta's server.js but dropped
during the rebase squash — the Features tab GET/PUT both 404'd, so
the customerPortal flag (and every other flag) couldn't be toggled.
Restored the mount in its upstream/beta position.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
server.js was still requiring ./src/middleware/requireCustomerPortal
— a file deleted during the AdvancedFeaturesTab cleanup — which
crashed the backend on boot in production (MODULE_NOT_FOUND).
The customerPortal feature flag is now enforced on the frontend via
<RequireFeature flag="customerPortal" /> route guards (App.tsx) and
AdminSidebar visibility. Defence in depth is provided by
customerAccountsService.isCustomerPortalEnabled() in adminEvents.
Routes themselves are still protected by adminAuth / customerAuth.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The customer-portal squash inadvertently reverted the upstream/beta
fix from PR #427: production NODE_ENV was flipping the cookie Secure
flag back to hard `true`, which broke admin login on
HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops
the Secure cookie over HTTP, login loops indefinitely).
Restored upstream/beta's tokenUtils.js verbatim and re-layered only
the customer cookie helpers (CUSTOMER_COOKIE_NAME,
setCustomerAuthCookie, clearCustomerAuthCookie,
getCustomerTokenFromRequest) on top.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Combined footer overhaul:
- Per-CMS-page show_in_footer toggle (#441) — admins can hide
Impressum / Datenschutz from the gallery footer when an external
privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
global default authored as markdown in branding settings, plus a
three-way per-event override on the Edit Event form
(inherit / custom / off). Backend nulls promo_markdown automatically
when mode != 'custom' so stale text never persists.
Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.
i18n covers all six locales (en/de/nl/pt/ru/fr).
Targets the beta branch.
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."
The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:
Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected
The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.
Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.
Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.
Verified end-to-end with toggle ON:
STEP 1: create with expiration → ok (unchanged)
STEP 2: create without expiration → backend auto-applies default 30d
(create-time enforcement intact)
STEP 3: PUT {expires_at: null} on existing → "Event updated
successfully" (was 400)
STEP 4: DB column expires_at is NULL
STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
date input sends when cleared)
Smoke 13/13 green; no regressions.
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.
The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.
Changes:
- Add getRange(relPath, start, end) to the StorageBackend interface +
LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
(downloadStream with Range header). Needed for video range requests
on S3 — previously the photo route did fs.createReadStream(filePath,
{start, end}) which is local-only.
- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
via storage.get. Watermark application path materializes the source
via withLocalCopy (no-op in local mode, downloads to a tmp file then
cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
works.
- /:slug/photo/:photoId — branches on source_origin: external/reference
photos still use the local fs path (NAS mounts are local), managed
photos use the storage abstraction. Video range requests pass through
to storage.getRange. Pre-generated watermarks served via storage too.
On-the-fly watermark generation uses withLocalCopy for managed photos.
- /:slug/hero/:photoId — hero images are always managed-storage keys
(imageProcessor.generateHeroImage writes via the storage abstraction),
so this just switches to storage.stat + storage.get. Watermark via
withLocalCopy.
Verified end-to-end against minio in dev:
POST /api/admin/photos/N/upload → photo + thumbnail land in S3
GET /api/gallery/<slug>/thumbnail/<id> → 200, JPEG 300x300 ✓
GET /api/gallery/<slug>/photo/<id> → 200, JPEG 1200x800 ✓
GET /api/gallery/<slug>/hero/<id> → 200, JPEG 1920x1080 ✓
ETag round-trip (If-None-Match) → 304 ✓
Backend logs → no errors
LocalFs regression: 13/13 smoke tests pass.
Closes#432.
Two intertwined bugs reported in #427 by @iSchumi6210:
1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
when NODE_ENV=production. Over plain HTTP the browser drops the Secure
cookie → next /auth/session request returns 401 → redirect back to
/admin/login → no error shown. picpeak-setup.sh writes
NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
install without a reverse proxy hits this.
2. Admin password is generated but admins can't find it. The 001_init.js
migration writes the generated password to data/ADMIN_CREDENTIALS.txt
inside the backend container, but picpeak-setup.sh only copies it out
when --reset-admin-password is passed. Default-path users never see it
and resort to manual bcrypt updates in psql.
Changes:
- tokenUtils.js: production default goes from `true` to `'auto'`. On real
HTTPS req.secure is true → Secure flag is still emitted (no security
regression for reverse-proxy deployments). On plain HTTP req.secure is
false → Secure flag omitted → login works. Users who explicitly want
the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.
- .env.example: rewrite the COOKIE_SECURE block to make the new default
obvious and explain when to override (set =true for strict, =false to
skip the per-request check, leave unset for the auto behaviour).
- picpeak-setup.sh (both Docker and native paths):
- Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
depth so the right behaviour is preserved even if the backend default
flips again later)
- After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
backend container/data dir to the host data dir, chmod 600, and print
the email + password to the install output. The credentials file
remains as a backup record that the operator should delete after
noting the password.
Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:
production, unset → HTTPS: secure=true ✓ HTTP: secure=false ✓ (was both true)
production, =true → both: secure=true (strict opt-in preserved)
production, =auto → HTTPS: secure=true HTTP: secure=false (already-correct)
development, unset → both: secure=false (dev unchanged)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.
Two halves:
1. import-external route generates the thumbnail right after each
successful insert and writes thumbnail_path on the row. Best-effort:
a single failure logs a warning and leaves thumbnail_path=NULL —
ensureThumbnail will retry lazily on first view. Synchronous in the
loop adds ~100-300ms per image; for the worst-case 1000-photo import
that's still under the typical request timeout.
2. ensureThumbnail() in imageProcessor handles external photos too —
resolves the local NAS mount path via resolvePhotoFilePath instead of
the storage-backend key. This covers existing externals already in
the database that were imported before this fix: first gallery view
per photo regenerates the thumbnail, subsequent views are fast.
Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.
Verified locally with a 3-photo external dir and a real NAS-style import:
POST /api/admin/external-media/events/N/import-external
→ {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
/api/gallery/<slug>/photos returns thumbnail_url for every photo
Lazy-regen path: clearing thumbnail_path + deleting the file, then
hitting /thumbnail/N regenerates and repopulates the row in 42ms.
Closes#423.
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.
Changes:
- Add migration 087: insert a dedicated `version_update_test` email
template (EN + DE, matching the existing version_update_available
convention) with copy that reads as a config-check rather than as a
real update notice. Subject prefixed with [TEST] so it's unambiguous
in the inbox. Variables: current_version, channel, recipient_email.
- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
in updateNotificationService.js. The new path:
- Always sends — no updateAvailable bail-out.
- Uses the version_update_test template.
- Falls back gracefully if checkForUpdates fails (so a transient
GitHub API hiccup doesn't block a config-check email).
- Does NOT update last_notified_version — that field stays owned by
the real-update path so a test send doesn't shadow a future
genuine notification for the same version.
- Wire /admin/system/updates/notifications/send to the renamed function.
No frontend change needed (the button already calls this endpoint).
Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).
Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.
Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.
i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.
Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings
were already resolved by PR #412 (the 18-CVE backport); this PR addresses
the residual real items:
* Drop unused `handlebars` from backend deps. The runtime require was
removed in PR #367 (#367) but the package.json line stayed. handlebars
was the source of two flagged criticals (CVE-2026-33937 RCE,
GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone.
* `npm audit fix` on backend + frontend. Bumps transitive picomatch,
flatted, postcss, brace-expansion via lockfile, and direct dompurify,
lodash, vite, i18next-http-backend within their existing semver ranges.
Both audits now report 0 vulnerabilities.
* Add `event.origin === window.location.origin` check to the THEME_PREVIEW
message listener in PreviewPage. The branding page posts from the same
origin, so nothing legitimate is rejected; without the check, any third
party that window.open()'d the preview could push arbitrary
branding/theme payloads (semgrep
insufficient-postmessage-origin-validation).
* nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Content-Security-Policy, Permissions-Policy,
Strict-Transport-Security at server level. nginx adds these itself, but
helmet on the backend was also emitting them — clients were seeing
duplicates (testssl flagged "Multiple X-Frame-Options / CSP /
Permissions-Policy / Referrer-Policy headers" on the live origin).
Single source of truth now.
* Dockerfile hardening (checkov):
- HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev,
frontend/Dockerfile.dev. Frontend production Dockerfile already had
one.
- USER node in frontend/Dockerfile.dev (was running as root).
* GitHub Actions docker-build.yml: explicit top-level
`permissions: contents: read`. Per-job blocks already declare
`packages: write` where needed; this stops future steps from
inheriting unintended privileges (CKV2_GHA_1).
Backend npm audit: 4 vulns -> 0.
Frontend npm audit: 6 vulns -> 0.
Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip).
Frontend type-check + lint: clean.
The pre-existing integration-test failures (live DB / S3 required) and
the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated
and reproduce on origin/beta without these changes.
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.
## Direct dependency bumps
| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |
## Transitive bumps (npm overrides)
For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:
| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |
## Why axios is now safe to bump past 1.14.0
PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.
## Verified
* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
warning, not new)
* Backend module-load smoke test — all critical modules load
(`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
`storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
the patched version range
## Remaining out of scope
* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
live in the Node base image and require a Node base image bump
with its own compatibility testing — separate PR.
Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.