feat(#567): buying requires a real account, and the tooling that moved the project - #613
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #611. Follows the org migration in #567.
Buying now requires a real account
/checkoutshipped with guest checkout built onsignInAnonymously(), 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
localStorageand itsauth.usersrow carries no email, soclearing 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-indropsthe
?sku=, so someone who just chose a $3,500 package lands back on a page that hasforgotten what they wanted. Verified live: the tabs render, the intake form is withheld,
the summary still reads
$3,500.00and the URL still carries?sku=svc-site.It keys off
session, not the form'sonSuccess.mailer_autoconfirmis off, sosigning 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 ownershipbefore the IF-NOT-EXISTS short-circuit, and
storage.bucketsis owned bysupabase_storage_admin. Three no-op statements failed with42501.DELETE FROM auth.users WHERE email = 'test@example.com'— the first statement inthe file, before the transaction opens. The error named the schema, not the
statement, which sent the search to the
auth.userstrigger 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; productioncaught a fifth:
auth.users.confirmed_atis GENERATED — a bareINSERT SELECT *dies on thefirst table. Columns now read from the target's catalog.
Wipe order violated
orders → payment_intents.Payloads past
ARG_MAX—OSError: Argument list too longfrom the exec itself, onlyon large tables. SQL now goes to psql on stdin; remote bodies via
-d @file.7,661 audit rows exceeded the API body limit — only reproducible remotely, since
psql-on-stdin has no such ceiling. Chunked at 500.
on_auth_user_createdfires during restore and writes placeholder profiles. WithON CONFLICT DO NOTHINGthe placeholders won: the first production restore came backwith 11 wrong fields, including the owner's
is_adminflippedTrue → Falseandevery
display_namenulled.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
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
in the deleted project's config. 3 of 20 users have no password and can sign in only via
OAuth.
may not arrive — which now blocks purchase, not just messaging.