Skip to main content
Petanque Life

Multi-factor Authentication & Advanced Security

F02.07 117 features Shipped

At a glance

Multi-factor Authentication & Advanced Security adds TOTP, SMS-OTP via 46elks, email-OTP fallback, WebAuthn passkeys, recovery codes and trusted-device skip on top of the authentication core, with per-tenant enforcement policy, exponential brute-force backoff, complete session management and security email notifications on every meaningful event. SecurityEvent provides an append-only audit trail across the entire surface, surfaced to the user via GET /auth/security-events.

How it works

MFA is decoupled from authentication: the user proves identity once via F02.02 or F02.06, and step-up factors are demanded by policy at sensitive endpoints. GET /auth/mfa/methods reports which factors a user has enrolled (TOTP, WebAuthn credential count, SMS-bound phone, email). TOTP integrates with Google Authenticator, 1Password, Authy and any RFC 6238 client.

SMS-OTP uses 46elks: POST /auth/mfa/sms/challenge issues a code stored as SHA-256, the user POSTs to /auth/mfa/sms/verify, and a 5-attempt cap with PII-masked phone numbers prevents enumeration. Email-OTP is the break-glass fallback for lost SIMs/authenticators. WebAuthn passkeys are surfaced via the same enrollment-detection endpoint and are preferred for high-assurance roles.

Enforcement is per-tenant, per-role: PUT /auth/mfa/policy lets a tenant admin require MFA for selected roles, choose allowed methods, set a grace_period_days for new users to enrol, and define fresh_window_seconds for step-up. GET /auth/mfa/enforcement tells a client whether the current session needs to enrol now, is in grace period, or is fully compliant. Recovery codes (10 single-use codes per user) are generated via POST /auth/mfa/recovery-codes/generate, stored hashed, verified via POST /auth/mfa/recovery-codes/verify and immediately upgrade the token to MFA-satisfied; regenerating invalidates the previous batch.

Trusted devices skip MFA for 30 days: POST /auth/mfa/trusted-devices stores a SHA-256-hashed fingerprint after a successful MFA, GET .../check confirms a fingerprint at login, and the user can list and DELETE individual devices from settings. Sessions are tracked as RefreshTokenFamily entries: GET /auth/sessions lists active sessions with device/location metadata, DELETE /auth/sessions/{id} revokes one, POST /auth/sessions/revoke-all keeps only the current device. Brute-force protection on the MFA verify endpoints uses a BruteForceRecord with exponential backoff (1→2→4→8→16→30 s, capped) per consecutive failure, reset on success.

Every meaningful event — new_login, mfa_enrolled, mfa_disabled, recovery_code_used, trusted_device_added/removed, session_revoked — produces a SecurityEvent and dispatches an email through SecurityNotificationService, giving users real-time visibility and an auditable history at GET /auth/security-events.

Key capabilities

  • TOTP, SMS-OTP (46elks), email-OTP and WebAuthn passkeys with enrolment detection
  • Per-tenant MFA policy: required roles, allowed methods, grace period, fresh window
  • 10 recovery codes per user, hashed, single-use, regenerable
  • Trusted-device skip for 30 days with SHA-256-hashed fingerprints
  • Session management: list, revoke single, revoke all but current
  • Exponential brute-force backoff on MFA verification
  • SecurityEvent append-only audit with email notifications via SecurityNotificationService

In practice

A national federation enables MFA-required for all Club Treasurers with a 14-day grace period. A treasurer logs in the next morning, sees a banner showing 12 days remaining, and enrols TOTP using 1Password. He generates 10 recovery codes and stores them in his password manager.

A week later he opens the financial export endpoint; the fresh-auth window has expired, so he is prompted for a TOTP code. On a business trip he loses his phone; from his laptop he uses a recovery code to sign in, the system burns the code, sends a security email, and creates a SecurityEvent. He then opens GET /auth/sessions, revokes the now-stolen device, and adds his replacement phone as a trusted device after re-enrolling TOTP — all without contacting support.

Features in this subsystem

117
ID Status Features
F02.07.01 Shipped TOTP authenticator app (Google Authenticator, 1Password, Authy) — enrollment detection via GET /auth/mfa/methods ✅ PL-F0207a
F02.07.02 Shipped SMS-OTP via 46elks — POST /auth/mfa/sms/challenge, POST /auth/mfa/sms/verify, SHA-256-hashad kodlagring, PII-maskering av telefonnummer, single-use med 5-försökstak ✅ PL-F0207a
F02.07.03 Shipped Email-OTP fallback — POST /auth/mfa/email/challenge, POST /auth/mfa/email/verify, break-glass recovery för tappad SIM/authenticator ✅ PL-F0207a
F02.07.04 Shipped WebAuthn / Passkeys — enrollment detection via GET /auth/mfa/methods, WebAuthnCredential-count ✅ PL-F0207a
F02.07.05 Shipped MFA enforcement per roll — GET /auth/mfa/enforcement med policy-besked (required/enrolled/grace_period), GET /auth/mfa/policy, PUT /auth/mfa/policy admin-upsert, per-tenant konfigurerbara required_roles/allowed_methods/grace_period_days/fresh_window_seconds ✅ PL-F0207a
F02.07.06 Shipped Recovery codes — 10 engångskoder per användare, POST /auth/mfa/recovery-codes/generate, GET /auth/mfa/recovery-codes/count, POST /auth/mfa/recovery-codes/verify med token-upgrade, SHA-256-hashad lagring, regenerering invaliderar gamla koder ✅ PL-F0207b
F02.07.07 Shipped Trusted devices med 30-dagars skip — POST /auth/mfa/trusted-devices registrering efter MFA, GET /auth/mfa/trusted-devices/check fingerprint-kontroll, GET /auth/mfa/trusted-devices lista, DELETE /auth/mfa/trusted-devices/{id} revokering, SHA-256-hashade fingerprints ✅ PL-F0207b
F02.07.08 Shipped Session management — GET /auth/sessions lista aktiva sessioner via RefreshTokenFamily, DELETE /auth/sessions/{id} revokera enskild session, POST /auth/sessions/revoke-all revokera alla utom aktuell ✅ PL-F0207b
F02.07.09 Shipped Brute-force-skydd med exponentiell backoff — BruteForceRecord-modell, 1s→2s→4s→8s→16s→30s (cap) delay per konsekutivt misslyckande, automatisk reset vid lyckad inloggning, applicerat på recovery-code-verify ✅ PL-F0207b
F02.07.10 Shipped Säkerhetsmail vid nya inloggningar och MFA-ändringar — SecurityEvent-modell med append-only audit, SecurityNotificationService dispatchar email vid new_login, mfa_enrolled, mfa_disabled, recovery_code_used, trusted_device_added/removed, session_revoked, GET /auth/security-events lista ✅ PL-F0207b
F02.08.01 Shipped individual_context — IndividualContext datamodell med user_id (unikt), display_name, country, language, birth_year, preferred_position (pointer/shooter/middle), handicap_hint, privacy_public_profile, subscription_tier (FREE/PREMIUM), premium_valid_until, Stripe-fält; 30-sekunders signup via POST /public/signup/individual; profil via GET/PATCH /me/individual-context ✅ PL-T005
F02.08.02 Shipped casual_match_p2p — IndividualCasualMatch venue-lös match mellan två individual-kontext; POST /me/casual-matches med FREE-tier-gating (50/månad), POST /me/casual-matches/{id}/verify motpartsverifiering, GET /me/casual-matches historik ✅ PL-T005
F02.08.03 Shipped friend_graph — IndividualFriend med status PENDING/ACCEPTED/BLOCKED; POST /me/friends/request, POST /me/friends/{id}/accept, POST /me/friends/{id}/block, GET /me/friends filtrering ✅ PL-T005
F02.08.04 Shipped subscription_tier_gate — individual_tier.py service med is_premium_active() (inkl. grace-period), check_casual_match_limit() (50/månad FREE), require_premium(), 402 Payment Required med error_code="premium_required"; upgrade-to-premium dev-stub, downgrade med bevarad premium_valid_until ✅ PL-T005
F02.09.01 Shipped chain_tenant — TenantType.CHAIN med ChainMembership (chain↔venue-länk), super-admin-onboarding via POST /admin/tenants/chain, venue-länkning via POST /chain/{id}/add-venue + POST /venue/{id}/accept-chain/{id}, bryt-länk via DELETE /chain/{id}/venues/{id} (soft-deactivate) ✅ PL-T016
F02.09.02 Shipped cross_venue_league — ChainLeague + ChainLeagueEntry; POST /chain/{id}/leagues med venue-multiselect, GET /chain/{id}/leagues/{id}/leaderboard aggregerat cross-venue-rankning, season_start/end-validering ✅ PL-T016
F02.09.03 Shipped chain_passport — ChainPassport loyalty-tracker med BRONZE (5+)/SILVER (20+)/GOLD (50+) tier-progression baserat på rolling visits; GET /chain/{id}/passport/{user_id}, POST /chain/{id}/passport/{user_id}/visit QR-check-in ✅ PL-T016
F02.09.04 Shipped corporate_event_module — CorporateEvent med INQUIRY→QUOTED→BOOKED→COMPLETED→CANCELLED pipeline; POST/GET/PATCH /chain/{id}/corporate-events, host_venue_tenant_id validerad mot aktiv ChainMembership ✅ PL-T016
F02.09.05 Shipped white_label_app — ChainWhiteLabelConfig med app_name, colors, logos, custom_domain, hide_petanque_life_branding; PUT/GET /chain/{id}/white-label, ChainBrandingProvider i app för dynamisk re-branding ✅ PL-T016
F02.09.06 Shipped multi_currency_chain_invoice — ChainInvoiceBatch med per-country ChainInvoiceLineItem (local_currency, ecb_rate_used, ecb_rate_date); kvartalsvis generering via tools/generate_chain_invoice_batch.py med ECB-fixing ✅ PL-T016
F02.09.07 Shipped chain_passport_v2 — ChainPassport19 med points-baserad tier-progression (REGULAR/GOLD/PLATINUM), passport_id format {CHAIN_CODE}-PASS-NNNNNN, unique index (tenant_id, auth_identity_id), ChainVenueVisit per-visit records; POST /chain/passports (create, 409 duplicate), GET /chain/passports/me, GET /chain/passports/{id}, PUT /chain/passports/{id} (profile + tier override + ban) ✅ PL-T019
F02.09.08 Shipped chain_checkin_points — POST /chain/passports/{id}/check-in (QR-scan, creates visit, increments total_games, 403 if banned), POST /chain/passports/{id}/award-points (manual points by venue staff, creates visit with points_earned, updates total_points) ✅ PL-T019
F02.09.09 Shipped chain_leaderboard — GET /chain/leaderboard med ?venue_id= (venue-filtrerat via aggregation), ?period=month year|alltime (tidsfiltrerat), top-50 paginering; alltime query direkt på total_points, period-baserat via an aggregation query på ChainVenueVisit | ✅ PL-T019
F02.09.10 Shipped chain_visit_history — GET /chain/passports/{id}/visits paginerad venue-besökshistorik sorterad senast-först; composite index (chain_passport_id, visited_at DESC) för performance ✅ PL-T019
F02.09.11 Shipped chain_passport_admin_ui — PassportSearch admin-vy med passport-ID-sökning, detail panel (namn, tier badge, status badge, stats, visit timeline), tier override (confirmation dialog), ban/unban actions; LeaderboardView med period tabs (All Time/This Year/This Month), venue filter dropdown, auto-refresh 60s, rank badges (#1 gold, #2 silver, #3 bronze) ✅ PL-T019
F02.10.01 Shipped tenant_tier_matrix — TENANT_TIER_MATRIX i services/tenant_tier.py mappar varje TenantType till included_domains + excluded_subsystems + all_access-flagga; FEDERATION/CONTINENTAL/FIPJP/SANDBOX = all_access, CLUB/AFFILIATE/VENUE/CHAIN/EVENT/INDIVIDUAL = matrix-baserat; byte-equivalent med www/src/data/tenant-tiers.ts (verifieras av services/tenant_tier_matrix_sync.py + test_pl225_tier_matrix_sync.py) ✅ PL-T225
F02.10.02 Shipped requires_subsystem — API-dependency i security/tenant_tier.py som gatear endpoints på subsystem-nivå (F<NN>.<MM>-format); andra gating-lagret efter capability-RBAC; 401 unauthenticated_tenant_context när tenant saknas, 403 tenant_type_not_eligible med subsystem + tenant_type i body när matrix/addon ej täcker ✅ PL-T225
F02.10.03 Shipped tenant_tier_resolver — TenantTierResolver med Redis 5-min cache (tenant_tier:{tenant_id}-nycklar), graceful fallback till per-request uppslag i datalagret när Redis saknas; is_subsystem_allowed() kollar all_access → matrix → aktiva addons; invalidate_cache(tenant_id) anropas av alla mutationer (grant/revoke/change_type) ✅ PL-T225
F02.10.04 Shipped tenant_tier_addon — TenantTierAddon datamodell med unique index (tenant_id, subsystem_id), optional expires_at (null = permanent), billing_reference, required reason (3–500 chars), granted_at/granted_by-audit; addons låser upp enskilda subsystems utan att flippa tenant_type ✅ PL-T225
F02.10.05 Shipped sys_tenant_tier_admin — routes/sys_tenant_tier.py med GET /sys/tenants/{id}/tier (read, support/finance/security), POST /sys/tenants/{id}/tier/addons (grant, finance/security), DELETE /sys/tenants/{id}/tier/addons/{addon_id} (revoke, finance/security), PATCH /sys/tenants/{id}/tier/type (security only, fresh-auth-required); alla emitterar tenant.tier.*-platform events + AuditLog-rad ✅ PL-T225
F02.10.06 Shipped me_tenant_tier — GET /me/tenant-tier returnerar tenant-snapshot (tenant_type, all_access, allowed_subsystems, addons-trim, included_domains, excluded_subsystems) för app/admin client-side hide; ["*"] när all_access så frontend slipper enumerera hela katalogen; useAllowedSubsystems()-hook i admin cachar 5 min med tenant-switch-bump ✅ PL-T225
F02.10.07 Shipped tier_aware_admin_ui — useAllowedSubsystems() filtrerar visibleSectionsFor() (sektioner med subsystem-mapping döljs i sidebar/CommandPalette när delsystemet ej är allowed); TenantTierUpgradeModal öppnas från modul-event-bus när någon endpoint returnerar 403 tenant_type_not_eligible; modal visar subsystem-id + tenant_type + kontaktuppgifter ✅ PL-T225
F02.10.08 Shipped sys_tenant_tier_view — sys/app/(dashboard)/tenants/[id]/tier.tsx är operator-arbetsytan: tier-summary-card (tenant_type-chip, included domains, excluded subsystems), addons-card (active addons med revoke), grant-addon-form (subsystem_id-regex, expiry, billing_reference, reason), change-tenant-type-card (chip-väljare för 9 TenantType-värden, fresh-auth-prompt) ✅ PL-T225
F02.10.09 Shipped tier_jobs — jobs/tenant_tier_jobs.py med tenant-tier-addon-expiry-tick (15 min, hård-raderar TenantTierAddon där expires_at <= now, behåller permanenta) + tenant-tier-matrix-audit-tick (1 h, jämför API- och www-matrix byte-by-byte, emitterar tenant.tier.matrix_drift på diff för operator-larm) ✅ PL-T225
F02.11.01 Shipped admin_session_devlogin — app-lokal session/token-provider (admin.auth.token) + AuthzClient-fabrik + inline "Dev login"-affordans på /login (dead-stripped i prod-bundle via NEXT_PUBLIC_ENVIRONMENT); auth-redirect till /login när session saknas ✅ f07177c7.m-6.t-2
F02.11.02 Shipped admin_roller_lista — roll-lista (GET /roles) + CRUD (POST/PATCH/DELETE /roles); systemmallar (is_system) visuellt åtskilda (ikon + text) utan redigera/radera-affordans ✅ f07177c7.m-6.t-2
F02.11.03 Shipped admin_capability_matris — grid roll × capability grupperad per namnrymd (kollapsbar progressive disclosure), GET/PUT /roles/{id}/capabilities (REPLACE), GET /capabilities; systemmall skrivskyddad ✅ f07177c7.m-6.t-2
F02.11.04 Shipped admin_rolltilldelning — tilldela roll till principal i scope (GET/POST/DELETE /role-assignments); tenant-scope fullt, org_node väljs i picker ur GET /v1/org-nodes (nodnamn, ärlig fallback vid läsfel), resurs via id-fält (ingen katalog) ✅ f07177c7.m-6.t-2, ✅ D-HARDEN 2fd8abe9.m-1.t-5 (rå-ID-fältet borta — premium-baren punkt 6)
F02.11.05 Shipped admin_rollrequests_inbox — läs + skapa role_requests (GET/POST /role-requests), status ikon + text; godkänn/avslå disabled (F1-6 äger statusmaskinen) ✅ f07177c7.m-6.t-2
F02.11.06 Shipped admin_ui_gating — all nav/åtgärd gatad mot /me/capabilities (fail-closed) via delad useCapabilities/<RequireCapability>; enhetlig RFC 9457-felpresentation (messageForProblem), 404-ej-403-semantik bevarad ✅ f07177c7.m-6.t-2
F02.11.07 Shipped admin_login_mfa_paritet — /login bygger på den delade login-UI:n (@petanque-life/shared/auth-ui: LoginCard + OAuthProviderButton, samma komponenter som sys) med bevarade testID:n och env-gatad dev-login; /auth/complete läser ?mfa_required=1 och renderar det delade MfaStep (TOTP · passkey · återställningskod, flow_kind:"web" mot preauth-cookien) innan den vanliga OnboardingLanding-vägen tar vid, och ?error=<code> ger ett ärligt felbesked med väg tillbaka till /login. Tidigare ignorerades ?mfa_required=1 — en admin vars konto krävde andra faktor fastnade efter Google-hoppet. Bevisat mot körande stack av L4-drivrutinen tools/gate/dauthux-m1-e2e.mjs. Kontrakt: docs/engineering/auth/login-flows.md §"Web-fullbordan av inloggningen" ✅ da906fd6.m-1.t-2
F02.12.01 Shipped sys_session_devlogin — app-lokal session/token-provider (sys.auth.token) + AuthzClient-fabrik + inline "Dev login"-affordans på /login (dead-stripped i prod via NEXT_PUBLIC_ENVIRONMENT); auth-redirect till /login när session saknas ✅ f07177c7.m-6.t-3
F02.12.02 Shipped sys_console_gating — konsol-nav (/roller, /audit) gatad fail-closed via oraklet capability-check (sys.console.access); tenant-token får ingen konsol; orakel-konsistent utan 403-benägna /me/capabilities ✅ f07177c7.m-6.t-3
F02.12.03 Shipped sys_roll_matris — rollkatalog (GET /sys/roles) + uppslag av identitets sys-roller (GET /sys/users/{email}/roles); ikon + text, ingen tenant-branding ✅ f07177c7.m-6.t-3
F02.12.04 Shipped sys_roll_grant_revoke — tilldela (POST /sys/users/{email}/roles) + återkalla (DELETE …/{role}) under fyra ögon; self-grant → 400 self_grant_blocked presenterat blockerande utan radändring; roller omläses från API efter mutation ✅ f07177c7.m-6.t-3
F02.12.05 Shipped sys_fresh_auth_seam — 401 reauth_required → uttrycklig sys-reauth-affordans (navigerar till /login), mutationen återupptas aldrig tyst; dokumenterat seam (m-5 saknar re-auth-endpoint, F1-3/F1-10) ✅ f07177c7.m-6.t-3
F02.12.06 Shipped sys_audit_seam — audit-ytan (/audit) som skal + seam: tomtillstånd "data levereras av F1-5", inga audit-row-* hävdas; live audit-läsning ägs av F1-5 ✅ f07177c7.m-6.t-3
F02.13.01 Shipped web_session_devlogin — app-lokal session/token-provider (web.auth.token) + AuthzClient-fabrik + inline "Dev login"-affordans på /login (dead-stripped i prod via NEXT_PUBLIC_ENVIRONMENT); anonym besökare felar closed utan att krascha den publika ytan ✅ f07177c7.m-6.t-4
F02.13.02 Shipped web_action_gating — gatad genväg "Förbundsadministration" (web-gated-action) i SiteHeader gatad mot admin.roles.manage via <RequireCapability> (fail-closed); dold för anonym/utan-capability, klick ger aldrig 403; full white-label (endast design-tokens) ✅ f07177c7.m-6.t-4
F02.13.03 Shipped app_session_devlogin — app-lokal session/token-provider (app.auth.token) + AuthzClient-fabrik + dev-login-affordans på /login (dead-stripped i prod via EXPO_PUBLIC_ENVIRONMENT); Metro-monorepo-resolution av @petanque-life/shared ✅ f07177c7.m-6.t-4
F02.13.04 Shipped app_action_gating_per_tenant — gatad åtgärd "Anmäl lag" (app-action-register-team) mot competition:register; cross-tenant <TenantSwitcher> (app-tenant-switcher/app-tenant-option-<key>) som laddar om /me/capabilities per aktiv tenant (X-Tenant-Id) — ingen capability-läcka mellan tenants (AC-14); fail-closed under omladdning ✅ f07177c7.m-6.t-4
F02.14.01 Shipped app_login_otp — /login enligt skärm-kontraktet design/src/pages/app/login.astro: e-post-engångskod som primär ingång (POST /auth/otp/start → kodfält → /auth/otp/verify), account-first-ruta + /signup-länk, inget lösenordsfält, dev-login-affordansen kvar men env-gatad (literal EXPO_PUBLIC_ENVIRONMENT ⇒ Metro dead-strippar den ur prod-bundlar). Fel kod ⇒ ärligt fel + fortsatt 401 ✅ da906fd6.m-2.t-1
F02.14.02 Shipped app_login_oidc — sociala inloggningar renderade dynamiskt ur GET /auth/oidc/providers?application=app (tom lista döljer sektionen utan fel); web navigerar till /auth/oidc/{provider}/authorize/app (PKCE S256), native öppnar systemwebbläsaren med ?flow=native och löser in deep-linkens engångskod via POST /auth/exchange ✅ da906fd6.m-2.t-1
F02.14.03 Shipped app_auth_complete — ny route /auth/complete: cookie-probe (GET /auth/me) → startsidan, ?mfa_required=1 → MFA-steget, ?error=<kod> → ärliga svenska besked för hela kodlistan med väg tillbaka till /login (tidigare 404:ade API:ts redirect — en social inloggning i appen kunde inte fullbordas alls) ✅ da906fd6.m-2.t-1
F02.14.04 Shipped app_mfa_steg — MfaStepNative (RN-tvillingen till delade MfaStep): TOTP · passkey · återställningskod mot auth-ui/mfa-client med injicerad request; credentialen väljs på leverans, inte plattform (body-levererad preauth ⇒ Bearer på web *och* native, redirect-levererad ⇒ pl_preauth-cookien). Fel kod ⇒ live-region-annonserat fel och fortsatt 401 ✅ da906fd6.m-2.t-1
F02.14.05 Shipped app_native_preauth_exchange (API, FR-5) — native redirect-flöden som stannar på MFA-gaten levererar preauth via en engångskod i deep-linken (petanquelife://auth/native-complete?mfa_required=1&preauth_code=…), inlöst på befintliga POST /auth/exchange → {mfa_required, preauth_token, methods}; aldrig råtoken i URL, aldrig session-cookies, replay = 401. Utan den fastnade ett MFA-konto i native (appen kan inte läsa pl_preauth-cookien) ✅ da906fd6.m-2.t-1
F02.14.06 Shipped app_session_gating — delad SessionGate monterad i app/_layout.tsx: default-deny med sluten publik lista (/, /login, /signup, /auth/complete, /resultat, /spectator, /live/*, /tavling/* samt /anmalan + /min-match per F02.14.11); sessionProbing väntar (app-gate-probing) så en cookie-inloggad spelare aldrig studsar till /login vid omladdning, anonym → /login utan att personligt innehåll når DOM:en ✅ da906fd6.m-2.t-1
F02.14.07 Shipped app_session_model — EN sessionsmodell för båda plattformarna: web bootstrappar cookie-sessionen via /auth/me-probe (+ kort retry, identitets-backfill), native persisterar tokenparet i expo-secure-store; tyst förnyelse (POST /auth/session/refresh) och onUnauthorized inkopplade i den delade transporten ✅ da906fd6.m-2.t-1
F02.14.08 Shipped app_logout — synlig utloggning (app-logout) i Inställningar: POST /auth/session/logout river refresh-familjen SERVER-side först, därefter store + säker persistens + tenant-token-cache; idempotent för användaren, landar på /login (publik ⇒ ingen loop) ✅ da906fd6.m-2.t-1
F02.14.09 Shipped app_login_gating_l4 — L4-röken tools/gate/dauthux-m2-e2e.mjs (descriptor 460-dauthux-m2.mjs): anonym → gatad sida → login-redirect → autentiserad → innehåll mot den KÖRANDE stacken; gating-svepet över samtliga gatade rutter + hela /me-svepet, publika ytor mot /v1/public/competitions, OTP-login med fel/rätt kod, probing utan studs, authorize-leget (S256/state/redirect_uri) mot en driver-egen sidecar, web-/native-fullbordan, MFA i BÅDA leveransvägarna, logout, 401-hantering, axe utan critical/serious och prod-exportens dead-strip (app-dev-login-*) ✅ da906fd6.m-2.t-2
F02.14.10 Shipped app_gate_redirect_recovery — guardens redirect överlever att rot-navigatorn rivs i SAMMA render som gaten slår till (fail-closed-mekaniken renderar platshållaren i stället för <Slot/>): router.replace körs i try/catch — obehandlat rev kastet *hela* React-trädet, dvs. den blanka sidan — med plattformens egen utväg som fallback (web window.location.replace('/login') + en 1,2 s landningskontroll; native osynlig ommontering av navigatorn). Stänger de TVÅ defekter F02.14.09 fann mot körande stack: in-app-navigering till en gatad rutt (URL:en stod still, ingen login-vy) och terminalt 401 under användning (blank sida i stället för login-vyn, spec AC-10). Underlag: …t-2.finding.md ✅ da906fd6.m-2.t-2
F02.14.11 Shipped app_inskarms_fail_closed — /anmalan och /min-match redirectar inte: de bär skärmens EGEN fail-closed-uppmaning (app-anmalan-login · "Logga in" i app-min-match) och bevarar därmed djuplänkens kontext (tävling + klass), vilket en /login-redirect tappar (return-to är Non-goal 6). Det bekräftar ägarbeslutet i D-COMP-PLAYER m-2 AC-9 / m-4 AC-8; spec FR-7 rättad + AC-1b tillagt. Eftersom guarden inte längre håller dessa två skärmar äger de nu SITT EGET probing-tillstånd: sessionProbing renderar skelett/Kontrollerar inloggning…, aldrig "du är utloggad" åt en inloggad spelare och aldrig personlig data — och useNextMatch stannar i loading (i stället för ready utan data) tills session + tenant finns, så ingen tyst blank vy och ingen läsning utan tenant-anspråk. Underlag: …t-2.finding.md §Fynd 5 ✅ da906fd6.m-2.t-2
F02.15.01 Shipped sys_mfa_gate — sys-konsolåtkomst utan aktiv passkey nekas med den DISTINKTA signalen mfa_enrollment_required (oraklet: {allowed:false, reason:…}; /v1/sys/*: 403 problem+json) ur ETT delat predikat som orakel-vägen, requireSysConsole-familjen och de direkta frågeställarna alla konsulterar. Beslutet tas ur AKTUELLT DB-tillstånd vid varje avgörande ⇒ kontinuerlig enforcement, ingen MFA-claim i tokenen. Ingen grace-period, inget trusted-device-skip för sys, ingen dev-login-bakdörr. ✅ c5488ceb.m-1.t-1
F02.15.02 Shipped sys_mfa_last_factor — DELETE /auth/mfa/passkey/credentials/{id} av den SISTA kvalificerande passkeyn för en sys-bärande identitet avvisas med 409 last_mfa_factor_required (inget raderas); ägarskapsguarden körs FÖRST, så en främmande credential-id svarar exakt som förr (ingen 409-läcka). GET /auth/mfa/enforcement bär sys_console_gated + reason:"sys_console". ✅ c5488ceb.m-1.t-1
F02.15.03 Shipped sys_mfa_enrollment_ux — ett TREDJE gate-tillstånd i sys/lib/authz.tsx (mfaEnrollmentRequired, ur oraklets reason — aldrig ett allow): SessionGate renderar den spärrande vyn sys-mfa-enroll på ALLA rutter utan ShellHost/nav/data. Flöde: passkey (primär) → återställningskoder EN gång med spärrande "jag har sparat"-bekräftelse → TOTP som frivilligt komplement → re-proba oraklet, konsolen öppnas UTAN ny inloggning. Utan WebAuthn-stöd: ärligt role="alert"-besked och ingen TOTP-genväg (fail-closed). Ett 500/nätverksfel på capability-check ger den befintliga fail-closed-vägen — ett fel tolkas ALDRIG som "saknar faktor". Utloggning alltid nåbar (sys-mfa-enroll-logout). ✅ c5488ceb.m-1.t-2
F02.15.04 Shipped sys_mfa_faktorhantering — ny sida /sakerhet (sys-security-factors) nåbar ur SysAccountMenu i BÅDA varianterna (sys-account-security, sys-account-security-mobile): passkey-lista med label/registrerad/senast använd ur GET /auth/mfa/passkey/credentials (aldrig publik nyckel), sys-passkey-add (riktig ceremoni), sys-passkey-remove (fresh-auth; sista ⇒ API:ts eget 409-besked), sys-recovery-count + sys-recovery-regenerate (nya koder EN gång) och sys-totp-toggle (otpauth-URI + kodfält). Riktiga loading/empty/error-tillstånd; alla anrop via den delade mfa-client (inga nya råa fetch(). ✅ c5488ceb.m-1.t-2
F02.15.05 Shipped mfa_enrollment_delad — MfaEnrollment + registreringsceremonin (registerPasskey, listPasskeys, removePasskey, recovery/TOTP-legen) ligger i packages/shared/src/auth-ui/, så admin-nivåns per-förbunds-policy monterar SAMMA flöde i stället för en kopia. Transportfel mappas till ett ärligt svenskt besked i stället för webbläsarens råa Failed to fetch. ✅ c5488ceb.m-1.t-2
F02.15.06 Shipped sys_mfa_l4 — L4-drivrutinen tools/gate/dauthpolicy-m1-e2e.mjs (descriptor 480-dauthpolicy-m1.mjs) kör WebAuthn-ceremonin på RIKTIGT via CDP:s virtuella authenticator mot körande stack: spärr på flera rutter med backendsignalen verifierad, felad ceremoni + runtime utan WebAuthn, utloggning i spärrat läge, fullföljd registrering ⇒ konsol med API:ts EGNA rader, faktorhantering i desktop OCH mobil chrome (inkl. 409-fallet), fail-closed vid 500/nedsläckt API, axe utan critical/serious. Övriga sys-drivrutiner (410, 430, 440, 450, 470) registrerar numera en riktig passkey i sin SETUP (tools/gate/lib/sys-passkey.mjs) — deras påståenden är oförändrade. ✅ c5488ceb.m-1.t-2
F02.16.01 Shipped admin_mfa_gate_ux — ett EGET gate-tillstånd i admin/lib/mfa-gate.tsx (probat ur GET /auth/mfa/enforcement, aldrig ett allow): ShellHost renderar den spärrande vyn admin-mfa-enroll på ALLA rutter utan skal/nav/data. Fyra lägen hålls isär — probing, open, gated (tenant_policy_gated), error — plus grace som ett femte, icke-blockerande. Probe-FEL ger den ärliga felvyn admin-mfa-probe-error, aldrig spärrvyn och aldrig konsolen (NFR-1). Gaten är PER TENANT: ett svar som gäller en annan (eller ännu ovald) tenant räknas som "probe pågår", kontrollerat vid render. Utloggning alltid nåbar (admin-mfa-enroll-logout) och river sessionen server-side. ✅ c5488ceb.m-2.t-2
F02.16.02 Shipped mfa_enrollment_policy_medveten — det DELADE MfaEnrollment (m-1 F02.15.05) utökas, aldrig forkas, med qualifyingMethods: innehåller policyns qualifying_methods totp blir autentiseringsappen en FULLT kvalificerande väg vid sidan av passkey (eget steg → samma återställningskoder EN gång → samma komplementsteg). En passkey-only-policy ger exakt sys-beteendet (utan WebAuthn: ärligt role="alert", ingen TOTP-genväg). Sys skickar ingen prop och är oförändrad. ✅ c5488ceb.m-2.t-2
F02.16.03 Shipped admin_sjalvbetjaning_faktorer — säkerhetssektionen på /installningar (admin-security-factors) för VARJE inloggad admin, utan capability-krav (sidans förbundsfält behåller sin gating): passkey-lista med label/registrerad/senast använd, admin-passkey-add/-remove, admin-totp-toggle (URI + kodfält), admin-recovery-count/-regenerate (nya koder EN gång). Sista KVALIFICERANDE faktorn (enligt policyns metodmängd) föregås av den ärliga varningen admin-last-factor-warning FÖRE borttagningen — API:t tillåter den för icke-sys-konton och gaten stänger då omedelbart (FR-B13); sys-bärare får API:ts eget 409 last_mfa_factor_required. Riktiga loading/empty/error-tillstånd, allt genom den delade mfa-client (inga nya råa fetch(). Mockupens "Change password"/"Session length" byggs medvetet INTE (inga lokala lösenord, plattformsstyrd sessionslängd — spec A4). ✅ c5488ceb.m-2.t-2
F02.16.04 Shipped admin_mfa_policyvy — ny vy /identitet-sakerhet (fliken "MFA policy") + NAV-post i ShellHost (Organisation & styrning, synlig-men-låst), gated tenant-config.manage: rollkryssrutor ur tenantens RIKTIGA rollista (GET /roles — aldrig hårdkodad), metodval (passkey/TOTP — de verkliga metoderna, spec A4), gracedagar, fresh window, trusted-device-valet, Spara ⇒ PUT /auth/mfa/tenant-policy med 422 vid rätt fält och ärlig 403, härkomst (egen rad vs ärvd default) och efterlevnadsvarningen ur compliance (ENDAST räknetal — aldrig namn). Utan capability: den ärliga behörighetsförklaringen, aldrig formulärdata. Mockupens övriga flikar ägs av andra initiativ och stubbas inte. ✅ c5488ceb.m-2.t-2
F02.16.05 Shipped admin_mfa_grace_banner — admin-mfa-grace-banner i skalet för en matchad, o-enrollad admin INOM grace: uppläsbar, med API:ts grace_expires_at som tidsfrist (aldrig klientberäknad), avvisbar för sessionen (admin-mfa-grace-dismiss, återkommer vid nästa inloggning) och länkad till /installningar. Visas ALDRIG för en omatchad aktör. ✅ c5488ceb.m-2.t-2
F02.16.06 Shipped admin_mfa_l4 — L4-drivrutinen tools/gate/dauthpolicy-m2-e2e.mjs (descriptor 490-dauthpolicy-m2.mjs) mot körande stack: spärr på flera rutter med backendsignalen (mfa_enrollment_required + reason:"tenant_policy") och orakel-konsistensen på /me/capabilities verifierade, avbruten ceremoni, BÅDA enrollment-vägarna (TOTP deterministiskt enligt RFC 6238, passkey via CDP:s virtuella authenticator), självbetjäningen i desktop OCH mobil chrome inkl. sista-faktor-varningen, policyvyn (roller nyckel-för-nyckel mot GET /roles, sparande verifierat via nytt API-läst tillstånd, efterlevnadsaggregatet, 403-vägen), grace-bannern, opåverkad omatchad aktör, fail-closed (500 på proben ⇒ varken konsol eller spärrvy; API:t nere ⇒ /login) och axe utan critical/serious på de tre nya ytorna. Policy och fixturer sätts uteslutande via API:t och nollställs före OCH efter körningen. ✅ c5488ceb.m-2.t-2
F02.16.07 Shipped mfa_rp_origin_lista — PL_MFA_WEBAUTHN_RP_ORIGIN accepterar kommaseparerade origins (api/internal/auth/mfa/webauthn.go). Nödvändigt eftersom admin nu enrollar passkeys från en ANNAN origin än sys, och WebAuthn verifierar origin strikt. Vidgar konfigurationen, aldrig verifieringen: varje origin matchas exakt, en olistad origin nekas, och ett enda värde beter sig precis som förut. ✅ c5488ceb.m-2.t-2
F02.17.01 Shipped auth_transport_malbild — beslutsdokumentet docs/migration/auth-transport.md: målbildstabell per yta (admin/sys/web + web-SSR-avvikelsen + app native/web), mekanismerna med namn och filpekare (GET /auth/me, POST /auth/session/refresh, POST /auth/session/logout, POST /auth/tenant/select, X-Tenant-Id), avvikelseregel + aktuell avvikelselista, landnings- och förbudsregler för m-2…m-4, dev-login/E2E-vägen och m-5-noteringen om de tre authHeaders-kopiorna i packages/shared. Länkat från CLAUDE.md och api-client-README:n. ✅ d78fbcde.m-1.t-1
F02.17.02 Shipped session_utan_localstorage_bearer — createSessionStore({ persistToken: false }) på alla webbytor (admin, sys, web, app-web-exporten): den råa bearern skrivs aldrig till Web Storage, en kvarliggande legacy-nyckel <app>.auth.token tas bort vid boot (engångsstädning, ingen läsning tillbaka in i sessionen) och hydrate() återuppväcker aldrig en session ur storage. Storen är fortsatt den synkrona in-memory-sanningen. Native (app på enhet) är oförändrat: där är backenden expo-secure-store (Keychain / EncryptedSharedPreferences), vilket ÄR den härdade modellen. XSS-exfiltrationsytan för sessionstoken är därmed stängd på webben. ✅ d78fbcde.m-1.t-1
F02.17.03 Shipped delad_cookie_probe — probeCookieSession(store, apiBase) i packages/shared/src/authz-ui/cookie-session.ts är den enda cookie-proben: GET /auth/me → session, 401 → EN tyst POST /auth/session/refresh → EN omprövad probe, terminalt fel ⇒ false utan att etablera session (fail-closed, kastar aldrig på ett rent 401). admin och web bootar nu genom den (defekten där en cookie-inloggad admin såg ut som anonym är stängd), sys och app har bytt sina egna kopior mot den — och fick därmed refresh-legen de saknade. sessionProbing gör att gatade ytor VÄNTAR i stället för att studsa till /login. ✅ d78fbcde.m-1.t-1
F02.17.04 Shipped provider_som_enda_klientplats — ApiClientProvider wiras i alla fyra rötter enligt målbilden: roten deklarerar modellen som ren data (basadress, silent-refresh-endpoint, landning vid terminalt 401) och den delade createSessionApiClientOptions bygger klientens options (token ur storen, cookie-refresh, onUnauthorized ⇒ storen töms + ytans landning). Ingen "naken" options={{ baseUrl }} kvar någonstans; antalet klientkonstruktioner ökar inte (baseline 77). useApiClient() är den dokumenterade konsumtionsvägen. ✅ d78fbcde.m-1.t-1
F02.17.05 Shipped tenant_injektion_i_transporten — ApiClientOptions.tenantId?: () => string \ undefined sätter X-Tenant-Id vid anropstid på hela transportytan (request/call, requestBlob, requestMultipart, requestEventStream), inklusive den omprövade requesten efter en tyst refresh. Utan provider sätts headern inte alls (JWT-claimen är auktoritativ) — additivt och bakåtkompatibelt. Migrerade anrop i m-2…m-4 behöver aldrig handrulla headern. | ✅ d78fbcde.m-1.t-1
F02.17.06 Shipped web_tenantbindning — den inloggade besökaren på en publik förbundssajt får nu en tenant-bunden session: web/lib/authz.tsx bekräftar medlemskapet med den genererade operationen getMyTenants genom provider-klienten (useApiClient()) och binder sessionen till deploymentens förbund (NEXT_PUBLIC_TENANT_CODE) via modellens egen mekanism POST /auth/tenant/select (delade hjälparen selectCookieTenant). Utan bindningen svarar /me/capabilities 400 tenant_id_required, och en förbundsadministratör såg exakt samma publika sajt som en anonym besökare. Är identiteten inte medlem i förbundet sker ingen bindning — ytan förblir anonym (fail-closed). ✅ d78fbcde.m-1.t-2
F02.17.07 Shipped vakt_i_shared — tools/gate/fetch-ratchet.mjs (gate-steg L3d) täcker även packages/shared/src/ (baseline-nyckel packages/shared): ett otaggat rått fetch( i delad kod gör gaten röd, så vakten inte kan kringgås genom att lägga anropet i shared och re-exportera det. ✅ d78fbcde.m-1.t-2
F02.18.01 Shipped identity_email — tabellen (migration 00141) med DB-tvingade invarianter: partiellt unikt index på email där verified_at is not null (en validerad adress kan bara ägas av EN identitet), unikt (auth_identity_id, email), exakt EN primär per identitet och den villkorade primärregeln: en ovaliderad rad kan inte bli primär när identiteten har någon validerad rad (den obetingade formen is_primary ⇒ verified_at is not null vore självmotsägande mot backfillen av ovaliderade legacy-konton och mot GDPR-anonymiseringens primära, ovaliderade erased-…-rad; säkerhetsegenskapen är exakt densamma och avvisas av databasen, inte av app-lagret). Backfill: en rad per befintlig auth_identity. Ovaliderade rader är medvetet inte globalt unika (anti-squatting, spec A-2). ✅ 037909c7.m-1.t-1 · doc-rättelse 037909c7.m-4.t-3
F02.18.02 Shipped primary_email_spegling — auth_identity.primary_email/email_verified gjordes härledda och speglades i databasen (trigger), så repotets ~90 befintliga läsare (sys-vyer, GDPR-export, signup, OIDC, medlemsimport, seedvägarnas råa insert … on conflict) var gröna under migreringen. Speglingen är AVVECKLAD i 037909c7.m-4 (migration 00144): kolumnerna och triggern är borta ur schemat och identity_email är enda sanningen (se F02.21.01). Divergensfrågan, som mätte spegeln, är därmed ersatt av föräldralös-frågan (F02.21.02). ✅ 037909c7.m-1.t-1 → ⛔️ avvecklad i 037909c7.m-4.t-2
F02.18.03 Shipped identitet_ur_valfri_validerad_adress — EN delad resolver (GetIdentityByVerifiedEmail) slår upp identiteten via identity_email där verified_at is not null. OTP- och magic-link-inloggning landar därför i samma konto oavsett vilken validerad adress som används, och OIDC-inloggning med en validerad sekundäradress binds till den befintliga identiteten i stället för att skapa ett andra konto. En ovaliderad adress svarar exakt som en okänd (ingen enumeration). ✅ 037909c7.m-1.t-1
F02.18.04 Shipped sjalvbetjaningsrutterna — GET/POST /v1/me/email-addresses, POST …/{id}/verify, DELETE …/{id}, POST …/{id}/make-primary. Identiteten tas ur claims (IDOR-skydd, 404 före 403), inkorgen är beviset för att lägga till/validera, fresh auth krävs för att ta bort/byta primär (401 reauth_required), taken är 10 adresser / 3 väntande, anti-abuse-fönstren delas med inloggningen (email_verify räknas i samma kvot) och varje mutation går atomärt med sin auditrad (identity_email_added/verified/removed/primary_changed/conflict). Kollisionen avslöjas först efter bevisad inkorgskontroll (409 email_owned_by_other_identity) och bär ingenting om den andra identiteten. Kontrakt: specs/api/endpoints/me-email-addresses.md. ✅ 037909c7.m-1.t-2
F02.18.05 Shipped app_konto_epostadresser — player-appens skärm Konto → E-postadresser (/e-postadresser, ingång i Inställningar): riktiga rader ur API:t med statusmärkena Primär / Validerad / Väntar på verifiering (ikon + text, aldrig enbart färg), lägg till + kodruta som namnger den adress koden gick till, skicka om (vänlig text när kvoten slår), ta bort med bekräftelsesteg och gör till primär. Borttagning renderas inte på den primära eller den sista validerade raden — skärmen förklarar varför i stället för att neka efteråt. 401 reauth_required ⇒ vänlig omauth-prompt, aldrig en utloggning; 409 email_owned_by_other_identity ⇒ ärlig text + supportväg utan att antyda vems kontot är och utan att lova en merge som inte finns än. Transport: useApiClient() + de genererade operationerna (noll råa fetchar). Vy-spec: specs/app/views/e-postadresser.md. ✅ 037909c7.m-1.t-3
F02.18.06 Shipped domain_401_i_transporten — den delade transporten fick den additiva RequestInput.domainUnauthorized: ett 401 som endpointen använder för att svara om REQUESTEN (fel engångskod; reauth_required på en giltig men för gammal session) mappas rakt till det typade ApiError — ingen tyst refresh, inget omförsök (som annars bränner ett andra försök mot serverns attempts-tak) och ingen sessionsrivning. Utan flaggan är beteendet oförändrat. Utan den hade appens adresskärm loggat ut personen för ett feltryck i kodrutan. ✅ 037909c7.m-1.t-3
F02.18.07 Shipped l4_bevis_pa_riktig_stack — tools/gate/dprofiles-m1-app-e2e.mjs (descriptor e2e.d/700-dprofiles-m1.mjs) kör hela resan i en riktig webbläsare mot körande API + riktig Postgres: riktig OTP-inloggning → lägg till → fel kod → skicka om → validera → gör till primär → ta bort med bekräftelse → logga ut → logga in med den nya adressen → samma identitet; plus kvotens vänliga 429-ruta, kollisionen mot en ANNAN riktig identitet, fresh-auth-prompten utan utloggning, axe utan critical/serious och DB-facit (adressrader, auditrader, divergensfrågan = 0). Hela resan körs på gratisnivån — utan medlemskap, licens eller betalning. Cutover i 037909c7.m-4.t-3: DB-facit läser numera föräldralös-frågan (divergensfrågan dog med spegelkolumnerna), och kollisionssteget accepterar båda de lagliga ytformerna — app-sammanslagning-erbjudande när servern sätter merge_available (formen sedan m-3) och annars m-1:s app-epostadresser-konflikt — med m-1:s krav (supportväg, ingen enumeration, ingen rå felkod, raden förblir väntande) oförändrade. ✅ 037909c7.m-1.t-3 · cutover 037909c7.m-4.t-3
F02.19.01 Shipped identity_profile — tabellen (migration 00142) 1:1 mot en validerad identity_email: unikt index på identity_email_id, DB-trigger som gör en profil på en rad med verified_at is null omöjlig (även med rå SQL), och on delete cascade så ingen föräldralös profil kan existera. Tenant-lös som identity_email/auth_identity, registrerad i tenant-konventionsvakten; +goose Down verifierad (tabellerna + mekanismerna bort, identity_email/auth_identity orörda). ✅ 037909c7.m-2.t-1
F02.19.02 Shipped default_profil_for_varje_skrivvag — profilen skapas på DB-nivå när en adressrad blir validerad, så mekanismen täcker även spegel-triggerns rader från de råa insert into auth_identity-seedvägarna (en Go-synk hade inte gjort det). Namnet tas ur auth_identity.display_name och blir aldrig e-postadressen eller dess local-part. Backfill i samma migration: exakt en profil per redan validerad rad; identiteter utan validerad adress får noll profiler (legacy-kanten — korrekt, inte ett fel). Divergens är ett fel: FR-2:s divergensfråga ska ge noll rader efter varje seed-, migrations- och API-väg. ✅ 037909c7.m-2.t-1
F02.19.03 Shipped kontaktprofilen_och_resolvern — identity_contact_profile bär en designation per identitet ("vilken adress når klubb och förbund mig på"), med primäradressens profil som dynamisk default och deterministiskt, auditerat fallback när den designerade profilen försvinner. GetContactEmailForIdentity(identityID) är den enda sanktionerade vägen för framtida kommunikationsytor (D-COMM, medlemsutskick) att slå upp en persons kontaktadress. En väntande adress kan aldrig vara kontaktväg — den har ingen profil. ✅ 037909c7.m-2.t-1
F02.19.04 Shipped schemavakten_for_en_manniska — ett låst konventionstest enumererar de sanktionerade referenserna till identity_profile (identity_contact_profile, social_play_member, identity_profile_photo) och gör varje annan FK — dvs. varje försök att hänga domändata (ELO, licens, medlemskap) på en presentationsprofil — till ett rött tills den uttryckligen sanktioneras med skriven motivering. Vaktens negativa gren bevisar att den biter. ✅ 037909c7.m-2.t-1
F02.19.05 Shipped gdpr_per_identitet — raderingsmodulen auth täcker identity_profile (namn, smeknamn, avatar = personuppgifter): icke-primära adressers profiler kaskadraderas, den anonymiserade primärraden (verified_at = null) lämnar ingen profil kvar — annars hade FR-2:s divergensfråga brutit. DSAR-exporten innehåller profilerna (namn, smeknamn, synlighetsval, kontaktdesignation, avatar-referens). Radering är fortsatt per identitet — det finns inget profil- eller adress-subjekt. Erasure-coverage-vakten grön med den nya tabellen täckt. ✅ 037909c7.m-2.t-1
F02.19.06 Shipped sjalvbetjaningsrutterna_for_profiler — GET /v1/me/identity-profiles, PATCH …/{id} (If-Match, 412 stale_version), POST/DELETE …/{id}/avatar och POST …/{id}/make-contact. Ruttfamiljen kolliderar inte med D-USERS /v1/me/profiles (rollprofiler per förbund) — begreppsdisambigueringen är ett krav, inte en stilfråga. Identiteten tas ur claims (IDOR-skydd, 404 före 403), namn får inte vara en e-postadress (422 validation_failed), och kontraktet har varken skapa- eller raderaväg: profiler föds av validering och dör med adressen. Kontrakt: specs/api/endpoints/me-identity-profiles.md. ✅ 037909c7.m-2.t-2
F02.19.07 Shipped avatar_per_profil_pa_befintligt_kuvert — per-profil-avatar (identity_profile_photo) återanvänder D-USERS fotomaskineri oförändrat: magic-byte-validering (aldrig bara Content-Type), PNG/JPEG/WebP, > 5 MB ⇒ 413 photo_too_large, förfalskad typ ⇒ 415, SHA-256-ETag och content-addresserad blob-URL utan PII. Ingen ny blob-mekanism, ingen ny lagringsyta; /v1/me/photo är orörd. ✅ 037909c7.m-2.t-2
F02.19.08 Shipped socialspelet_presenterar_profilen — social_play_member.identity_profile_id (nullbar FK, on delete set null) fångar den agerande profilen vid join, och presentationen resolverar profil-först: coalesce(guest_name, profilens utåtvända namn enligt synlighetsval, auth_identity.display_name, <neutralt fallback>) — aldrig e-postadressen. En vänsterjoin, ingen N+1. Historik skrivs inte om av en senare växling; raderas profilen faller presentationen tillbaka till identitetens namn utan bruten vy. Befintliga rader utan bindning presenteras exakt som före m-2, och en främmande profil kan inte ageras med (404). ✅ 037909c7.m-2.t-2
F02.19.09 Shipped en_manniska_under_ytan_last — låsta bevis (inte påståenden) att profilväxling bara är presentation: samma sub/tenants/capabilities/medlemskapsbild under varje profil, en enda ELO/rankingbild nycklad på auth_identity_id, samma anti-dubbelanmälan (registration_active_entrant_unique) oavsett profil, spärr som följer identiteten, och regelverksytor (licenskontroll, domare/admin/sys) som fortsatt läser identitetsnivån. Reglerna är skrivna som bindande för framtida ytor i docs/engineering/architecture/02a-authentication.md. ✅ 037909c7.m-2.t-2
F02.19.10 Shipped app_konto_profiler — player-appens skärm Konto → Profiler (/profiler, ingång i Inställningar direkt efter E-postadresser): riktiga rader ur API:t med märkena Primär adress / Kontaktprofil / Aktiv (ikon + text, aldrig enbart färg), redigering av visningsnamn/smeknamn/synlighetsval, avatar upp/av med vänliga feltexter per felkod, kontaktvalet med förklarande text där det gäller, och verkliga ladd-/läsfel-/tomtillstånd (noll-profil-läget länkar till adressvalideringen i stället för att krascha). Terminologin disambiguerar mot nav-postens Profil (D-USERS rollprofil per förbund). Transport: useApiClient() + de genererade operationerna (noll råa fetchar). Vy-spec: specs/app/views/profiler.md. ✅ 037909c7.m-2.t-3
F02.19.11 Shipped snabbvaxlaren_i_kontomenyn — profilväxlaren bor i skalets kontomeny-slot som syskon till TenantSwitcher (tenantväxlaren byter förbund = rättigheter, profilväxlaren byter ansikte = presentation). Den aktiva profilen är markerad i text och form; med 0–1 profiler renderas ingen växlare alls (inget dött UI, och noll-profil-läget kan aldrig låsa skalet). Valet är en preferens per enhet — profilens id i enhetslagringen (app.identity-profile.active), aldrig en token, ingen serverlagring, inga nya claims — och överlever omladdning/appomstart. Fallbacket är deterministiskt: sparat id → primäradressens profil → första raden → identitetens egen presentation. Den aktiva profilen följer med som explicit parameter i socialspelets join; aktören kommer alltid ur claims. ✅ 037909c7.m-2.t-3
F02.19.12 Shipped l4_bevis_pa_riktig_stack_m2 — tools/gate/dprofiles-m2-app-e2e.mjs (descriptor e2e.d/710-dprofiles-m2.mjs) kör hela resan i en riktig webbläsare mot körande API + riktig Postgres: riktig OTP-inloggning → ingen växlare vid en profil → lägg till + validera adress ⇒ profilen dyker upp automatiskt → redigera → e-postadress som namn avvisas vänligt → sätt kontaktprofil → växla profil → omladdning ⇒ valet består → socialspelets deltagarrad visar profilnamnet (två riktiga sessioner) → väntande adress syns inte bland profilerna → logga in med den andra adressen ⇒ samma identitet → ta bort adressen ⇒ profilen kaskadraderad + deterministiskt fallback utan fel. Plus axe utan critical/serious (även med växlarmenyn öppen) och DB-facit: profil-, bindnings- och auditrader samt båda divergensfrågorna 0. Hela resan körs på gratisnivån — utan medlemskap, licens eller betalning. ✅ 037909c7.m-2.t-3
F02.20.01 Shipped merge_motorn_och_journalen — store.ExecuteIdentityMerge(survivor, absorbed) flyttar rollprofiler, medlemskap, licenser, ranking/ELO, anmälningar, samtycken, adresser (med sina profiler) och inloggningsvägar i EN transaktion med radlås på båda identiteterna i id-ordning, löser varje kollision deterministiskt (klassreglerna), tar spärr-unionen, revokerar B:s säkerhetsartefakter och terminalstänger B som en anonymiserad tombstone (status='merged', merged-<id>@merged.invalid). Journalen identity_merge_journal (migration 00143, tenant-lös, append-only, PII-maskerad) är den enda sanktionerade supportvägen: vem, när, vilka konton, vad som flyttades och varje kollisionsupplösning. Ett unikt index på absorbed_auth_identity_id gör att en identitet kan absorberas en gång — terminaliteten ligger i databasen. ✅ 037909c7.m-3.t-1
F02.20.02 Shipped merge_manifestet_och_vakten — api/db/migrations/identity-merge-manifest.json räknar upp varje identitetsreferens mot auth_identity (live-schemats FK:er + de dokumenterat FK-lösa) med exakt ETT beslut ur en sluten vokabulär (move, move-dedupe, resolve, revoke, actor-repoint, actor-keep, blocker, sanctioned-residual; de två sista kräver motivering ≥ 40 tecken). Motorns plan speglar manifestet och vägrar köra vid divergens; ett stående vakttest gör varje osanktionerad ny FK röd. Restinvarianten: efter en merge ger varje post vars beslut inte är sanctioned-residual/actor-keep/blocker noll rader med B:s id. En ny tabell kan alltså inte glömmas bort av mergen. ✅ 037909c7.m-3.t-1
F02.20.03 Shipped blockerare_audit_och_gdpr — öppen Art. 17-begäran, målsmanslänk och terminal status blockerar (merge_blocked_*, re-validerade under låset, noll ändringar, auditrad på båda identiteterna); suspended blockerar medvetet inte — spärren följer i stället med. DSAR-exporten bär sammanslagningarna för båda parter, och vid radering anonymiseras journalraden (raderas aldrig — den är bevisningen), sista-tenant-gatad som personfotot. Erasure-coverage-vakten grön med båda subjektkolumnerna klassade. ✅ 037909c7.m-3.t-1
F02.20.04 Shipped merge_sessionen_och_rutterna — /v1/me/identity-merge (start, fresh auth), …/verify-code (tvåsidigt bevis, fri ordning), …/summary (öppnar först när båda koderna är inlösta), …/confirm (fresh auth) och …/cancel. Starten kräver ett serverkänt 409-bevis (≤ 15 min) — utan det svarar rutten 409 merge_not_available med en byte-identisk kropp oavsett om adressen är fri eller upptagen (ingen enumeration). Koderna är auth_challenge-rader under metodnamnet identity_merge, räknade i samma anti-abuse-fönster som inloggningen, med kvotkontroll för båda inkorgarna före minten. Högst EN levande session per inblandat konto. Kontrakt: specs/api/endpoints/me-identity-merge.md. ✅ 037909c7.m-3.t-2
F02.20.05 Shipped bindande_sammanfattning — GET …/summary kommer ur en torrkörning av den riktiga motorn (en transaktion som alltid rullas tillbaka), så bekräftelsen utför exakt det som redovisas: flyttade adresser, berörda förbund/klubbar, varje kollisionsupplösning med sin klass och sin regel i klartext, spärr-unionen och konsekvenserna. Redovisningen binds vid ett fingeravtryck av världen — har något ändrats vid bekräftelsen svarar den 409 merge_stale och ingenting är halvgjort. Före det tvåsidiga beviset bär inget svar ett tecken om det andra kontot (NFR-1). ✅ 037909c7.m-3.t-2
F02.20.06 Shipped kollisionens_additiva_signal — m-1:s 409 email_owned_by_other_identity bär sedan m-3 det additiva merge_available: true: maskinläsbart "för den här raden kan en sammanslagning startas", utan ett ord om den andra identiteten. Status, felkod och struktur är oförändrade, så varje befintlig läsare och m-1:s låsta assertions är gröna (NFR-5). Appytan grindar sitt erbjudande på exakt det värdet — utan signalen står m-1:s supporttext kvar orörd. ✅ 037909c7.m-3.t-2
F02.20.07 Shipped app_guidat_merge_flode — player-appens skärm Slå ihop två konton (/konto-sammanslagning), nådd ur kollisionens ärliga erbjudande i /e-postadresser: fyra steg med textburen stegindikator (aldrig färg ensam), två tydligt märkta kodrutor ("koden som skickades till <adressen>" / "…till din primära adress"), "skicka nya koder" (cancel + start, sagt rakt ut) med vänlig väntetext ur Retry-After, sammanfattningen renderad ur API-svaret (ingen klientgissning om vad som flyttas), uttrycklig bekräftelse, klart-läge som pekar mot adress- och profillistan, och ett nej-läge med läsbar förklaring + supportväg — aldrig en rå felsträng, aldrig ett B-läckage. Fresh-auth ⇒ vänlig prompt, aldrig en utloggning. Transport: useApiClient() + de genererade operationerna (noll råa fetchar, ratchet-baseline app: 0 orörd). Vy-spec: specs/app/views/konto-sammanslagning.md. ✅ 037909c7.m-3.t-3
F02.20.08 Shipped l4_bevis_pa_riktig_stack_m3 — tools/gate/dprofiles-m3-app-e2e.mjs (descriptor e2e.d/720-dprofiles-m3.mjs) kör hela resan i en riktig webbläsare mot körande API + riktig Postgres: två riktiga identiteter via OTP (B med två validerade adresser/profiler, båda i samma socialspelsrunda ⇒ en riktig dubblett) → kollision ⇒ erbjudandet → guidat flöde → fel kod ⇒ vänlig felruta → avbrott ⇒ ingenting ändrat → båda koderna ur dev-återläsningen (method=identity_merge) → sammanfattningen jämförd rad för rad mot API:t (adresser, notiser, kollisionsregeln i klartext) → bekräftelse → klart-läget → adress- och profillistan (tre + tre) → logga ut → logga in med B:s gamla adress ⇒ A:s konto. DB-facit: journalraden finns, den manifestdrivna restinvarianten ger noll osanktionerade rader med B:s id och båda divergensfrågorna ger noll rader. Plus axe utan critical/serious på flödets skärmar och gratisnivån (noll medlemskap/licens/betalning) före och efter. ✅ 037909c7.m-3.t-3
F02.21.01 Shipped identity_email_enda_sanningen — migration 00144 kör cutovern i säkerhetskritisk ordning: pre-flight (divergerar spegeln > 0 rader avbryts HELA migrationen — cutover på divergent data är ett stopp, inte en chansning), spegel-trigger + funktion droppas, kolumnerna primary_email/email_verified droppas, ersättningsinvarianterna installeras och 00141:s primärvakt skrivs om kolumnfritt. Down är förlustfri: kolumnerna återskapas och backfillas radvis ur primärraden (primary_email = ie.email, email_verified = (ie.verified_at is not null)), så upp → ned → upp är radvis identiskt. ✅ 037909c7.m-4.t-2
F02.21.02 Shipped ersattningsinvarianterna — det kolumnen garanterade garanteras igen av DATABASEN: partiellt unikt index identity_email_primary_email_unique på (email) where is_primary (global primär-unikhet), constraint-triggern auth_identity_require_primary_email_trg (DEFERRABLE INITIALLY DEFERRED — identitet + primär adressrad skrivs i EN transaktion) och identity_email_require_primary_survivor_trg (en radering får aldrig lämna en levande identitet utan primär rad). Föräldralös-frågan — identiteter utan primär identity_email-rad — är initiativets nya stående 0-rads-invariant och ersätter m-1:s divergensfråga, som dog med spegeln. ✅ 037909c7.m-4.t-2
F02.21.03 Shipped kolumn_referensvakten — api/db/migrations/identity_email_truth_guard_test.go gör bygget rött om produktions-SQL eller produktions-Go refererar de droppade kolumnerna igen (on conflict (primary_email), insert/update mot dem, kvalificerad kolumnreferens mot vilket alias som helst, where/select-former), med en negativ gren som bevisar att vakten biter. Allowlist: migrationer ≤ 00144, docs, testfiler och det sanktionerade kontraktsaliaset … as primary_email (så de yttre JSON-fälten kan behålla sina namn). Att bredda allowlisten för att bli grön är en defekt värre än ett ärligt rött. ✅ 037909c7.m-4.t-2
F02.21.04 Shipped legacy_bryggan_avvecklar_sig — resolverns steg 2 grundas i identity_email (identiteten vars primära rad bär adressen utan verified_at) och promoterar raden vid inloggning: verified_at sätts i samma transaktion som sessionen mintas, auditerat i den befintliga familjen (identity_email_verified, reason: legacy_bridge_login), varpå m-2:s trigger föder default-profilen. En väntande sekundär rad matchar aldrig (squatting-garantin orörd), och statusgrinden håller: ett konto med status='erased'/'merged' kan aldrig logga in genom bryggan. ✅ 037909c7.m-4.t-1
F02.21.05 Shipped gdpr_och_seeds_mot_sanningen — Art. 17-raderingen opererar direkt på identity_email (icke-primära rader raderas, primärraden anonymiseras till erased-<id>@erased.invalid med verified_at = null, profilerna följer med), DSAR-exporten läser adresserna ur samma tabell, och samtliga seedvägars insert into auth_identity … on conflict (primary_email) är omskrivna med bevarad idempotens (hela seedmängden körd två gånger ⇒ identiskt sluttillstånd, bortsett från den append-only körhistoriken seed_run). ✅ 037909c7.m-4.t-2
F02.21.06 Shipped felkodsnycklarna_rattade — appens merge-skärm band sina två blockeringstexter till nycklar backend aldrig skickar (merge_blocked_deletion/merge_blocked_guardian), så de specifika texterna var död kod och personen fick alltid den generiska fallbacken. Nycklarna är nu backends ordagranna kodsträngar merge_blocked_deletion_request/merge_blocked_guardian_link (m-3 hardening-fynd §5.2.1); fallbacken för varje ANNAN merge_blocked*-kod står kvar som sista utväg. Bindningen är exekverbart bevisad tautologifritt: acceptansgrinden läser koderna ur api/internal/store/auth_identity_merge.go, aldrig ur en kopia. ✅ 037909c7.m-4.t-3
F02.21.07 Shipped initiativets_helhetsbevis — tools/gate/dprofiles-init-e2e.mjs (descriptor e2e.d/730-dprofiles-init.mjs) kör visionens hela DoD-resa i EN sekvens mot byggd app + körande API + färsk post-cutover-databas: konto A via riktig OTP → adress 2 tillagd (väntande rad utan profil — en ovaliderad adress gäller inte) → validerad med koden ⇒ profilen föds → snabbväxlaren byter aktiv profil och valet överlever omladdning → logga in med adress 2 ⇒ samma identitet → konto B med två validerade adresser → kollision ⇒ merge-erbjudandet ⇒ guidat flöde ⇒ tvåsidig kodverifiering (en kod räcker inte — negativt bevisat) ⇒ sammanfattningen ur API:t ⇒ bekräftelse → slutbilden: EN identitet med fyra adresser som fyra profiler, växling till en inflyttad profil, och inloggning med B:s gamla adress ⇒ A:s konto. DB-facit: föräldralös-frågan 0, merge-restinvarianten 0, journalraden finns. Plus axe utan critical/serious och gratisnivån (noll medlemskap/licenser/betalningar) före OCH efter. 700/710/720 består — 730 är ett tillägg, inte en ersättning; alla fyra kördes gröna på samma post-cutover-databas (per-milstolpe-resornas DB-frågor och m-1:s kollisionsyta är mekaniskt cutover-ade i t-3, med oförändrade förväntningsvärden). ✅ 037909c7.m-4.t-3
F02.21.08 Shipped initiativ_dod_matrisen — milestones/037909c7.m-4/initiative-dod.md mappar varje punkt i visionens DoD (DOD-1…DOD-8) till en namngiven exekverbar kontroll (testfunktion, drivrutin eller vakt) och till var den senast kördes grön. En punkt vars kontroll inte finns eller inte är grön är ett rött, inte en fotnot — acceptansgrinden app/__acceptance__/dprofiles-m4-helhet.acceptance.mjs verifierar mekaniskt att varje rad finns och att varje refererad kontroll existerar i trädet. ✅ 037909c7.m-4.t-3