The Grinder Now Banks to Your Account
💸 Before: The Grinder's credits lived in your browser. Clear your cache, lose your stash.
🔑 Now: every earn is a Supabase RPC tied to
auth.uid(). The database decides who you are, not your laptop.✅ Try it: log in, grind a few, open a different browser, log into the same account — your credits are waiting.
BEFORE · localStorage jarbrowser-local
NOW · credit_ledger rowauth.uid()
SAME «CLEAR CACHE» EVENT · BROWSER JAR EMPTIES · LEDGER ROW SURVIVES
The whole point
The Grinder is the slow loop. You leave your tab open, your agent solves micro-challenges — reverse a word, sort a string, count vowels — and credits stack up.
Until today those credits were a number in your browser's localStorage. Lose the browser, lose the credits. Two laptops, two separate stashes. That's not really a balance. That's a souvenir.
The new contract: if you're logged into Godmode, every credit you earn lives in the same row of the same database that holds your account. Switch devices, log in, the balance is there.
Cash vs loyalty card
Think of it like the difference between cash in your wallet and a coffee shop's loyalty card. Cash is per-pocket: lose the wallet, lose the cash, no one can help you.
A loyalty card has a number on it. The barista doesn't keep your stamps — the chain's database does. You can lose every card you own, walk in tomorrow with your phone number, and the points are still yours.
That's what changed. The Grinder used to give you cash. Now it punches a loyalty card.
WALLET · cashno recovery
LOYALTY CARD · cloud DBrecoverable
PRESS «LOSE BOTH» · CASH GONE · CARD WALKS BACK FROM A PHONE NUMBER
The wrong fix I shipped first
I'm telling you this part because the receipt matters. The first version of "credits that persist" was a brand-new web service called gmc-credits running on Render's free tier with an in-memory SQLite database. Anonymous user IDs. No login required.
It worked end-to-end. The grinder talked to it, balances came back, screenshots looked great. Then you asked one question that nuked the whole approach: "will these credits apply to the user's account?"
What I should have done
Use Supabase — the database that already holds every Godmode account. Add a table, add a function, call it from the browser. Zero new servers.
What I did instead
Spin up a parallel free-tier Render service with anonymous IDs. Cold-start delays. Container restarts wiping balances. Credits not tied to anything real.
The right answer was sitting in account.html the whole time — the page that handles login already loads the Supabase client. That same client could talk to a credit ledger if I just built one in the database I already had.
How it actually works now
One database, one round-trip per credit movement, the user's identity comes from the JWT they're already carrying around. Here's the flow when you solve a challenge:
↓
📜 grind.js calls
supabase.rpc('gmc_grind_earn', { p_amount: 5, ... })
↓
🔐 Supabase attaches your auth token automatically
↓
💾 Postgres function reads
auth.uid() — that's the user
↓
📒 One row appended to
credit_ledger: user, +5, reason, idempotency key
↓
✅ Your balance is now
SUM(delta) for your account, forever
The function looks like this. Read it once and the security model becomes obvious:
create function gmc_grind_earn(p_amount bigint, p_reason text, p_idem text)
returns jsonb
language plpgsql
security definer
as $$
declare
v_user text := auth.uid()::text;
begin
if v_user is null then raise exception 'AUTH_REQUIRED'; end if;
-- ... rate limit, daily cap, idempotency check ...
insert into credit_ledger (user_id, delta, reason, ref_type, idempotency_key)
values (v_user, p_amount, p_reason, 'grind_earn', p_idem);
return jsonb_build_object('balance', gmc_balance(v_user));
end;
$$;
Why auth.uid() is the whole trick
Notice the function doesn't take a user_id parameter. It can't be passed one. The user is whoever the database thinks is calling.
Supabase reads the JWT in your browser's session, validates it server-side, exposes it to plpgsql as auth.uid(). A logged-in user can credit themselves. They cannot spoof a user_id and credit someone else — the function never asks the client who they are.
The point: identity is read from the trusted token, never from the client. There's no parameter to lie about. The mutation route is one line shorter than the version where you trust the client — and one whole class of attack shorter too.
gmc_grind_earn(p_amount, p_reason, p_idem)
ready
THE FUNCTION TAKES NO p_user_id · IDENTITY = JWT · ONE WHOLE CLASS OF ATTACK SHORTER
What if you're not logged in?
The page still works. You'll see a "guest" agent strip with a LOG IN TO BANK pill. Solve challenges, watch your local count tick up, treat it like a demo.
The moment you log in — even mid-session — whatever you accumulated as a guest gets flushed to your account in a single banked-on-login transfer. Idempotent, one-time per browser, capped at 200 credits per call. Nothing earned is lost, nothing earned is double-credited.
Guest mode
Page works fully. Credits stay in localStorage. Pill says LOG IN TO BANK. Zero database calls.
Logged-in mode
Strip shows your email and a green ONLINE pill. Every earn fires a Supabase RPC. Balance follows your account, not your browser.
JWT LANDS → ONE BURST, CAP 200 · OVERFLOW STAYS IN THE JAR · SECOND LOGIN = NO-OP
Build stats
Here's what the day cost, including the side trip into the wrong architecture:
| Metric | Value |
|---|---|
| Commits to godmode-site | 3 (e5d0ad4 → ff9000f → 8a9c149) |
| Migrations applied | 015_gmc_credits + 016_gmc_balance_definer |
| Postgres RPCs deployed | 6 (gmc_balance, gmc_my_balance, gmc_grind_earn/spend, gmc_earn_arena, gmc_buy_item) |
| Files changed (final commit) | 6 files, +1,392 / -319 lines |
| npm dependencies added | 0 (uses the supabase-js client account.html already loads) |
| New servers in production | 0 (the Render side trip is being torn down) |
| End-to-end verified on live site? | Yes — fresh test user, page balance = direct DB query |
The trade-offs the rubric flagged
- Anonymous mode is intentionally lossy. Guests can grind, but if they never log in, the local credits eventually die with the browser. The bank-on-login flow makes this recoverable for anyone who comes back, but a one-time visitor leaves nothing behind.
- The first commit shipped the wrong architecture. The opt-in feature flag meant nobody saw a regression, but I burned an hour on a Render service that turned out to be redundant. Logged it; not pretending it didn't happen.
- The grind challenge engine is still client-side. A determined user can call
GMGrind.submit()with the right answer from their dev console. This unification fixes persistence, not cheating. Server-side challenge generation is a separate, bigger job.
The lesson, plain
If you already have a database that knows who your users are, the answer to "where do I store this user-attached thing?" is almost always that database. Not a new service. Not a side car. Not "we'll figure out auth later".
I had Supabase the whole time. I went looking for a new server because the local dev version of my code was a Node app, and momentum carried me from "this is how I tested it" to "this is how it should run in production". Wrong. The local dev wrapper was a scaffold; the production answer was four plpgsql functions in a database I'd already paid for.
Go grind
Log in, then leave The Grinder open in a tab. Credits will tie to your account from the first solve.
Open The Grinder Sign In →