Supabase setup
IsotopeAI stores everything in a Supabase project you own. There are two ways to set one up: run one command, or do it by hand in the dashboard. The command is not a shortcut for the impatient — it does four things the manual route leaves out, and two of them are invisible until the app breaks.
The one-command setup #
From an isotope-apk checkout, with a
personal access token
and nothing else:
# provision a project you already created and left empty ./supabase.sh setup --pat sbp_xxx --ref abcdefghijklmnop # or create the project too, wait for its database, then provision it ./supabase.sh setup --pat sbp_xxx --create "my-isotope"
It runs five phases in dependency order and asserts each one landed:
| Phase | What it does |
|---|---|
| schema | 1,812 statements in batches of 50 — 42 tables, 73 public functions, 15 triggers, 153 policies. Aborts on the first real failure rather than continuing against a half-built database. |
| storage | All four buckets with their size caps and MIME allow-lists. Clamps any cap above the project's plan limit instead of failing. |
| auth | The signup trigger on auth.users. This is the step the manual route cannot express — see below. |
| verify | Eight assertions that the project can actually run the app, not just that the SQL returned success. |
| .env | Written last, and only if verify passed. An existing .env is moved to .env.old. |
It writes no database rows — no users, no seed data, no files. The project it leaves behind is empty and ready for its owner's first signup.
The step that is easy to miss entirely
handle_new_user() seeds five rows on every signup —
public.users, user_profiles, user_points,
user_stats_summary, user_presence. The trigger that fires it lives on
auth.users, and for a period this schema file did not contain it: the function
existed, nothing called it, and 32 of 43 accounts had an auth identity and no
application data at all. Signup appeared to succeed and then nothing worked —
enrolment failed, Community was empty, the leaderboard was blank. The file now includes
the trigger and ./supabase.sh check asserts it, but if you are applying an older
copy of the schema, this is the thing to verify.
Writing .env
On success, setup writes .env pointing at the new project. It never
overwrites an existing one — that file holds the service-role key and every API key on
the machine, so it is moved to .env.old first. Run it twice and the
original is rotated to a timestamped name rather than buried.
These four are owned by the script, because they identify the project it just set up:
SUPABASE_URL SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY SUPABASE_ACCESS_TOKEN
Everything else is carried across from the old file: PORT, the AI keys, the
admin settings, and SESSION_SECRET — regenerating that last one would silently
log out every existing browser session for no reason. Any key the script does not
recognise is preserved under a comment rather than dropped. The file is written
0600.
Pass --no-env to skip it, for a checkout whose secrets are managed elsewhere
or a project you are provisioning for a different machine. The web console offers the same
choice as a checkbox.
Checking a project without changing it
check is read-only and answers the question the counts alone cannot:
./supabase.sh check --pat sbp_xxx --ref abcdefghijklmnop
Thirteen checks in four groups. Every one of them exists because that exact thing silently failed at some point:
| Group | Catches |
|---|---|
| schema | Missing tables, functions, policies, indexes; the six tables signup writes to; ten RPCs the compiled app calls directly. |
| auth | The signup trigger, and any auth user with no public.users row. |
| community | Buddy handles populated; the overview returning buddies; and the buddy payload shape — the compiled bundle reads presence.state at 22 unguarded sites, so a flat-only payload crashes the Community tab on the first accepted buddy. |
| storage | All four buckets present, and the object policies that let uploads through. |
Each failure prints the command or migration that fixes it. Add --json for a
machine-readable report; that is what the web console consumes.
The web console
./supabase.sh ui # http://127.0.0.1:8000
A four-step flow — connect, choose a project, read the readiness report, then run. Step four does not appear until step three has run, so you cannot act on a project before the console has told you what state it is in. It binds to loopback only, because the page accepts a management-API token and can provision or overwrite a project.
Jobs run detached with their state on disk, so closing the tab or restarting the server
does not stop or lose one. ./supabase.sh status reports the same job from a
terminal with no server running.
Doing it by hand #
Everything below is the manual equivalent. It is worth reading even if you use the command, because it explains what the command is doing — and if you follow it instead, remember the signup trigger and the buckets, which are the two things the SQL file alone does not fully cover.
Two facts worth having up front, because they change what you need to do.
| Fact | Consequence |
|---|---|
isotope-complete.sql is 272 KB / 5,368 lines | Paste it as a file upload, not into the editor box. The browser SQL editor will accept it, but a paste that large is slow and easy to truncate silently. |
The whole file is wrapped in BEGIN; … COMMIT; | It is one transaction. Either the entire schema lands or none of it does — there is no half-applied state to clean up. |
What it creates, counted from the file rather than estimated:
Row-level security is enabled on all 42 tables — not most of them. If you find a table without it after running the file, something did not apply.
What the file does not contain
The auth.* and storage.* schemas are managed by Supabase and are
deliberately excluded — you cannot recreate them and should not try. No user data is
included either. Buckets are the one thing you create separately; see
Storage buckets.
Create the project #
-
Create a free project
At supabase.com/dashboard. Pick the region closest to you — every request from your device makes a round trip, so region is the single largest factor in how fast the app feels.
Save the database password Supabase shows you. It is displayed once, it is not the same as any API key, and
backup.shneeds it forDATABASE_URL. -
Wait for provisioning to finish
Roughly two minutes. Running SQL against a project that is still starting fails in ways that look like schema errors rather than timing errors, which sends you debugging the wrong thing.
-
Run the schema
SQL Editor → New query. Upload
isotope-complete.sqlrather than pasting it, then run.Expect Success. No rows returned. That is the correct output — the file creates objects and selects nothing.
-
Confirm it actually applied
Do not take the success message on trust. Run this and check the numbers:
select (select count(*) from pg_tables where schemaname = 'public') as tables, (select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public') as functions, (select count(*) from pg_policies where schemaname = 'public') as policies, (select count(*) from pg_tables where schemaname = 'public' and rowsecurity = false) as tables_without_rls;tables_without_rlsmust be 0. Anything else means a table is world-readable to any holder of the anon key — which is a public value.
Re-running is safe
Every statement is IF NOT EXISTS or CREATE OR REPLACE. Run it again
after an upgrade and it adds what is missing without touching what is there. This is also how
you repair a partially-configured project.
Extensions
The file enables five, all of which Supabase already ships — nothing needs installing:
| Extension | Why it is needed |
|---|---|
pgcrypto | gen_random_uuid() for primary keys. |
uuid-ossp | Legacy UUID generation, kept for older function bodies. |
plpgsql | The language every one of the 80 functions is written in. |
pg_stat_statements | Query statistics. Used when diagnosing a slow leaderboard. |
supabase_vault | Supabase's own secret storage. Present in the dump because the platform expects it. |
Storage buckets #
Four buckets, and the exact values matter — the server checks them on startup and
corrects the public flag if it is wrong. Taken from ensureStorageBuckets() in
server.mjs:
| Bucket | Public | Size limit | MIME types | Holds |
|---|---|---|---|---|
user-content | No | 50 MB | Any | Sync payloads, backups, note attachments |
avatars | Yes | 2 MB | Images only | Profile images |
group-icons | Yes | 10 MB | Images only | Group icons |
study-material | No | 100 MB | Any | A student's own PDFs and scans |
Paths in the last two are owner-scoped: {auth.uid()}/…. The first segment
being the owner's uid is what makes the RLS policy expressible as
(storage.foldername(name))[1] = auth.uid()::text.
Two of these were missing for weeks
group-icons and study-material did not exist on the project while
the shipped app uploaded to both, so every group-icon and every study-material upload
returned 404 NoSuchBucket. Nothing caught it because the backup tool's reference
point was the database — it dumped three buckets, compared against three, and passed. If you
are on an older project, apply
supabase/023_wire_missing_storage_buckets.sql or run
./supabase.sh check, which now asserts the required set rather than whatever
happens to be there.
A fifth bucket, notes, was removed. It had a 10 MB limit, an
owner-scoped policy, zero objects and zero references — no upload path in the bridge, none
in any reachable bundle. Its only mention anywhere was a health check asserting it existed,
which is worse than no check: it could only ever fail for a reason nobody should act on,
and it passed happily while the two buckets above were absent. If your project still has
it, supabase/024_drop_unused_notes_bucket.sql removes it — and refuses if it
turns out to contain anything.
Two are public, and only those two
A public bucket means any URL under it is readable by anyone who has the URL, with no token.
That is correct for profile images and group icons, which render in Discover for people who
are not members and may not be signed in — a signed URL per icon per render would be the
wrong shape. It would be very wrong for user-content, which holds every backup
you ever upload, or study-material, which holds a student’s own notes. Public
read never means public write: writes are owner-scoped in all four. If you create the buckets
by hand, check that flag twice.
You can skip creating them manually. ./supabase.sh setup creates all four
as one of its phases. Failing that, with ENABLE_ADMIN_MODE=true and a
service-role key present, the server creates them on startup and logs each one:
[Storage] Created bucket "user-content" (public=false) [Storage] Created bucket "avatars" (public=true) [Storage] Created bucket "group-icons" (public=true) [Storage] Created bucket "study-material" (public=false)
Free-plan size cap
Supabase enforces a project-wide upload limit of 50 MB on the free plan, and it cannot
be raised — the API returns 402. So study-material's 100 MB is
clamped to 50 MB, which setup reports rather than failing on. Before that
was handled, bucket creation died with 413 EntityTooLarge and left the bucket
absent — on exactly the plan most first-time users are on.
It only creates buckets that return 404, so it is safe on every restart. Without admin mode it does nothing, and you create them in the dashboard instead.
Keys, and which are safe to expose #
Project Settings → API. Four values exist and they are not equally sensitive — treating them as if they were is how the service key ends up somewhere it should not be.
| Value | Sensitivity | Who needs it |
|---|---|---|
SUPABASE_URL | Public | Browser and server. It is in every request URL. |
SUPABASE_ANON_KEY | Public by design | Browser. It grants nothing on its own — RLS decides every row it can reach. |
SUPABASE_SERVICE_ROLE_KEY | Secret | Owner tooling only. Bypasses RLS entirely. |
SUPABASE_ACCESS_TOKEN | Secret | Management API — schema dumps and backup.sh. Account-wide, not project-scoped. |
The minimum .env to boot is two lines:
SUPABASE_URL=https://<project-ref>.supabase.co SUPABASE_ANON_KEY=<your-anon-public-key>
The anon key being public is not a compromise
It is the design. Supabase ships it to every browser on purpose, and your security comes
entirely from row-level security — which is why the verification query above insists on
tables_without_rls = 0. The service-role key is the opposite: it ignores RLS
completely, so anything holding it has full read and write access to every row. Keep it out of
.env unless you are running owner tooling, and see
Configuration for what actually needs it.
Finding your project ref
The ref is the subdomain of your project URL — for
https://abcdefghijklm.supabase.co it is abcdefghijklm. It appears
in three places you will need later: the OAuth callback URL, the auth storage key
sb-<ref>-auth-token in localStorage, and the
DATABASE_URL host for backups. When a session appears to vanish on login, a
mismatched ref is the first thing to check.
Google sign-in (optional) #
Email and password work out of the box and need no configuration. Google sign-in is extra work, and on a self-hosted install that work has a catch worth understanding before you start.
The button is hidden by default, on purpose
server.mjs removes the Google button from the sign-in screen
(hideGoogleUI). A visible button that fails on click is worse than no
button, and it would fail: Google will not accept
http://127.0.0.1:3000 as an authorised origin for a production
OAuth client. Nothing below takes effect until you re-enable the button at the
end.
Why 127.0.0.1 is the whole problem
The app calls signInWithOAuth({ provider: 'google' }) with
redirectTo: window.location.origin + '/dashboard'. That origin is
whatever you loaded the app from — normally http://127.0.0.1:3000.
The redirect chain is:
your browser → accounts.google.com (consent screen) → <project-ref>.supabase.co/auth/v1/callback (Supabase exchanges the code) → http://127.0.0.1:3000/dashboard?code=... (back to your device)
Google only needs to trust the middle URL, which is a real HTTPS address on
supabase.co — so that part is fine. The last hop is between Supabase
and your browser, and Supabase does permit loopback redirect URLs. This
is why the setup is possible at all.
PKCE is already enabled
server.mjs patches the Supabase client to
flowType: "pkce", so the redirect carries
?code= rather than #access_token=. Tokens never appear
in a URL, which matters more than usual here — a loopback URL with a token in
the fragment is trivially leaked by screen sharing or shell history.
1. Create the OAuth client in Google Cloud
Open Google Cloud Console → APIs & Services → Credentials. Create a project first if you have none.
Configure the OAuth consent screen. Choose External, fill in an app name and support email, and add the scopes
userinfo.emailanduserinfo.profile. Nothing else is needed — the app reads only email and display name.Leave publishing status as Testing and add your own Google account under Test users. A testing app is capped at 100 users and shows an "unverified app" warning; for a personal install that is the correct trade rather than submitting for verification.
Create credentials → OAuth client ID → Web application.
-
Authorised redirect URI. Exactly one entry, and it is your Supabase callback — not your local address:
https://<project-ref>.supabase.co/auth/v1/callback
Find
<project-ref>in yourSUPABASE_URL. Leave Authorised JavaScript origins empty: the browser never calls Google directly, so it needs no origin, and adding127.0.0.1there is the most common way this setup goes wrong. Copy the Client ID and Client secret.
2. Enable the provider in Supabase
Supabase dashboard → Authentication → Providers → Google. Toggle it on and paste the Client ID and Client secret.
-
Still in Authentication, open URL Configuration and add your local address to Redirect URLs:
http://127.0.0.1:3000/dashboard http://localhost:3000/dashboard
Both, because
127.0.0.1andlocalhostare different origins to a browser and you will not always arrive by the same one.These are the exact paths the app redirects to —
signInWithOAuthsendsredirectTo: window.location.origin + '/dashboard', and nothing else. Listing the exact URL rather than a pattern is what Supabase itself recommends for anything you are not treating as a preview deployment. Set Site URL to
http://127.0.0.1:3000. This is the fallback Supabase uses when noredirectTois supplied — password-reset and email-confirmation links use it.
Wildcards: allowed on one side, forbidden on the other This trips people up because the two systems differ, and the error you get does not say so.
| Where | Wildcards | Detail |
|---|---|---|
| Supabase Redirect URLs | Allowed | Supports * (one path segment), ** (any depth) and ? (one character). Separators are . and /, so /* matches /dashboard but not /a/b. Supabase still recommends an exact path outside of preview-deployment use. |
| Google Authorised redirect URIs | Forbidden | Google's URI validation rules reject wildcard characters outright. The scheme, case and trailing slash must all match exactly, and the value must be a domain you own. |
In this setup the difference never bites, because the Google side takes exactly one literal URI — your Supabase callback — and never sees your local address at all. The only place a pattern could appear is Supabase's own list, where it is supported. If you have read that “wildcards are not allowed”, that is Google's rule being applied to the wrong system.
Running on your phone or another device?
If you reach the app over your LAN — say http://10.0.0.5:3000 — that
exact origin must also be in Redirect URLs. A phone-hosted install whose
IP changes with the network will need this updated each time, which is a real
reason to stay with email sign-in on mobile.
3. Re-enable the button
With the provider configured, remove the hiding logic. In
server.mjs, find hideGoogleUI and return early:
function hideGoogleUI() {
return; // Google OAuth configured — see docs/supabase-setup.html#google-auth
}
Then restart and confirm the round trip:
isotope restart # the button should now be visible on /auth # after signing in, confirm the identity was linked: # Supabase dashboard → Authentication → Users → your row → Identities
Common failures
| What you see | Cause |
|---|---|
redirect_uri_mismatch | The URI in Google Cloud is not exactly your Supabase callback. It must be the supabase.co address, with no trailing slash. |
| Returns to the app but still signed out | http://127.0.0.1:3000/dashboard is missing from Supabase Redirect URLs. Supabase completed the code exchange, then refused to redirect back to an unlisted URL. |
Lands on /dashboard, then bounces to /auth | The session was written but AUTH_GUARD_SCRIPT did not see it. Check localStorage for sb-<ref>-auth-token — if it is missing, the project ref in .env does not match the project that issued the session. |
OAuth requires cloud connection | The app is in local-only mode. OAuth cannot work without reaching Supabase; email sign-in still will. |
| "Access blocked: this app is not verified" | Expected on a Testing app. Your account must be listed under Test users; click through the advanced warning. |
| Signed in, but no profile rows | OAuth creates the auth.users row directly. Confirm the triggers from Database exist, since they are what populate the satellite tables. |
Email sign-in is not a lesser option It needs no third-party client, no consent screen, no redirect list to maintain, and it keeps working when your IP changes. Google sign-in buys one fewer password to remember, at the cost of a configuration surface that breaks quietly. Set it up if you want it; skipping it costs you nothing.
Confirming the provider is live
Supabase exposes its own auth configuration publicly. This is the fastest way to know whether the provider is actually enabled, without touching the dashboard:
curl -s https://<project-ref>.supabase.co/auth/v1/settings \ -H "apikey: <your-anon-public-key>" | python3 -m json.tool
Look at external. A correctly configured project shows both:
{
"external": {
"google": true,
"email": true,
...
},
"disable_signup": false,
"mailer_autoconfirm": true
}
Three fields are worth reading while you are here:
| Field | Means |
|---|---|
external.google | false means the provider is off, whatever the dashboard appears to show. Check this before debugging redirect URLs. |
disable_signup | true blocks all new accounts, including the first one. A locked-out fresh project usually has this set. |
mailer_autoconfirm | true means accounts work immediately without email confirmation — the right setting for a self-hosted install with no mail provider. false and no SMTP means every signup is stuck unconfirmed. |
This endpoint needs only the anon key, which is public by design — so it is safe to run and safe to paste into a bug report.
Verify #
isotope restart # reachable and schema present? curl -s http://127.0.0.1:3000/api/health | python3 -m json.tool
Then sign up in the app. A successful signup writes one row to
public.users and triggers immediately create the matching rows in
user_profiles, user_stats_summary,
user_points, user_onboarding and
community_enrollments.
Upgrading an existing project #
isotope-complete.sql is safe to re-run and will add anything missing.
Measured on a live project: 1771 statements, 0 failures on the first run and 0 on a
second run over the same database, with table, policy, function, trigger, index and
constraint counts identical afterwards. Three patches apply only to older projects:
| File | When you need it | Re-runnable |
|---|---|---|
leaderboard-rls-fix.sql | If the leaderboard renders empty. An older
stats_own policy blocked public SELECT on
user_stats_summary and daily_user_stats. Fresh installs
already have the correct policy, so on a current database this file is a
no-op that re-creates the same two stats_read_all /
daily_read_all policies. | Yes — drops 7 policies and re-creates 4, so re-running converges rather than accumulating |
community-patch-v6.sql | The cumulative community patch, also served by
/__admin/patch. Supersedes v4. Also removes Events and Store
— see the warning below. | Yes — 932 statements. Every CREATE is IF NOT EXISTS or OR REPLACE, and every DROP is IF EXISTS |
performance-patch.sql | Indexes for RLS membership subqueries and leaderboard date sorts. Adds 9 indexes and re-creates 28 policies over a fresh install. | Yes — 73 statements, all idempotent |
Two files remove Events and Store
events-expansion.sql no longer creates the Events feature — it
removes it. community-patch-v6.sql ends with the same removal
block, so the cumulative community patch is destructive too. Both drop
four tables that isotope-complete.sql creates:
store_items, user_inventory, community_events and
community_event_attendees, plus seven RPCs
(purchase_store_item, join_community_event,
leave_community_event, create_community_event,
update_community_event, delete_community_event,
get_event_attendees). Counted from the two files: of the 20 tables and
28 functions community-patch-v6.sql drops, 4 tables and 15 function names
exist in the base schema — the rest are from an Events expansion the base schema never
created. Net effect on a fresh install: 42 tables → 38, and
73 public functions → 57 (16 CREATE
statements removed, one of them an overload). The patch also drops 70 policies and
creates 78, so the policy count moves rather than simply shrinking.
This is intended — Events and Store were removed from the product — but it means the order matters. On a fresh project the correct sequence is:
isotope-complete.sql # full schema, 42 tables community-patch-v6.sql # optional; removes Events + Store, leaves 38 tables performance-patch.sql # indexes sql/verify-security.sql # check
Run community-patch-v6.sql only if you want Events and Store gone, or
if you are repairing an older database. events-expansion.sql is now
redundant with the removal block inside v6; it exists so legacy setup instructions
that reference it stay safe rather than recreating a feature that no longer ships.
Running isotope-complete.sql again after either file will recreate all
four tables.