PR-3 follow-up. Live smoke surfaced that the @sentry/vue SDK was
running correctly and emitting events, but Crewli's strict
connect-src directive blocked every POST at the browser layer. No
fallback — events evaporated silently with a CSP-violation log in
DevTools console only.
Updated locations (audited the CSP surface; only two locations actually
need the whitelist):
- apps/app/index.html — dev meta CSP, adds http://localhost:8200 to
connect-src so local dev hits the docker-compose GlitchTip stack.
- deploy/nginx/csp-spa.conf — prod organizer SPA CSP, adds
https://monitoring.hausdesign.nl to BOTH the report-only and enforce
add_header lines so a future flip between modes can't silently break
observability.
NOT updated (deviation from prompt):
- api/config/security.php — the API CSP is `default-src 'none';
frame-ancestors 'none'` for JSON responses. Browsers don't enforce
connect-src on JSON contexts (no document, no fetch origin). Adding
connect-src would be semantically a no-op and confuse the deny-by-
default policy.
Regression guard: tests/Feature/Security/CspConnectsToObservabilityTest.
Reads both the dev meta tag and the prod nginx conf directly (the SPA's
CSP is not Laravel-served, so $this->get() can't reach it). Apply-with-
revert verified: stashing both fixes makes both cases fail with a clear
"Refused to connect because it violates the following CSP directive"
hint; popping the stash restores green.
SECURITY_AUDIT.md A13-9 updated with a WS-7 follow-up note documenting
the GlitchTip whitelist as an explicit security control: outgoing
observability traffic restricted to a single known host.
Test count 1549 to 1551. Larastan + Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dual-cookie machinery (crewli_app_token + crewli_portal_token,
Origin-based resolution) was load-bearing only when the second SPA
existed. apps/portal/ was deleted in WS-3 PR-B1; the resolver code
has been carrying dead branches since then. Collapse to one cookie.
Cookie name retained as crewli_app_token — no session breakage on
deploy. crewli_portal_token is fully purged from the server-side.
CookieBearerToken middleware:
- COOKIE_NAMES array → single COOKIE_NAME constant
- resolveCookieName method (Origin/Referer parsing, host+port
matching against frontend_app_url/frontend_portal_url) → removed
- Body collapses to: skip if Authorization header present; else
read crewli_app_token cookie and inject Bearer header
SetAuthCookie trait:
- COOKIE_MAP / resolveCookieName / originMatches → removed
- makeAuthCookie / forgetAuthCookie now take only $token; the
cookie name is the trait's private constant
Five callers updated to drop the resolveCookieName($request) line
and the cookie-name argument: LoginController (3 sites),
MfaVerifyController (1 site), AuthRefreshController (1 site),
LogoutController (1 site), InvitationController (1 site — caller
list in the prompt missed this one but the same pattern applies).
frontend_portal_url config key retained (per Phase A directive Q1):
EmailChangeController, PasswordResetController, PersonController are
non-auth consumers that build per-app URL maps for outbound emails.
The map structure is now functionally redundant (production resolves
all FRONTEND_* env vars to the same host) but stays structurally
intact. Refactor tracked as TECH-FRONTEND-URL-CONSOLIDATE in the
upcoming docs commit.
HttpOnlyCookieAuthTest:
- Removed 4 dual-cookie tests (login_sets_portal_cookie_for_portal_origin,
app_cookie_does_not_authenticate_portal_requests,
portal_cookie_does_not_authenticate_app_requests,
correct_cookie_authenticates_with_matching_origin)
- Renamed login_sets_app_cookie_for_unknown_origin →
login_sets_app_cookie_regardless_of_origin; expanded to four
Origin variants (none, app, unknown, foreign) — pins the new
origin-agnostic contract
- Removed Origin headers from request calls in remaining tests
(now meaningless)
Backend test count: 1491 → 1487 (-4 deleted, dual-cookie tests
encoding the obsolete contract). Pint clean. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: browsers don't scope cookies by port. With SESSION_DOMAIN=
localhost, all three SPAs share cookies. The CookieBearerToken middleware
iterated all cookie names and picked the first match, so logging into
the organizer app (port 5174) also authenticated the portal (port 5175).
Fix: CookieBearerToken now resolves the correct cookie name from the
Origin header (same logic as SetAuthCookie trait). It only reads the
cookie matching the requesting app — portal origin reads only
crewli_portal_token, app origin reads only crewli_app_token, etc.
Falls back to first-available cookie when no Origin header is present
(server-to-server requests, tests without explicit Origin).
Added 3 cross-app isolation tests:
- app cookie does NOT authenticate portal requests
- portal cookie does NOT authenticate app requests
- correct cookie + matching origin = authenticated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
API middleware:
- SecurityHeaders now sets Content-Security-Policy from config/security.php
- Default API policy: "default-src 'none'; frame-ancestors 'none'"
- Supports report-only mode via CSP_REPORT_ONLY env var
- Policy value configurable via CSP_POLICY env var
Nginx deployment configs (deploy/nginx/):
- security-headers.conf: shared headers for all server blocks
- csp-api.conf: restrictive JSON-only policy for api.crewli.app
- csp-spa.conf: SPA policy for app/admin (self + unsafe-inline styles)
- csp-portal.conf: portal policy matching SPA
Development:
- CSP meta tags added to all three index.html files
- Includes 'unsafe-inline' + 'unsafe-eval' for Vite HMR/loader script
- Each app allows its own ws:// port for HMR websocket
Resolves security finding A13-9.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backend:
- CookieBearerToken middleware reads httpOnly cookie and injects Authorization
header before Sanctum validates (prepended to API middleware group)
- SetAuthCookie trait provides cookie creation/expiry helpers with per-app
cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token)
- LoginController sets token via Set-Cookie, removes it from JSON body
- LogoutController expires the auth cookie on logout
- AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie
- InvitationController accept also sets token via cookie, not JSON body
- All cookies: httpOnly, SameSite=Strict, Secure (in production)
Frontend (all three SPAs):
- Removed all localStorage token storage (apps/app, apps/portal)
- Removed all JS-readable cookie token storage (apps/admin)
- Removed Authorization: Bearer header interceptors from axios
- Auth stores now rely on GET /auth/me to validate httpOnly cookie
- Admin app: new Pinia auth store replaces useCookie-based auth pattern
- withCredentials: true ensures browser sends cookies automatically
Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin
cookie flags). Tokens are now invisible to JavaScript.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Password reset: multi-app support with custom notification linking to correct
frontend (app/portal/admin). Email change: self-service with password
confirmation and admin-initiated, both sending verification to new address
with 24h expiry. Confirmation sent to old email on completion. Password
change: authenticated endpoint revoking other sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all authenticated organiser-facing event sub-resource routes from
/events/{event}/... to /organisations/{organisation}/events/{event}/...
to enforce multi-tenancy at the routing layer.
Changes:
- Routes: restructured api.php to nest all event sub-resources under
the existing organisation prefix group
- Controllers: added Organisation parameter and VerifiesOrganisationEvent
trait to all 12 affected controllers (sections, time-slots, shifts,
persons, crowd-lists, locations, shift-assignments, registration-fields,
availabilities, field-values, section-preferences, stats)
- Tests: updated all 20 feature test files with new route paths
- Frontend: updated 8 API composables and 20 Vue components/pages
- API.md: updated documentation to reflect new route structure
Portal routes, public routes (volunteer-register), and invitation routes
remain unchanged as they operate without organisation context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>