Schema v2: the four missing tables, with the RLS holes closed - #43
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
mapzimus
marked this pull request as ready for review
August 22, 2026 18:34
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
classroom_codeshad a broad SELECT so/class/<CODE>could resolve a code — which also let anyone enumerate every active code, teacher UUID and mapclassroom_lookup()returns only the map + title for one active code, neverteacher_iduser_mapswrites open to any authenticated user, so "cloud sync is Pro" was true only in the UIgallery_publish_countswas a client-written value, so the rate limit reset itselfgallery_claim_publish_slot()checks and increments in one statement; the tier sets the allowancemap_reportsclient-readableAlso locks
is_featuredbehind a trigger, and codifies the RLS already live onuser_subscriptions/export_counts— which had no migration and so couldn't be reviewed.Entitlements now hold in the database
Via
user_subscriptions.tier, mirroringverify-subscription's grace logic (past_duestill entitles; a row stops entitling 3 days after its period ends). The webhook and the reconcile path both writetier— without it RLS would refuse a paying customer — and existing rows are backfilled topro.Verified adversarially against production, with the real public anon key
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 existsthroughout, DDL transactional). The file insupabase/migrations/20260822_schema_v2.sqlis 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