mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
cc85c2f9da
* 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