Files
pad/internal/email
xarmian cc85c2f9da feat(auth): social claim of a never-verified account, provider subjects, cloud-only sidecar routes (TASK-3351) (#1760)
* feat(auth): sidecar OAuth routes are cloud-only; provider subjects bind on next sign-in (TASK-3351)

U1: oauth-login, oauth-link and oauth-unlink answer only in cloud mode. A
self-hosted install carrying a cloud secret created accounts and minted
sessions on a caller's word about an email.

U2: user_oauth_identities (SQLite 111 / PG 085) binds a provider's stable
account id to one pad account. oauth-login and oauth-link take an optional
subject. The first sign-in carrying one binds it, and a different one is
refused 403 oauth_subject_mismatch, naming the recovery route (password reset,
then relink). A subject bound to another account creates nothing. Unlink
forgets the binding. The subject is checked at the door (no NUL trigger),
and the four columns are recorded in the NUL baseline.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* feat(auth): a provider that vouches for an address claims the never-verified account (TASK-3351)

U3: oauth-login for an existing account whose provider is not linked used to
refuse. Now, if the account has never been verified and the provider vouches
for the address, it is claimed:
- Google and Apple: email_verified is enough.
- GitHub: the sidecar must also send email_primary=true (ruling 2), and
  sidecars today don't, so GitHub claims stay refused until one does.

The claim runs the BUG-3382 core, now shared as claimAccountTx:
- credentials reset and the epoch bumped
- access stripped in workspaces the account doesn't own, and the ones it owns
  soft-deleted (ruling 3)
- an account with billing refused with account_claim_needs_support (ruling 4)

The core now also unlinks every sign-in provider and forgets their bound
subjects; those were linked by whoever held the account. The claimant's
provider is then linked and bound, the display name replaced with the
provider's, and a session minted on the claim's epoch.

The response carries claimed, stripped_workspaces and deleted_workspaces. The
mailbox gets a notice listing them (lead's ruling: the redirect may never
render the response); without email it is logged.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): claim, link and bind commit together; serialize provider writes; claim lock order (TASK-3351)

Codex round 1:
- P1: a social claim committed before its provider subject was checked, so a
  sign-in refused on the subject left the account claimed. ClaimAccountByProvider
  now takes the provider and subject and links and binds in the claim's own
  transaction; a mismatch rolls the whole claim back.
- P2: a sign-in that read the link before an unlink could re-bind its subject
  afterwards. Every provider write (link, bind, unlink, claim) now locks the
  account row, and a bind requires the provider to still be linked
  (ErrOAuthProviderNotLinked, answered as oauth_provider_not_linked).
- P2: the social claim locked the account before its verification tokens,
  the reverse of verification, and the two deadlocked on Postgres. A test hook
  holds the claim at its account lock while a consume starts; without the fix
  it fails with 40P01 under test-pg, and with it it passes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): one lock order, account before tokens; a failed provider link stops the sign-in (TASK-3351)

Codex round 2:
- P2: the token-first order adopted in round 1 deadlocked against account
  deletion, which locks the account first. Every path now locks the account
  before its tokens:
  - deletion (unchanged)
  - the social claim (back to account-first)
  - ConsumeEmailVerification and the link claim: each finds the token's
    account without a lock, locks the account, then spends the token
    conditionally
  This also removes an older consume-vs-deletion deadlock. Two tests hold the
  claim, or deletion, at the account lock while the others start. Under
  test-pg, the consume-first order fails both with 40P01 and the claim-first
  order fails the deletion test; the fixed order passes both.
- P2: on a new account, a failed provider link was only logged and the
  sign-in continued, leaving an account that could not sign in with that
  provider again. Any link failure now stops the sign-in.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): a link claim checks its token's expiry after waiting for the account (TASK-3351)

Codex round 3: the spend reused a timestamp taken before the account lock,
so a token that expired while the claim waited still claimed the account. The
spend now reads the clock itself. Test: a token expiring in 1s, with the claim
held at the account lock for 2.1s, claims nothing. It fails with the stale
timestamp.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): a claim resets the registrant's username and display name (TASK-3351)

Lead's ruling on #1760: the registrant chose the username and display name,
and either can impersonate ("support", a staff name). Nothing live depends on
the username once the claim has deleted the account's own workspaces. The
shared claim core (both the link claim and the provider claim) now resets
both, in the claim's transaction under the account lock:
- the name: the provider's name, or the address's local part
- the username: generated from that name with GenerateUsername, and unique
  among other accounts
The handler's post-claim rename moves into the core.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): a claimed account's username follows the username rules and survives a lost race (TASK-3351)

Codex round 5:
- P2: the regenerated username could be reserved (support@ gave "support",
  a provider name "Console" gave "console"). The store cannot import the
  server's ValidateUsername, so the server installs it on the store
  (SetUsernameValidator), and candidates that fail it are skipped.
- P2: a suffix could push a 39-character base past the limit. Each candidate
  now cuts the base to fit its suffix.
- P2: another account taking the chosen username before the claim wrote it
  failed the claim with a unique-index violation. The claim now retries, up
  to three times. Its transaction rolled back whole, the link claim's token
  included.
Tests: reserved and length (store, with a stub validator); the real
validator through the social claim (server); the lost race, Postgres only,
through a hook that takes the username before the write. Each test fails
without its fix.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): a claim falls back to a random username when no candidate from the name will do (TASK-3351)

Codex round 6: an email fallback the generator leaves invalid (foo--bar), or
a name whose first 1,000 candidates are all held, failed the claim. After the
derived candidates, the claim now tries random user-xxxxxxxx names under the
same rules and uniqueness check. The test fails without the fallback.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 12:15:04 -04:00
..