RFC §3.6 — context tagging tabel volledig vervangen na de PR-2 follow-up
architecturale fixes. Belangrijkste wijzigingen:
- Tag-binding gesplitst in route-scope (BindSentryRouteContext middleware)
en auth-scope (AuthScopeContextListener op Authenticated event).
- Nieuwe actor_scope tag (organisation/platform/user/anonymous).
- Multi-tenant invariant verfijnd: organisation_id is altijd correct
gerelateerd aan actor_scope in plaats van "altijd aanwezig". Platform-
routes zonder org-context worden niet meer gefabriceerd; default
authenticated user-scope omitt organisation_id (Crewli's User<->Organisation
is many-to-many, geen reliable single-org hint).
- impersonation.* tags expliciet gedocumenteerd als afkomstig uit
HandleImpersonation middleware (post-swap), niet uit auth-listener.
- ActorType waarden bijgewerkt na verwijdering van VOLUNTEER case.
RFC §3.14 — status-note toegevoegd dat D-06 indexes al via Spatie's
nullableMorphs default-migratie zijn aangemaakt, met regression-guard
verwijzing.
§6 acceptance criterium 12 markeert D-06 als al voldaan.
BACKLOG.md krijgt vier nieuwe OBS-entries:
- OBS-1: VOLUNTEER actor_type promotion wanneer rol komt
- OBS-4: PHPUnit metadata deprecation cleanup pre-PHPUnit-12
- OBS-6: sentry-laravel install gap awareness + bootstrap test
- OBS-7: custom render handlers report() invariant + coverage
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-2 verified that Spatie's activitylog default migration creates the
composite indexes RFC-WS-7 §3.14 / addendum D-06 require — via
nullableMorphs('subject') and nullableMorphs('causer'), which emit
indexes named `subject` on (subject_type, subject_id) and `causer` on
(causer_type, causer_id).
This test queries information_schema.STATISTICS and fails if either
composite is missing, regardless of the index name. It guards against
silent regression when:
- A future Spatie major release changes nullableMorphs semantics.
- A developer rewrites the activity_log migration without preserving
the morph indexes.
- A schema-dump regeneration drops them.
Test count 1539 to 1541. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-2 live smoke test surfaced that super_admin platform-route
exceptions arrived without organisation_id, and the original RFC §3.6
invariant (always-present organisation_id on authenticated events)
would force misleading attribution if it tried to fill that gap.
Refined invariant: every authenticated event carries actor_scope
(organisation/platform/user/anonymous), AND when actor_scope is
organisation, organisation_id MUST be a valid ULID. Platform-mode
correctly omits organisation_id rather than fabricate one.
Resolution chain in AuthScopeContextListener:
1. {organisation} or {event} URI parameter -> actor_scope=organisation
2. portal_event request attribute -> actor_scope=organisation
3. super_admin on admin.* named route -> actor_scope=platform
(Crewli's platform-admin routes use the admin. name prefix)
4. Default authenticated -> actor_scope=user, no org tag
(User<->Organisation is many-to-many; no reliable single-org hint)
Eight new test cases in AuthScopeContextListenerTest cover each branch
and the conditional invariant, including ULID validity via
Symfony\Component\Uid\Ulid::isValid.
Test count 1531 to 1539. Larastan clean. Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sentry-context binding split into two responsibilities:
- Route-scope (app, http.method, route_name) stays in middleware on
the api group as BindSentryRouteContext — works on every request,
no auth required.
- Auth-scope (user_id, actor_type) moves to AuthScopeContextListener
on Illuminate\Auth\Events\Authenticated — works on every
authentication mechanism (Sanctum, portal-tokens, future
authenticators) without per-route middleware-attachment. Listener
also augments Log::withContext with user_id (closes OBS-2).
Architecturally fault-preventing rather than fault-detecting: new
authenticated route groups need no separate sentry.context aliasing,
so silent observability gaps are no longer possible (closes OBS-3).
Impersonation tagging is co-located with HandleImpersonation: after
the user-swap, the middleware re-tags Sentry scope with the target
user_id/actor_type and adds impersonation.active /
impersonation.impersonator_user_id / impersonation.session_id. The
Authenticated event fires for the admin (Sanctum's natural flow),
the listener tags the admin, then HandleImpersonation overwrites
post-swap.
Files renamed:
- BindSentryContext -> BindSentryRouteContext (route-scope only)
- BindSentryContextTest -> BindSentryRouteContextTest (4 cases)
Files added:
- AuthScopeContextListener
- AuthScopeContextListenerTest (6 cases)
bootstrap/app.php drops the sentry.context alias and prepends
BindSentryRouteContext to the api group. routes/api.php drops every
sentry.context middleware string from auth:sanctum groups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VOLUNTEER was reserved-but-unused. Resolver mapped non-admin
authenticated users to ORG_MEMBER because Crewli has no dedicated
volunteer Spatie role; volunteer-ness is behaviour (shift assignments),
not identity.
Dead enum cases are YAGNI violations under zero-compromise: a future
developer could use the case without realising no resolution path
leads to it, producing a silent no-op. Re-introduce alongside a real
volunteer role split when that lands (BACKLOG OBS-1).
ActorType keeps ORGANIZER_ADMIN, SUPER_ADMIN, PORTAL_TOKEN, ORG_MEMBER,
UNAUTHENTICATED. Tests at 1537, Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scrubber is fully stateless. Container-resolution per event was
overhead without value, the closure indirection polluted the config
layer with executable logic, and stack traces showed an anonymous
closure frame instead of the class name.
- SentryEventScrubber::scrub() and its private helpers all become
static methods. No instance fields, so the change is mechanical.
- config/sentry.php before_send switches from a closure that calls
app() to PHP array-callable notation [Class, method]. Symfony
OptionsResolver accepts array-callables for static methods.
- PiiScrubbingTest swaps (new SentryEventScrubber)->scrub(...) for
SentryEventScrubber::scrub(...). Semantics unchanged.
Tests 1537 unchanged. Larastan and Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-2 follow-up. The PR-2 backend SDK install passed unit tests because
they exercised the scrubber and the BindSentryContext scope writer in
isolation, but live exceptions from controllers never reached
GlitchTip — they were correctly logged to laravel.log but the report()
call had no Sentry-aware reporter to invoke.
Root cause: sentry-laravel 4.x does NOT auto-register an exception
reporter. The host application is required to wire Integration::handles
inside withExceptions in bootstrap/app.php (per the package README and
Sentry docs). Without it, report and Laravels automatic
report-before-render flow only hit the default log channel.
Fix: add Integration::handles at the top of withExceptions so
sentry-laravel registers a reportable callback that calls
captureUnhandledException for every reported throwable. Filtering
remains downstream:
- ignore_exceptions in config/sentry.php drops Validation,
Authentication, Authorization (RFC §3.10).
- SentryEventScrubber::scrub returns null for sub-500 HttpException
via the before_send hook (RFC §3.7).
Regression coverage: tests/Feature/Observability/ExceptionReportingTest
installs a real Sentry client with a recording before_send and exercises
the full request to capture pipeline through the auth and sentry.context
middleware. Five cases: RuntimeException IS captured (with §3.6 tags
attached), ValidationException is not, NotFoundHttpException 404 is
not, AuthorizationException 403 is not, request-context tags ride along
on the captured event.
Test count: 1532 to 1537. Larastan clean. Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-7 PR-2 commit 3. RFC §3.13.
- app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log
line written during the request with request_id, organisation_id,
user_id, and route name. Sets X-Request-Id on the response so the
SPA can correlate to backend log lines via one click.
- Client-supplied X-Request-Id is honoured only if it parses as a ULID
via Str::isUlid. Junk input (empty, non-ULID) is rejected and a
fresh ULID is generated server-side.
- Registered as a global api-group middleware via the prepend list so
it runs before authentication. Unauthenticated 4xx responses still
carry the X-Request-Id header.
- Test count: 1523 to 1532. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-7 PR-2 commit 2.
- app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the
active Sentry scope (app, http.method, route_name, actor_type,
user_id, organisation_id, event_id, impersonation). Multi-tenant
invariant: throws RuntimeException in local/testing when an auth
request to a tenant-scoped route lacks organisation_id; logs a
warning in production so the user flow still completes.
- app/Listeners/Observability/TagJobAttemptOnSentry.php: tags
queue.attempt on the scope from the JobProcessing event. Default
stack-trace grouping preserved per §3.11.
- ActorType: VOLUNTEER case reserved for a future role split. Current
resolver maps non-admin authenticated users to ORG_MEMBER.
- bootstrap/app.php: registers sentry.context alias. Applied inside
auth:sanctum groups in routes/api.php so it runs after auth.
- AppServiceProvider::boot registers the queue listener.
Test count: 1507 to 1523. Larastan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operational docs for the GlitchTip stack landed in the previous two
commits.
- dev-docs/GLITCHTIP.md: new runbook covering local dev, project
provisioning + DSN-to-vault flow, production deploy on
monitoring.hausdesign.nl (DNS, DirectAdmin Let's Encrypt, Apache
reverse proxy with WS upgrade), backup install + restore drill,
smoke tests, troubleshooting.
- dev-docs/SETUP.md: services table now includes GlitchTip; new
docker/glitchtip/.env subsection points at the runbook.
- dev-docs/RFC-WS-7-OBSERVABILITY.md §3.1: amended to record that the
same compose file drives local dev (Mailpit at bm_mailpit:1025), so
prod and dev cannot drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Daily pg_dump → gzip → retention pipe for the GlitchTip database.
Configurable via env vars (defaults: ./backups/glitchtip, 30-day
retention, glitchtip-postgres container). Streams directly through
gzip so no plaintext dump touches disk; output 0600.
Cron example in the script header. RFC-WS-7-OBSERVABILITY §5
acceptance criterion 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-7 PR-1 — bring up self-hosted GlitchTip alongside the existing
dev stack. One compose file is portable to the production monitoring
host (RFC-WS-7 §3.1).
- docker-compose.glitchtip.yml: web/worker/postgres/redis pinned, web
bound to 127.0.0.1:8200, internal network for postgres + valkey.
- docker/glitchtip/.env.example: documented dev defaults + production
checklist; .env itself ignored.
- Makefile: services / services-stop merge both compose files; new
services-glitchtip-status tail target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two charter amendments from the original WS-7 brief:
- Sentry -> GlitchTip (self-hosted, protocol-compatible). Same
Sentry SDKs on backend (sentry-laravel) and frontend
(@sentry/vue), pointed at a self-hosted GlitchTip DSN. Avoids
Sentry SaaS pricing and keeps event data on infrastructure
Bert controls.
- Performance monitoring out of scope (errors-only). WS-7
delivers exception capture + alerting + scrubbing + RBAC
only. APM/tracing/spans deferred to a later workstream if
ever needed; pre-launch with no users, the cost/benefit
doesn't justify it now.
RFC-as-first-commit pattern (per WS-6) so the scope-alignment
document is in main before any infra/code changes land.
WS-3 PR-C delivered the per-file DELETE/REWRITE/KEEP_AND_PURGE
matrix on all 9 files referenced in the entry, plus the
out-of-scope post-edit-eslint.sh hook fix. Doc-rot removed:
~80 KB of obsolete bootstrap and prompt-template content.
Single SPA, single cookie, single deploy host. WS-3 complete.
Five files removed, all describing project states that no longer
apply post-WS-TOOLING-001:
- .cursor/instructions.md (8.4 KB): Phase 1-4 roadmap with all
checkboxes empty; Phase 1 has been done for ~6 months. Broken
'make portal' target. Content overlaps with CLAUDE.md.
- .cursor/ARCHITECTURE.md (18.9 KB): pre-WS-3 framing (dual SPA,
dual cookies, dual SANCTUM_STATEFUL_DOMAINS) AND pre-Form-Builder
schema (volunteer_profiles, public_forms with JSON fields). All
six sections superseded by SCHEMA.md, AUTH_ARCHITECTURE.md,
design-document.md, API.md, 102_multi_tenancy.mdc.
- dev-docs/MASTER_PROMPT_CC.md (13 KB): 'paste this above every task'
workflow superseded by auto-loaded CLAUDE.md and structured
Claude Chat-authored prompts. Stale dual-SPA + pre-Form-Builder
assumptions throughout.
- dev-docs/MASTER_PROMPT_CURSOR.md (7.5 KB): same workflow obsoletion;
Cursor is now IDE-only (Claude Code does all implementation).
.cursor/rules/ system handles IDE-level guidance.
- dev-docs/dev-guide.md (32 KB): bootstrap-from-scratch document
containing embedded snapshots of pre-Form-Builder CLAUDE.md,
pre-Form-Builder SCHEMA.md, pre-Form-Builder API.md as
copy-paste templates. Section 5 prompts pre-WS-TOOLING-001 era.
Section 6 (agents) overlaps with CLAUDE_CODE_TOOLING.md.
Total: ~80 KB doc-rot removed.
Cross-reference check found four files outside the deleted set
referencing the deleted paths; all updated in the same commit:
- README.md: Documentation table rebuilt around CLAUDE.md +
dev-docs/* (also dropped stale resources/design/ row pointing
at a directory that no longer exists, and corrected docs/*
paths to dev-docs/*)
- dev-docs/CLAUDE_DESKTOP_SETUP.md: dropped MASTER_PROMPT_CC,
MASTER_PROMPT_CURSOR, dev-guide entries from the
bewust-verwijderd exclusion list; updated Gerelateerd pointer
from dev-guide.md -> SETUP.md
- dev-docs/ARCH-CONSOLIDATION-2026-04.md: updated future-distribution
pointer from dev-guide.md -> SETUP.md (sprint briefing is
historical so the change is purely doc-hygiene)
- dev-docs/VIBE_CODING_CHECKLIST.md: removed Dev guide row from
the bestandspaden table
Remaining references in dev-docs/BACKLOG.md (lines 862-869) live
inside the TECH-DOCS-APPS-PORTAL-PURGE entry that closes in the
next commit.
Canonical replacements: dev-docs/SETUP.md (rewritten this PR),
CLAUDE.md, CLAUDE_CODE_TOOLING.md, and the ARCH-*.md series.
Three pre-WS-3 references purged: regex (apps/(app|portal)),
grep (apps/(app|portal)), and the now-obsolete "apps/portal/ is
planned but not present" defensive comment. The $spa variable
becomes redundant with only one SPA — collapsed to direct
apps/app/ references.
Net: simpler script, no behavioural change for actual files in
apps/app/ (still runs pnpm eslint --fix). Files outside apps/app/
were already a no-op.
Single-line fix in the hooks reference table. The post-edit-eslint
hook used to scope to apps/app/ or apps/portal/; post-WS-3 there's
only apps/app/.
Code change in the hook script itself lands in the next commit.
Surgical updates reflecting post-WS-3 single-SPA reality. The
OrganisationScope rules, three-level authorization, and invitation
flow are unchanged — they're still the canonical guidance.
Changes:
- globs: drop apps/portal/**/*.{vue,ts}
- Portal Architecture: "two access modes in apps/portal/" ->
"two access modes under /portal/* routes within apps/app/"
- Token flow URL example: portal.crewli.app -> crewli.app/portal/
with note about 301 redirect from legacy host
- Login flow URL: portal.crewli.app -> crewli.app
- CORS allowed_origins: drop FRONTEND_PORTAL_URL line
- Production example: collapse dual-host to single-host with
pointer to AUTH_ARCHITECTURE.md §11 for the legacy env key
Drops 17 KB of embedded code templates that had drifted from actual
implementations in apps/app/src/ (auth store template still used
localStorage; portal router guards still showed dual-mode logic that
was consolidated to /portal/* routes within apps/app in PR-B1/B2a).
Slim rewrite: principles + file structure + pointers to actual
reference code in apps/app/src/. Globs narrowed to apps/app/**/*
since apps/portal/ no longer exists. Vuexy component selection
deferred to dev-docs/VUEXY_COMPONENTS.md as canonical registry.
Net: ~17 KB -> ~3 KB, less drift surface, points at living code
instead of duplicating it.
Three load-bearing files still described the pre-WS-3 dual-SPA
reality. Surgical edits to reflect the single-SPA architecture
shipped in WS-3 PR-B (B1: portal moves; B2a: auth+routing
consolidation; B2b: server-side cookie consolidation).
CLAUDE.md:
- Quality-gates ts-reset bullet (line 27): "both SPAs" → "the SPA"
- Quality-gates Vitest bullet (lines 30-32): rewrite from "apps/portal
has 113+ tests; apps/app currently has no Vitest setup (TECH-APP-VITEST)"
to current truth: apps/app has Vitest with 213 tests as of PR-B2a.
TECH-APP-VITEST is implicitly closed.
- Repository layout (line 44): drop apps/portal/ bullet; rephrase
apps/app/ as the single workspace
- "Apps and portal architecture" → "App architecture": rewrite for
single-workspace + two access modes. Login-based covers
organizers + volunteers + crew + super_admin (context-routed
in-app via useAuthStore.availableContexts); token-based covers
artists, suppliers, press
- CORS subsection: collapse two-origin config to single origin
(localhost:5174 dev, https://crewli.app prod). Preserve the
existing crewli.nl marketing-only note
WS-TOOLING-001 sections (Larastan, Rector, Telescope tooling
configuration) verified untouched via `git diff CLAUDE.md`.
README.md (line 25): collapse the Applications table from two rows
(Organizer + Portal) to one (SPA). Adjust trailing sentence accordingly.
Makefile:
- .PHONY list: drop `portal`
- help echo: drop "make portal" line
- portal target: removed (the underlying `cd apps/portal && pnpm dev`
would fail since the directory was removed in PR-B1)
Out of scope (deferred to TECH-DOCS-APPS-PORTAL-PURGE backlog item):
.cursor/ instructions, MASTER_PROMPT_*, dev-docs/SETUP, dev-docs/dev-guide,
dev-docs/CLAUDE_CODE_TOOLING. WS-3-SESSION-1C-AUDIT.md skipped (frozen
historical doc).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dev-docs/AUTH_ARCHITECTURE.md (v1.0 → v2.0):
- Title section updated to single-SPA / single-cookie reality
- Client Applications table collapsed to one row
- Cookie Specification table collapsed to one row (crewli_app_token)
- Token Lifecycle / Validation section: Origin-based resolution
language removed; middleware described as origin-agnostic
- Cross-app isolation paragraph removed (no second app)
- Configuration Reference table marks FRONTEND_PORTAL_URL as legacy,
pointing at TECH-FRONTEND-URL-CONSOLIDATE
- New §11 "History" preserves the pre-WS-3 dual-cookie context for
future readers, mentions PR-B2a + PR-B2b roles in the unwind
dev-docs/BACKLOG.md — three new entries:
- TECH-FRONTEND-URL-CONSOLIDATE: refactor email controllers to drop
per-app URL map (EmailChangeController, PasswordResetController,
PersonController) — low priority, code-cleanliness only
- TECH-DOCS-APPS-PORTAL-PURGE: sweep apps/portal references from
briefing/tooling docs (.cursor/, MASTER_PROMPT_*, SETUP, dev-guide,
CLAUDE_CODE_TOOLING) — single chore(docs) PR, low priority
- OPS — DNS retirement of portal.crewli.app — operational task,
deferred until traffic monitoring confirms zero usage
dev-docs/SECURITY_AUDIT.md:
- A13-1 narrative actualised: pre-WS-3 dual-cookie context kept as
history, status flipped to RESOLVED (the localStorage→httpOnly
migration shipped earlier in the consolidation arc)
- A13-3: status flipped to RESOLVED by WS-3 PR-B2b; description
rewritten to reflect the new postLoginRedirect.ts validator and
the 16 spec coverage
- Priority remediation table item 8 strikes through A13-3
Backend test suite: 1487 passed (unchanged from Commit 2 baseline).
Frontend: 223 passed (unchanged from Commit 1 baseline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
deploy.sh referenced apps/portal which was deleted in WS-3 PR-B1; the
script has been broken in main since that merge (npm run build
-w apps/portal would fail). Collapse to a single-app build.
Changes:
- deploy.sh: replace dual-build block (build app + portal, verify both
dist/) with single-app build (build app, verify dist/index.html)
- deploy/nginx/csp-portal.conf: deleted (content was identical to
csp-spa.conf — verified before removal)
- deploy/README.md: replace "Portal (portal.crewli.app)" server-block
section with "Legacy portal redirect" — a 301 server block
template that redirects portal.crewli.app → crewli.app preserving
the request URI. Notes that DNS retirement is a separate ops task
Out of scope: actually retiring the portal.crewli.app DNS record
(operational, tracked separately).
bash -n deploy.sh: 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>
Replaces the WS-3 PR-B2a minimum precaution (`startsWith('/') &&
!startsWith('//')`) with a layered validator that rejects every input
that is not a strict relative path.
isSafeRelativePath rejects:
- Empty / null / undefined input
- Non-`/`-prefixed paths (including leading whitespace)
- Protocol-relative URLs (`//evil.com`)
- Backslash anywhere (browsers normalise `\` → `/` in some contexts;
`/\evil.com` parses as `//evil.com`)
- ASCII control characters `\x00`–`\x1F` and `\x7F` (NUL, tab, LF, CR,
DEL, etc. — header-injection vectors)
- Anything the URL constructor parses to a different origin than the
synthetic invalid origin used as the resolution base
The URL-constructor check is the authoritative guard; the prefix and
character checks are fast pre-filters that short-circuit common
attack shapes without paying the URL allocation.
Test coverage expands from 6 → 16 cases. New cases pin the
backslash, control-character, leading-whitespace, and positive-
character-set contracts. The URL-encoded-slash-in-query case
documents that we don't false-positive on `%2F` in query strings.
Closes A13-3 (open-redirect on post-login).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the deterministic guard-rail layer: hooks reference,
crewli-reviewer subagent usage, three slash commands, how to test
hooks, how to disable temporarily, and the binding design principle
(settings/hooks deterministic, CLAUDE.md advisory — never duplicate).
/sprint-status — branch, last package, uncommitted work, next BACKLOG item.
/review-multitenancy <Model> — model+migration+policy+tests checklist.
/sync-docs — runs the dev-docs sync pipeline and reminds to upload .claude-sync/.
Each command's frontmatter declares a least-privilege allowed-tools list.
Isolated-context code review against the zero-compromise principles.
Read/Grep/Glob/Bash only — no Edit, so the reviewer cannot patch
code. Outputs MUST FIX / SHOULD FIX / CONSIDER, every finding cited
as path:line.
inject-sprint-context.sh fires on SessionStart with matcher=compact
and emits branch, last 10 commits, and the top of BACKLOG.md so
Claude resumes with sprint context after auto-compaction. Output
capped at ~600 tokens.
post-edit-pint.sh runs vendor/bin/pint --dirty from api/ after any
.php edit. post-edit-eslint.sh runs pnpm eslint --fix inside the
matching SPA dir for .vue/.ts/.tsx/.js files under apps/app/ or
apps/portal/. Both exit 0 unconditionally — formatting failures must
not block the agent.
protect-files.sh blocks Edit/Write to secrets, lock files, default
Laravel migrations, the deleted apps/admin/ tree, .claude/ itself,
and dev-docs/SCHEMA.md.
block-dangerous-bash.sh blocks destructive git operations, blanket
dependency updates, and database wipes that aren't scoped to the
testing environment.
Both signal block via exit 2 with a reason on stderr; both stay well
under 500ms per invocation.
Registers PreToolUse, PostToolUse, and SessionStart hooks for the
deterministic guard-rail layer. settings.local.json stays gitignored
for per-user overrides.
Workstream-sized item geborgt voor uniforme typed + runtime-validated
contracts op de API-grens (backend PHP Enums, frontend Zod schemas,
codegen TS types). Scope, sequentie (post-PR-C/WS-7, pre-RFC-FORM-BUILDER-UI),
en open beslissingen vastgelegd. Verwijst naar dev-docs/ARCH-API-VALIDATION.md
skeleton voor architectuur-detail.
Voorkomt dat S3b technische schuld stapelt — landt vóór RFC-FORM-BUILDER-UI
zodat nieuwe composables vanaf dag één het gevalideerde patroon consumeren.
The card consumed the API directly via useVerifyMfa() (TanStack Query
mutation). Per Decision F's intent (store owns business logic, the
component consumes typed results), the card now calls
useAuthStore.verifyMfa() and pattern-matches on the MfaVerifyResult
discriminated union.
Changes:
- MfaChallengeCard: drop useVerifyMfa import; call authStore.verifyMfa
with camelCase args (sessionToken, trustDevice, deviceFingerprint,
deviceName); local isVerifying ref replaces verifyMutation.isPending.
On result.kind === 'authenticated' emit `verified` (no payload —
the store has already refreshed user state); on 'failed' surface
result.reason with a generic fallback.
- emit signature: `verified: [data: unknown]` → `verified: []`.
- login.vue: onMfaVerified no longer calls authStore.refreshUser —
authStore.verifyMfa() refreshes internally. Page just routes to
resolvePostLoginTarget().
Adds 4 vitest specs in components/auth/__tests__/MfaChallengeCard.spec.ts
covering: success path emits `verified` with camelCase args, failure
path shows reason and suppresses emit, trustDevice toggle honours
fingerprint + device name, fallback message when reason is empty.
Test count 209 → 213. Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>