Skip to content

Identity Case Matrix ​

One row per behavioral case for every route src/modules/identity/presentation/controllers/ exposes. Each case names the test that proves it, quoted verbatim as a spec file path, then the separator "›", then the exact test title. A case with no proving test answers — and is listed under Gaps, together with the lowest layer that could prove it honestly.

npm run lint:case-matrix parses every such reference on this page and fails the build if the file does not exist or carries no it(/it.each( with that exact title. This table cannot silently drift from the suite it describes.

Totals ​

Routes28
Per-route cases346
Per-route cases proven344 (the 2 unproven rows name a case with no failure mode to prove: issuance that never refuses, and a route with no guard)
Cross-route invariants11 (11 proven)
Gaps0

Registration and session entry ​

sign-ups.controller.ts, sign-up-confirmations.controller.ts, sign-ins.controller.ts, sign-in-second-factors.controller.ts, session-renewals.controller.ts, sign-outs.controller.ts. None of these seven routes carries ElevationGuard; only the two sign-out routes carry SessionGuard at all.

POST /sign-ups ​

CaseExpected answerProving test
Happy path: registers a brand-new email/phone identity and queues the verification code for asynchronous delivery201 Created · { data: { challengeExpiresAt } }, no delivery fieldtest/e2e/sign-up.e2e-spec.ts › registers an email identity and answers 201 inside the envelope, accepted with only an expiry
Response never carries the code it just queued201 Created, body excludes the codetest/e2e/sign-up.e2e-spec.ts › never answers with the verification code it just queued
Idempotent repeat: registering the same still-unverified address again201 Created, same accepted shape, a fresh message is queued without superseding the older one — sign_up messages are never superseded across registrants, so both codes stay pending (OX-S8)test/e2e/sign-up.e2e-spec.ts › answers a second registration for an unverified address the same way, queuing its own message without superseding the first (OX-S8)
Indistinguishable refusal: a squatter registers first, the real owner registers the same address with a different password201 Created both times, byte-identical accepted body, so a caller cannot tell whether they were firsttest/e2e/sign-up.e2e-spec.ts › answers the real owner the same way as the squatter, so neither learns anything
Another account left the address unconfirmed (pending sign-in-method addition); its real owner signs upThe unconfirmed claim and its challenges are revoked; the owner gets a new identity with its own password; the other account is untouchedtest/e2e/sign-in-methods.e2e-spec.ts › never lets the real owner of an address sign up into the account that left that address unconfirmed
Repository: an unconfirmed address held by an established account (verified identifier or active method)Released; a new identity claims itsrc/modules/identity/infrastructure/registration/identity-registration.repository.integration-spec.ts › gives the owner of an address its own identity when another account left that address unconfirmed
Refusal: identifier already verified by its owner409 · identity.identifier_takentest/e2e/sign-up-confirmation.e2e-spec.ts › refuses a registration for an address whose owner verified it
Refusal: identifier fails normalization (bad email/phone shape)422 · identity.identifier_unusabletest/e2e/sign-up.e2e-spec.ts › answers an unusable identifier as unprocessable content
Refusal: no channel can notify the identifier (e.g. phone with nothing to reach it), refused before any write422 · identity.identifier_channel_unavailable, no transaction openedsrc/modules/identity/application/registration/register-identity.use-case.spec.ts › refuses before writing anything when nothing could notify that identifier; test/e2e/sign-up.e2e-spec.ts › answers an unprocessable identifier when no configured channel could ever reach it
Validation: password too short400 · foundation.validation_failed, body never echoes the address or passwordtest/e2e/sign-up.e2e-spec.ts › never echoes the address or the password it refused
Validation: unknown field in body (strict schema)400 · foundation.validation_failed, no message senttest/e2e/sign-up.e2e-spec.ts › refuses a body carrying a field the contract does not declare
Validation: country supplied on an email registration400 · foundation.validation_failed, no message senttest/e2e/sign-up.e2e-spec.ts › refuses a country on an email registration, because it means nothing there
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT429 · rate_limit.exceeded, retryable: true, no message sent for the refused attempttest/e2e/rate-limit.e2e-spec.ts › refuses the registration that goes past the limit
Concurrency: five simultaneous registrations for the same addressExactly one wins the claim, the rest see the losing branch (re-challenge), never two identities for one addresssrc/modules/identity/application/registration/register-identity.use-case.integration-spec.ts › lets exactly one of five concurrent registrations for one address win

POST /sign-ups/confirmations ​

CaseExpected answerProving test
Happy path: verifies the identifier with the code it was queued204 No Contenttest/e2e/sign-up-confirmation.e2e-spec.ts › verifies the identifier with the code it was queued
Idempotent repeat: presenting the exact same already-spent code again422 · identity.verification_refusedtest/e2e/sign-up-confirmation.e2e-spec.ts › spends the code, so the same one never verifies twice
Refusal: wrong code, body reveals nothing about the account422 · identity.verification_refusedtest/e2e/sign-up-confirmation.e2e-spec.ts › refuses a wrong code with a body that says nothing about the account
Indistinguishable refusal: wrong code on a real address vs. an address nobody registeredIdentical 422 · identity.verification_refused bodytest/e2e/sign-up-confirmation.e2e-spec.ts › answers an address nobody registered exactly as it answers a wrong code
Refusal: wrong password paired with the correct code422 · identity.verification_refusedtest/e2e/sign-up-confirmation.e2e-spec.ts › refuses the code when the password is not the one the registration carried
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/modules/identity/application/verification/verify-identifier.use-case.spec.ts › refuses an unusable identifier before opening a transaction
Attempt budget: the challenge stops accepting attempts once its allowance is spent, even a correct code afterward422 · identity.verification_refusedtest/e2e/sign-up-confirmation.e2e-spec.ts › stops accepting attempts once the challenge has none left
Lockout: a wrong code on an already locked account answers the same ordinary refusal, never 423, and the lock is not audited again422 · identity.verification_refused, nothing auditedsrc/modules/identity/application/verification/verify-identifier.use-case.spec.ts › answers a wrong verification code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again
Account lockout: trips once wrong guesses across several requests reach the threshold, each request trying every one of this identity's own live challenges, but the owner still answers a fresh code afterwardAccount locks, a freshly issued code still consumes a challenge normallysrc/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › locks the account once wrong guesses across several requests reach the threshold, each request trying every one of its own live challenges, but still lets the owner in afterward with a fresh code
One wrong-code request spends exactly one account failure, no matter how many of the registrant's own live challenges it tries; each of those challenges still gets its own attempt chargedAccount failure count +1; every tried challenge's attempt_count +1src/modules/identity/application/verification/verify-identifier.use-case.integration-spec.ts › spends exactly one account failure per request, no matter how many of its own live challenges get a charged attempt; src/modules/identity/application/verification/verify-identifier.use-case.spec.ts › spends exactly one account failure for the whole request, no matter how many of its own live challenges it tried
Validation: code is not exactly six digits, refused before touching the database400 · foundation.validation_failedtest/e2e/sign-up-confirmation.e2e-spec.ts › refuses a code that is not six digits before touching the database
Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents204/422/400 per case, per configured formattest/e2e/out-of-band-code-formats.e2e-spec.ts › sign-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Concurrency: a stranger re-registers the same address while the owner's code is still in flightThe owner's original code still verifies afterward; the stranger's competing claim gets its own, separately-verifiable codetest/e2e/sign-up-confirmation.e2e-spec.ts › lets a stranger change nothing about the code already in flight

POST /sign-ins ​

Includes provider/OAuth sign-in — the same route and handler, discriminated by identifierType.

CaseExpected answerProving test
Happy path: password sign-in opens a session201 Created · { data: { kind: 'session', accessToken, refreshToken, ... } }test/e2e/sign-in.e2e-spec.ts › opens a session for an account that proved its identifier
Happy path: provider (Google/Apple) sign-in opens a session for a vouched-for token201 Created · sessiontest/e2e/provider-sign-in.e2e-spec.ts › opens a session like any other, for a token the provider vouches for
Happy-path variant: identity holds an active second factor — answers a ticket, never a token201 Created · { data: { kind: 'second_factor_required', secondFactorTicket, ticketExpiresAt } }test/e2e/sign-in-second-factor.e2e-spec.ts › answers a ticket instead of a session when the identity holds an active second factor, and never a token
Never repeats a refresh token across sessionsEach session gets a distinct refresh tokentest/e2e/sign-in.e2e-spec.ts › never repeats a refresh token across two sessions
Refusal: wrong password401 · identity.sign_in_refusedtest/e2e/sign-in.e2e-spec.ts › refuses a password that is not the one on the account
Refusal: identifier never verified401 · identity.sign_in_refusedtest/e2e/sign-in.e2e-spec.ts › refuses an identifier nobody has confirmed yet
Indistinguishable refusal: wrong password vs. unknown accountByte-identical 401 bodytest/e2e/sign-in.e2e-spec.ts › answers an unknown account exactly like a wrong password
Indistinguishable refusal: an already-locked account presenting a wrong password vs. an ordinary wrong password on any other accountByte-identical 401 · identity.sign_in_refused bodytest/e2e/account-lockout.e2e-spec.ts › locks a sign-in account after enough wrong passwords, and answers a further wrong password exactly like a wrong password on any other account
Indistinguishable refusal: a disabled/unconfigured provider vs. wrong passwordByte-identical 401 body, reveals nothing about which providers are configuredtest/e2e/sign-in.e2e-spec.ts › answers a disabled provider exactly like a wrong password, revealing nothing about which providers are configured
Indistinguishable refusal: a provider token the verifier refuses (bad signature/audience) vs. wrong passwordByte-identical 401 bodytest/e2e/provider-sign-in.e2e-spec.ts › answers a refused token exactly like a wrong password, so the discriminated body leaks nothing
Response never leaks the password or the id token401 response excludes bothtest/e2e/sign-in.e2e-spec.ts › never says a word about the password in what it answers
Account lockout: the correct password still opens a session even once the account is locked — a lock only ever stops guessing, never checked before a proof201 Created session, lock state never consultedtest/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing
The second-factor gate is checked only after the password is proven, and leaves clearing the account lock to that second stepSecond factor checked strictly after password proofsrc/modules/identity/application/sessions/sign-in.use-case.spec.ts › checks for a second factor only after the password is proven, and leaves the lock for the second step to clear
Idempotent repeat / replay: the exact same provider id token, replayed after it already opened a session401 · identity.sign_in_refused (single-use nonce already spent)test/e2e/provider-sign-in.e2e-spec.ts › refuses the exact same token replayed after it already opened a session
Repeat visit with the same provider subject (a different token) signs the same identity in again, creating nothing new201 Created, no new identitytest/e2e/provider-sign-in.e2e-spec.ts › signs the same provider identity in again on a second visit
Race: a provider sign-in against the removal of that same provider, in either orderNever a live session from a removed method: a sign-in that committed first has its session revoked by the removal, a removal that committed first leaves the sign-in refused for the old identity; no deadlock over 20 rounds of each ordersrc/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session when the removal starts after the holder was read and the sign-in commits first; src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session and refuses the old identity when the removal commits before the sign-in session insert; src/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › never deadlocks and never leaves a live session when both race freely
Race: a provider sign-in against its method being marked compromised after the holder was readThe session the sign-in created is revokedsrc/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › leaves no live session when the method is marked compromised after the holder was read
Another identity removing its own provider while this provider sign-in is in flightNeither waits nor revokes: the sign-in completes and its session stays livesrc/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › does not wait for, nor revoke, a sign-in when ANOTHER identity removes its provider
A provider sign-in on a provider whose removal already committedThe old identity is not signed in; no live session on itsrc/modules/identity/infrastructure/sessions/oauth-sign-in-removal-race.integration-spec.ts › refuses a sign-in on a provider whose removal already committed
Refusal: an unknown provider subject whose token vouches for (email_verified: true) an email that already belongs to an active, verified identifier on another identity — most often a Google/Apple account that was unlinked and is signing in again — never creates a second identity for the same person409 · identity.account_exists_for_provider_email, identity count unchangedtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses signing in with an unlinked Google subject once its verified email already belongs to this account; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses to create a new identity when the verified email already belongs to an active identifier
That same refusal is case-insensitive on the email comparison409 · identity.account_exists_for_provider_emailsrc/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses regardless of the case the provider token used for the email
Unrelated case: an unknown provider subject whose vouched-for email matches no one on record still opens a brand-new identity, exactly like before201 Created, a new identitytest/e2e/provider-sign-in.e2e-spec.ts › opens a new identity for a brand-new subject whose verified email matches nobody; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › creates a new identity when the email matches nothing on record
Unrelated case: an unknown provider subject whose token carries an email the provider never marked verified still opens a brand-new identity, even if that email matches an existing account201 Created, a new identitytest/e2e/provider-sign-in.e2e-spec.ts › opens a new identity when the provider never marked the matching email verified
Indistinguishable refusal: no nonce claim / an unissued nonce / an expired nonce401 · identity.sign_in_refused, identical across all threetest/e2e/provider-sign-in.e2e-spec.ts › refuses a token with $what, byte-identically to an unissued nonce
Validation: unsupported client type400 · foundation.validation_failedtest/e2e/sign-in.e2e-spec.ts › refuses a body that names a client nobody supports
Concurrency: password/method/identifier changes committed while password verification is in flight (revocation, disablement, block, password/method replacement)Refused, byte-identical to a wrong password, no session createdtest/integration/sign-in-credential-race.integration-spec.ts › refuses %s committed while password verification was pending
Concurrency: method/principal revoked concurrently, ordered after session issuanceIssuance still succeeds, but the freshly-opened session is automatically revoked by the concurrent revocationtest/integration/sign-in-credential-race.integration-spec.ts › serializes %s revocation after issuance and automatically revokes the new session
Concurrency baseline: credential remains eligible throughoutExactly one session openedtest/integration/sign-in-credential-race.integration-spec.ts › opens exactly one session when the verified credential remains eligible
Rate limit: exceeds PROVES_A_PASSWORD_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone

POST /sign-ins/second-factors ​

CaseExpected answerProving test
Happy path: current TOTP code completes the sign-in, opens a session usable on a guarded route201 Created · session; challenge consumed, lock clearedtest/e2e/sign-in-second-factor.e2e-spec.ts › completes the sign-in with the current code and opens a session that works on a guarded route
Happy-path variant: a live recovery code completes the sign-in without ever checking a TOTP method201 Created · sessionsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › proves a live recovery code and opens a session, clearing the lock and consuming the challenge, without ever checking a TOTP method
Refusal: unknown/invented ticket401 · identity.second_factor_refused, untouched challenge/lockoutsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses an unknown ticket without touching the challenge or the lockout
Refusal: wrong TOTP code401 · identity.second_factor_refusedsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong code and records a failed attempt on the challenge and the lockout
Refusal: a code already spent on this exact time step (replay), exactly like a wrong code401 · identity.second_factor_refusedtest/e2e/sign-in-second-factor.e2e-spec.ts › refuses the same code a second time presented with a fresh ticket, proving replay protection
Refusal: a wrong, spent, revoked or foreign recovery code, exactly like a wrong TOTP code401 · identity.second_factor_refusedsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › refuses a wrong, spent, revoked or foreign recovery code exactly like a wrong TOTP code, recording the failed attempt and the lockout
Indistinguishable refusal: a wrong code vs. an invented ticketByte-identical 401 bodytest/e2e/sign-in-second-factor.e2e-spec.ts › refuses a wrong code, and an invented ticket answers the byte-identical refusal
Indistinguishable refusal: wrong password on a second-factor account answers exactly like an ordinary sign-in refusal on /sign-insByte-identical 401 · identity.sign_in_refusedtest/e2e/sign-in-second-factor.e2e-spec.ts › answers a wrong password on an account with a second factor exactly like an ordinary sign-in refusal
Idempotent repeat / replay: the same ticket cannot be reused, even to answer with the previously-correct code401 · identity.second_factor_refused on the second usetest/e2e/sign-in-second-factor.e2e-spec.ts › cannot use the same ticket twice, even to answer with the same correct code
Attempt budget (per-ticket, max_attempts = 5): five wrong attempts exhaust the ticket itself, so even a later correct code on that same ticket is refused401 · identity.second_factor_refused, never 423test/e2e/sign-in-second-factor.e2e-spec.ts › exhausts the attempt budget of the ticket, refusing even a later correct code
Account lockout (identity-wide, threshold 5, distinct from the per-ticket budget): wrong codes across several fresh tickets accumulate against the identity; a proven password never clears it6th cumulative failure (fresh ticket) answers 423 · identity.sign_in_second_factor_lockedtest/e2e/sign-in-second-factor.e2e-spec.ts › keeps counting wrong codes across fresh tickets, because a proven password alone never clears the lock
The exact failure that trips the identity-wide lock still answers the ordinary refusal, not the locked response401 · identity.second_factor_refused (only the next attempt gets 423)src/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it
Already-locked account answers the locked response423 · identity.sign_in_second_factor_lockedsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › answers a locked account following the step-up precedent, once the lockout reports it was already locked
Lockout never gates a correct proof: the correct code still opens a session even while the account is already locked201 Created · session, lock never consultedsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › opens a session with the correct code even though the account is already locked, without ever consulting the lock
The same lockout-bypass guarantee applies to the recovery-code path201 Created, lock never consulted before the recovery code is checkedsrc/modules/identity/application/sessions/complete-sign-in-second-factor.use-case.spec.ts › never consults the lockout before the recovery code is checked
Validation: body carries both a code and a recovery code400 · foundation.validation_failedsrc/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying both an authenticator code and a recovery code
Validation: body carries neither400 · foundation.validation_failedsrc/modules/identity/presentation/dtos/sign-in-second-factor-request.schema.spec.ts › refuses a body carrying neither
A provider sign-in also requires this second step when a second factor is active201 Created (ticket first, then session)test/e2e/sign-in-second-factor.e2e-spec.ts › requires the second step for a provider sign-in too, when the identity holds an active second factor
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Concurrency: two simultaneous completions racing to spend the same ticketExactly one session opened, the other refusedtest/integration/sign-in-second-factor-ticket-race.integration-spec.ts › lets exactly one of two simultaneous completions spend the same ticket
Pending ticket versus credential change: a ticket opened before a password recovery, a password change, the removal of a linked provider or a method marked compromised is revoked in that same transaction, so completing it afterwards, with an authenticator code or a recovery code, is refused and opens no session401 · identity.second_factor_refused, the refusal an unknown ticket gets, no new live sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password recovery cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password change cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a linked provider is removed cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a sign-in method is marked compromised cannot be completed with an %s
A ticket opened after the credential change still completes201 Created · sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened after the password recovery still completes; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened after the password change still completes
A stored hash rewritten without a real password change (no password_changed_at move) leaves a pending ticket alive201 Created · sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › rewriting the stored hash without a real password change leaves a pending ticket alive
What a caller from another identity sees: recovering one identity's password leaves another identity's pending ticket alive201 Created · session for the bystandersrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a password recovery for one identity leaves the pending ticket of another identity alive

POST /sign-ins/renewals ​

CaseExpected answerProving test
Happy path: exchanges the refresh token for a new pair201 Created · new accessToken/refreshTokentest/e2e/session-renewal.e2e-spec.ts › hands out a new pair for the token the session is holding
Renewing keeps the original session deadline (not a sliding extension)sessionExpiresAt unchanged from the original sign-intest/e2e/session-renewal.e2e-spec.ts › keeps the deadline the sign in set, because renewing is not extending
Idempotent repeat / replay: presenting the same (now-spent) refresh token again401 · identity.renewal_refused, and the whole session ends as a suspected leaktest/e2e/session-renewal.e2e-spec.ts › ends the session when a spent token comes back, so a leak costs the thief the account
Refusal: an unknown/invented token401 · identity.renewal_refusedtest/e2e/session-renewal.e2e-spec.ts › refuses a token nobody ever issued
Refusal: a token spent several renewals ago (old-generation replay) ends the current session too401 · identity.renewal_refused, and the session's current token also stops working afterwardtest/e2e/session-renewal.e2e-spec.ts › ends the current session when a token spent several renewals ago returns
Indistinguishable refusal: a replayed (already-spent) token vs. a wholly invented oneByte-identical 401 bodysrc/modules/identity/application/sessions/renew-session.use-case.spec.ts › answers a replay and an invented token with the same refusal
An unrelated real session remains usable after an unknown token is refused201 Created for the still-valid sessiontest/e2e/session-renewal.e2e-spec.ts › keeps a real session usable after an unknown token is refused
Refuses when access-token signing fails, without starting the mutating transaction or rotating anythingRejects, no rotation performedsrc/modules/identity/application/sessions/renew-session.use-case.spec.ts › does not start mutation or rotate when access signing fails
Concurrency: a locked renewal candidate that changed shape (different session id, identity id, or expiry) between find and lock is refused without rotating401 · identity.renewal_refused, no rotationsrc/modules/identity/application/sessions/renew-session.use-case.spec.ts › refuses a changed locked candidate without rotating: %o
Concurrency: replay revocation still commits even if the candidate rotates mid-signingRevocation is not lost to the racesrc/modules/identity/application/sessions/renew-session.use-case.spec.ts › commits replay revocation if the candidate rotates while signing
Concurrency (database-level race): an in-flight renewal beats an old-generation replay, which is then revoked once the new token is committedNew token survives, old-generation replay revokes it correctlytest/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight renewal then revokes the newly issued token on old replay
Concurrency (database-level race): an in-flight old replay beats the current renewal, which is then refused post-revocationCurrent token refused after the race resolvestest/integration/refresh-token-replay.integration-spec.ts › waits for an in-flight old replay then refuses the current token after revocation
Several honest generations renew in sequence without ever revoking the sessionNo false-positive replay revocationtest/integration/refresh-token-replay.integration-spec.ts › renews several generations without revoking an honest session
Rate limit: exceeds PROVES_A_PASSWORD_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone

POST /sign-outs ​

CaseExpected answerProving test
Happy path: ends the session belonging to the presented access token; its refresh token stops working immediately204 No Content, refresh token then refused with 401 · identity.renewal_refusedtest/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working
Isolation: leaves this identity's sessions on other clients untouchedOther sessions of the same identity keep renewingtest/e2e/sign-out.e2e-spec.ts › leaves the sessions this caller opened on other clients alone
Idempotent repeat: signing out again with the same (now-dead) access token401 · identity.session_required — the token itself is refused because its session is no longer live, not a 204 no-optest/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it
Repeat at the domain layer keeps the original revocation reason rather than overwriting itReason field stable across a second calltest/integration/sign-out.integration-spec.ts › keeps the reason a session was already ended with
What a caller from another identity sees: a caller/session pair that does not match any owned session ends nothingTargeted session's revoked_at stays NULLtest/integration/sign-out.integration-spec.ts › refuses a session that belongs to somebody else
Concurrency: two simultaneous sign-outs of the same session serialize on the row guard, revoking it exactly oncerevoked_at set once, reason stabletest/integration/sign-out.integration-spec.ts › serializes two simultaneous sign-outs of the same session, revoking it exactly once
Guard — no session: no Authorization header at all401 · identity.session_requiredtest/e2e/sign-out.e2e-spec.ts › refuses $what the same way
Guard — forged/invalid access token: non-Bearer scheme, a token nobody issued, or an empty bearer value, all indistinguishable401 · identity.session_requiredtest/e2e/sign-out.e2e-spec.ts › refuses $what the same way
Guard — revoked session: an access token whose session was already ended answers the same refusal (see the idempotent-repeat row above)401 · identity.session_requiredtest/e2e/sign-out.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it

POST /sign-outs/everywhere ​

CaseExpected answerProving test
Happy path: ends every session of this identity, so every client's refresh token stops renewing204 No Content, all refresh tokens refused afterwardtest/e2e/sign-out-everywhere.e2e-spec.ts › ends every session of this identity, so refresh tokens from every client stop renewing
Happy-path effect: seals a platform Audit Record with the identity as both actor and targetAudit record identity.sessions.revoked_everywhere, outcome successtest/e2e/sign-out-everywhere.e2e-spec.ts › seals a platform Audit Record for the identity-wide revocation, with the identity as actor and target
What a caller from another identity sees: a second identity's live sessions are left completely untouchedOther identity's sessions stay livetest/integration/sign-out-everywhere.integration-spec.ts › leaves a second identity sessions untouched
Not an error for an identity with no live sessions left to endCompletes normally, no exceptiontest/integration/sign-out-everywhere.integration-spec.ts › is not an error for an identity with no live sessions
Idempotent repeat: repeating with the same (now-dead) access token401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it
Repeat at the domain layer keeps the original revocation reason rather than overwriting itReason field stable across a second calltest/integration/sign-out-everywhere.integration-spec.ts › keeps the reason a session was already ended with, rather than overwriting it
Concurrency: a session still committing its sign-in when the everywhere-revocation runs is not left liveThat session ends up revoked too, not orphaned as "live"test/integration/sign-out-everywhere.integration-spec.ts › does not leave live a session whose sign-in was still committing when the revocation ran
Concurrency: a session created after the revocation already began is revoked too, instead of aborting the whole operationNew session is caught and revoked; the operation still succeedstest/integration/sign-out-everywhere.integration-spec.ts › revokes a session created after the revocation began, instead of aborting the whole operation
Guard — no session: no Authorization header at all401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way
Guard — forged/invalid access token, indistinguishable across every sub-case401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses $what the same way
Guard — revoked session: same refusal as the idempotent-repeat row above401 · identity.session_requiredtest/e2e/sign-out-everywhere.e2e-spec.ts › refuses to repeat the sign-out with the same access token, because signing out already killed it

Session surface, step-ups and provider nonces ​

session-context.controller.ts, session-history.controller.ts, step-ups.controller.ts, step-up-confirmations.controller.ts, step-up-providers.controller.ts, provider-nonces.controller.ts. All five session/step-up routes carry SessionGuard only; provider-nonces carries no guard at all.

GET /session/context ​

CaseExpected answerProving test
Happy path: freshly confirmed identity200 OK with identityId, masked verified identifiers, active methods, empty organization contexttest/e2e/session-context.e2e-spec.ts › answers the identity, its verified identifiers, active methods and organization state for a freshly confirmed account
Response never leaks the raw identifier200 OK, masked values onlytest/e2e/session-context.e2e-spec.ts › never puts the full email address or an unmasked phone number in the response
What a caller from another identity sees: caller B never sees A's identifiers/methodsQuery scoped by identityId; no leakagesrc/modules/identity/infrastructure/session-context/session-context.repository.integration-spec.ts › never returns an identifier or a method belonging to another identity
Guard — no authorization header / non-Bearer scheme / unissued token, indistinguishable401 · identity.session_requiredtest/e2e/session-context.e2e-spec.ts › refuses $what the same way
Guard — revoked session, next request with the same token401 · identity.session_requiredtest/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out
Guard — revoked/dead session vs. a purely forged token, byte-identical401 · identity.session_required, identical body/headerstest/e2e/session-revocation.e2e-spec.ts › answers a dead session and an invented token with the exact same refusal body
Guard — an elevation token presented as the bearer credential401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses an elevation token presented as a bearer credential on an ordinary authenticated route
Organization selected, organization-wide owner: grouped access, capabilities, profile and workspace200 OK · access.organizationWide holds the owner permissions, access.scoped empty, permissions equals access.organizationWide, capabilities equal to GET /subscription, profile equal to the locale and currency of GET /organization, every active business unit and outlet, truncated: false, selection: nulltest/e2e/session-context-workspace.e2e-spec.ts › answers the owner its organization-wide permissions, the capabilities of GET /subscription, the profile of GET /organization and every active business unit and outlet
Member scoped to one outlet: only that outlet and its business unit; the deprecated flat field stays honest200 OK · access.organizationWide: [], one access.scoped entry for the outlet with its permissions, permissions: [], workspace with that outlet (business unit and legal entity of today, site, timezone) and its business unit with its active verticalstest/e2e/session-context-workspace.e2e-spec.ts › answers a cashier scoped to one outlet only that outlet and its business unit, its permission only as scoped, and nothing in the deprecated flat permissions
Member scoped to one of two business units200 OK · only that business unit and its outlets in workspace, one business_unit entry in access.scopedtest/e2e/session-context-workspace.e2e-spec.ts › answers a manager scoped to one of two business units only that unit and its outlets
The scoped outlet is deactivated200 OK · the outlet leaves workspace and access.scopedtest/e2e/session-context-workspace.e2e-spec.ts › drops a deactivated outlet from the workspace and the scoped access of the cashier
Valid selection: outletId, or businessUnitId alone200 OK · selection with the business unit, the outlet, the legal entity of the outlet today, its site, its timezone and the active verticals of the unit; a unit alone answers the outlet fields nulltest/e2e/session-context-workspace.e2e-spec.ts › answers a valid outlet selection with its business unit, the legal entity that operates it today, its site, timezone and verticals
Selection of an outlet of another business unit, of another organization, unknown, inactive, an unreached unit, a unit of another organization or a unit that does not match the outlet, indistinguishable404 · organization.structure_not_found, byte-identical bodytest/e2e/session-context-workspace.e2e-spec.ts › answers the same 404 for an outlet of another business unit, a foreign, an unknown and an inactive outlet, an unreached unit and a unit that does not match the outlet
Guard and validation: no session; outletId that is not a UUID401 · identity.session_required; 400 · foundation.validation_failedtest/e2e/session-context-workspace.e2e-spec.ts › refuses a call without a session with 401 and a malformed selection with 400
No organization selected; a selection sent without one200 OK with empty lists and profile/selection null; the selection alone answers 404 · organization.structure_not_foundtest/e2e/session-context-workspace.e2e-spec.ts › answers empty lists and a null profile and selection without an organization, and the same 404 for a selection sent without one
Member whose roles grant no permission200 OK · empty access and empty workspacetest/e2e/session-context-workspace.e2e-spec.ts › answers a member whose role grants nothing empty access and an empty workspace
More than 500 reachable outlets (501 seeded with SQL)200 OK · 500 outlets in creation order, truncated: truetest/e2e/session-context-workspace.e2e-spec.ts › caps the workspace at 500 outlets and says it was truncated (501 outlets seeded with SQL)

GET /sign-ins/history ​

CaseExpected answerProving test
Happy path: lists open sessions, flags the caller's own200 OK, isCurrentSession true only for the presented token's sessiontest/e2e/session-history.e2e-spec.ts › lists every open session and flags the one the caller is using
Pagination: meta.pageInfo shape, walking pages with limit/after200 OK, pageInfo: { hasNextPage, nextCursor }, no gaps or duplicatestest/e2e/session-history.e2e-spec.ts › walks every open session across pages with the limit and cursor query params
Pagination: default limit (50) when limit is absentApplies the documented default limitsrc/foundation/pagination/collection-query.schema.spec.ts › defaults to the documented default limit when absent
Pagination: maximum limit (100) accepted200 OK at 100src/foundation/pagination/collection-query.schema.spec.ts › accepts the documented maximum limit
Pagination: limit above 100 rejected400 · foundation.validation_failedsrc/foundation/pagination/collection-query.schema.spec.ts › rejects a limit above the documented maximum
Pagination: structurally malformed cursor400 · collection.cursor_invalidtest/e2e/session-history.e2e-spec.ts › refuses a structurally malformed cursor as collection.cursor_invalid
Pagination: tampered cursor signature400 · collection.cursor_tamperedsrc/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › refuses a cursor whose signature was tampered with
Pagination: a cursor minted for a different identity400 · collection.cursor_incompatibletest/e2e/session-history.e2e-spec.ts › refuses a cursor minted for a different identity as collection.cursor_incompatible
Guard — no header / non-Bearer / unissued token / empty bearer value, all indistinguishable401 · identity.session_requiredtest/e2e/session-history.e2e-spec.ts › refuses $what the same way
Guard — revoked session / expired / session naming another identity / non-existent session, all indistinguishable from a missing token401 · identity.session_required (byte-identical)src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
What a caller from another identity sees: caller B never sees A's sessions200 OK, only the caller's own sessions returnedsrc/modules/identity/infrastructure/sessions/identity-session.repository.integration-spec.ts › never returns a session that belongs to another identity

POST /step-ups ​

CaseExpected answerProving test
Happy path: opens a challenge and queues a code for the verified identifier, inside the same transaction (fixes notify-before-commit)201 Created with challengeExpiresAttest/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session
Refusal: identity has no verified identifier to reach422 · identity.step_up_unreachablesrc/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses an identity with no verified identifier to reach, without hashing anything
Refusal: none of the identity's verified, non-revoked identifiers can be notified422 · identity.identifier_channel_unavailablesrc/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › refuses when nothing can reach any candidate it found, before opening a challenge
Preference order: a primary identifier the notifier cannot reach is skipped for the next verified, non-revoked identifier in preference order that it canCode goes to the first candidate stepUpNotifier.canNotify(...) accepts, not necessarily the primary onesrc/modules/identity/application/step-ups/open-step-up.use-case.spec.ts › skips a preferred identifier the notifier cannot reach, and uses the next one in preference order
Idempotent repeat: a second open supersedes the first; only the newest challenge answersOnly the newest challenge is lockable/answerablesrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › locks the newest live challenge for the identity, ignoring a stale one
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Guard — no header / unissued token, indistinguishable401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses opening a step-up with $what
Guard — revoked session / forged token, byte-identical to missing401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why

POST /step-ups/confirmations ​

CaseExpected answerProving test
Happy path: correct code mints an elevation bound to the calling session201 Created with elevationToken, expiresAt in the futuretest/e2e/step-up.e2e-spec.ts › proves the code queued for a verified identifier and hands back an elevation token bound to this session
Refusal: wrong code, charges an attempt, session survives403 · identity.step_up_confirmation_refusedtest/e2e/step-up.e2e-spec.ts › refuses a wrong code without ending the session, charging an attempt
Indistinguishable refusal: wrong code vs. no live challenge to answer403 · identity.step_up_confirmation_refused, byte-identicalsrc/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal
Attempt budget: 5 attempts per challenge before it is revokedAttempts counted 0-4, challenge unlockable afterwardsrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › holds the attempt budget, revoking the challenge once it is exhausted
Lockout: the wrong code that crosses the lock threshold is still answered as an ordinary refusal, not as locked403 · identity.step_up_confirmation_refused, not 423src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › records the account as locked when a wrong step-up code is the failure that locks it
Lockout: a further wrong code once already locked423 · identity.step_up_locked, the lock is not audited a second timetest/e2e/account-lockout.e2e-spec.ts › locks a step-up account after wrong codes spread across several of its own challenges, and refuses a further wrong code with a distinct, informative response; src/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › answers a further wrong step-up code on an already locked account with the lock expiry, without auditing the lock again
Lockout: a wrong code below the threshold refuses and records no lock403 · identity.step_up_confirmation_refused, nothing auditedsrc/modules/identity/application/step-ups/confirm-step-up.use-case.spec.ts › refuses a wrong step-up code below the threshold without recording any lock
Lockout: the correct code still mints an elevation even while locked201 Createdtest/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are
Idempotent repeat: replaying the same already-consumed correct code403 · identity.step_up_confirmation_refusedsrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › cannot reuse a spent challenge
What a caller from another identity sees: one identity's confirmation attempts never touch another identity's challengeSecond identity's challenge/attempt count untouchedsrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › leaves a second identity completely untouched
Concurrent revocation: the identifier a live code was queued for gets revoked before confirmation; the challenge is simply not found, never a raw database error403 · identity.step_up_confirmation_refused, never 500test/e2e/step-up.e2e-spec.ts › refuses confirming a step-up once the identifier its code was queued for gets revoked mid-flight, instead of a raw database error; src/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › excludes a challenge from the newest-live lookup once the identifier that received its code was revoked
Guard — no header / unissued token401 · identity.session_requiredtest/e2e/step-up.e2e-spec.ts › refuses confirming a step-up with $what
Guard — revoked session / forged token, byte-identical to missing401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Concurrency: two simultaneous confirmations against the same challengeExactly one consumes itsrc/modules/identity/infrastructure/step-ups/identity-step-up.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations lock and consume the same challenge
Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents201/403/400 per case, per configured formattest/e2e/out-of-band-code-formats.e2e-spec.ts › step-up confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents

POST /step-ups/providers ​

CaseExpected answerProving test
Happy path: fresh provider ID token proves a linked identity201 Created with elevationToken, expiresAt in the futuretest/e2e/step-up-with-a-provider.e2e-spec.ts › steps up a provider-only identity with a fresh token and enrols an authenticator app with the elevation
Indistinguishable refusal: stale token / wrong subject / unlinked provider / forged signature403 · identity.step_up_confirmation_refused, byte-identicaltest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal
Refusal freshness boundary: more than 5 minutes stale refused, exactly 5 minutes accepted403 past the boundary; 201 at exactly 5:00src/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses a token issued more than five minutes ago, without ever spending a nonce or reaching the repository
Indistinguishable refusal: no nonce claim / nonce nobody issued / nonce expired403 · identity.step_up_confirmation_refused, byte-identicaltest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token with $what, byte-identically to an unissued nonce
No dedicated attempt budget/lockout on this route: repeated refusals never escalate to 423403 on every consecutive refusaltest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a stale token, a token for another subject, a token for an unlinked provider, and a forged token with the byte-identical refusal
Idempotent repeat: replaying the exact same ID token after it already minted an elevation403 · identity.step_up_confirmation_refusedtest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a step-up token replayed after it already minted an elevation
What a caller from another identity sees: a token linked to a different identity than the caller403 · identity.step_up_confirmation_refusedsrc/modules/identity/application/step-ups/confirm-step-up-with-provider.use-case.spec.ts › refuses when the linked provider identity belongs to a different identity than the caller
Never leaks the presented ID token in the responseResponse body excludes the raw tokentest/e2e/step-up-with-a-provider.e2e-spec.ts › never lets the id token reach what it answers
Cross-route invariant: a nonce spent by POST /sign-ins cannot be spent again here403 · identity.step_up_confirmation_refusedtest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up
Guard — no header / unissued token401 · identity.session_requiredtest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses stepping up with a provider with $what
Guard — revoked session / forged token, byte-identical to missing401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Rate limit: exceeds ANSWERS_A_CHALLENGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Concurrency: simultaneous nonce-spend vs. confirmExactly one mints an elevation, the other refusestest/e2e/step-up-with-a-provider.e2e-spec.ts › lets exactly one of two simultaneous step-ups spend the same nonce

POST /provider-nonces ​

CaseExpected answerProving test
Happy path: mints a nonce and a 10-minute expiry, exactly once per call201 Created with nonce, expiresAt = now + 10 minsrc/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › answers the raw nonce and an expiry ten minutes out, once
Storage: persists the hash, never the raw nonceStored row has nonce_hash, no plaintext nonce anywheresrc/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › stores the hash of the nonce, never the nonce itself
No refusal cause: issuance never fails on domain groundsAlways 201 Created—
Repeat: repeating the identical POST does not reuse anything; every call mints a materially distinct nonceTwo calls in a row yield two different ids/noncessrc/modules/identity/application/provider-nonces/issue-provider-nonce.use-case.spec.ts › gives every issued nonce a distinct id
No guard: route is intentionally public/unauthenticatedReachable with or without any authorization header—
Rate limit: exceeds SENDS_A_MESSAGE_RATE_LIMIT429 · rate_limit.exceededtest/e2e/rate-limit.e2e-spec.ts › refuses the $routeName request that goes past its own limit, counted on this route alone
Concurrency: simultaneous spend of the same nonce; exactly one winsOne true, one falsesrc/modules/identity/infrastructure/provider-nonces/identity-provider-nonce.repository.integration-spec.ts › lets exactly one of two simultaneous spends of the same nonce win

Password lifecycle ​

password-methods.controller.ts, password-changes.controller.ts, password-recoveries.controller.ts, password-recovery-confirmations.controller.ts. password-methods and password-changes carry SessionGuard; only password-methods also carries ElevationGuard. The two recovery routes are public.

POST /password-methods ​

CaseExpected answerProving test
Happy path: creates a password for an identity with none, and other sessions keep working204 No Contenttest/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email
Afterward, a verified identifier can sign in with the new password201 Created sessiontest/e2e/password-methods.e2e-spec.ts › lets a provider-only identity create a password and sign in with it once it also has a verified email
Refusal: an active password method already exists409 · identity.password_method_existstest/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched; src/modules/identity/application/password-methods/create-password-method.use-case.spec.ts › refuses cleanly when an active password method already exists, without auditing anything
Refusing an existing password leaves every other session of this identity untouchedOther sessions' access tokens keep workingtest/e2e/password-methods.e2e-spec.ts › refuses cleanly when an active password already exists, leaving other sessions untouched
Concurrency: two simultaneous claims for the same identity serialize on the identity lock; exactly one winsOne created, one already_existssrc/modules/identity/infrastructure/password-methods/identity-password-method.repository.integration-spec.ts › lets two simultaneous claims for the same identity serialize on the identity lock, exactly one winning
Guard — missing elevation403 · identity.elevation_requiredtest/e2e/password-methods.e2e-spec.ts › refuses without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/e2e/password-methods.e2e-spec.ts › refuses an elevation minted for a different session
Validation: new password shorter than the documented minimum400 · foundation.validation_failedtest/e2e/password-methods.e2e-spec.ts › refuses a new password shorter than the documented minimum

POST /password-changes ​

CaseExpected answerProving test
Happy path: current password proven, new password committed, every other session ends, the calling session keeps working204 No Contenttest/e2e/password-change.e2e-spec.ts › replaces the password and keeps the calling session alive while every other session ends
Indistinguishable refusal: wrong current password vs. a new password identical to the current one403 · identity.password_change_refused, same code and detail for both causestest/e2e/password-change.e2e-spec.ts › refuses a new password identical to the current one, the same way as a wrong current password
Indistinguishable refusal, edge case: identity has no active password credential at all — a decoy hash is still compared so timing leaks nothing403 · identity.password_change_refusedsrc/modules/identity/application/password-changes/password-change.use-case.spec.ts › refuses an identity with no password credential, after still comparing a password
Idempotent repeat: the exact same body replayed right after it already succeeded refuses, because the current password it names has already moved on403 · identity.password_change_refusedtest/e2e/password-change.e2e-spec.ts › refuses the identical body replayed right after it already succeeded, because the current password it names has already moved on
Concurrency: the identity row is locked for the duration of the commit, serializing a change against other writers204 for the writer that wins the locktest/integration/password-change.integration-spec.ts › does not leave live a session whose sign-in was still committing when the change ran
Concurrency: two simultaneous changes for the same identity serialize on the identity lock; exactly one winsOne committed: true, one committed: false, the winning hash persiststest/integration/password-change.integration-spec.ts › lets exactly one of two simultaneous changes for the same identity win, serialized by the identity lock
Downstream effect: a pending second-factor ticket opened before the change is revoked in the same transaction401 · identity.second_factor_refused on completing it; no new sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password change cannot be completed with an %s
What a caller from another identity sees: changing identity A's password never touches identity B's sessions or credentialIdentity B's password hash unchanged, sessions not revokedtest/integration/password-change.integration-spec.ts › leaves a second identity sessions and password untouched
Guard — no session at all401 · identity.session_requiredtest/e2e/password-change.e2e-spec.ts › refuses $what
Guard — forged/invalid access token401 · identity.session_requiredtest/e2e/password-change.e2e-spec.ts › refuses $what
Guard — revoked session, byte-identical to a missing token401 · identity.session_requiredsrc/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Validation: a body carrying a field the contract does not declare400 · foundation.validation_failedtest/e2e/password-change.e2e-spec.ts › refuses a body carrying a field the contract does not declare

POST /password-recoveries ​

CaseExpected answerProving test
Indistinguishable happy path: a registered identifier and an unregistered one answer with the exact same body shape201 Created with { data: { challengeExpiresAt } }, identical field set for bothtest/e2e/password-recovery.e2e-spec.ts › answers an address nobody registered exactly like a registered one, field by field
Refusal: nothing can reach that identifier type422 · identity.identifier_channel_unavailablesrc/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses before opening a transaction when nothing can notify that identifier type
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/modules/identity/application/password-recoveries/request-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction
Infrastructure failure before the account is known (transaction cannot begin, the account lookup throws, or a transaction that found no account fails to commit): reported, identical for a registered and an unregistered address503 · Retry-After for a transient database failure, 500 otherwise; never the 201 used for the happy pathtest/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers a transient database failure with the same 503 and Retry-After for a registered and an unregistered address; test/e2e/password-recovery-infrastructure-failure.e2e-spec.ts › answers any other database failure with the same 500 for a registered and an unregistered address; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › reports the failure of the account lookup instead of answering accepted
Infrastructure failure that can only happen for an existing account (the challenge insert, the enqueue, or the commit of a transaction that wrote a challenge): hidden so existence is not revealed, logged at error level with only the error name and code201 Created with { data: { challengeExpiresAt } }, the same as an unregistered identifiersrc/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides an enqueue failure for an existing account behind the exact outcome of an unknown one; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › raises the hidden enqueue failure to an error carrying only its name and code; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a failure of the challenge insert for an existing account behind the exact outcome of an unknown one; src/modules/identity/application/password-recoveries/request-password-recovery.use-case.failures.spec.ts › hides a commit failure of a transaction that wrote a challenge, and raises it to an error
Idempotent repeat: requesting recovery again for the same identifier opens a second live challenge alongside the first; consuming one later revokes the sibling201 Created again, a new challengeExpiresAttest/integration/password-recovery.integration-spec.ts › revokes sibling live challenges once one is consumed
Validation: a body carrying a field the contract does not declare400 · foundation.validation_failedtest/e2e/password-recovery.e2e-spec.ts › refuses a recovery request carrying a field the contract does not declare

POST /password-recoveries/confirmations ​

CaseExpected answerProving test
Happy path: correct code replaces the password; every session of that identity ends, including the one that requested the recovery204 No Contenttest/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one
Indistinguishable refusal: wrong code, an already-consumed/reused code, and an unregistered address all answer identically422 · identity.password_recovery_confirmation_refused, same code and detail, body never contains the identifiertest/e2e/password-recovery.e2e-spec.ts › answers a wrong code, a reused code and an unregistered address with the same refusal
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › refuses an unusable identifier before opening a transaction
Validation: malformed code shape (not exactly six digits) rejected before touching the database400 · foundation.validation_failedtest/e2e/password-recovery.e2e-spec.ts › refuses a code that is not six digits before touching the database
Attempt budget: the challenge's own attempt cap is spent — answers exactly like a missing/wrong code, no distinct locked status422 · identity.password_recovery_confirmation_refusedtest/integration/password-recovery.integration-spec.ts › holds the attempt budget and stops accepting attempts once it is spent
Attempt-budget side effect: the failure that crosses the separate account-lockout threshold still answers the ordinary refusal, never 423422 · identity.password_recovery_confirmation_refused (audited as accountLocked)src/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › records the account as locked when the wrong recovery code is the failure that locks it
Lockout: a wrong code on an already locked account answers the same ordinary refusal, never 423, and the lock is not audited again422 · identity.password_recovery_confirmation_refused, nothing auditedsrc/modules/identity/application/password-recoveries/confirm-password-recovery.use-case.spec.ts › answers a wrong recovery code on an already locked account with the ordinary refusal, never a locked answer, and does not audit the lock again
Idempotent repeat: replaying the same confirmation after the code already recovered the password422 · identity.password_recovery_confirmation_refusedtest/e2e/password-recovery.e2e-spec.ts › cannot reuse a code once it has recovered the password
Concurrency: the identity row is locked for the commit, serializing recovery against other writers204 for the winnertest/integration/password-recovery.integration-spec.ts › does not leave live a session whose sign-in was still committing when the recovery ran
Concurrency: two simultaneous confirmations of the same code serialize on the challenge lock; exactly one winsOne succeeds, the other refusestest/integration/password-recovery.integration-spec.ts › lets exactly one of two simultaneous confirmations for the same code win, serialized by the challenge lock
Downstream effect: a pending second-factor ticket opened before the recovery is revoked in the same transaction401 · identity.second_factor_refused on completing it; no new sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a password recovery cannot be completed with an %s
The code is the only credential: the confirm request carries no session, token or caller; anyone holding identifier and code completes it204, no authentication required or checkedtest/e2e/password-recovery.e2e-spec.ts › walks the whole recovery flow: lose it, recover it, sign in with the new one, fail with the old one
Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents204/422/400 per case, per configured formattest/e2e/out-of-band-code-formats.e2e-spec.ts › password recovery confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents

Sign-in methods ​

sign-in-methods.controller.ts, sign-in-method-confirmations.controller.ts, sign-in-method-removals.controller.ts, sign-in-method-providers.controller.ts. GET /sign-in-methods carries SessionGuard only; the four mutating routes carry SessionGuard and ElevationGuard.

GET /sign-in-methods ​

CaseExpected answerProving test
Happy path: lists every way in (email, password, phone) after add and confirm200 OKtest/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in
Happy path: a linked Google/Apple provider is listed alongside the password, with its provider email masked as a safe display value200 OK, entry kind: 'google'/'apple', masked maskedValuesrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider alongside the password, masking its provider email
A linked provider with no provider email lists a safe null display value instead of guessing one200 OK, maskedValue: nullsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lists a linked provider with no provider email as a safe null display value
What a caller from another identity sees: identity B's list never contains identity A's sign-in methods200 OK (own methods only)src/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity
Guard — no session presented401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request
Guard — revoked session, byte-identical to a missing token401 · identity.session_requiredsrc/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Guard — missing token and forged token answer byte-identically401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals
Guard — revoked session and forged token answer byte-identically401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › answers a missing header and an unrecognised token with byte-identical refusals; src/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why

POST /sign-in-methods ​

CaseExpected answerProving test
Happy path: claims a new identifier and queues its code, inside the same transaction as the claim201 Createdtest/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in
Refusal: nothing can reach that identifier kind422 · identity.identifier_channel_unavailablesrc/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses when nothing can reach that kind of identifier, without ever claiming it
Refusal: identifier already verified by a different identity409 · identity.identifier_takensrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier already verified by a different identity
Refusal: identifier is mid-claim (pending, unconfirmed) by a different identity409 · identity.identifier_takensrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses an identifier a different identity is still mid-way through claiming
Refusal: identifier fails normalization422 · identity.identifier_unusablesrc/modules/identity/application/sign-in-methods/add-sign-in-method.use-case.spec.ts › refuses an unusable identifier before ever checking whether it can be reached
Self-collision: re-adding an identifier the same caller already has verified409 · identity.identifier_takensrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses re-adding an identifier the same caller has already verified
Idempotent repeat: re-adding the same still-pending identifier queues a fresh code that supersedes the older one instead of erroring (OX-S8)201 Created, new code, new expirysrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › still resends its own challenge when the same identity retries its own pending claim, superseding the older message (OX-S8)
Concurrency: a rival identity claiming the same identifier while the first claim is pending loses under the per-identifier advisory lock409 · identity.identifier_takensrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets exactly one of two identities simultaneously claiming the same identifier win, serialized by the per-identifier advisory lock
Guard — no session presented401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Guard — missing elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to add a sign-in method backed by an elevation minted for a different session

POST /sign-in-methods/confirmations ​

CaseExpected answerProving test
Happy path: code matches its own live challenge, verifies the identifier, audits it204 No Contenttest/e2e/sign-in-methods.e2e-spec.ts › adds a phone to an email account and confirms it, listing every way in
Refusal: wrong code, tried against every live challenge (each charged)422 · identity.sign_in_method_confirmation_refusedsrc/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses and charges every live challenge once none of them match, but only charges the account lockout once for the whole request
Refusal: no live challenge exists to answer422 · identity.sign_in_method_confirmation_refusedsrc/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › refuses without charging anything when there is no live challenge to answer
Indistinguishable refusal: wrong code and no live challenge422 · identity.sign_in_method_confirmation_refused, byte-identicalsrc/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › answers a wrong code and a missing challenge with the same refusal
Attempt budget/lockout: the request whose failure reaches ACCOUNT_LOCKOUT_THRESHOLD locks the account and audits it exactly once but still answers the ordinary refusal; only the next attempt answers locked422 · identity.sign_in_method_confirmation_refused on the crossing request, then 423 · identity.sign_in_method_lockedsrc/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › locks the account once wrong codes accumulate one request at a time across several of its own live challenges, none of them exhausted individually, and records the lock exactly once
Lockout: the correct code still succeeds while the account is locked, and clears the lock204 No Contentsrc/modules/identity/application/sign-in-methods/confirm-sign-in-method.use-case.spec.ts › does not refuse the correct code once the account is locked; it succeeds and clears the lock
Idempotent repeat: replaying an already-consumed code422 · identity.sign_in_method_confirmation_refusedsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses replaying an already-consumed confirmation code
Concurrency: two simultaneous confirm attempts with the same codeExactly one succeeds, the other refuses cleanlysrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › lets at most one of two simultaneous confirmations with the same code consume the challenge
What a caller from another identity sees: identity B's code cannot confirm identity A's pending identifier422 · identity.sign_in_method_confirmation_refusedsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lets identity B's code confirm identity A's pending identifier
Guard — no session presented401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Guard — missing elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses to confirm a sign-in method backed by an elevation minted for a different session
Configuration: numeric, alphabetic and alphanumeric out-of-band codes each confirm; a wrong code refuses; lower-case succeeds; surrounding whitespace is refused as the schema documents204/422/400 per case, per configured formattest/e2e/out-of-band-code-formats.e2e-spec.ts › sign-in-method confirmation: issued code succeeds, wrong code refuses, lower-case succeeds and surrounding whitespace is refused as the schema documents

POST /sign-in-methods/:id/removals ​

CaseExpected answerProving test
Happy path: removes one of two verified identifiers, leaving the other listed204 No Contenttest/e2e/sign-in-methods.e2e-spec.ts › removes a method once a live elevation proves this exact session
Session contract: removing a password method ends every other session of this identity while the calling session keeps working, the same contract password change already documents204 No Content, calling session's revoked_at IS NULL, every other session's revoked_at IS NOT NULLsrc/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing the password method ends every other session while the calling session survives; src/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › revokes the target method with the calling session id, so the caller session can survive
Session contract: removing a linked provider method ends every other session while the calling session keeps working204 No Content, calling session's revoked_at IS NULL, every other session's revoked_at IS NOT NULLsrc/modules/identity/application/sign-in-methods/remove-sign-in-method-sessions.integration-spec.ts › removing a linked provider method ends every other session while the calling session survives
Refusal: id names nothing this identity currently holds404 · identity.sign_in_method_not_foundsrc/modules/identity/application/sign-in-methods/remove-sign-in-method.use-case.spec.ts › refuses an id that names nothing this identity currently holds, without locking anything else
Idempotent repeat: removing an already-removed method by the same id again404 · identity.sign_in_method_not_foundsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing an already-removed method a second time, exactly like an id that never existed
Concurrency: two concurrent removals of different targets on the same identity serialize instead of deadlocking, both succeed204 No Content (both)test/integration/sign-in-method-removal-lock-order.integration-spec.ts › serialises two concurrent removals of different targets on the same identity instead of deadlocking
What a caller from another identity sees: an id belonging to another identity answers exactly like one that does not exist, leaving it untouched404 · identity.sign_in_method_not_foundsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › never lists, never removes, and leaves untouched a real stranger identity
Last-way-in invariant: refuses removing the only verified identifier, leaving the password stranded409 · identity.last_sign_in_methodtest/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in
Last-way-in invariant: refuses removing the password when nothing else can prove the owner409 · identity.last_sign_in_methodsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses to remove the password when nothing else can prove the owner
Last-way-in invariant boundary: removing the password is allowed once a self-sufficient method (external provider/passkey) still proves the owner204 No Contentsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes the password once a self-sufficient method can still prove the owner
Happy path: removes a linked Google/Apple provider, leaving another self-sufficient method reachable204 No Contentsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › removes a linked provider once another self-sufficient method can still prove the owner
Last-way-in invariant: refuses removing the only linked provider, leaving no way in409 · identity.last_sign_in_methodsrc/modules/identity/application/sign-in-methods/sign-in-methods.integration-spec.ts › refuses removing the only linked provider, leaving no way in
Last-way-in invariant boundary: unlinking Google from a password identity is allowed and still leaves email+password reachable, whichever kind is removed204 No Content; 201 Created afterward with email+passwordtest/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else
Last-way-in invariant boundary: unlinking one of two linked providers is allowed, unlinking the very last one is refused204 No Content, then 409 · identity.last_sign_in_methodtest/e2e/sign-in-method-providers.e2e-spec.ts › unlinks a provider once another self-sufficient method still proves the owner, but refuses removing the very last one
Domain invariant: a way in remains exactly when a self-sufficient method survives, or a verified identifier and an active password both survive — evaluated the same way whichever kind (identifier, password or provider) is removedtrue/false per table rowsrc/modules/identity/domain/sign-in-methods/last-way-in-policy.spec.ts › $name
Downstream effect: unlinking a provider frees that exact (provider, subject) — a step-up-with-provider from the original session is refused, since it is no longer linked to that identity403 · identity.step_up_confirmation_refusedtest/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else
Downstream effect: an unlinked provider subject is unclaimed, not permanently blocked — signing in with it again opens a session for a fresh identity, never the original one201 Created, a different identity/session than the originaltest/e2e/sign-in-method-providers.e2e-spec.ts › unlinks Google from a password identity, still signs in with email and password, and treats the unlinked Google subject as unclaimed everywhere else
Downstream effect: an unlinked provider subject can be linked again, to a different identity204 No Contentsrc/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets a provider subject be relinked, to a different identity, once it was unlinked
Downstream effect: an unlinked provider subject can be linked again, to the same identity that unlinked it204 No Contenttest/e2e/sign-in-method-providers.e2e-spec.ts › lets the same Google subject be linked again after being unlinked
Downstream effect: removing a linked provider, or marking a method compromised, revokes every pending second-factor ticket of that identity in the same transaction401 · identity.second_factor_refused on completing it; no new sessionsrc/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a linked provider is removed cannot be completed with an %s; src/modules/identity/application/sessions/pending-sign-in-ticket-revocation.integration-spec.ts › a ticket opened before a sign-in method is marked compromised cannot be completed with an %s
Validation: a non-UUID :id fails before it reaches the domain400 · foundation.validation_failedtest/e2e/sign-in-methods.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database
Guard — no session presented401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses $what with the session-required failure, without asking the authenticator
Guard — forged/unrecognized access token401 · identity.session_requiredsrc/foundation/auth/session.guard.spec.ts › refuses a token the authenticator does not recognise, and leaves nobody on the request
Guard — revoked session401 · identity.session_requiredsrc/modules/identity/application/session-context/authenticate-caller.use-case.spec.ts › refuses a well-signed token whose liveness port answers not live, because $why
Guard — missing elevation403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses the same removal without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/e2e/sign-in-methods.e2e-spec.ts › refuses a removal backed by an elevation minted for a different session

POST /sign-in-methods/providers ​

CaseExpected answerProving test
Happy path: links a Google/Apple identity to a signed-in identity, listed and usable to sign in afterward204 No Contenttest/e2e/sign-in-method-providers.e2e-spec.ts › lets a password identity link Google and sign in with it afterward
Refusal: that provider subject already belongs to a different identity, never revealing whose409 · identity.provider_link_refusedtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses cleanly when that provider subject already belongs to a different identity, never revealing whose
Refusal: that provider subject is already linked to the caller identity itself, the same way409 · identity.provider_link_refusedtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses relinking a provider subject already linked to the caller itself; src/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › refuses re-linking a subject already linked to the caller identity itself
Never links automatically by matching a provider email address; only the (provider, subject) pair decidesLinking never consults the presented emailsrc/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › links exactly the presented provider, verified subject and email, onto the caller identity
Idempotent repeat / replay: the exact same ID token replayed after it already linked a provider409 · identity.provider_link_refused (single-use nonce already spent)test/e2e/sign-in-method-providers.e2e-spec.ts › refuses the exact same token replayed after it already linked a provider
Refusal: the verifier could not vouch for the token, without ever spending a nonce or reaching the repository409 · identity.provider_link_refusedsrc/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses a token the verifier could not vouch for, without ever spending a nonce or reaching the repository
Cross-route invariant: the nonce is spent in the same transaction that links the identity, the same binding used by provider sign-in409 · identity.provider_link_refused when unspendable, before the repository is ever reachedsrc/modules/identity/application/sign-in-methods/link-provider.use-case.spec.ts › refuses when the nonce is unknown, expired or already spent, without ever reaching the repository
Concurrency: two simultaneous links of the same provider subject onto two different identities; exactly one winsOne linked, one already_linkedsrc/modules/identity/infrastructure/sessions/oauth-sign-in.repository.integration-spec.ts › lets exactly one of two simultaneous links of the same subject win
Validation: a body naming a provider nobody supports400 · foundation.validation_failedtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses a body naming a provider nobody supports
Guard — missing elevation403 · identity.elevation_requiredtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses without any elevation presented
Guard — elevation minted for a different session403 · identity.elevation_requiredtest/e2e/sign-in-method-providers.e2e-spec.ts › refuses an elevation minted for a different session

Second factors and recovery codes ​

second-factors.controller.ts, second-factor-confirmations.controller.ts, second-factor-removals.controller.ts, recovery-codes.controller.ts. GET /second-factors, POST /second-factors/confirmations and GET /recovery-codes carry SessionGuard only; POST /second-factors, POST /second-factors/:id/removals and POST /recovery-codes carry SessionGuard and ElevationGuard.

GET /second-factors ​

CaseExpected answerProving test
Happy path: lists only verified, non-revoked methods; secret/pending enrolment never surface200 OKtest/e2e/second-factors.e2e-spec.ts › lists a verified authenticator app without any secret material, and never a pending enrolment
What a caller from another identity sees: identity B never sees identity A's verified methods200 OK with an empty/foreign-free listsrc/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › never lists another identity method
Guard — no session / forged or unrecognised token, indistinguishable401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses listing with $what
Guard — revoked/dead session presenting an otherwise well-signed token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses listing with a session that has since signed out

POST /second-factors ​

CaseExpected answerProving test
Happy path: enrols, returns secret and otpauth URI once, confirmable with the current code201 Createdtest/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret
Configuration: a configured eight-digit SHA256 policy is enrolled, confirmed and signed in with end to end, and the otpauth URI carries that exact algorithm/digits/period201 Created; otpauth URI contains algorithm=SHA256, digits=8, period=60test/e2e/totp-policy.e2e-spec.ts › enrols, confirms and signs in with an authenticator app under a configured eight-digit SHA256 policy, and the otpauth URI carries that exact configuration
Configuration: the default six-digit SHA1 policy keeps working end to end under an unset configuration201 Created; otpauth URI contains algorithm=SHA1, digits=6, period=30test/e2e/totp-policy.e2e-spec.ts › leaves an authenticator app enrolled under the default six-digit SHA1 policy working end to end under an unset (default) configuration
Configuration: a global TOTP policy change never invalidates an already-enrolled authenticator, and a new enrolment after the change gets the new policy201 Created sign-in with the pre-change authenticator after the application restarts under a different policy; new enrolment's otpauth URI carries the new policytest/e2e/totp-policy.e2e-spec.ts › never locks out an authenticator enrolled before a global TOTP policy change, while a new enrolment after the change gets the new policy
Idempotent repeat: enrolling again before confirming revokes the prior pending enrolment (documented rotation, not a distinct error)201 Created; old secret's code no longer confirmstest/e2e/second-factors.e2e-spec.ts › enrolling twice leaves only the newest secret confirmable, and the old secret no longer works
Concurrency: the unique-pending invariant holds across repeated enrolmentsOnly the newest pending row survivessrc/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › holds the unique pending index: re-enrolling three times in a row still leaves only one live pending row; src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets two simultaneous enrolments for the same identity serialize on the advisory lock, leaving one live pending row
Refusal: no live elevation presented403 · identity.elevation_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolment without any elevation presented
Guard — no session / forged or unrecognised token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolling with $what
Guard — revoked/dead session401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses enrolling with a session that has since signed out

POST /second-factors/confirmations ​

CaseExpected answerProving test
Happy path: proves a current code, verifies the pending method204 No Contenttest/e2e/second-factors.e2e-spec.ts › enrols an authenticator app and confirms it with the current code computed from the returned secret
Indistinguishable refusal: wrong code, no pending enrolment, and an already-verified pending step all collapse to the same refusal403 · identity.second_factor_confirmation_refusedtest/e2e/second-factors.e2e-spec.ts › refuses a wrong code without consuming the pending enrolment, so the real code still confirms afterward
Idempotent repeat: confirming again once already verified answers the same refusal as above403 · identity.second_factor_confirmation_refusedtest/e2e/second-factors.e2e-spec.ts › refuses confirmation once the pending enrolment is already verified
Attempt budget/lockout: the failure that reaches the shared per-identity threshold locks the account and audits it but still refuses normally; only a later wrong code on the locked account answers locked, without a second audit403 · identity.second_factor_confirmation_refused on the crossing code, then 423 · identity.second_factor_lockedsrc/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › records the account as locked when a wrong second-factor code is the failure that locks it; src/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › throws SecondFactorLockedError once the account is already locked
Lockout: a wrong code below the threshold refuses and records no lock403 · identity.second_factor_confirmation_refused, nothing auditedsrc/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › refuses a wrong code below the threshold without recording any lock
Lockout does not gate a genuinely correct code; success never consults or clears the lock204 No Content even while lockedsrc/modules/identity/application/second-factors/confirm-second-factor.use-case.spec.ts › confirms a correct code while the account is locked, without consulting or clearing the lock
Concurrency: double-confirming the same enrolment at once, via a compare-and-set on the last used stepSecond attempt refusedsrc/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › the compare-and-set refuses a step equal to the one already spent; src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › lets exactly one of two simultaneous confirmations with the same step win the compare-and-set
Guard — no session / forged or unrecognised token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses confirming with $what
Guard — revoked/dead session401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses confirming with a session that has since signed out

POST /second-factors/:id/removals ​

CaseExpected answerProving test
Happy path: removes a verified method with a live elevation; list becomes empty204 No Contenttest/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty
Every second factor can be removed freely; the primary sign-in method still exists, so there is no last-way-in gate here204 No Content down to zero second factorssrc/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › revokes the method scoped by both the caller identity and the target id, and records the removal
Removing an authenticator ends every other live session while the calling session survives204 No Content; the other session's revoked_at is set, the caller's stays NULLsrc/modules/identity/application/second-factors/remove-second-factor.use-case.spec.ts › ends every other live session of the identity while sparing the calling session; src/modules/identity/application/second-factors/remove-second-factor-sessions.integration-spec.ts › ends every other session while the calling session survives
Indistinguishable refusal: an id belonging to another identity, an unknown id, and an already-removed id are all 404, so this route is deliberately not an existence oracle404 · identity.second_factor_not_foundtest/e2e/second-factors.e2e-spec.ts › answers 404 for a method id belonging to another identity, leaving it intact for its owner
Idempotent repeat: removing an already-removed id404 · identity.second_factor_not_foundtest/e2e/second-factors.e2e-spec.ts › answers the same 404 for an unknown id and for an id already removed
Refusal: no live elevation403 · identity.elevation_requiredtest/e2e/second-factors.e2e-spec.ts › refuses removal without a live elevation
Validation: id not a UUID, refused before the database is touched400 · foundation.validation_failedtest/e2e/second-factors.e2e-spec.ts › refuses a method id that is not a UUID as a validation failure, before it reaches the database
Concurrency: removing the same factor twice at once, via a conditional update guarded by revoked_at IS NULL (proven sequentially, not as a true race)One winner, one 404src/modules/identity/infrastructure/second-factors/identity-second-factor.repository.integration-spec.ts › affects zero rows the second time it revokes the same method
Guard — revoked/dead session (the only removal route with a route-specific proof of this sub-case)401 · identity.session_requiredtest/e2e/session-revocation.e2e-spec.ts › refuses an elevation token minted for a session that is later signed out, because ElevationGuard never runs without a session SessionGuard already admitted
Guard — no session / forged or unrecognised token401 · identity.session_requiredtest/e2e/second-factors.e2e-spec.ts › refuses removal with $what

GET /recovery-codes ​

CaseExpected answerProving test
Happy path: reports the live batch's remaining count and issuedAt200 OKtest/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining
Zero/never-issued: remaining: 0, no issuedAt, before any batch was ever issued200 OK, { remaining: 0 }, no issuedAt keytest/e2e/recovery-codes.e2e-spec.ts › answers remaining: 0 and no issuedAt before any batch was ever issued
Live count reflects a spend200 OK, { remaining: 9 }test/e2e/recovery-codes.e2e-spec.ts › signs in with a recovery code when the authenticator app is unreachable, opening a working session, and GET now reports nine
Guard — no session / forged or unrecognised token / revoked session401 · identity.session_requiredtest/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with $what; test/e2e/recovery-codes.e2e-spec.ts › refuses reading the status with a session that has since signed out
What a caller from another identity sees: does B's GET ever reflect A's batch/count200 OK, never (scoped by identity_id)src/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › never reflects a stranger's live batch or its issuedAt

POST /recovery-codes ​

CaseExpected answerProving test
Happy path: issues 10 fresh codes in xxxx-xxxx-xxxx-xxxx shape, returned once201 Created, 10 unique codestest/e2e/recovery-codes.e2e-spec.ts › issues a batch of ten codes in the documented four-groups-of-four format, and GET reports ten remaining
Re-issuing is documented rotation (happy path, not idempotency-preservation): every issue revokes the prior live batch, retiring its codes201 Created; old batch's codes stop redeemingtest/e2e/recovery-codes.e2e-spec.ts › refuses a code from a retired batch once a fresh batch has been issued, and GET reports the new batch of ten
A spent code from the retired batch stays untouched by a later re-issueSpent code keeps used_at set, revoked_at nullsrc/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a used code out of the replaced batch untouched by a later issuance
What a caller from another identity sees: issuing for A never revokes or otherwise touches B's live batchB's batch unaffectedsrc/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › leaves a second identity completely untouched
Concurrency: two concurrent issues for the same identity, via a per-identity advisory transaction lockLast write wins, exactly one live batch survivessrc/modules/identity/infrastructure/recovery-codes/identity-recovery-code.repository.integration-spec.ts › lets two simultaneous issuances for the same identity serialize on the advisory lock, leaving exactly one live batch
Refusal: no live elevation, and no session is ever opened for that request403 · identity.elevation_requiredtest/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch without a live elevation, and never opens a session for that request
Guard — no session at all401 · identity.session_requiredtest/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with no session at all
Guard — forged/unrecognised token / revoked session401 · identity.session_requiredtest/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a token nobody ever issued; test/e2e/recovery-codes.e2e-spec.ts › refuses to issue a batch with a session that has since signed out

Cross-route invariants ​

InvariantExpected answerProving test
Signing out closes renewal401 · identity.renewal_refusedtest/e2e/sign-out.e2e-spec.ts › ends the session, so the refresh token it was holding stops working
Changing the password closes other sessions, the calling one keeps working401 · identity.session_required on every other session's access token; the calling session keeps workingtest/e2e/session-revocation.e2e-spec.ts › refuses every other access token once the password changes, while the calling one keeps working
Recovering the password closes every session, with no exception for the one that requested it401 · identity.session_required on every session's access tokentest/e2e/session-revocation.e2e-spec.ts › refuses every access token, including the one that requested it, once the password is recovered
Removing the last way in is refused, and only on POST /sign-in-methods/:id/removals — POST /second-factors/:id/removals carries no equivalent guard, and every second factor can be removed freely409 · identity.last_sign_in_method on the sign-in-methods route; 204 No Content down to zero on the second-factors routetest/e2e/sign-in-methods.e2e-spec.ts › refuses to remove the last way in; test/e2e/second-factors.e2e-spec.ts › removes a verified second factor once a live elevation proves it is safe, and the list becomes empty
A lock set by one route refuses the next wrong guess on every route; a correct secret is still admitted everywhere, because a lock only ever stops guessing201 Created for a correct credential/code on a locked account, on every route a test exercises; 423 on the very first wrong guess a different route sees afterwardtest/e2e/account-lockout.e2e-spec.ts › still opens a session for the correct password once the account is locked, because a lock only ever stops guessing; test/e2e/account-lockout.e2e-spec.ts › still mints the elevation for the correct code once the step-up account is locked, because the caller already proved who they are; test/e2e/account-lockout.e2e-spec.ts › locks a step-up on its very first wrong code once wrong passwords already locked the account on sign-in
The failure that crosses the lock threshold locks the account and audits it exactly once but answers the route's ordinary refusal, on every route that records a lockout failure; only a later wrong guess answers 423, and only on the four routes that expose a locked answer (sign-in second factor, step-up, sign-in method confirmation, second-factor confirmation)Crossing failure: the route's ordinary refusal, one accountLocked audit; later wrong guess: 423 (or the ordinary refusal on the routes that never answer 423), no second auditsrc/modules/identity/application/lockouts/record-lockout-failure.spec.ts › locks the account on the failure that crosses the threshold and audits it once, still answering the ordinary refusal; src/modules/identity/application/lockouts/record-lockout-failure.spec.ts › answers the lock expiry on every later attempt and never audits the lock a second time
A revoked session's access token is refused on the next request401 · identity.session_requiredtest/e2e/session-revocation.e2e-spec.ts › refuses the same access token on the very next request once its session signs out
A spent provider nonce cannot be spent on the other provider route403 · identity.step_up_confirmation_refused when a nonce spent by POST /sign-ins is replayed at POST /step-ups/providerstest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a sign-in when it is presented again to a step-up
The same cross-route invariant holds in the other direction401 · identity.sign_in_refused when a nonce spent by POST /step-ups/providers is replayed at POST /sign-instest/e2e/step-up-with-a-provider.e2e-spec.ts › refuses a nonce already spent by a step-up when it is presented again to a sign-in
An out-of-band code issued under one configured policy still confirms after the application restarts under a different one, before it expires204 No Contenttest/e2e/verification-code-policy.e2e-spec.ts › confirms a code issued under the default policy after the application restarts under a different one, before it expires
A challenge's attempt budget and the identity's account lockout counter are not reset by an out-of-band policy change across a restart422 · identity.verification_refused for the correct code, unchanged even after the restarttest/e2e/verification-code-policy.e2e-spec.ts › does not reset the challenge attempt budget or the account lockout counter across a restart that changes the policy

Gaps ​

None open. Every case a proving test could reach names one; the two rows that still answer — describe a route with no failure mode to prove (an issuance that never refuses, and a route deliberately carrying no guard), not an untested case.

Application Foundation in progress. Tracked in issue #13.