Repository navigation
Password reset now ends custody of the AT Protocol account - #598
Conversation
Change made because a custodial user who reset their PDS password was left half-migrated: the PDS had their new password while the API kept the old encrypted credentials and still treated the account as custodial, so every later custodial session attempt got a 401 and the user's events silently stopped publishing. - resetPdsPassword ends custody in the same request as the PDS write (clear stored credentials, mark non-custodial) instead of trusting the client to finish a 3-call flow - completeTakeOwnership becomes an idempotent no-op when the identity is already non-custodial, so the platform's existing flow still succeeds with no client change - completeTakeOwnership invalidates the cached PDS session so it cannot serve a stale agent - unit specs assert the custody write and session invalidation; the devnet e2e verifies custody ends on reset and that a failed reset leaves custody untouched
Change made because the token is consumed the moment the PDS accepts the reset, so a crash or failed database write immediately after left the account stranded with stale custodial credentials, and the error response stopped the client from making the completion call that would have retried the flip. - resetPdsPassword reports success once the PDS write has succeeded; a custody-flip failure is logged loudly instead of masking the reset, which keeps the client's take-ownership/complete call as a live retry - PdsSessionService finishes an unrecorded handoff on use: a definitive 401 for stored custodial credentials can only mean the user reset the password themselves, so the service clears the credentials, marks the identity non-custodial, and invalidates the cached session; strictly 401-only, never on network errors or 5xx, and repair failures never mask the session error - no schema change: the PDS itself is the durable record of the handoff, and the sync scheduler's session attempts are the retry loop This also lazily heals identities that were stranded before the fix, the next time anything attempts a session for them.
032c548 to
d47606b
Compare
|
Addressed the durability finding in d47606b — the finding was correct: the token is consumed the moment the PDS accepts the reset, so a crash or failed DB write in that window stranded the account, and the 500 also suppressed the client's completion call that would have retried the flip. Rather than adding a pending-handoff column and a reconciliation job, the fix uses state that already exists:
A bonus of the on-use repair: identities stranded before this fix are healed the next time anything attempts a session for them, instead of failing silently forever. New tests: reset still returns success when the flip fails after a successful PDS write; 401 triggers the repair (credential clear + custody flip + cache invalidation); 502, status-less network errors, and plain exceptions do not; repair failure never masks the session error. |
Change made because a PDS login 401 alone cannot prove the user reset their password: the PDS deliberately returns the identical 401 for unknown accounts to prevent enumeration, so a wrong PDS URL, an incomplete PDS restore, or a deleted account would have read as an ownership handoff and converted every touched custodial identity to non-custodial. - new nullable takeOwnershipPendingAt column on userAtprotoIdentities (additive migration, no backfill): written immediately before the reset is submitted to the PDS, withdrawn if the PDS rejects it, resolved by the same write that ends custody - the session-401 repair now requires the marker; identities without recorded provenance are never modified regardless of PDS response - tests: 401 with marker repairs (credential clear + flip + cache invalidation resolve the marker); 401 without marker never writes; the controller records the marker before the PDS call and withdraws it on a failed reset
|
Addressed in cc12cbe — the finding was right, and the failure mode was worse than a single wrong conversion: since The fix is the pending-handoff marker you prescribed:
Tests added/updated: 401 with marker → repair (flip + credential clear + cache invalidation + marker resolved in one write); 401 without marker → no write at all (the anti-enumeration scenario); marker recorded before the PDS call and withdrawn on failed reset; the recovery flip resolves the marker atomically with ending custody. One residual case, deliberately accepted: a user who obtains a reset token and completes the reset entirely out-of-band (never through our endpoint) gets no marker, so they land in today's silently-stranded state rather than being auto-repaired. The platform UI always drives resets through this endpoint, and repairing unmarked identities is exactly the inference this finding prohibits. |
Change made because a single pre-call marker conflated intent with evidence: it was withdrawn on every PDS error (including timeouts and 5xx, which do not prove the reset failed), and a crash before the PDS submission left a marker that a later unrelated 401 could satisfy. Replace the timestamp marker with a three-state column: - 'pending' is written before the PDS call and records intent only. The session-401 repair never acts on it. - 'ambiguous' replaces it when the PDS gives no definitive answer (timeout, connection loss, 5xx) - the reset may have committed, so the repair may finish the handoff. The client gets a 502 and can safely retry. - 'confirmed' is written the moment the PDS acknowledges the reset, before the custody flip, so a durable repair-eligible record exists even if the flip then fails. The marker only ever advances toward stronger evidence; rejections never withdraw it, because a rejected retry may have been refused precisely because an earlier attempt consumed the token. Proof removes it instead: the custody flip, or a successful login with the stored credentials, which shows no reset committed and sweeps a stale 'pending'/'ambiguous' marker on the next session use.
Change made because this test assumed its event lands on page 1 of the dashboard, but every e2e suite logs in as the same admin user and the dashboard sorts by startDate ascending with a default page size of 10. The test's far-future (2099) event sorts last, so once concurrently running suites have created ten earlier events the lookup missed it and the test failed without any product defect. Walk the dashboard pages until the event is found or the listing is exhausted, which is deterministic regardless of what parallel suites have created.
…irmed only Change made because unconditional marker writes let stale readers overwrite stronger states: a session attempt that read 'pending' could authenticate before the reset committed and then clear the marker after the controller wrote 'confirmed', losing the repair provenance exactly when it was needed; concurrent reset requests could similarly downgrade 'confirmed' to 'ambiguous'. And 'ambiguous' explicitly includes "the request never reached the PDS", so it could not safely authorize the destructive 401 repair - a failed connection followed by a systemic 401 (wrong PDS URL, incomplete restore) would still falsely convert that identity. - All marker state changes go through a compare-and-set transition (UPDATE ... WHERE the row still holds one of the expected states and is still custodial). Stale writers lose instead of overwriting: the sweep clears exactly the state it observed, 'ambiguous' can never replace 'confirmed', and markers are never written to identities whose custody already flipped. The generic update() now only accepts clearing the marker as part of a custody flip. - The automatic session-401 repair now requires 'confirmed' - the PDS acknowledged the reset, so the handoff is certain. An 'ambiguous' identity drawing a 401 is logged loudly for reconciliation instead of being converted; the client's 502-retry path remains the promotion route to 'confirmed', and a successful login still sweeps stale 'pending'/'ambiguous' markers.
Problem
A custodial user who reset their PDS password was left half-migrated. The reset endpoint changed the password on the PDS, then returned — the API kept the old encrypted credentials and still treated the account as custodial. Every later custodial session attempt hit a 401, and the user's events silently stopped publishing.
The take-ownership flow was three client calls (reset → complete → OAuth link) in one try block, so any interruption after step 1 stranded the account. The server now closes that gap itself.
Fix
resetPdsPasswordends custody in the same request as the PDS write: stored credentials cleared, identity marked non-custodial, cached session invalidated. There is no legitimate change-password-but-stay-custodial flow — this endpoint is only reachable while custodial, and its one purpose is the ownership handoff.completeTakeOwnershipis now an idempotent no-op when the identity is already non-custodial, so the platform's existing 3-call flow still succeeds unchanged (previously it would 400 on the second call). It also invalidates the cached PDS session, for every caller.Durability and provenance
The reset consumes the token on the PDS, so every failure mode after that write has to leave a path back to ended custody — and a PDS login 401 alone can never justify ending custody, because the PDS returns the identical 401 for unknown accounts (anti-enumeration): a wrong PDS URL or incomplete restore would read as mass handoffs.
A new nullable
takeOwnershipStatuscolumn onuserAtprotoIdentitiesrecords provenance as a small state machine:pending— written before the PDS call. Records intent only; the reset may never have reached the PDS.ambiguous— the PDS gave no definitive answer (timeout, connection loss, 5xx). The reset may have committed with the response lost — or the request may never have arrived. The client gets a 502 and can retry safely.confirmed— the PDS acknowledged the reset. Written before the custody flip is attempted, so a durable record exists even if the flip then fails (the response is stillsuccess; the client's follow-up complete call retries the flip).Automatic repair requires
confirmed. On a session 401 for aconfirmedidentity,PdsSessionServicefinishes the handoff (clear credentials, mark non-custodial, drop the cached session) — the PDS acknowledged that reset, so the handoff is certain. Anambiguousidentity drawing a 401 is logged loudly for reconciliation instead: ambiguity includes "the request never reached the PDS", so its 401 could still be systemic. Identities with no marker or onlypendingare never touched, whatever the PDS said.All marker writes are compare-and-set transitions (
UPDATE … WHEREthe row still holds one of the expected states and is still custodial), so the marker only advances toward stronger evidence and stale writers lose instead of overwriting — a sweep that readpendingcannot clear a marker that meanwhile becameconfirmed, and a concurrent reset cannot downgradeconfirmedtoambiguous. The genericupdate()only accepts clearing the marker as part of a custody flip.Rejections never withdraw the marker — a rejected retry may have been refused precisely because an earlier attempt consumed the token when it committed. Only proof removes it: the custody flip, or a successful login with the stored credentials (which shows no reset had committed when the identity was read); the sweep clears exactly the
pending/ambiguousstate it observed and never touchesconfirmed.Testing
ambiguousand return 502; a storedambiguoussurvives a rejected retry (the CAS refuses the downgrade) and upgrades toconfirmedon a successful one.confirmed; never forambiguous(surfaced for reconciliation),pending, no marker, non-401 errors, or non-custodial identities; repair failure doesn't mask the session error; the sweep CAS-clears the exactpending/ambiguousstate it read, leavesconfirmed, and never breaks session creation when it loses the race or the write fails.test/pds/pds-password-reset.e2e-spec.ts) walks the real flow against a live PDS: invalid token leaves custody intact → real reset ends custody and the new password works → a follow-up complete call reports success → further resets are rejected. This exercises the real CAS SQL (including the NULL-expected branch) against Postgres. Runs in PR CI.Also includes a test-only deflake: the events-attendees dashboard test now pages through the dashboard instead of assuming its event lands on page 1 (all e2e suites share one admin user, and the dashboard sorts by startDate ascending with a page size of 10, so the test's 2099-dated event falls off page 1 once parallel suites have created ten earlier events — the failure the earlier CI runs showed).
Guards, throttle behavior, and the migration pattern follow the existing code.