Skip to content

Schema v2: the four missing tables, with the RLS holes closed - #43

Merged
mapzimus merged 2 commits into
masterfrom
claude/tappymaps-revamp-audit-4gm8pj
Aug 22, 2026
Merged

Schema v2: the four missing tables, with the RLS holes closed#43
mapzimus merged 2 commits into
masterfrom
claude/tappymaps-revamp-audit-4gm8pj

Conversation

@mapzimus

Copy link
Copy Markdown
Owner

Closes the biggest item from the audit: the client queried four tables that did not exist in the live database. The 2026-08-01 migrations were written and never run, so cloud My Maps sync, the public gallery, and the $12/mo Classroom tier have never worked in production.

You said redo it, so the schema is redone rather than replayed — the old design had real RLS holes.

What changed vs. the superseded migrations

Old design Now
classroom_codes had a broad SELECT so /class/<CODE> could resolve a code — which also let anyone enumerate every active code, teacher UUID and map No client SELECT at all. classroom_lookup() returns only the map + title for one active code, never teacher_id
Any signed-in account could mint a class code — for a $12/mo feature Requires the classroom tier, enforced in the DB
user_maps writes open to any authenticated user, so "cloud sync is Pro" was true only in the UI Requires an active subscription
gallery_publish_counts was a client-written value, so the rate limit reset itself gallery_claim_publish_slot() checks and increments in one statement; the tier sets the allowance
map_reports client-readable Write-only from any client

Also locks is_featured behind a trigger, and codifies the RLS already live on user_subscriptions / export_counts — which had no migration and so couldn't be reviewed.

Entitlements now hold in the database

Via user_subscriptions.tier, mirroring verify-subscription's grace logic (past_due still entitles; a row stops entitling 3 days after its period ends). The webhook and the reconcile path both write tierwithout it RLS would refuse a paying customer — and existing rows are backfilled to pro.

Verified adversarially against production, with the real public anon key

dump classroom_codes                       -> 401 permission denied
classroom_lookup('ZZTEST')                 -> 200, map + title only, no teacher_id
read a private user_map                    -> invisible; only the published one returns
map_reports / user_subscriptions /
  export_counts / gallery_publish_counts   -> 401 each
call the trigger fn over RPC               -> 404, no longer exposed
insert a map as anon                       -> RLS violation

Supabase's security advisors caught one thing this pass introduced — the trigger function was reachable at /rest/v1/rpc/user_maps_lock_featured. Fixed, along with narrowing the default PostgREST grants to match the policies.

Test fixtures removed; all four tables are empty.

Applied to production

The migration is already applied (additive, create table if not exists throughout, DDL transactional). The file in supabase/migrations/20260822_schema_v2.sql is the annotated source of truth and re-running it is a no-op.

Tests

validate · 18 unit · 16 API · 24-check smoke · cloud-maps-smoke · csv-import-smoke — all green.

Still needs you

Terms of Service, Privacy Policy and a refund policy. The billing portal shipped in #42, so "Cancel anytime" is true — the policies aren't mine to invent. Supabase also has leaked-password protection disabled; worth enabling in Auth settings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9


Generated by Claude Code

The client queried user_maps, map_reports, gallery_publish_counts and
classroom_codes. None existed in the live database: the 2026-08-01 migrations
were written and never run. Cloud My Maps sync, the public gallery, and the
$12/mo Classroom tier have therefore never worked in production.

Redone rather than replayed, because the audit found the old design leaky.
Applied to the live project and verified against it.

  • classroom_codes had a broad SELECT policy so /class/<CODE> could look a
    code up. That also let any anonymous visitor enumerate every active code,
    its teacher's UUID and their map. There is now NO client SELECT policy;
    joining goes through classroom_lookup(), which returns only the map and
    title for one active code and never exposes teacher_id.
  • Minting a class code was open to any signed-in account — for a feature
    sold at $12/mo. Now requires the classroom tier, enforced in the database.
  • user_maps writes were open to any authenticated user, so "Cloud My Maps
    sync is Pro" was true only in the UI. Now requires a subscription.
  • gallery_publish_counts was a client-written value, so the publish rate
    limit reset itself on request, and two parallel publishes could both read
    the same count. Replaced by gallery_claim_publish_slot(), which checks and
    increments in one statement with the tier deciding the allowance.
  • map_reports was client-readable. Now write-only from any client.
  • is_featured is locked by a trigger so nobody can feature themselves.
  • Codifies the RLS already live on user_subscriptions and export_counts,
    which had no migration and so could not be reviewed.

Entitlements are enforced in the database via user_subscriptions.tier, mirroring
the grace logic in verify-subscription (past_due still entitles; a row stops
entitling three days after its period ends). The webhook and the reconcile path
both write tier now — without it RLS would refuse a paying customer — and
existing rows are backfilled to 'pro' (the Classroom tier could not previously
exist, so that is the only correct value).

Verified adversarially against production with the real public anon key:

  dump classroom_codes            -> 401 permission denied
  classroom_lookup('ZZTEST')      -> 200, map + title only, no teacher_id
  read a private user_map         -> invisible; only the published one returns
  map_reports / user_subscriptions
    / export_counts / publish counts -> 401 each
  call the trigger fn over RPC    -> 404, no longer exposed
  insert a map as anon            -> RLS violation

Supabase's security advisors caught one thing this pass introduced: the trigger
function was reachable at /rest/v1/rpc/user_maps_lock_featured. Fixed, along
with narrowing the default PostgREST grants to match the policies.

Test fixtures were removed; all four tables are empty. validate, 18 unit tests,
16 API tests, the 24-check smoke suite and cloud-maps-smoke all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mapparatus Ready Ready Preview Aug 22, 2026 6:29pm
tappymaps Ready Ready Preview Aug 22, 2026 6:29pm

Schema v2 is applied to the live project, so the first blocked item is closed.
Adds the one thing a future session must not undo: billing code has to keep
writing user_subscriptions.tier, because RLS reads it and a null tier refuses
a paying customer at the database level.

Also notes the Supabase leaked-password-protection switch the security
advisors flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmhvochBpXL4rpuccjAQx9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants