Skip to content

[00] Critical Foundations — server-side entitlement, quota enforcement, QBank session #1

Description

@OsamaShihadaMedDev

PRD — 00 Critical Foundations

Status: Approved for implementation · Owner: Osama · Supersedes ambiguities in: specs/00-critical-foundations.md
Companion reference: CONTEXT.md (vocabulary, table/column map, RLS posture). Read it first.

Every decision below was resolved in a design session on 2026-07-08 and is normative — an implementing engineer should not re-litigate them, only flag genuine blockers.


1. Problem

The monetization boundary lives in the browser:

  1. supabase/functions/medical-notes/index.ts trusts client-sent isPro/isAnonymous/userId/preferredModel. Replaying with "isPro": true routes to Claude Haiku free, forever.
  2. Daily quota (usage_records) is incremented by the client, and RLS policy "Users can update own usage" lets any user reset their own count to 0. MAX_DAILY_SHEETS = 5 is decorative.
  3. QBank ships the answer key: fetchQuestions() selects correct_option, explanation, teaching_point for all matching rows before the user answers anything. Attempts data is cheat-poisoned; all rows ship to the client (breaks at scale).

Features 02 (Persona Tiers) and 03 (On-Demand QBank) multiply the cost of each flaw. This workstream must land first.

2. Goals

  • All entitlement decisions (tier, model routing, quota) computed server-side from a verified JWT.
  • Quota is exact under concurrency, burns only on successful generation, resets at UTC midnight.
  • No QBank network response contains answer fields before the corresponding question is answered by that user in that session.
  • One QBank engine, one sheet-prompt schema, one typed edge-call helper.

3. Non-goals (explicitly out of scope — do not build)

Gap Rationale Disposition
Citation quota enforcement (citation_usage client writes, unauthenticated get-citations) Burns NCBI rate limits, not OpenRouter spend Follow-up ticket; document, don't fix
Server-side QBank session resume after localStorage loss Same UX as today; server rows make it possible later Later
Rewriting historical user_attempts rows Cheat-poisoned data stays; flag in analytics if needed Never
Server-verified time_taken_ms Analytics, not money Later, if ever
User-local quota reset timezones Client-asserted tz = new abuse vector Rejected
Stripe, new model providers, surfacing pro codes, strict-mode TS Hard constraints (CLAUDE.md) Never

4. Requirements

Fix 1 — Server-side entitlement (PR1, CRITICAL)

Client — new helper src/lib/edge.tscallMedicalNotes(params):

  • Calls supabase.auth.getSession() per request (supabase-js auto-refreshes stale tokens), sends session.access_token as the Authorization: Bearer header. The publishable-key Bearer is removed.
  • All four call sites route through it: SheetGenerator.tsx (:380), FlashcardsGenerator.tsx (:124), OutputSection.tsx (:636), StudyMode.tsx (:376).
  • Request body = content params only: notes, difficulty, focus, length, examMode, cardsOnly, cardCount, focusCard, explainMode, enhanceMode, itemText, sectionKey, sectionItems, enhanceTopic. The fields isPro, isAnonymous, userId, preferredModel are deleted from the contract.
  • Streaming read stays in the helper or call sites as-is; x-model-used / x-is-premium header handling unchanged.

Server — medical-notes/index.ts:

  • Verify: adminClient.auth.getUser(jwt) from the Bearer. Invalid/missing → 401 {"error":"invalid_token"} before any streaming. No retry logic server-side.
  • Anon vs signed-in: user.is_anonymous claim from the verified user object (anon users hold real JWTs — a missing token is a broken client, never a legitimate anon).
  • Entitlement: one service-role SELECT per request — is_pro, pro_expires_at, premium_used, preferred_model FROM profiles WHERE id = user.id. No caching. This replaces the two existing profiles round-trips (index.ts:437-461).
  • Tier: pro if is_pro && (pro_expires_at IS NULL || pro_expires_at > now()); else hook if premium_used < (1 anon / 3 free); else free. Model routing logic otherwise unchanged (pro honors profiles.preferred_model; hook → Claude Haiku + increment premium_used; free → GPT-OSS).
  • Client UX on 401: toast "Session expired — refresh the page."

