Commit Graph

5 Commits

Author SHA1 Message Date
xarmian 70c57f8202 feat(auth): a first Cloud signup lands in its new workspace; OAuth comes first (TASK-3595) (#2075)
* feat(auth): a first Cloud signup lands in its new workspace; OAuth comes first (TASK-3595)

A Cloud signup auto-creates and seeds a workspace, then landed on
/console, a list with a Create button.

- autoCreateWorkspace returns the workspace only when it is usable.
  Every failure arm returns nil, including the two that keep a
  workspace with an unknown or wrong-role owner row.
- Register (self-serve) and oauth-login add an additive workspace_path
  ("/<username>/<slug>") when a workspace was just made. The pad-cloud
  sidecar uses it for OAuth signups (its own PR, stacked on the go-live
  line).
- The register page lands there through postSignupTarget, the "Continue
  to Pad" of an unverified signup included. An explicit ?redirect=
  (OAuth authorize, CLI/MCP auth, an invitation, a deep link) or an
  accepted invitation still wins.
- The register page puts the GitHub/Google buttons first, with the "or"
  divider below them (AuthOAuthButtons divider="below").
- The generated username becomes a "Username: ada · Change" line. It
  opens on click (focus follows), or by itself without moving focus
  when the generated name is taken.
- The register page moves from $app/stores to $app/state, so it mounts
  under jsdom.

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

* test(cloud): the oauth-login contract golden carries workspace_path (TASK-3595)

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

* fix(web): a taken generated username keeps its field open while the user types (TASK-3595 codex r1)

Editing the field reset the availability check, and the field's
visibility read it, so the input vanished under the typing. A taken
name now opens the field for good. Also note in postSignupTarget that
an explicit /console counts as no destination, by design.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:09:29 -04:00
xarmian 3201342d3d feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half) (#2043)
* feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half)

pad-cloud marked a webhook event processed BEFORE its handler ran, and the
mark was final. A sidecar crash, OOM or redeploy mid-handler, or a failed
unmark, left the event 'processed' forever: every Stripe retry was
acknowledged as a duplicate. On an annual plan cancelled early, the user kept
Pro until the period end.

The marker now has two states (migration 136 / PG 108; existing rows read
as 'done', which is what they meant):
- ClaimStripeEvent inserts a new event 'processing' with a 5-minute lease
  (claimed). A 'done' event is a duplicate. A 'processing' event with a
  live lease is in_progress. One with an EXPIRED lease is taken over by one
  conditional UPDATE with a new token: of several racing retries exactly
  one wins.
- POST /api/v1/admin/stripe-event-complete {event_id, processed_at} marks it
  done after the handler succeeds. It is token-scoped, so a handler whose
  lease was taken over cannot complete it for the one that took it.
- POST /admin/stripe-event-processed keeps already_processed with its old
  meaning (anything but a fresh claim) and adds 'state', which a newer
  sidecar feature-detects (lead ruling). A sidecar that predates this never
  completes; its rows stay 'processing', and a redelivery after the lease
  re-runs a handler that re-syncs from current Stripe state, which is
  harmless.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, BUG-3361's cloud-only route list, and the cloud contract
goldens (processed gains 'state'; complete is new). The NUL census records
the two new server-written columns. Test: claim, in-progress, done, an
expired lease taken over exactly once by 4 racing claimers, and a
stale-token complete as a no-op. Without the takeover, 0 of 4 claim.

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

* test(cloud): the contract test gets a roomy cloud-admin limiter (BUG-3561)

It calls every sidecar route once from one address to pin response shapes,
and with billing-users and stripe-event-complete the list outgrew the real
limiter's burst of 10 (the 11th call answered 429). Rate limiting is not what
it tests.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:02:50 -04:00
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00
xarmian 772c1a86ba feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549) (#2027)
* feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549)

POST /admin/stripe-customer-id no longer replaces a DIFFERENT stored
customer: it answers 409 stripe_customer_conflict with
details.stored_customer_id. The same id again is idempotent. Overwriting
the link is how a second checkout orphaned the first customer's
subscription (still charging, its events finding no user). An admin USER
may still replace with replace:true; the cloud secret alone cannot.

GET /admin/user-by-id (cloud secret or admin) returns user_id, email, plan
and stripe_customer_id, so the sidecar reads the stored customer before it
writes one (an older pad still overwrites) and can compare a webhook's
customer with it. Added to the cloud-admin allow-list, rate limiter,
compress census (no secret) and the cloud-only admin route list.

* test(cloud): golden user-by-id and the compare-and-set answers pad-cloud decodes (TASK-3549)

The contract shaper keeps "code" values verbatim: pad-cloud branches on
stripe_customer_conflict, so a placeholder there would hand it a golden
it cannot decode.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 15:59:17 -04:00
xarmian 503afd5c3a test(cloud): golden the pad responses pad-cloud decodes (#2026)
TestCloudContract drives the real handlers for every pad endpoint the
pad-cloud sidecar decodes (user-by-customer, plan, stripe-event-processed,
stripe-event-unmark, payment-failed, oauth-login, auth/me) and pins each
response's SHAPE (values replaced by type placeholders) in
testdata/cloud_contract/. pad-cloud keeps a copy and decodes each file
through its own client, so a key renamed or dropped on either side of
the repo boundary fails a test. The class this closes: user-by-customer
answered user_id while pad-cloud read id, so every subscription webhook
synced user "".
2026-10-10 14:41:23 -04:00