Skip to content

Tiered access: guests browse, accounts upload (1/day), verified researchers get 10/day - #212

Merged
andyy-yang merged 6 commits into
BodyMaps:mainfrom
andyy-yang:andy/guest-hardening
Sep 1, 2026
Merged

Tiered access: guests browse, accounts upload (1/day), verified researchers get 10/day#212
andyy-yang merged 6 commits into
BodyMaps:mainfrom
andyy-yang:andy/guest-hardening

Conversation

@andyy-yang

@andyy-yang andyy-yang commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

TLDR

The tiered-access system, built and verified end to end locally before pushing: guests browse (uploads and the assistant now require an account — enforced server-side, where previously the entire data plane was open to anonymous callers), signed-up accounts get 1 scan/day, and verified researchers — verified email + completed profile — are automatically promoted to Pro at 10 scans/day. Six commits, reviewable one by one.

Major decisions

Decision Rationale
Server-side gates, not UI gates The four upload endpoints took anonymous 2 GB POSTs and the assistant's plan check was dead code; buttons are not a boundary
Free = 1/day, Pro = 10/day Simple signup is a taste; the verified tier is the working allowance. Pro's invented 50/day and prices are gone
Promotion is computed, never written limits_for_user resolves free → pro when email_verified_at + all three profile fields are present. No trigger to miss, no backfill, self-reversing if the profile is cleared; the stored plan column stays free and /me/plan still refuses self-service changes
Email verification clones the password-reset pattern Hashed single-use tokens, newest link supersedes, 24 h window (a stale link can only finish its own job); OAuth accounts arrive verified from the provider
Mail degrades gracefully SMTP unset → links print to the server log, password signups stay at 1/day, nothing breaks. Production uses a send-only SES credential that is handed to the deployer privately and never enters this repository

What changed (per commit)

Commit Content
Guests browse; accounts upload and chat @require_auth on upload-inference-chunk/finalize-upload/upload-dicom-slice/finalize-dicom; _ai_gate delegates to plan_store.check_assistant (401 signed out / 402 spent allowance, same body shape as /run-inference); upload fetches now send the session cookie; explicit auth check in the pre-upload effect; new test_guest_gating.py run by CI (backend job gains scikit-image/pandas/trimesh so the blueprint imports)
Quotas free 3→1/day, pro/team 50→10/day; singular grammar ("your scan", "one scan a day"); card copy follows
Profile fields organization/occupation/role_description on user_account (additive migration 7d4f8c2a9b1e); PATCH /auth/me accepts them; Settings → Profile gains a "Verified researcher profile" group with commit-on-blur fields
Email verification email_verification_token table (migration 9e6a1b5d4c2f); POST /auth/send-verification (signed-in, rate-limited, reports honestly whether mail left) and POST /auth/verify-email (public — the link may open anywhere); registration sends the first link best-effort; /verify-email page redeems on load, built on the reset card's structure; Settings shows Verified/Resend
Auto-promotion the limits_for_user predicate; /me/usage and 402 bodies report the effective plan; Pro card reads "For verified researchers / Free / verify email + complete profile", button "Verify to unlock"; plan page names the effective plan
README deploy walkthrough updated: this release's mandatory migrations named, optional SMTP step (credential handled privately), tier smoke checks with expected outputs, and the old "signed-out visitor can start a run" check replaced — that behaviour changed on purpose

Testing

  • Real-life email verification: a genuine SES email was sent to and received in a real inbox (jy168@duke.edu); the emailed link verified the account through the deployed-style flow. No simulated results.
  • Local end-to-end against the running app (alembic-migrated SQLite — both new migrations applied in the real chain — live backend + curl): guest 401s → register free/1-day → complete profile alone stays free → emailed link verifies → usage flips to pro/10-day → stored column stays free, /me/plan 403 → signed-in chunk upload 200 → resend reports already-verified.
  • Gates on the final tree: backend — CI's full list + new files, 212+3 passed, compileall clean; frontend — typecheck 0 errors, 47 files / 314 tests, build OK. Merge-tree clean against current main.
  • Edge cases pinned by tests: every missing promotion ingredient individually; expired/garbage/superseded/reused tokens; wrong-type profile fields; the export payload shape; guest 401 on all six gated endpoints; the 11th free assistant message → 402; singular limit grammar.

Verified correct (no change needed): /run-inference's existing auth+plan gate; dataset browse/view endpoints stay public; admin unlimited resolution; OAuth linking; Live Rooms.

Deferred (deliberate): ownership checks on session-artifact reads (needs a user id on job records — follow-up); per-IP rate limits on uploads; account-type label cleanup.

Deployer notes

alembic upgrade head is mandatory (two revisions). The SES SMTP block is optional and arrives privately — see the README's step 4b. Without it the site runs and verification links go to the gunicorn log.

