IsotopeAI / docs

Guides

Getting started Configuration Supabase setup Sync & backup Backup console Community

Reference

Architecture API reference Database CLI Android APK Admin console

Help

Troubleshooting FAQ Contributing Changelog

Legal

Security Privacy Terms License

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:

PhaseWhat it does
schema1,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.
storageAll four buckets with their size caps and MIME allow-lists. Clamps any cap above the project's plan limit instead of failing.
authThe signup trigger on auth.users. This is the step the manual route cannot express — see below.
verifyEight assertions that the project can actually run the app, not just that the SQL returned success.
.envWritten 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:

GroupCatches
schemaMissing tables, functions, policies, indexes; the six tables signup writes to; ten RPCs the compiled app calls directly.
authThe signup trigger, and any auth user with no public.users row.
communityBuddy 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.
storageAll 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.

FactConsequence
isotope-complete.sql is 272 KB / 5,368 linesPaste 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:

42Tables
80Functions
153RLS policies
15Triggers

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 #

  1. 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.sh needs it for DATABASE_URL.

  2. 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.

  3. Run the schema

    SQL Editor → New query. Upload isotope-complete.sql rather than pasting it, then run.

    Expect Success. No rows returned. That is the correct output — the file creates objects and selects nothing.

  4. 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_rls must 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:

ExtensionWhy it is needed
pgcryptogen_random_uuid() for primary keys.
uuid-osspLegacy UUID generation, kept for older function bodies.
plpgsqlThe language every one of the 80 functions is written in.
pg_stat_statementsQuery statistics. Used when diagnosing a slow leaderboard.
supabase_vaultSupabase'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:

BucketPublicSize limitMIME typesHolds
user-contentNo50 MBAnySync payloads, backups, note attachments
avatarsYes2 MBImages onlyProfile images
group-iconsYes10 MBImages onlyGroup icons
study-materialNo100 MBAnyA 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.

ValueSensitivityWho needs it
SUPABASE_URLPublicBrowser and server. It is in every request URL.
SUPABASE_ANON_KEYPublic by designBrowser. It grants nothing on its own — RLS decides every row it can reach.
SUPABASE_SERVICE_ROLE_KEYSecretOwner tooling only. Bypasses RLS entirely.
SUPABASE_ACCESS_TOKENSecretManagement 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

  1. Open Google Cloud Console → APIs & Services → Credentials. Create a project first if you have none.

  2. Configure the OAuth consent screen. Choose External, fill in an app name and support email, and add the scopes userinfo.email and userinfo.profile. Nothing else is needed — the app reads only email and display name.

  3. 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.

  4. Create credentials → OAuth client IDWeb application.

  5. 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 your SUPABASE_URL. Leave Authorised JavaScript origins empty: the browser never calls Google directly, so it needs no origin, and adding 127.0.0.1 there is the most common way this setup goes wrong.

  6. Copy the Client ID and Client secret.

2. Enable the provider in Supabase

  1. Supabase dashboard → Authentication → Providers → Google. Toggle it on and paste the Client ID and Client secret.

  2. 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.1 and localhost are different origins to a browser and you will not always arrive by the same one.

    These are the exact paths the app redirects to — signInWithOAuth sends redirectTo: 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.

  3. Set Site URL to http://127.0.0.1:3000. This is the fallback Supabase uses when no redirectTo is 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.

WhereWildcardsDetail
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 seeCause
redirect_uri_mismatchThe 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 outhttp://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 /authThe 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 connectionThe 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 rowsOAuth 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:

FieldMeans
external.googlefalse means the provider is off, whatever the dashboard appears to show. Check this before debugging redirect URLs.
disable_signuptrue blocks all new accounts, including the first one. A locked-out fresh project usually has this set.
mailer_autoconfirmtrue 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:

FileWhen you need itRe-runnable
leaderboard-rls-fix.sqlIf 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.sqlThe 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.sqlIndexes 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.