Skip to content

feat(#567): buying requires a real account, and the tooling that moved the project - #613

Merged
TortoiseWolfe merged 6 commits into
mainfrom
feat/supabase-restore-tool
Aug 7, 2026
Merged

feat(#567): buying requires a real account, and the tooling that moved the project#613
TortoiseWolfe merged 6 commits into
mainfrom
feat/supabase-restore-tool

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

Closes #611. Follows the org migration in #567.

Buying now requires a real account

/checkout shipped with guest checkout built on signInAnonymously(), per FR-007
("no account required to buy"). That is reversed here: money must be attached to an
account that cannot evaporate.

It is not only a product preference — it dissolves #611 rather than patching it. An
anonymous session lives in localStorage and its auth.users row carries no email, so
clearing the browser or switching device orphaned a paid order with no way to sign
in, no address to recover from and no password to reset
. With accounts required, no
guest orders can exist.

The gate renders inline, not via ProtectedRoute. A redirect to /sign-in drops
the ?sku=, so someone who just chose a $3,500 package lands back on a page that has
forgotten what they wanted. Verified live: the tabs render, the intake form is withheld,
the summary still reads $3,500.00 and the URL still carries ?sku=svc-site.

It keys off session, not the form's onSuccess. mailer_autoconfirm is off, so
signing up returns a user with no session until the address is confirmed. A new
account is told to confirm by email and come back, instead of being left on a form that
looks like it failed.

Two statements aborted the whole migration on a fresh cloud project

Both local-only, both now guarded:

  • ALTER TABLE storage.buckets ADD COLUMN IF NOT EXISTS … — Postgres checks ownership
    before the IF-NOT-EXISTS short-circuit, and storage.buckets is owned by
    supabase_storage_admin. Three no-op statements failed with 42501.
  • DELETE FROM auth.users WHERE email = 'test@example.com' — the first statement in
    the file, before the transaction opens. The error named the schema, not the
    statement, which sent the search to the auth.users trigger 700 lines further down.

The restore tool, and the five defects it took to get right

scripts/supabase/restore-from-backup.py. The local dry run caught four; production
caught a fifth:

  1. auth.users.confirmed_at is GENERATED — a bare INSERT SELECT * dies on the
    first table. Columns now read from the target's catalog.

  2. Wipe order violated orders → payment_intents.

  3. Payloads past ARG_MAXOSError: Argument list too long from the exec itself, only
    on large tables. SQL now goes to psql on stdin; remote bodies via -d @file.

  4. 7,661 audit rows exceeded the API body limit — only reproducible remotely, since
    psql-on-stdin has no such ceiling. Chunked at 500.

  5. on_auth_user_created fires during restore and writes placeholder profiles. With
    ON CONFLICT DO NOTHING the placeholders won: the first production restore came back
    with 11 wrong fields, including the owner's is_admin flipped True → False and
    every display_name nulled.

    Nothing else caught it. Row counts matched, uuid sets were identical, every orphan
    check was zero — the rows existed, they were just the wrong rows. Only a
    field-by-field diff against the backup found it. Now an upsert, backup authoritative.

Verified against live production

/rest/v1/products                     HTTP 200   (was 402 for two days)
restored user signs in                HTTP 200   (bcrypt hashes survived the move)
create-order, client sent amount:100  charged 175000 against a 350000 list price
order owner    has_email=true   is_anonymous=false   can_sign_in=true

That last line is the assertion that matters — checking only that an order exists would
pass against the anonymous flow being removed.

Also: type-check and lint clean, 215 tests across 23 files.

Follow-ups

The ScriptHammer project has to move organisations — the free-tier project cap is
per USER across all orgs, so the burned org cannot host a working replacement and a
new org alone buys nothing. Moving means delete-and-recreate, and doing that to 20
real accounts is only defensible if the restore has been proven while the original
still exists.

THE DRY RUN CAUGHT THREE DEFECTS, none of them visible from reading the code:

1. `auth.users.confirmed_at` is a GENERATED column. Postgres refuses any
   non-DEFAULT value, so `INSERT INTO ... SELECT *` dies on the very first table.
   The column list is now read from the target's own catalog
   (`is_generated = 'NEVER'`) rather than hardcoded, so it survives Supabase
   generating another one.

2. The wipe order was wrong. `orders.intent_id` references `payment_intents`, so
   deleting intents first fails the FK. On a real target the wipe would have
   half-completed and left the database in a state that was neither the old one nor
   the new one.

3. Neither transport could carry the payload in argv. 1,910 conversations produce a
   statement past ARG_MAX and the failure is `OSError: [Errno 7] Argument list too
   long` from the exec itself — not SQL, and it only lands on the big tables, so a
   restore tested on small ones looks perfect. SQL now goes to psql on stdin and the
   remote body to a temp file read with `-d @file`.

VERIFIED BY IDENTITY, NOT BY COUNT. The uuids are the whole game: every encryption
key, profile, connection and RLS policy keys off `auth.users.id`, so a restore that
minted fresh users would let everyone log in while silently orphaning keys that
cannot be regenerated — the messages are E2E encrypted. So the check compares the
SET of user ids, and asserts orphans are zero:

  user ids in backup : 20      identical set : YES
  user ids restored  : 20
  password hashes    : 17 of 17 carried over
  orphaned profiles / keys / connections / identities : 0 / 0 / 0 / 0
  user_profiles 20, user_encryption_keys 7, conversations 1910, connections 2

`encrypted_password` is copied as-is — it is a bcrypt hash, not a password — so
restored users keep their credentials instead of being forced through a reset.
…d the project

Closes #611.

WHY THE FEATURE CHANGED. /checkout shipped with guest checkout built on
`signInAnonymously()`, per FR-007 ("no account required to buy"). The owner
overruled it: money must be attached to an account that cannot evaporate.

That is not only a preference — it dissolves #611 instead of patching it. An
anonymous session lives in localStorage and its auth.users row carries no email,
so clearing the browser or switching device orphaned a paid order with no way to
sign in, no address to recover from and no password to reset. With accounts
required, no guest orders can exist.

The gate renders INLINE, not via ProtectedRoute. A redirect to /sign-in drops the
`?sku=`, so someone who just chose a $3,500 package lands back on a page that has
forgotten what they wanted. Verified live: the sign-in/create-account tabs render,
the intake form is withheld, the order summary still reads $3,500.00 and the URL
still carries ?sku=svc-site.

The gate keys off `session`, NOT the form's onSuccess. `mailer_autoconfirm` is off,
so signing up returns a user with NO session until the address is confirmed — a new
account is told to confirm by email and come back, rather than being left on a form
that looks like it failed.

MIGRATION PORTABILITY. Two statements aborted the entire monolithic file on a fresh
cloud project, both local-only:

  - `ALTER TABLE storage.buckets ADD COLUMN IF NOT EXISTS …` — Postgres checks
    ownership BEFORE the IF-NOT-EXISTS short-circuit, and storage.buckets is owned
    by supabase_storage_admin, so three no-op statements failed with 42501.
  - `DELETE FROM auth.users WHERE email = 'test@example.com'` — the very FIRST
    statement, before the transaction opens. auth.users is owned by
    supabase_auth_admin. The error named the schema rather than the statement,
    which sent the search to the auth.users trigger 700 lines further down.

Both are now guarded so cloud skips them and only the local stack, where the
columns really are missing and we really are the owner, executes them.

RESTORE TOOL. scripts/supabase/restore-from-backup.py, used for the real move. The
local dry run caught four defects and production caught a fifth:

  1. auth.users.confirmed_at is GENERATED — a bare INSERT SELECT * dies on the
     first table. Columns now read from the target's own catalog.
  2. Wipe order violated orders -> payment_intents.
  3. Payloads past ARG_MAX: `OSError: Argument list too long` from the exec, only
     on large tables. SQL now goes to psql on stdin, remote bodies via -d @file.
  4. 7,661 audit rows exceeded the API body limit — only reproducible remotely,
     since psql-on-stdin has no such ceiling. Now chunked at 500.
  5. `on_auth_user_created` fires during restore and writes PLACEHOLDER profiles.
     With ON CONFLICT DO NOTHING the placeholders won: the first production
     restore came back with 11 wrong fields, including the owner's is_admin
     flipped True -> False and every display_name nulled. Row counts matched, the
     uuid sets were identical and every orphan check was zero, because the rows
     existed — they were just the wrong rows. Only a field-by-field diff found it.
     Now an upsert, with the backup as the authority.

VERIFIED AGAINST LIVE PRODUCTION, not asserted:
  /rest/v1/products                     HTTP 200  (was 402 for two days)
  restored user signs in                HTTP 200  (bcrypt hashes survived)
  create-order, client sent amount:100  charged 175000 of a 350000 list price
  order owner                           has_email=true, is_anonymous=false,
                                        can_sign_in=true
  type-check + lint clean, 215 tests across 23 files

FR-007 is now false and the spec needs updating; tracked separately.
The owner asked directly, twice, whether things were backed up before the
Supabase project was deleted. The answer given was "yes", and the proof was
good: 24 auth.users, identical uuid sets, 17 password hashes, 0 orphans, the
restore rehearsed against a throwaway database first. All true. None of it
sufficient.

A Supabase project holds things that live nowhere in Postgres, and deleting it
destroyed them:

  - Google + GitHub OAuth client ids and secrets
  - the Cloudflare Turnstile secret (CAPTCHA had been live since #353)
  - SMTP credentials, site_url, redirect allow-list

Supabase deletes are HARD. Afterwards `GET /v1/projects/<ref>` returns
"Resource has been removed", the ref is gone from every listing, and /restore
returns 400. None of those three credentials existed in this repo, in .env, in
GitHub secrets, or anywhere on the machine — and THREE OF TWENTY USERS had no
password and could sign in only via OAuth.

The repo had a restore script and no backup script, which is how the export
came to be an ad-hoc json_agg loop over public tables. This is its companion.

WHAT IT DOES DIFFERENTLY. Config is fetched FIRST, so a run that dies halfway
still captured the irreplaceable half. Then every public table plus auth.users
and auth.identities.

WHAT IT CANNOT DO, SAID OUT LOUD. The Management API masks secret values and
returns only NAMES for Edge Function secrets. So the script ends by printing
exactly which values it could not capture — verified against the live project,
where it names precisely the three that were lost, and correctly stays silent
about smtp_pass, which is set. It also reminds the operator to check anything
pointing AT the project by ref (OAuth redirect URIs, Stripe/PayPal webhook
URLs), because those break on a new one even when the secret survives.

Had this existed, it would have printed that warning before the delete instead
of the gap being discovered after.
Rotating credentials tonight could not be scripted, because the answer to "where
are this project's secrets?" was three different places:

  .env                            13  Supabase keys, Resend, GH_TOKEN, test users
  edge-function-secrets.json       6  Stripe x2, PayPal x4
  nowhere                          5  Google/GitHub OAuth, Turnstile secret

The third row is the expensive one. Those five lived ONLY in the Supabase project's
auth config, so deleting the project during the #567 org migration destroyed them,
and three of twenty users could sign in only via OAuth.

WHAT CHANGED

set-edge-function-secrets.ts now reads `.env` by default instead of its own JSON
sidecar, via an ALLOW-LIST of the eight keys Edge Functions should see. Not
"everything in .env" — that file also holds GH_TOKEN, the Supabase access token and
test-user passwords, none of which belong in the function runtime.

A `.json` path still works, so anyone with an existing sidecar is not broken.

Empty values are skipped rather than pushed. The placeholders added to `.env` for
the unrecoverable OAuth and Turnstile credentials are deliberately blank, and
POSTing an empty string would overwrite a good vault value with nothing.

edge-function-secrets.json is deleted — after asserting it and `.env` agreed on all
six values, and after archiving a copy outside the repo. It was also the file sitting
at mode 644 with a live Stripe secret key in it (#614).

VERIFIED, not assumed

  - dry run reads /app/.env and finds 8 secrets (one MORE than the old JSON, which
    never carried RESEND_API_KEY)
  - --apply writes all 8 and the vault reads them back by name
  - the rotated SUPABASE_DB_PASSWORD in .env authenticates through the pooler with a
    real psql connection returning `postgres` — the Management API `select 1` I tried
    first proved nothing, since it authenticates with the access token, not the
    password
  - the encrypted backup round-trips byte-identical, WITH a negative control: a wrong
    passphrase is refused. A backup nobody has decrypted is the same mistake as a
    backup nobody has restored, which is what started this

Manual-only rotations are ticketed rather than left in a chat log.
…backend

Production served a bundle pointing at a DELETED Supabase project for hours tonight,
behind entirely green checks. Two causes, both fixed here.

CAUSE 1 — THE SAME CREDENTIAL READ FROM TWO STORES.

The deploy reads `vars.*`. The operator was setting `gh secret set`. Same names,
different stores, and nothing reconciles them: `vars.NEXT_PUBLIC_SUPABASE_URL` still
held the deleted project's ref while every secret update went somewhere the build
never reads.

e2e.yml was the worst of it, reading the SAME two values from BOTH stores — `vars.*`
at lines 77-78 and `secrets.*` at 738/739, 926/927, 1089/1090. auth-config-drift.yml
and data-retention.yml used `secrets.SUPABASE_PROJECT_REF` while everything else used
vars; they agreed only by luck.

**Secrets are write-only, so the divergence is undetectable by inspection.** You
cannot diff what you cannot read. The failure mode is silent by construction.

The rule now is one store per credential, chosen by whether the value is actually
secret:
  - NEXT_PUBLIC_* ship inside the browser bundle and are public by definition -> vars.
    Storing them as secrets also masks them in CI logs, which costs debuggability for
    no security gain.
  - SUPABASE_SERVICE_ROLE_KEY and friends stay secrets.
  - SUPABASE_PROJECT_REF is not secret -> vars.

CAUSE 2 — NOTHING ASSERTED WHAT THE DEPLOYED BUNDLE POINTS AT.

Every existing check asked the BACKEND whether it was alive. It was — just not the one
the bundle called. So smoke.yml now fetches the live JS chunks and asserts the Supabase
URL they embed equals vars.NEXT_PUBLIC_SUPABASE_URL, before the browser suite runs,
since a wrong backend makes every test below it meaningless.

MUTATION-TESTED, not assumed. Run against live production:
  want = ozbdyopxmeqmwnfsmglp (real)    -> PASS
  want = huvitqubafsrazpjxsax (deleted) -> FAIL

The second line is the outage. This gate would have caught it on the first deploy
instead of never.

It also fails when no Supabase URL appears in any chunk at all — a build that shipped
without a backend would otherwise sail through a naive "does the URL match" check by
matching nothing.
The keepalive pinged exactly ONE project — whatever vars.NEXT_PUBLIC_SUPABASE_URL
held. Every other project the owner runs was outside its scope and paused on
schedule with nothing to prevent it. Five were found paused at once, in a SECOND
Supabase account this workflow had no idea existed.

It also spent a stretch priming a project that had been DELETED, which is the
failure mode that matters: a keepalive that errors looks identical to a keepalive
nobody reads. Its Aug 6 run failed and nothing surfaced it.

WHAT CHANGED

  - A LIST of refs (vars.SUPABASE_KEEPALIVE_REFS) instead of one URL.
  - Two tokens, because the projects span two Supabase accounts. Each ref is tried
    with each; whichever authenticates wins.
  - The Management API `database/query` instead of the anon-key data path. It
    answers even when a project is quota-restricted — the data API returns 402,
    which would make a keepalive fail precisely when the project most needs
    touching. And a QUERY, not a status GET: the point is real database activity,
    since a status read does not stop Supabase considering the project idle.
  - Failures name the ref. "One project failed" is not actionable, and not knowing
    WHICH is why this went unnoticed.
  - A step summary table, so the result is legible without opening logs.

REFS ARE A VARIABLE, NOT A SECRET. Project refs are not sensitive, and a secret
cannot be read back to check what it holds — which is exactly how this repo came to
deploy against a deleted project while every check stayed green.

VERIFIED both directions, against live infrastructure:

  ozbdyopxmeqmwnfsmglp  OK    (production)
  vswxgxbjodpgwfgsjrhq  OK    (ScriptHammer, second account, just restored)
  utxdunkaropkwnrqrsef  OK    (SpokeToWork, second account, just restored)

  aaaaaaaaaaaaaaaaaaaa  FAIL 404  (bogus ref)
  huvitqubafsrazpjxsax  FAIL 400  (the deleted project the old one was pinging)

The negative control is the point. A keepalive that cannot report a dead project is
the thing being fixed, not a detail of testing it.
@TortoiseWolfe
TortoiseWolfe merged commit 7cd2d94 into main Aug 7, 2026
12 of 15 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the feat/supabase-restore-tool branch August 7, 2026 08:24
TortoiseWolfe pushed a commit that referenced this pull request Aug 7, 2026
…tradicting itself

Two things the owner found on the live checkout.

**The copy contradicted the page before it.** The signed-in branch still read "No
account needed. Terms are shown before payment." #613 replaced guest checkout with a
required account and updated the copy on the GATE, but not here — so a buyer who had
just been forced to create an account was then told they did not need one.

**It did not look like the app.** Three concrete defects, not taste:

- `Field` wrapped label and input in a bare `<div>` with NO spacing utility between
  them. DaisyUI's `.label` ships only its own padding, so there was nothing to
  separate them at all — reported as "labels too close to inputs". Now the two-column
  row from SignInForm.tsx:278-298, whose `gap-2` IS that missing space. `items-start`
  rather than SignInForm's `items-center`, because this form has hints, errors and a
  textarea that must align under the input column.
- Inputs emitted bare `.input` — no `-bordered`, and no `min-h-11`. The 44px touch
  target was missing on the one form in the product that takes money, and invisible
  to mobile-touch-targets.spec.ts, which measures buttons and links.
- The form floated directly on the page background. /sign-in and /reset-password both
  sit on `sh-plate` (sign-in/page.tsx:135); this now does too, which is the single
  biggest reason the screen read as a different product.

Submit moves from `btn btn-primary` to `sh-btn sh-btn-primary`. Not cosmetic:
btn-primary reads as DISABLED on scripthammer-dark (SignInForm.tsx:386-391).

No box-shadow is set by hand — globals.css:523-551 already gives .input and .textarea
the --sh-groove recess under the house themes, and adding one would double it. Labels
use `label-text` so they inherit the repo-wide contrast correction at globals.css:61-85
rather than DaisyUI's muted 5.86:1.

Verified by rendering the real signed-in page against the live catalog and measuring:
label→input gap 0px → 24px, input height → 44px, border 0 → 1px, panel shadow present,
submit carries sh-btn sh-btn-primary, and every label now fits one line.

Refs #560
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.

A guest's paid order becomes unreachable to them: anonymous user has no email and no way back in

2 participants