Remove the Classroom tier; entitle owner accounts at the database layer - #44
Merged
Merged
Conversation
…e UI Schema v2 moved entitlement enforcement into RLS, but the client has granted Pro + Classroom to ADMIN_EMAILS since long before that. The two layers now disagreed: the owner accounts would see a Classroom UI while Postgres refused their cloud-sync writes and class-code creation. A user being told they have a feature and then silently denied it is worse than either answer alone. Surfaced by the one live subscription row — it belongs to max@mapparatus.org, on the grandfathered $5/mo price, with its period ended 3 months 19 days ago. Entitled in the UI, past grace in the database. app_admins mirrors ADMIN_EMAILS by user id, seeded from auth.users by email so it cannot drift onto the wrong account. RLS is enabled with no policies at all, which denies every client — only the service role and SECURITY DEFINER functions can read it. Verified by impersonating each account's JWT claims: max@mapparatus.org -> classroom (admin; subscription expired 3mo ago) mhowe.gis@gmail.com -> classroom (admin; no subscription row at all) random authenticated -> null anonymous -> null so the allowlist entitles exactly the two intended accounts and nobody else. 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.
|
mapzimus
marked this pull request as ready for review
August 23, 2026 02:22
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.
Two commits. The second is the substantive one.
1. Owner accounts were entitled in the UI but not in the database (
dd0d331)Follow-up to #43, which shipped the schema v2 RLS policies.
ADMIN_EMAILSinindex.htmlgrants Pro client-side only, so once RLS started deciding cloud-sync and gallery writes fromuser_subscriptions.tier, Postgres stopped agreeing with the UI — an owner account would see the paid tools, click one, and get a denial. The one real subscription row ismax@mapparatus.orgon the grandfathered$5/moprice withcurrent_period_endback in May, so both owner accounts were free-tier as far as the database was concerned.supabase/migrations/20260822_app_admins.sqladds anapp_adminstable with RLS enabled and no policies at all — no client, anon or authenticated, can read or write it; onlySECURITY DEFINERfunctions and the service role see it. It is seeded fromauth.usersby email, andactive_subscription_tier()reads it, so every existing policy picks the grant up with no policy edits.This is an owner-access convenience, not a security boundary —
verify-subscriptionremains the authoritative paid check.2. Remove the Classroom tier (
2687acd)Tappymaps is not a school product. The $12/mo Classroom tier — class codes, printable worksheet packs, a
/class/:coderoute — framed it as one, so it comes out in full rather than hiding behind a flag.Checked before removing anything destructive: production held 0 rows in
classroom_codesand 0 subscriptions at tierclassroom. Nobody loses access and no paying customer is affected.App (
index.html, −497 lines)classroom_lookupRPC call,exportWorksheetPack,Modes.ClassJoinand its route, andwireClassroomUI.appState.classroomUnlocked,isClassroom(),startClassroomCheckout(),updateClassroomUI(). Owner accounts auto-grantpro./pricingas Free vs Pro (was Pro vs Classroom-for-teachers) and dropped the "Have a class code?" join box.Billing handlers
create-checkoutno longer resolves aclassroomplan or readsSTRIPE_CLASSROOM_MONTHLY_PRICE_ID.verify-subscriptiondropsisClassroomfrom its payload;tierispro | free.webhook'stierForPricealways resolves'pro'. The indirection stays so a future second tier is a one-line change rather than a migration.Database —
supabase/migrations/20260822_remove_classroom.sql, applied to productionclassroom_lookup(text)andclassroom_codes(taking its two RLS policies with it).active_subscription_tier()so admins get'pro', andhas_subscription()without the classroom-implies-pro special case.Verified by JWT impersonation after applying:
Guardrails.
checkout-plan-smokenow asserts that classroom / teacher / worksheet / class-code wording cannot reappear inindex.htmlor the checkout handler, andCLAUDE.mdrecords the constraint so a later session does not rebuild it.Map subject matter is deliberately untouched: a Census
Bachelor's Degree+dataset, or a GeoDraft backlog category like "high school graduation rate", is ordinary demographic data rather than school framing. None of those backlog categories are wired into the app today.Verification
npm run validate— both blocks pass; Block 1 unchanged at 34,471 charsnode --test test/*.test.mjs— 18/18node scripts/api-subscription-test.mjs— 16/16node scripts/checkout-plan-smoke.mjs— pass, including the new negative guardsnode scripts/extract-data.mjs --check— all modules currentnpm run smoke— all checks pass, zero console errors/pricingand the Create Upgrade panel inspected in a real browserNote for whoever touches billing next
Every write to
user_subscriptionsmust keep settingtier. The database cannot read Stripe price IDs, so a row written without it locks a paying customer out at the RLS layer even though checkout succeeded.