Reviewer checklist

  • Signed out: upload prompts sign-in; curl -X POST /api/upload-inference-chunk → 401; assistant asks to sign in
  • Fresh signup: verification email arrives (or link in log); usage shows Free, 1 scan/day
  • Fill Organization/Occupation/About + verified email → Plan page shows Pro, 10 scans/day; clearing a field drops it back
  • /me/plan still 403 for non-admins; admin flows unchanged
  • Signed-in upload + run works end to end

…ta plane

The four upload endpoints (upload-inference-chunk, finalize-upload,
upload-dicom-slice, finalize-dicom) accepted anonymous POSTs with no rate
limit - a guest with curl could write 2 GB files into the sessions tree.
The AI assistant's gate returned None unconditionally, so signing out was
an unlimited tier and plan_store.check_assistant was dead code. The only
real gate on the whole data plane was /run-inference.

- @require_auth on the four upload endpoints.
- _ai_gate delegates to plan_store.check_assistant: signed out gets a 401,
  a spent allowance a 402 with the same body shape /run-inference uses.
  The sidebar already handles both statuses; no frontend change needed.
- The upload fetches never sent the session cookie; add
  credentials: "include" to all six call sites so signed-in uploads keep
  working once the server refuses anonymous ones.
- The pre-upload effect gets an explicit isAuthenticated check instead of
  relying on every setSelectedItems writer sitting behind ensureAccount.

New tests/functional/test_guest_gating.py pins the boundary (401s for
guests, chunk upload still works signed in, 402 at the assistant
allowance); ci.yml runs it and gains scikit-image/pandas/trimesh so
api_blueprint imports in the backend job.
Free drops from 3 daily scans to 1 - the simple-signup tier is a taste,
not a workload - and Pro drops from the invented 50 to the 10/day the
verified-researcher tier grants (Team follows Pro per member). Other
limits are untouched. The daily-limit refusal and the upgrade dialog
learn singular grammar so a 1-scan plan never renders '1 scans a day'.
Card copy follows: Free says '1 scan a day', Pro says '10 scans a day'.

Backend and frontend limit mirrors, their tests, and the plan-gating
fixtures all move to the new numbers.
Three nullable free-text columns on user_account - organization,
occupation, role_description - with an additive migration (7d4f8c2a9b1e).
PATCH /auth/me accepts any subset (empty string clears back to NULL,
wrong type is a 400), the public dict and the account export carry them,
and Settings -> Profile gains a 'Verified researcher profile' group with
three commit-on-blur fields and the copy explaining what a complete
profile unlocks. Nothing reads the fields yet; the tier check that will
arrives with the promotion commit.

Functional tests cover trim/clear/type-refusal and the widened export
shape; frontend tests cover the PATCH field names and the unlock copy.
Password signups were permanently unverified - email_verified_at existed
but nothing ever set it outside OAuth. Add the flow, cloned from the
password-reset pattern: an email_verification_token table (hashed token,
single-use, superseded by a newer request, 24 h window - a stale link can
only finish its own job, unlike a reset), POST /auth/send-verification
(signed-in, rate-limited, reports honestly whether mail left) and POST
/auth/verify-email (public - the link may open in a browser that has
never seen the site). Registration sends the first link best-effort; an
unconfigured mailer prints it to the server log, which is the dev flow.

Frontend: the account carries emailVerified; Settings -> Profile shows
the state with a Resend button; /verify-email redeems the link on load
and explains an expired one. Boot purges spent tokens alongside reset
tokens; the new unit file runs in CI's auth step.

Unit tests cover issue/redeem/single-use/expiry/supersede; functional
tests the endpoint gates; frontend tests the resend row and the landing
page's three states.
limits_for_user computes the tier instead of anything writing the plan
column: a free account with email_verified_at set and all three profile
fields filled in resolves to pro (10 scans/day), reported as its plan by
/me/usage and every 402 body, so the usage page and the upgrade dialog
name it consistently. No trigger to miss, no backfill, and clearing the
profile drops the account back on its own. The stored column stays
'free' and self-service plan writes stay closed.

The Pro card now tells that story - 'For verified researchers', price
'Free', 'verify email + complete profile' - and its button reads 'Verify
to unlock' for non-admins instead of 'Coming soon'. The settings hero
names the effective plan from /me/usage. gatingPlan mirrors the
promotion for the upload page's advisory checks.

Unit tests pin promotion, each missing ingredient, and the untouched
column; a functional test walks the whole journey (register -> profile
-> verify -> usage says pro, /me/plan still 403).
The deploy manual is what the person running bdmap1 actually follows, so
the mail section now covers verification email (which gates the 10/day
tier), points at the SES values used in production, and states the two
properties a deployer needs to know: the credential is handed over
privately and never lives in this repository, and leaving SMTP unset
degrades gracefully - links go to the server log, password signups stay
on 1/day, OAuth accounts are unaffected.
@andyy-yang andyy-yang changed the title Close the unauthenticated data plane: uploads and assistant require an account Tiered access: guests browse, accounts upload (1/day), verified researchers get 10/day Sep 1, 2026
@andyy-yang
andyy-yang merged commit d6b3768 into BodyMaps:main Sep 1, 2026
8 checks passed
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.

1 participant