Appearance
Production credentials & environment setup
Owner-facing runbook, 2026-08-16. Everything the Ganapati MVP needs wired before the money loop can run against real infrastructure: the new production Supabase project, the api.qrsetu.com custom domain, Cloudflare R2, and Razorpay Route.
NO CREDENTIAL VALUE EVER APPEARS IN THIS FILE, IN CHAT, OR IN A COMMIT
Every secret below is set by the owner directly into Supabase (dashboard or supabase secrets set). Nothing here asks you to paste a key anywhere it could be logged. The one thing to send over a private channel is apps/mobile/.env.production, and that file is git-ignored by design.
0 · What changed, and the one access fact that blocks deployment
| Production | ikkwqowfnbhdasfejojg, named qr-setu-prod — probed 2026-08-16, alive (/auth/v1/health returns 401, i.e. reachable and asking for a key). Separate Supabase account; ⚠ in the same org as Nefoxx-Prod. |
| Retired | ygmqxyrbnemhwkiyoboc, renamed qr-setu-legacy-bkp (Sydney) — ⚠ it was called qr-setu-prod until 2026-08-16, so both projects have held that name and only the REF is unambiguous. A backup, never a deploy target. |
api.qrsetu.com | probed 2026-08-16, NOT live yet — returns 403 from Cloudflare, so the hostname resolves but no Supabase custom domain is attached |
⚠ The new project is in a DIFFERENT Supabase account, so the tooling in this repo cannot reach it.list_projects returns only qr-setu-dev and the old qr-setu-prod. Nothing can be deployed to ikkwqowfnbhdasfejojg — no migrations, no Edge Functions, no secrets — until the CLI is authenticated against that account:
bash
# OWNER ACTION. Generate a Personal Access Token in the NEW account:
# Supabase dashboard -> (top-right avatar) -> Account -> Access Tokens -> Generate new token
supabase login --token <PAT-from-the-new-account>
supabase projects list # must now list ikkwqowfnbhdasfejojgThis stays an owner action and never enters a script (the same rule npm run deploy:reminders already follows). Until it is done, the production cutover cannot start.
1 · Supabase: api.qrsetu.com as the primary endpoint
The short answer: yes, this works, and the app already has the right shape for it
The Supabase URL is already a single environment variable at every layer, and nothing anywhere hardcodes a project URL — initSupabaseClient() throws on an empty URL rather than falling back to a real project, deliberately, so a misconfigured build fails loudly instead of silently reaching the wrong backend. So "switch the endpoint without touching code" is satisfied today.
⚠ But "no code change" and "no rebuild" are different claims, and the difference decides your timing
| Surface | How it reads the URL | Switching to api.qrsetu.com costs |
|---|---|---|
apps/web (public Setu Card, SSR) | process.env.SUPABASE_URL, read at runtime in the route loader (apps/web/src/app/env.server.ts:23) | Config only. Change the Cloudflare Pages variable, redeploy. No code, no rebuild. |
apps/mobile (the merchant app, all three surfaces) | EXPO_PUBLIC_SUPABASE_URL, inlined into the JS bundle at build time | A new build, and redistribution to every device that already has one. |
| Edge Functions | SUPABASE_URL, auto-injected by Supabase | Nothing — and it must stay that way (below). |
EXPO_PUBLIC_* is a build-time inline, not a runtime lookup. That is Expo's design, not a repo choice, and it is why the timing below matters more than the configuration.
The recommendation, and it is a scheduling one
Enable the custom domain BEFORE the first production mobile build, and bake
api.qrsetu.cominto build #1.
Right now nothing has shipped — no store presence, no vendor has an APK. That is a free window in which the endpoint can be chosen at no cost. It closes the moment the first vendor installs the app. After that, moving the endpoint means a new APK pushed to twelve vendors by hand today, and a store update later once DUNS lands.
So:
- If
api.qrsetu.comis live before the production build → bake it in, and the defaultikkwqowfnbhdasfejojg.supabase.cois never referenced by a shipped client at all. - If it is not live in time → ship with the default, and accept a rebuild + re-distribution when you switch. Say so explicitly rather than discovering it later; it is a real cost, not a formality.
⚠ Three things that are NOT "just update the domain in Supabase"
The API plane switches cleanly. Auth does not, and these are outside Supabase's dashboard:
- Google OAuth redirect URI. Google Cloud Console → APIs & Services → Credentials → your OAuth client → Authorised redirect URIs must gain
https://api.qrsetu.com/auth/v1/callback. Add it alongside the existing one, never replace it — during the switchover both hosts must work, and a replaced URI breaks sign-in instantly for anyone mid-flow. The same applies to the Supabase dashboard's Authentication → URL Configuration redirect allow-list. - JWT issuer. A token minted through
api.qrsetu.comcarriesiss: https://api.qrsetu.com/auth/v1; one minted earlier carries the project host. Both verify against the same signing key, so existing sessions keep working — but if anything ever validatesissby string comparison, it will see two values. Nothing in this repo does today. Stated so it is not discovered later. - The default endpoint keeps working, and that is the fallback — Supabase does not retire
<ref>.supabase.cowhen a custom domain is attached. So the fallback you asked for exists at the server. It is not a client-side failover: the app is built with exactly one URL.
⚠ Do NOT build a runtime "try custom domain, fall back to default"
It sounds like the safe option and it is the opposite:
- The Supabase client is constructed once, at startup, with one URL. A failover would need a health probe on the coldest path in the app, adding latency to every cold start against QRS-290's measured 130-160 ms RTT floor.
- It makes "which backend was this session talking to?" non-deterministic — which is precisely the class of bug that cannot be reproduced from an incident report.
- It would also mean a session minted against one host can be used against the other mid-flight.
The correct safety mechanism is a build-time probe, not a runtime fallback. npm run check:env:prod already asserts the variables exist and prints which project the build will talk to; extending it to actually resolve and hit the configured URL turns "pointed at a domain that is not live yet" into a failed build instead of an app that installs fine and dies at the first sign-in. That is the same failure check:env was written for (QRS-668) and it is a small change — I will make it part of the cutover.
Edge Functions must keep the injected URL
SUPABASE_URL inside an Edge Function is injected by the platform and points at the project internally. Do not override it with api.qrsetu.com. A function calling its own project through a public custom domain adds a DNS dependency and an external hop to every internal query, and it would break the moment the domain has a problem — for no benefit, since no browser sees it.
What to set, per surface
| Where | Variable | Value |
|---|---|---|
apps/mobile/.env.production (git-ignored; transfer by AirDrop/USB, never chat) | EXPO_PUBLIC_SUPABASE_URL | https://api.qrsetu.com once live, else https://ikkwqowfnbhdasfejojg.supabase.co |
| " | EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY | the new project's sb_publishable_… key |
| " | EXPO_PUBLIC_ENV | production |
Cloudflare Pages (apps/web, Production env) | SUPABASE_URL | same URL as above |
| " | SUPABASE_PUBLISHABLE_KEY | same publishable key |
Where to find the publishable key: new project → Project Settings → API Keys → the sb_publishable_… value. Not the legacy JWT anon key (both exist and both work; this repo uses the publishable one), and never the service_role key in any of the above.
2 · Cloudflare R2
The four buckets are created (qrsetu-media-dev · qrsetu-media-prod · qrsetu-private-dev · qrsetu-private-prod) — that public/private split is the right shape and matches how the code needs to treat them.
Which file holds what
None of the R2 credentials belong in any file in this repo. They are Supabase Edge Function secrets, set per project. supabase/functions/_shared/r2.ts:50-53 reads exactly four names:
| Secret | What it is | Where you find it |
|---|---|---|
R2_ACCOUNT_ID | Cloudflare account id | Cloudflare dashboard → R2 → right-hand panel, or the hex string in your dashboard URL after /accounts/ |
R2_ACCESS_KEY_ID | R2 API token access key | R2 → Manage R2 API Tokens → Create API token |
R2_SECRET_ACCESS_KEY | its secret | shown once at creation — copy it then |
R2_BUCKET | the public media bucket for that environment | qrsetu-media-dev on Dev, qrsetu-media-prod on Prod |
Token scope: Object Read & Write, restricted to the two buckets for that environment only. Create two separate tokens — one scoped to the -dev buckets, one to the -prod buckets. A single shared token means a Dev bug can delete production images, and there is no undo.
The one value that IS client-side
Images are rendered by a browser and by the app, so the public read base URL is necessarily public. It is not a credential:
| Where | Variable | Value |
|---|---|---|
apps/mobile/.env.* | EXPO_PUBLIC_MEDIA_BASE_URL | the public base for qrsetu-media-* |
| Cloudflare Pages | MEDIA_BASE_URL | same |
R2 buckets are private by default, so this needs one decision from you:
- Recommended for production: attach a custom domain to
qrsetu-media-prod— e.g.media.qrsetu.com(R2 → bucket → Settings → Public access → Connect Domain). Stable, cacheable, no rate limit, and it keeps the images on your brand. - Fine for Dev: enable the
r2.devpublic URL (same panel, Allow Access). It is rate-limited and Cloudflare explicitly says not to use it for production traffic.
qrsetu-private-* must stay private, permanently — chat voice notes, photos and documents are served through short-lived presigned GET URLs, never a public path. It needs no base URL at all.
CORS on each bucket, and why this section exists
⚠⚠ THIS GUIDE COVERED BUCKETS, CREDENTIALS, PUBLIC ACCESS AND THE PRIVATE-BUCKET RULE, AND SAID NOTHING ABOUT CORS — WHICH IS WHY A BROWSER UPLOAD WAS DEAD ON DEV FOR DAYS (QRS-1245). A cross-origin PUT is always preflighted, so a bucket with no rule for the page's origin can never be written to from a web surface, whatever the signature says. A native build sends no preflight and the same code succeeds — which is exactly why the two surfaces disagreed on identical code and the diagnosis took two wrong turns.
Four buckets, four policies. They are committed, one file per bucket:
| Bucket | Policy file | Origins |
|---|---|---|
qrsetu-media-dev | supabase/storage/r2-cors-media.json | prod + devv + uatt + localhost |
qrsetu-private-dev | supabase/storage/r2-cors-private.json | prod + devv + uatt + localhost + LAN |
qrsetu-media-prod | supabase/storage/r2-cors-media-prod.json | qrsetu.com, www.qrsetu.com |
qrsetu-private-prod | supabase/storage/r2-cors-private-prod.json | qrsetu.com, www.qrsetu.com |
Where: R2 → the bucket → Settings → CORS Policy → Add CORS policy → JSON tab.
⚠ THE DASHBOARD DOES NOT ACCEPT THE COMMITTED SHAPE. Its JSON tab takes a bare array with PascalCase keys — AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders, MaxAgeSeconds — while the files are the Cloudflare API shape (rules[].allowed.origins, camelCase). Convert, never retype: two hand-typed copies of an origins list is how one surface ends up allowed and another silently refused.
⚠ Content-Type in AllowedHeaders is required, not decorative. The presigned PUT signs Content-Type as a query parameter, so a preflight that strips the header gives a 403 SignatureDoesNotMatch — which reads like a credentials problem and is not.
⚠ A public bucket still needs CORS, and this is the part that looks wrong. Reading an image needs none (<img src> is not a CORS request, and reads go through media.qrsetu.com). It is the merchant's browser upload that is preflighted. QRS-831 was exactly this.
⚠ Production is deliberately tighter than Dev, and devv/uatt are absent on purpose. Dev and UAT share the Dev Supabase project, so those pages receive presigned URLs for the -dev buckets and never for the production ones. Listing them on a production bucket would grant nothing while reading as though it did.
Separate Dev and Prod without code changes
The mechanism is already in place and needs nothing new: the same four secret names on both projects, with different values. The Dev project's R2_BUCKET is qrsetu-media-dev; the new Prod project's is qrsetu-media-prod. No code reads a bucket name, an environment name, or a branch — it reads R2_BUCKET. Switching environments is switching projects.
bash
# Dev
supabase secrets set --project-ref dyhjofjjuazhyqcvlrkx \
R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=qrsetu-media-dev
# Production (after `supabase login` against the new account)
supabase secrets set --project-ref ikkwqowfnbhdasfejojg \
R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=qrsetu-media-prod⚠ A note on the private bucket: r2Config() currently reads one R2_BUCKET. Chat media (Wave 2) needs the second bucket, and it will read R2_PRIVATE_BUCKET — the name is fixed now so you can set both in one pass and there is no rework later. Set it to qrsetu-private-dev / qrsetu-private-prod respectively. Wave 1 (item photos) only uses R2_BUCKET.
3 · Razorpay
3.1 API keys
Dashboard → Account & Settings → API Keys (older layouts: Settings → API Keys).
- The Test / Live toggle is top-left of the dashboard and it changes which keys you see. Generate keys in each mode.
- Test keys look like
rzp_test_…, live keysrzp_live_…. - The secret is displayed exactly once, at generation. If it was closed without copying, regenerate — there is no way to read it back.
| Secret | Dev project | Production project |
|---|---|---|
RAZORPAY_KEY_ID | rzp_test_… | rzp_live_… |
RAZORPAY_KEY_SECRET | test secret | live secret |
Both stay server-side. Normally key_id is public (Checkout embeds it), but this implementation uses Payment Links created server-side, so the client never needs it — and anything the client does not need should not be in the bundle.
3.2 Route
Route must be activated on the account — it is not on by default.
Dashboard → Account & Settings → look for Route (it may appear under Products or require enabling by your Razorpay account manager / support). Confirm it is active before the money path is switched on; a Payment Link carrying a transfers[] block against a non-Route account fails at creation, not silently.
What I need from you per vendor, for the manual onboarding path we agreed:
Dashboard → Route → Linked Accounts → + Create Linked Account. For each of the twelve vendors:
| Field | Notes |
|---|---|
| Business / legal name | as on the bank account |
| Email + phone | phone is 8-15 digits with NO country code |
| Business type | individual / proprietorship / partnership … |
| Bank account number + IFSC + beneficiary name | goes on the product configuration step, not account creation |
| PAN | needed for the s.194-O small-seller TDS exemption — collect it even though Razorpay may not force it |
Razorpay returns an acc_… linked-account id. That id is what I need — it goes into payout_accounts.provider_account_id, and writing it is what flips that vendor's payments availability grant on. The acc_… ids are not secrets; the bank details never enter this repo at all (Razorpay is the authoritative copy — we store IFSC + last-4 only).
⚠ Only activation_status = 'activated' may take money. The account-level status field is only created/suspended and is not the one that gates transfers — a state machine watching the wrong field reads healthy while payouts are blocked. I check the product's activation_status.
3.3 Webhook
Dashboard → Account & Settings → Webhooks → + Add New Webhook.
| Field | Value |
|---|---|
| Webhook URL | https://ikkwqowfnbhdasfejojg.supabase.co/functions/v1/razorpay-webhook |
| Secret | you invent it — any long random string. This is not issued by Razorpay. |
| Active events | payment_link.paid · payment.captured · payment.failed · order.paid · refund.processed |
Then set the same string as a Supabase secret:
bash
supabase secrets set --project-ref <ref> RAZORPAY_WEBHOOK_SECRET=<the-string-you-chose>⚠ Use the <ref>.supabase.co URL for the webhook, not api.qrsetu.com — at least until the custom domain has been live and stable for a while. Razorpay retries a failing webhook on its own schedule, and a DNS or certificate problem on a brand-new custom domain would turn a payment notification into a retry backlog. The default host has no such dependency.
⚠ This function is verify_jwt = false, necessarily — Razorpay cannot send a Supabase JWT. Its authentication is the HMAC signature, verified against the raw request body before any parsing (_shared/webhook.ts, 13 tests covering tampering, wrong secret, replay window). It will be the only verify_jwt = false function in the project, and config.toml will record why.
⚠ Route event names are UNVERIFIED. The events that fire when a linked account activates are not confirmed against the live API, and I will not guess them into a state machine. I will confirm them in the dashboard's webhook event list when wiring the payment path. Until then, activation is the manual runbook step above — which is what we already agreed for this cohort.
3.4 Commission
bash
supabase secrets set --project-ref <ref> PLATFORM_COMMISSION_BP=500500 basis points = 5%. It lives in configuration rather than in the platform_plans row on purpose: the rate is snapshotted into each payments row at link creation (commission_rate_bp/commission_minor/vendor_minor, CHECK-verified to add up), so a future rate change cannot rewrite the history of money that already moved.
4 · The complete secret list, per project
None of these are in the repo. All are set with supabase secrets set or the dashboard's Edge Functions → Secrets panel.
| Secret | Dev (dyhjofjjuazhyqcvlrkx) | Prod (ikkwqowfnbhdasfejojg) |
|---|---|---|
R2_ACCOUNT_ID | same account | same account |
R2_ACCESS_KEY_ID | dev-scoped token | prod-scoped token |
R2_SECRET_ACCESS_KEY | dev-scoped token | prod-scoped token |
R2_BUCKET | qrsetu-media-dev | qrsetu-media-prod |
R2_PRIVATE_BUCKET | qrsetu-private-dev | qrsetu-private-prod |
RAZORPAY_KEY_ID | rzp_test_… | rzp_live_… |
RAZORPAY_KEY_SECRET | test | live |
RAZORPAY_WEBHOOK_SECRET | per-project string | different string |
PLATFORM_COMMISSION_BP | 500 | 500 |
Never set as a secret and never in any client file: the service_role key (Supabase injects it into Edge Functions automatically) and any vendor's full bank account number.
Use a different RAZORPAY_WEBHOOK_SECRET on Dev and Prod. If they match, a test webhook aimed at the wrong host verifies successfully — and a signature check that passes on the wrong environment is worse than none, because it looks like it worked.
5 · Order of operations
supabase login --tokenagainst the new account — nothing else can start until this is done.- New project: run every migration, then
list_migrationsas a read-back (QRS-267 — MCP stamps its own version, so the read-back is what catches orphan rows). - Delete the four archived Edge Functions that are still ACTIVE on Dev (
manage-profile,manage-settings,create-payment-link,razorpay-webhook— QRS-694) before deploying the v2 payment functions, which reuse two of those names. Never deploy over a stale bundle on the money path. - Set all secrets on both projects.
- Google OAuth client + Supabase redirect allow-list: add both hosts.
- Attach
api.qrsetu.comin the Supabase dashboard, and wait for it to verify. npm run -w @qrsetu/mobile check:env:prod— confirm it prints the intended project.- Build, then the live ₹1 settlement test through a real linked account.
6 · What is still blocked, stated plainly
- SMTP is broken (QRS-285) — the relay is pointed at Hostinger while the verified sender is ZeptoMail, and SPF authorises neither correctly. Consequence for launch: Google sign-in only. Email OTP will not deliver on the new project either, because this is a DNS/relay problem rather than a per-project setting.
- Route webhook events unverified (above) — manual activation for this cohort.
api.qrsetu.comnot yet attached — probed 2026-08-16.