mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
82064526e3 |
fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is no dba, so "Perpetual Software" never names the entity without "LLC". Fixed: the ChatGPT plugin's author and developerName, both Claude plugin manifests' author/owner, the brand spec's copyright format, the cloud email footers (HTML and plain), and the auth-page footer. The guard test now pins the ChatGPT package's developerName and author to the full name, and the email tests expect it. LICENSE already said "Perpetual Software LLC"; goreleaser, the Dockerfiles and Nix carry no entity name. The self-hosted email test keeps asserting the bare substring is ABSENT: the shorter string is the stricter check there. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
b0eeef16ce |
feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no endpoint consumes it until Wave 3). - Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table, cloning the password_resets shape (id/user_id FK/token_hash/expires_at/ used_at/created_at + token_hash + user_id indexes), per-dialect created_at default. - Store email_verification.go: 256-bit crypto/rand token, padver_ prefix, SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume. Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint (resend burns the old link), consume side-effect sets users.email_verified_at (RFC3339-with-Z, same format Wave 1's migration used) in one transaction — no password reset, no session mint. - Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours". - Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC — self-registers on Server.bg, context-cancellable via stop channel, started only from cmd/pad/main.go so unit tests don't leak goroutines) calling the four previously-unwired CleanExpired* methods (email verifications, password resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications. - Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin. Gates: make check + make test-pg green (store + migration on both dialects). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST |
||
|
|
cec056cefe |
feat(email): cloud-mode marketing footer in transactional emails (TASK-907) (#317)
* feat(email): cloud-mode marketing footer in transactional emails (TASK-907)
Extracts a shared HTML/plain shell helper for the five existing
transactional-email templates (SendInvitation, SendWelcome,
SendPasswordReset, SendPaymentFailed, SendTest) and adds a Cloud-only
marketing footer that mirrors the auth-page AuthFooter component:
GitHub / Docs / Changelog / Privacy / Terms link list plus a
"© <year> Pad · Perpetual Software" copyright line.
Self-hosted output (the default for any pad instance NOT in
PAD_CLOUD/PAD_MODE=cloud) is byte-equivalent to the prior inline
templates: same wordmark header, same body, same footer-note disclosure,
no marketing links. Operators ship Pad under their own brand and
getpad.dev's link list would be wrong on their notifications.
Plumbing:
- email.Sender gains a cloudMode bool + SetCloudMode/CloudMode
accessors. Configure() does not touch cloudMode (it's set
independently from API-key/from-addr config).
- Server.SetCloudMode now propagates to s.email.SetCloudMode(true)
so existing senders pick up the flag.
- Server.SetEmailSender propagates s.cloudMode → e.cloudMode when
email is wired AFTER cloud mode (handles the cmd/pad/main.go
ordering where SetEmailSender is called from main).
- Server.reconfigureEmail() (admin-settings reload path) does the
same so an admin reconfiguring email mid-flight doesn't end up
with a sender stuck in self-hosted mode.
The email accent color (#2563eb) is preserved from the prior templates
— it has known contrast properties on white email backgrounds. Email
is light-themed for cross-client readability; the dark-theme tokens
from docs/brand.md §3 are for in-app/auth surfaces, not transactional
mail.
Pinned with three regression tests:
- self-hosted shell renders no Cloud-only markers
- Cloud shell renders the link list in canonical order (GitHub →
Docs → Changelog → Privacy → Terms)
- plain-text shell branches identically
Visual contract: docs/brand.md §7 (link order) and §6 (Pad wordmark).
Companion to AuthHeader, AuthFooter, +error.svelte, and UserMenuResources
already shipped on PLAN-900.
Test plan:
- go build ./... — clean
- go vet ./... — clean
- go test ./... — all pass (including new shell_test.go cases)
- web/npm run check — 0 errors
- web/npm run build — clean
* fix(email): full canonical link list per Codex (round 2)
Codex caught that the Cloud-mode email footer carried only 5 of the 9
canonical links from docs/brand.md §7 (GitHub / Docs / Changelog /
Privacy / Terms — omitted Contribute / FAQ / Security / Sub-processors).
The brand spec §1 says transactional emails get "Full parity" with the
auth-page AuthFooter; my trim violated that contract.
Add the four missing links to both the HTML and plain-text shells in
the canonical order: GitHub → Docs → Changelog → Contribute → FAQ →
Security → Privacy → Terms → Sub-processors. Update the regression
tests to pin all 9 markers + their pairwise ordering.
The "keep emails small" instinct that motivated the trim was a real
design concern but not strong enough to defy the brand spec. If we
later decide email needs a reduced subset, the right move is to
update §7 in docs/brand.md FIRST (acknowledging email as a surface
with a smaller link list) and trim the implementation to match.
|
||
|
|
119e2d8aa2 |
feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping next) to give paying users a chance to update their card before dunning exhausts and the subscription cancels. pad owns the Maileroo integration and the user→email mapping; the sidecar forwards the invoice metadata here. Changes: - email.Sender.SendPaymentFailed — new template (HTML + plain). Subject "Your Pad payment couldn't be processed"; body names the amount + next retry date when provided, falls back to generic copy when Stripe omits them, and CTAs to the billing portal so the user can update their card. Transactional (no unsubscribe link) — users who want the emails to stop either fix their card or cancel the subscription. - POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint (handlers_cloud.go). Accepts stripe_customer_id + optional pre- formatted amount_display + next_retry_display. Looks up the user, sends the email, logs a payment_failed_email_sent audit entry. Returns 200 + email_sent=false with a reason string for every non-error skip (unknown customer, no email on file, Maileroo not configured) so the sidecar never rolls back the Stripe webhook over an email failure. Returns 200 + email_sent=false + reason=send_failed when Maileroo itself errors — still no rollback. - Registered the path in cloudAdminPaths, the server router, and the CloudAdmin rate limiter so the sidecar's calls share the same rate bucket as /plan + /stripe-customer-id. - ActionPaymentFailedEmailSent audit constant for the new entry. - Three focused tests: cus_ prefix validation, unknown-customer 200, and email-not-configured 200. Added an entry to the cloud-mode gate table-driven test to confirm /admin/payment-failed also 404s when cloud mode is off. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR. * fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1) Addresses PR #232 round 1 findings: MEDIUM — payment-failed handler only wrote an audit row on the actual send attempt, so no_customer / no_email_address / email_not_configured skip paths left no durable trail. Consolidated the audit + response into a single auditAndRespond closure called from every outcome branch, so operators can always reconstruct whether (and why) a customer was notified during dunning reconciliation. MEDIUM — audit UserID was set to actorID, which is empty for sidecar calls. /audit-log?user=<target-user-id> would never surface these events. Now set UserID to targetUser.ID whenever we have one; the no_customer branch still writes a row but with empty UserID (filtered only by action + stripe_customer_id metadata). Moved actor identity into an actor_is_admin metadata field instead. LOW — test coverage was thin: no assertion on the most important contract ("return 200 with reason=send_failed and still record the attempt"), no test of the happy send path, no audit-log assertions. Added email.Sender.SetEndpoint (exported, test-only — comment says so) so tests can point the Sender at a mock Maileroo server, plus three new tests: - TestPaymentFailed_HappyPath_SendsAndAudits - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID The first two verify audit metadata per outcome; the third proves unknown-customer cases still leave a findable audit row. Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint outside the sender's RWMutex — fine before the mutable SetEndpoint existed, now a data race. Pulled the endpoint read into the same RLock scope as fromAddr/fromName. * fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2) Addresses PR #232 round 2 findings: MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not which admin. For manual operator-triggered calls, that meant the audit trail could not answer "who sent the dunning email?" when multiple admins touched the endpoint. Added admin_actor_id to the metadata whenever the authenticated caller has role=admin. Sidecar calls with no authenticated user still have no admin_actor_id, which correctly distinguishes them from manual admin operations. LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back to "first 3 metadata keys" when no formatter exists for an action, which could hide the important reason/sent fields. Added a dedicated case for payment_failed_email_sent that renders either "sent (cus_...)" or "skipped: <reason> (cus_...)" depending on the outcome, matching the terse display style of the other switch cases. * fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3) Addresses PR #232 round 3 LOWs: - The formatter lumped every sent=false outcome under 'skipped', which conflates a genuine Maileroo delivery failure with a pre-send skip. Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other reasons → 'skipped (<reason>) (...)'. - admin_actor_id was recorded in metadata but invisible in the UI: the User column shows the target user via a.user_id. Appended 'by admin:<id>' to the formatted string whenever admin_actor_id is present, so manual operator calls are attributable at a glance. Sidecar calls have no admin_actor_id and render without the suffix. * fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4) The backend emits payment_failed_email_sent and the custom formatter knows how to render it, but the audit-log page's ACTION_TYPES / ACTION_LABELS registry omitted the action, so admins couldn't filter for these events from the dropdown — undercutting the dunning reconciliation workflow this PR is adding. Added 'payment_failed_email_sent' to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS. |
||
|
|
1ba9c91992 |
feat: email unsubscribe for non-transactional emails (#96)
* feat: email unsubscribe for non-transactional emails Add CAN-SPAM compliant unsubscribe support: - New email_optouts table (by email address, not user ID) so uninvited recipients can opt out without an account - HMAC-signed unsubscribe tokens (derived from Maileroo API key) so links work without authentication - GET /api/v1/unsubscribe endpoint with simple HTML confirmation page - Invitation emails now include unsubscribe footer link - Welcome emails accept unsubscribe URL parameter - Before sending invitation emails, check opt-out table and silently skip opted-out addresses (prevents invite spam) - Password reset emails are exempt (transactional, user-initiated) Fixes BUG-256. * fix: hide "Copy invite link" when code is unrecoverable For hashed invitations the plaintext code can't be recovered, so the button was copying a broken URL. Now shows "Sent via email" label instead. Only shows the copy button when join_url or code is available. Fixes BUG-255. |
||
|
|
d94a28c3e8 |
feat: account settings, email infrastructure, and UX polish (#23)
- Add Account tab to settings: profile editing, password change, API token management - Add PATCH /api/v1/auth/me endpoint for profile updates with password verification - Add email sending infrastructure via Maileroo with contextual sender names - Add Platform settings tab (admin-only) for email configuration with test send - Add platform_settings table for instance-wide configuration - Add tab visibility refresh: silently sync data when browser tab regains focus - Fix filters icon: replace broken Unicode character with proper SVG funnel - Add cancel invitation support, TypeScript User/APIToken types |