Skip to content

Password reset now ends custody of the AT Protocol account - #598

Merged
tompscanlan merged 6 commits into
mainfrom
fix/reset-pds-password-ends-custody
Aug 15, 2026
Merged

tompscanlan merged 6 commits into
mainfrom
fix/reset-pds-password-ends-custody

Conversation

@tompscanlan

@tompscanlan tompscanlan commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

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

  • resetPdsPassword ends 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.
  • completeTakeOwnership is 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.
  • A definitively rejected reset (the PDS evaluated it and refused: invalid/expired token, any 4xx) still leaves custody fully intact and returns 400.

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 takeOwnershipStatus column on userAtprotoIdentities records 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 still success; the client's follow-up complete call retries the flip).

Automatic repair requires confirmed. On a session 401 for a confirmed identity, PdsSessionService finishes the handoff (clear credentials, mark non-custodial, drop the cached session) — the PDS acknowledged that reset, so the handoff is certain. An ambiguous identity 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 only pending are never touched, whatever the PDS said.

All marker writes are compare-and-set transitions (UPDATE … WHERE the 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 read pending cannot clear a marker that meanwhile became confirmed, and a concurrent reset cannot downgrade confirmed to ambiguous. The generic update() 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/ambiguous state it observed and never touches confirmed.

Testing

  • Controller spec covers: pending recorded before the PDS call and confirmed on acknowledgement (both as CAS transitions); custody ends server-side on success; success still returned when the flip fails; 4xx rejection leaves custody and the marker untouched; ambiguous outcomes (502, no-response network failure) transition to ambiguous and return 502; a stored ambiguous survives a rejected retry (the CAS refuses the downgrade) and upgrades to confirmed on a successful one.
  • Session service spec covers: repair fires on 401 only for confirmed; never for ambiguous (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 exact pending/ambiguous state it read, leaves confirmed, and never breaks session creation when it loses the race or the write fails.
  • Recovery service spec asserts the credential clear + marker resolution + session invalidation in one write, and the idempotent no-op.
  • The devnet e2e (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.

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.
@tompscanlan
tompscanlan force-pushed the fix/reset-pds-password-ends-custody branch from 032c548 to d47606b Compare August 14, 2026 12:51
@tompscanlan

Copy link
Copy Markdown
Contributor Author

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:

  1. The reset reports success once the PDS write has succeeded. A custody-flip failure is logged loudly but no longer masks the reset — which keeps the client's existing take-ownership/complete call live as the natural retry for the flip. A user who walks away can also simply re-run the reset (still custodial, new token).

  2. Repair-on-use in PdsSessionService. The PDS itself is the durable record of the handoff: stored custodial credentials that draw a definitive 401 can only mean the user reset the password themselves, since the email-token reset is the sole flow that changes a custodial account's password. On that signal — strictly a 401 PdsApiError, never network errors or 5xx — the service clears the credentials, marks the identity non-custodial, and invalidates the cached session. The sync scheduler's 5-minute session attempts are the retry loop, so no new cron and no schema change.

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
@tompscanlan

Copy link
Copy Markdown
Contributor Author

Addressed in cc12cbe — the finding was right, and the failure mode was worse than a single wrong conversion: since createSession targets the configured PDS URL, a systemic condition (wrong URL, incomplete restore) would have 401'd every custodial identity and the repair would have converted each one as it was touched.

The fix is the pending-handoff marker you prescribed:

  • New nullable takeOwnershipPendingAt on userAtprotoIdentities (additive ADD COLUMN IF NOT EXISTS migration, no backfill, no data rewrite).
  • resetPdsPassword records it before submitting the reset to the PDS, withdraws it if the PDS rejects the token, and the custody flip resolves it in the same write that clears credentials.
  • The session-401 repair now requires the marker: identities without recorded provenance are never modified regardless of what the PDS returns. The 401 status check remains, but only as a filter — provenance comes solely from the marker.

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.
@tompscanlan
tompscanlan merged commit 12567d5 into main Aug 15, 2026
4 checks passed
@tompscanlan
tompscanlan deleted the fix/reset-pds-password-ends-custody branch August 17, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant