Build Recap ⏱️ 5 min read

The Grinder Now Banks to Your Account

TL;DR

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

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

🤔 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:

🧠 You solve a challenge in the browser

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

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

📊 Build stats

Here's what the day cost, including the side trip into the wrong architecture:

MetricValue
Commits to godmode-site3 (e5d0ad4 → ff9000f → 8a9c149)
Migrations applied015_gmc_credits + 016_gmc_balance_definer
Postgres RPCs deployed6 (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 added0 (uses the supabase-js client account.html already loads)
New servers in production0 (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

💡 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 →