Deploy: edge fn + Vercel together. Accepted: stale open tabs 401 (with toast) for the minutes until users refresh.

Fix 2 — Server-side quota (PR2, CRITICAL — depends on PR1)

New migration (additive: new function + policy swap, no column/table drops):

  • consume_usage(p_kind text, p_cap int) → jsonb — SECURITY DEFINER, EXECUTE revoked from anon/authenticated (service-role/edge-fn only). Atomically: upsert row for (user, p_kind, current_date) with count = count + 1 only where count < p_cap; return {allowed: bool, count: int}. Single statement / row-lock semantics — two parallel requests at count=4 must resolve to exactly one allowed.
  • refund_usage(p_kind text) (or a decrement arm of the same function) for the failure path.
  • Drop policies "Users can insert own usage" and "Users can update own usage" on usage_records. Keep "Users can view own usage" (UI display).

Server — medical-notes/index.ts, order of operations:

  1. Verify JWT (Fix 1) → 401.
  2. If tier ≠ pro: consume_usage(kind, 5) where kind = 'sheet' | 'cards' (from cardsOnly). Denied → 429 {"error":"quota_exceeded"} pre-stream, friendly client copy. (explainMode/enhanceMode calls: not quota'd — unchanged from today.)
  3. Call OpenRouter. Non-2xx or network failure → refund the unit, return 502. Failures never burn quota.
  4. Stream response.
  • Caps are server constants; reset boundary is UTC midnight (current_date, matching all existing rows — zero data migration).

Client:

  • Delete incrementSheet() / incrementCards() calls from SheetGenerator.tsx (:377), FlashcardsGenerator.tsx, StudyMode.tsx.
  • use-usage-limit.ts becomes read-only (keep MAX_DAILY_* for display; remove increment). Add "resets in Nh" copy computed from UTC midnight.
  • Handle 429 with a distinct message (not the generic error path).

Deploy order: Vercel first (stop client increments) → edge fn (server consume) → RLS migration last. Accepted: cached old clients throw on incrementSheet() after the migration until refreshed.

Fix 3 — QBank answer-key removal (PR3, HIGH — independent of PR1/PR2)

New migration — additive columns + four SECURITY DEFINER RPCs (pattern precedent: redeem_pro_code) + column REVOKE:

ALTER TABLE qbank_sessions
  ADD COLUMN status text NOT NULL DEFAULT 'completed',  -- old rows stay valid
  ADD COLUMN question_ids uuid[];

REVOKE SELECT (correct_option, explanation, teaching_point)
  ON questions FROM anon, authenticated;

Note: questions/qbank_sessions/user_attempts were dashboard-created (no migrations in repo — see CONTEXT.md "Known messes"). Write these changes as a fresh migration; verify live constraint state before applying.

  • start_qbank_session(p_domains text[], p_limit int, p_system text) → json
    Samples server-side: ORDER BY random() LIMIT least(p_limit, 40), is_active = true, filtered by domains/system. Inserts qbank_sessions row up front with status='active', question_ids in shuffled order, and placeholders for the live NOT NULL columns (ended_at = started_at, score = 0, total = 0, total_time_ms = 0). Returns session_id + sanitized questions: stems, options a–e, subject/domain/topic/difficulty/competency, media (via question_media/media join) — no answer fields.
  • submit_answer(p_session uuid, p_question uuid, p_selected text, p_time_ms int) → json
    Guards (all required — this is the anti-oracle boundary): session owned by auth.uid()status = 'active'p_question = ANY(question_ids) ∧ no existing attempt for (session, question). Grades server-side, inserts the user_attempts row (per-answer, replacing today's batch-at-end), returns {is_correct, correct_option, explanation, teaching_point}.
  • end_qbank_session(p_session uuid) → json — owner guard; sets status='completed', real score/total/ended_at/total_time_ms (computed from attempts server-side).
  • get_session_review(p_session uuid) → json — owner guard; returns attempts + full question data (including answer fields) for that session. This is the sanctioned path for the ?session=<id> deep-link.

Client:

  • QBankContext.tsx: fetchQuestions() deleted → startSession calls the RPC; submitAnswer becomes async (awaits RPC, caches returned grading in state + localStorage); endSession calls the RPC (navigation-after-DB-write behavior preserved); localStorage payload sb_qbank_session becomes a cache of server state — stores session_id, sanitized questions, and graded results as they arrive. 24h TTL + resume banner behavior unchanged; cache loss = session lost (same as today).
  • QBankSession.tsx: answer click awaits submit_answer (add a pending state on the option buttons); type imports unchanged.
  • QBankSummary.tsx: replace the embedded user_attempts → questions(...) join (:73-99) with get_session_review.
  • qbank-count query: select("*", {count:"exact", head:true})select("id", …) — a * select 403s under column-level grants. Landing meta queries (subject, domain, id) are unaffected.
  • React Query invalidation keys unchanged (qbank-count, qbank-domain-meta, qbank-systems, qbank-meta, qbank-sessions).

Engine consolidation (same PR): src/hooks/use-qbank.ts stripped to shared types only (Question, QuestionMedia, OptionKey, SessionAnswer, SessionState) — all three consumers already import types from that path, so zero import churn. DoD: the file exports no functions.

Back-compat: existing user_attempts rows keep client-computed is_correct (no rewrite). time_taken_ms remains client-reported. flagged_questions flow unchanged. Abandoned 'active' session rows are harmless; a new start creates a new session.

Deploy order: Vercel first, REVOKE migration last (old clients' fetchQuestions breaks the moment the REVOKE lands).

PR4 — Prompt dedup (chore, in scope for 00; sequenced last)

  • gptOss* and haiku* sheet prompts in medical-notes/index.ts are ~95% identical. Extract one shared task-spec/JSON-schema block + thin per-model-family adapters. Pure refactor, no behavior change (same prompts byte-for-byte where they already agree; diffs limited to the genuine model-family deltas).
  • This is step 1 of feature 02 (Persona Tiers). DoD: the sheet output schema exists exactly once in the function.
  • Sequenced after PR1/PR2 only to avoid merge conflicts in the same file.

5. Acceptance criteria

  1. curl medical-notes with missing/garbage Bearer → 401 {"error":"invalid_token"}. Valid anon token → 200.
  2. {"isPro": true} in the body as a free user → x-model-used shows gpt-oss (field has no effect; also true for userId/preferredModel injection).
  3. Browser console: supabase.from("usage_records").update({count: 0}) → RLS error.
  4. Free user's 6th sheet of the (UTC) day → 429 with friendly message; UI shows 5/5 and reset countdown.
  5. Kill the OpenRouter path mid-request on a fresh slot → count unchanged (refund). Scripted parallel pair at count=4 → exactly one 429.
  6. New QBank session: no correct_option/explanation/teaching_point in any network response pre-answer. Console supabase.from("questions").select("correct_option") → permission denied.
  7. Answering returns grading + explanation via RPC; summary renders; ?session=<id> in a fresh tab renders via get_session_review; landing count/domain chips still render.
  8. submit_answer with a question not in the session, an already-answered question, or another user's session → error (oracle guard).
  9. use-qbank.ts exports types only. Sheet prompt schema appears once.
  10. npm run test / lint / build green; manual pass of sheet, flashcards, explain, and enhance flows — all through callMedicalNotes.

6. Rollout summary

PR Contents Depends on Deploy order within PR
1 JWT verify + server entitlement + callMedicalNotes edge fn + Vercel together
2 consume_usage RPC + policy swap + client increment removal PR1 Vercel → edge fn → migration
3 QBank RPCs + additive cols + column REVOKE + engine consolidation Vercel → migration (REVOKE last)
4 Prompt dedup merge-order only edge fn only

7. Hard constraints (inherited, non-negotiable)

Schema changes additive only · no Stripe · OpenRouter only (no direct Anthropic/OpenAI/Gemini clients) · pro codes stay hidden · permissive TS config stays.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions