2.7.0
Released
Features
The
stripeaction can switch which Stripe account or sandbox the CLI acts as. It lists the authorized accounts with the active one marked, runsstripe switch contexton a yes, and points atstripe reauthfor one that is not listed yet. This was previously reachable only by knowing the command, which made "the keys are in a different environment than the CLI" a dead end inside the TUI. The prompt defaults to yes only when the price ids say the active account is the wrong one. After a switch it re-probes rather than assuming, defaults the signing-secret re-fetch to yes, and says that any listener started earlier is still attached to the old account — both belong to the account, not the machine (#486).The
stripeaction now reports the local billing state and names the stage the flow stopped at. "The checkout worked and the plan still says Free" has four causes that are indistinguishable from the UI, because each one looks exactly like a successful purchase followed by nothing — but each leaves a different row behind, so the first empty table identifies it: no Stripe customer means checkout never ran, nosubscription_eventsrow means no webhook was ever accepted, nosubscriptionsrow means the event could not be attributed to a user, and a Pro-granting subscription over a free profile is the one case that is a bug in our code rather than a setup mistake. Like the seeder, it refuses to read anything but an exact loopback Supabase origin (#486).The
stripeaction cross-checks the price ids in.envagainst the account the CLI is acting as. Stripe object ids embed their account, so this is an offline check for a failure with no other symptom: with the keys in one environment and the CLI in another, checkout succeeds against the first while the listener, correctly attached to the second, receives nothing at all — every visible piece working, no error anywhere, and the tier never moving. The encoding is observed rather than documented, so an id that does not fit the shape produces no opinion instead of a wrong one (#486).Added
pnpm dev:tui -- stripe(aliasesbilling,stripe-setup): a guided walk through everything a local end-to-end checkout needs, in dependency order — install the Stripe CLI, sign it in, fetch the webhook signing secret into.env, and open the listener. It reports the CLI, the login, the fourSTRIPE_*values (secrets masked, price ids legible so a pasted product id is catchable) and the storefront flag, and names the actual blocker first rather than printing the whole setup to diff against. Every action is confirmation-gated with the command shown first, and nothing runs without a yes (#486).The
stripeaction fetches the webhook signing secret withstripe listen --print-secret, which removes the ordering trap that makes this setup fail: the secret is normally printed bystripe listen, but the app must already hold it when its container is created, so configuring the keys first leaves every event failing signature verification on a secret that was never stored.--print-secretprints it and exits without opening a listener. The fetch is gated behind a confirmed login, because that command blocks indefinitely when the CLI has no config at all rather than failing — running it while signed out would hang the TUI with no output (#486).The
stripeaction can install the CLI (Homebrew only) and runstripe login, and on macOS offers to openstripe listenin a new Terminal window — the listener has to stay up for the whole test, so it cannot run inside the TUI. The install is the only thing the TUI does outside the repository and its containers, so it stays Homebrew-only and points at the Stripe docs rather than inventing a package manager on a machine it cannot verify (#486).Added
pnpm dev:tui -- confirm(aliasesconfirm-email,confirm-signup,confirm-user), which marks local signups still waiting on an email link as confirmed through the Supabase Auth admin API — the sameemail_confirm: truecall the seeder already makes for its fixture users. This is now the supported way to get into the app locally after signing up, in place of editing[auth.email].enable_confirmations: that file is tracked, so a local override is onegit commit -aaway from the repository andpnpm test:dev-tuifails on it by design. Nothing is configured or edited, so local behaviour stays identical to production and the/auth/confirm→/auth/callbackround trip keeps getting exercised by hand. Like the seeder, it refuses to run against anything but an exact loopback Supabase URL (#487).Added the Pro subscription, behind
NEXT_PUBLIC_FEATURE_BILLING(default off): a public/pricingpage, an authenticated/settings/billingpanel, a Billing nav entry, andPOST /api/billing/checkout,/portal, and/webhook. Cancelling, resuming, switching between monthly and annual, updating a card, and invoice history are all delegated to the Stripe Billing Portal rather than rebuilt — Stripe already implements each with the proration and dunning rules they need (#486)./pricingreads its amounts back from Stripe rather than hardcoding them, so the dashboard is the only place a price is set. It also renders the trading name, product description, support contact, and refund/cancellation terms outside the login, which is what Stripe's account activation review checks for;e2e/pricing.spec.tsguards that they stay reachable signed out (#486).Locked personas in the model picker now link to
/pricingwhere billing is enabled, via a new optionalupgradeHref. With the flag off the copy stays plain text, since there is no pricing page to link to (#486).
Fixes
POST /api/billing/checkoutrefuses an account that already holds a Pro-granting subscription, with a 409 pointing at the Billing Portal. It checks both the owner-readable local mirror and Stripe itself after resolving the customer: Checkout can finish before its asynchronous webhook reaches Supabase, and consulting the mirror alone would reopen the upgrade path during that delay and let the same customer pay twice. Hiding the buy buttons is not enforcement either — a retry or a direct call still reaches the route (#486).Stripe customer creation carries an idempotency key derived from the user id, so two concurrent first checkouts converge on one customer. Without it both saw no mapping, both created a customer, and the losing upsert left one holding a live Checkout session and its invoices that neither the mapping nor the Billing Portal could find again (#486).
Locked personas in the model picker render as plain cards rather than disabled buttons. The "Upgrade to Pro" link was interactive content nested inside a disabled
<button>— invalid HTML, and unreachable by pointer and assistive technology alike, so the one control that could unlock the card was the one control that could not be used (#486).The dev TUI re-checks for a live-mode context after an account switch. The guard ran before the switch was offered, so choosing a live account from the picker walked straight past it into fetching a live webhook signing secret into
.envand forwarding production events at localhost (#486).The dev TUI reads the Stripe CLI's login from
stripe whoami --format jsoninstead ofstripe config --list, which fixes a state the action could not get out of: signed in, but the action insisting otherwise and refusing to fetch the webhook secret. Since the CLI's OAuth login there is nothing about being signed in inconfig.tomlat all — the credentials live in the OS keyring and the account choice in a global "active context" — so the file probe reports every modern login as signed out.whoamialso reports key availability without printing the key, unlikeconfig --list. A CLI too old to have the subcommand still falls back to the file (#486).The TUI no longer tries to scope the Stripe CLI per repository with
--project-name. That flag selects aconfig.tomlprofile and predates the OAuth session, which is machine-global:stripe login --project-name questifyanswers "You're already logged in" and exits without doing anything, so the action offered a login that could never take. It now reports the active context — the account and whether it is a sandbox or live — and offersstripe switch contextorstripe reauthwhen that is the thing that would actually help (#486).A live-mode context now stops the stripe action instead of proceeding.
stripe listenwould forward production events at localhost, and--print-secretwould write a live signing secret into.env, where the next rebuild bakes it into the app (#486).Probes in the dev TUI can carry a timeout, and a failed command's stdout is preserved on the error.
stripe whoamireaches the network in OAuth mode and exits non-zero while printing the JSON that explains why, so without both the probe could hang the TUI or discard its own answer (#486).The dev TUI's menu can reach every action it lists. The menu and the CLI carried separate dispatch chains, and
stripewas only ever added to the CLI's, so choosing "Stripe setup" from the menu fell through to the workflow lookup and failed with "No workflow exists for action: stripe" — whilepnpm dev:tui -- stripeworked, which made it look like a Stripe problem rather than a routing one. Both paths now read one table, and a test asserts every menu entry resolves to a handler or a workflow plan (#486).signupActionno longer consultsNEXT_PUBLIC_AUTH_EMAIL_CONFIRMATION; a withheld session from Supabase is the whole signal. The flag could only ever make the check stricter, and the single state where it changed the outcome was the broken one — flag on, Supabase confirmation off — which redirected a user holding a live session to "Check your inbox" for mail nobody had sent, a dead end they could only leave by editing the URL. The flag stays as the mirrore2e/auth.spec.tsand the deploy runbook read to know which behaviour to expect (#487).The dev TUI's service-role-key redaction no longer mangles ordinary error text. It is a blind substring replace, so a short placeholder key matched inside unrelated words — with
SUPABASE_SERVICE_ROLE_KEY="k", "loopback" was printed as "loopbac[redacted]". It now only redacts keys long enough to be real credentials, which leaves the protection unchanged for the JWTs Supabase actually issues (#487).
Security
Stripe configuration is checked per capability rather than as one predicate over every value. The webhook and the portal use no price at all, so requiring both price ids meant a retired price would 503 lifecycle events for subscriptions that are still being charged — Stripe retries those for days and then disables the endpoint — while locking those same subscribers out of the only page where they can cancel or fix a card (#486).
The billing webhook verifies Stripe's signature against the raw request body and is excluded from the Next middleware matcher, so nothing rewrites the bytes the signature covers. It is gated on Stripe configuration and never on
NEXT_PUBLIC_FEATURE_BILLING: turning the storefront off while subscriptions exist must not start dropping their lifecycle events, or Stripe would retry for days, disable the endpoint, and leave accounts out of sync with what people are being charged. Customer attribution acknowledges a successfully retrieved Stripe customer with no usable metadata as unknown, but lets retrieval failures propagate to a 500 so a transient Stripe outage is retried instead of permanently dropping the subscription update or cancellation (#486).apply_subscription_state()is revoked fromanonandauthenticatedby name rather than only frompublic. Supabase's default privileges grant EXECUTE to those roles directly when a function is created, and a direct grant survives a revoke from PUBLIC — left reachable, the function is a self-service route toplan_tier = 'pro'(#486).Checkout and portal errors are logged rather than returned. Stripe's messages name price ids, modes, and configuration state, none of which belongs in a response body (#486).
Technical changes· 26 entries for database, tooling, CI, tests and docs
Database
Refreshed the paid model persona catalog.
deepseek-reasonerwas retired by DeepSeek on 2026-07-24 along withdeepseek-chat, so the seeded DeepSeek persona named a model id that no longer resolves and every investigation routed to it failed; it now points atdeepseek-v4-proand its key moves fromdeepseek-r1todeepseek, because a key naming a model generation guarantees the same migration has to be written again at V5. Claude Sonnet moves fromclaude-sonnet-4-6toclaude-sonnet-5, which is both better and a third cheaper per token, and Claude Opus fromclaude-opus-4-6toclaude-opus-5at identical pricing.claude-haiku-4-5-20251001,gpt-5andgpt-5-miniare current and deliberately untouched —gpt-5is now cheaper than its own successor for this workload, so "newer" is not by itself a reason to move. Personas are updated in place by key, so everyquests.model_persona_idkeeps pointing at the same row and no quest loses its configured persona.seed.sqlcould not have fixed any of this: its inserts areon conflict (key) do nothing, so replaying it against a database that already holds these keys changes nothing — the mirror image of the gap that made 20260825120000 necessary (#488).apply_subscription_state()now records the Stripe event id itself, in the same transaction as the write it guards, and returns false when that id was already recorded. Recording it from a separate statement — as the webhook did — left a window in which a process killed between the record and the write leaves a record with no work behind it, and every Stripe retry then reads as a duplicate: a subscription that is paid for, never granted, and reports no error anywhere. A crash now rolls back both (#486).apply_subscription_state()takes a row lock on the owner's profile before it writes. Two events for different subscriptions of the same account each derived the tier from a snapshot that could not see the other's uncommitted rows, so whichever committed last won — a newactivesubscription racing an old one's cancellation could leave the account on Free while Stripe charged for Pro (#486).Added
profiles.plan_tier_source, recording whether the current tier was granted administratively or by billing, and taught reconciliation to revoke only what billing granted. The billing panel already surfaced accounts "granted Pro directly rather than through a subscription", and without an owner recorded on the tier the next lifecycle event for such an account — an abandoned checkout goingincomplete, a retry of an old cancellation — computedfreeand silently revoked the comp.set_user_plan_tier()marks the tier administratively owned, and the existing self-service guard now covers the new column, so an account holder cannot re-label a billing grant as permanent (#486).Added Stripe billing storage:
billing_customers(the Stripe-customer ↔ Supabase-user mapping),subscriptions(a mirror of the Stripe subscription, keyed by its Stripe id), andsubscription_events(an idempotency ledger of processed Stripe event ids). All three are owner-read and service-role-write — none has an insert, update, or delete policy, and those privileges are additionally revoked fromanonandauthenticated(#486).Added
public.apply_subscription_state(), the billing webhook's single write entry point. It upserts the subscription mirror and reconciles the owner'sprofiles.plan_tierin one statement, calling the existingpublic.set_user_plan_tier()rather than writingprofilesdirectly, so the grant path and its privilege check stay the only way a tier moves. Two things it gets right that a naive upsert would not: the tier is derived from all of a user's subscriptions, so a latecustomer.subscription.deletedfor a cancelled plan cannot downgrade an account that has since resubscribed; and alast_event_atcomparison drops an event older than the one already applied, so a Stripe retry of the originalincompletecannot land after theactivethat followed it (#486).
Tooling & Configuration
Added
pnpm check:models, which asks each provider whether the model ids in the live persona catalog still exist, and a weeklyModel catalogworkflow that runs it. This is the alarm that was missing whendeepseek-reasonerwas retired: paid personas are pinned by necessity — the free tier uses OpenRouter's router precisely to avoid pinning — so nothing in the repository would ever have noticed. A provider's own list is authoritative when its API key is present and a missing model fails the run; without a key the check falls back to OpenRouter's public catalog, where a miss is only ever a warning, because OpenRouter normalises slugs (our datedclaude-haiku-4-5-20251001isanthropic/claude-haiku-4.5there) and a weekly false alarm trains everyone to ignore the real one. It is deliberately not part of CI and carries nopull_requesttrigger at all: nothing in a pull request can change whether a provider retired a model overnight, and a catalog-repair PR is exactly the case where production still holds the id the PR is about to fix, so the live check would fail the fix with its own alarm. A provider answering 200 with an unusable body — an expired key, a plan problem, a provider mid-incident — falls back rather than reporting every one of that provider's personas as retired, because an empty model list is not the same claim as "this model is gone". The run skips with exit 0 rather than failing when the Supabase credentials are absent (#488).Ignored
_to_delete/. An agent working through the Cowork device bridge cannot unlink files, so stale git lock files and other leftovers get moved there for a human to delete; untracked and unignored, they were onegit add -Aaway from being committed (#485).Added
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_PRO_MONTHLY, andSTRIPE_PRICE_PRO_YEARLYto.env.example, the web env schema, Turborepo's env lists, and the dev TUI's managed keys, with price ids validated against theprice_…shape so a pasted product id or dashboard URL is caught while it is still visible on screen. RegisteredNEXT_PUBLIC_FEATURE_BILLINGin the env schema,.env.example, the Dockerfile, Compose, Turborepo, and the Playwright web-server allow-list (#486).
Tests
supabase/tests/rls.sqlnow asserts thatanthropic/claude-opus-5is a live pro persona before the two tests that use it as a literal. Those tests were about to pass for the wrong reason:user_can_use_model_type()rejects an unknown model exactly as it rejects an out-of-tier one, so once the catalog moved offclaude-opus-4-6, "Alice cannot queue a pro model" would have kept passing while testing nothing about tiers at all. Plan raised to 163.pnpm test:scriptsruns the newscripts/*.test.mjs, andpnpm testruns it (#488).Extended
supabase/tests/rls.sqlto 162 assertions, covering the atomic event ledger (a replayed event id is reported as a duplicate and writes nothing), that a tier moved by billing is marked as billing-owned, that a cancellation cannot revoke an administrative grant, and that an account holder cannot re-label who owns their tier. Addedstripe.test.tsfor the split configuration predicates, pinned mostly by what each one is deliberately blind to (#486).Added
scripts/dev-tui/billing-status.test.mjs: each diagnosis against the row shape that produces it, the loopback guard refusing a hosted Supabase URL before any request goes out, and a parity test that reads the Pro-granting statuses back out ofsubscription_grants_pro()in the migration — the list now exists in SQL, in the app's TypeScript, and in the TUI, none of which can import from the others. The account-suffix check is tested against the real ids from the sandbox where this was found (#486).The dev-TUI Stripe fake models
stripe whoamias the real CLI behaves, which is what turns the differences between its two output schemas into test failures rather than surprises in a terminal: the OAuth object carries noauthenticatedfield and proves itself by naming an account, the legacy object carries the flag but nomode, and the signed-out case exits non-zero while still printing the JSON that says so. Added coverage that a live context blocks the secret fetch and the listener and leaves.envuntouched, that an unsupportedwhoamifalls back to the config file rather than being read as signed out, and that neither probe leaks a plaintext key (#486).Added
scripts/dev-tui/stripe-setup.test.mjs: the config reader (masking,KEY=""as unset, the flag tracked separately from the keys because the webhook is gated on configuration alone), secret parsing past the CLI's update banner, login detection that returns a boolean and never the plaintext keystripe config --listprints, install-command refusal on a platform or machine it cannot verify, and AppleScript escaping for a repo path containing both a space and an apostrophe. The action is covered end to end against an injected runner: nothing runs without a yes, a declined install stops rather than pretending the CLI is there, the secret fetch is unreachable while signed out,--print-secretis what gets invoked rather than a barelistenthat would block forever, the fetched secret reaches.envwithout being echoed, and the listener is never run inline.main()gained arunnerImplseam so the paths that shell out and then write to.envare testable (#486).Pinned the loopback allow-list's IPv6 behaviour, after review on #487 raised the concern that
new URL(...).hostnamedrops the brackets from an IPv6 host and so could never match"[::1]". It does not — WHATWG serializes an IPv6 host with its brackets — sohttp://[::1]:54321was already accepted andhttp://[0:0:0:0:0:0:0:1]:54321normalizes to the same origin; both are now asserted, along with a routable IPv6 address being rejected. The allow-list also exists in two modules, so a test now pins them to agree rather than leaving two copies of a security-relevant list free to drift, and the bare"::1"spelling was added to the seeder's copy so the two are identical for a hostname that never went through a URL parse (#486).Added
scripts/dev-tui/confirm-signups.test.mjscovering which accounts count as pending (eitheremail_confirmed_atorconfirmed_atmeans already usable), the admin-API calls made for each, the no-op case, and the loopback guard refusing a hosted Supabase URL before any request goes out. Added a workflow test assertingconfirmloads the local credentials before it touches a user, and a signup test for the state the removed flag check used to break (#487).Added coverage for the billing modules and routes: the status→tier rule (including a test that reads
subscription_grants_pro()out of the migration and fails if the SQL and TypeScript copies disagree), webhook idempotency, out-of-order delivery, unattributable customers, and claim release on failure; checkout and portal route behaviour across flag-off, unconfigured, unauthenticated, and Stripe-error paths; and the billing panel's free, active, cancelling, past-due, lapsed, and admin-granted states. Extendedsupabase/tests/rls.sqlby 16 assertions covering who may read and write billing state and how the tier follows a subscription through dunning, cancellation, resubscription, and out-of-order events (#486).
Documentation
Added
docs/pricing.md: what a Pro subscription costs to serve, why unlimited frontier-model access cannot survive a flat price, and the AI credit design that replaces it. The load-bearing number is that at C$9.99/month a subscription funds about 199 Claude Opus actions — 6.6 a day, which is an engaged user rather than an abuser — while a ten-image investigation costs 3.3× a text one and the 14-day refund window turns unmetered usage into an invitation. It recommends C$12.99/month or C$129/year with a 400-credit monthly allowance, and is explicit that 400 is arithmetic rather than evidence: the implementation plan lands the metering with enforcement off first, so the allowance is set from observed usage. This change is that plan's stage 0 (#488).docs/billing.mdnow says to pair the CLI with a sandbox rather than the account's legacy test mode. Both issuesk_test_…keys and neither moves real money, but test mode is one permanent environment per account that cannot be cleared, while a sandbox is isolated and disposable — and subscription testing is unusually good at accumulating abandoned checkouts and orphaned subscriptions. Records the consequence that matters: products, prices, and the Customer portal configuration live inside whichever environment you pick, so theprice_…ids are sandbox-specific. Documented how the CLI actually picks an account — one machine-global OAuth context, switched withstripe switch contextand extended withstripe reauth— indocs/billing.mdanddocs/local-docker.md, with triage rows for the live-context and no-account-selected states (#486).Rewrote
docs/billing.md's local-development section as an ordered walkthrough: the one-off CLI and dashboard setup (including the Customer portal configuration, whose absence surfaces as a Stripe error that reads like our bug), the per-checkout TUI commands, why the listener ordering trap no longer applies, and a triage table. It now states plainly thatstripe triggercreates its own fixture customer and so answersunknown_customerwithout moving any tier — the guard working, but indistinguishable from a failure unless you know. Documented the new action indocs/local-docker.md,AGENTS.md, and the README (#486).Rewrote the local signup-confirmation section of
docs/local-docker.mdaround the three ways through — seed fixtures,confirm, or the real Mailpit flow — and stated plainly that a local stack never delivers mail to an external address. Correcteddocs/ops/deployments.md,.env.example, and theenv.tsdoc comment, which all describedNEXT_PUBLIC_AUTH_EMAIL_CONFIRMATIONas driving routing; it declares how the Supabase project is configured and changes no app behaviour (#487).AGENTS.mdnow states the branch policy it previously left to "the active integration branch specified by the maintainer": a hotfix branches frommainand targetsmain, and is merged back intodevelopafter release; everything else branches from and targetsdevelop(#485).AGENTS.mdnow tells agents to fetch and fast-forward the integration branch they are branching from —develop, ormainfor a hotfix — before cutting a new branch. Branching off a stale local ref is how a PR arrives carrying an unrelated diff or a conflict that was already resolved upstream, and how a migration ends up cut from a stale schema history (#485).Added a repository
CLAUDE.md. It defers toAGENTS.mdas canonical and carries only the Claude Code specifics: which CI jobs are worth running before a commit, theapps/webcoverage ratchet, and the tracked files that a repo-widepnpm formatreflows wholesale, since formatting is not a CI gate here (#485).Moved the production seed invariant into
AGENTS.md, where every agent sees it:supabase/seed.sqldoes not reach a hosted database that was migrated by hand, so catalog rows the product cannot run without belong in a migration (#485).Added
docs/billing.md: why Questify runs one Stripe account per app under a single legal entity grouped in a Stripe Organization, why questify.co rather than a corporate page is the business website, the five-point activation checklist that is where these get rejected, why a merchant of record was considered and declined (a tax-administration tradeoff, not a structural one), and the dashboard setup no migration can do. Documented the new tables indocs/schema.mdand the three routes indocs/api-endpoints.md(#486).