Skip to content

feat(auth): replace invite-code registration with an admin-managed email allowlist (#374) - #375

Merged
parthrohit22 merged 2 commits into
devfrom
feature/374-approved-email-allowlist
Aug 29, 2026
Merged

feat(auth): replace invite-code registration with an admin-managed email allowlist (#374)#375
parthrohit22 merged 2 commits into
devfrom
feature/374-approved-email-allowlist

Conversation

@parthrohit22

Copy link
Copy Markdown
Collaborator

Summary

Replaces the single-use invite-code registration system (#341) with an admin-managed allowlist of approved email addresses. Product decision (owner): access stays invite-only during the beta, but codes are retired in favor of pre-approving specific addresses. Both password registration and OAuth first-time sign-in (#288) now check the exact same gate.

Linked issue

Closes #374

Scope

What changed

Backend

  • New approved_emails table (migration 0016_approved_emails): email (unique), note, added_by, created_at, and — mirroring the invite_tokens audit trail's spirit — used_at/used_by_user_id (purely informational, not a gate: approval isn't consumed by use the way an invite code was).
  • The migration seeds parthrohit60@gmail.com so this change can never lock the product owner out. I could not identify any other existing "owner" account to also seed — the only pre-existing seed user (system@partha.local) is an explicit non-login placeholder (password_hash null, excluded from both password and OAuth login already), not a real account — so I deliberately did not guess a second address. Flagging this rather than fabricating one.
  • AuthService.register() now checks the allowlist instead of an invite code, with a clear rejection message pointing at the waitlist. New AuthService.register_oauth_user() shares the identical approval-gate-and-audit-trail code path for OAuth's brand-new-account case (verified by grep -rn "User(id=" app/ — there is exactly one other call site, both funnel through the same gate).
  • invite_tokens is left in place as a historical audit record — not dropped, matching this codebase's existing survives-deletion audit conventions. scripts/issue_invite.py is removed (nothing can consume its output anymore); scripts/approve_email.py is the new v1 admin mechanism, the same script-only scope the retired one had (no admin UI/API exists for either, by design per the issue).

Frontend

  • RegisterPage: invite code field removed; a plain-language note points an unapproved visitor at the same "Get in touch" link, and the backend's rejection message surfaces as the normal form error.
  • OAuthCompletePage: the unapproved-email error copy is updated, and the (now genuinely reachable) already-registered-by-email case has a message too.

Acceptance criteria completed

  • New approved_emails table with note, added-by, and used-at/by audit fields.
  • POST /auth/register checks the allowlist; a non-allowlisted email is rejected with a clear message pointing at the waitlist.
  • OAuth first-time sign-in may create a new account only if the verified email is on the allowlist (AuthService.register_oauth_user), otherwise refused — same non-bypass guarantee as before, now gated by the allowlist instead of nothing.
  • Invite-code requirement removed from the live registration path; invite_tokens data preserved, issuing script retired.
  • scripts/approve_email.py as the v1 admin mechanism.
  • Product owner's own access pre-approved via the migration.
  • Waitlist form unchanged.
  • Full test coverage, verified on SQLite and real PostgreSQL.

Testing performed

# Backend, SQLite
python3 -m pytest -q
→ full suite green (0 failures)

# Backend, real PostgreSQL
PARTHA_TEST_PG_URL=postgresql+psycopg://...  python3 -m pytest -q
→ full suite green (0 failures)

# Backend migration rehearsal, real disposable PostgreSQL
PARTHA_MIGRATION_REHEARSAL_PG_URL=... PARTHA_MIGRATION_REHEARSAL_CONFIRM=disposable \
  python3 scripts/rehearse_migrations.py --postgres
→ PASS clean upgrade -> clean downgrade -> re-upgrade
→ PASS representative 0004 baseline -> head

ruff check app scripts && ruff format --check app scripts && python3 -m mypy app
→ all clean

npx tsc --noEmit -p . && npx eslint . && npx vitest run && npm run build
→ tsc clean, eslint clean, 436/436 tests, build succeeds

npm --prefix apps/frontend run generate:api-contract -- --check
→ API contract up to date (no drift)

python3 scripts/check-capabilities.py
→ unaffected, still valid

Manual: ran the frontend dev server against no backend and confirmed /register
renders with no invite-code field and the correct waitlist-pointer copy, no
console errors beyond the expected backend-unreachable network failures.

Screenshots

Not applicable (text/copy changes only; see manual verification above).

Security and data considerations

This is an access-control change — it changes who can create a PARTHA account, hence explicit reviewer attention requested per CONTRIBUTING.md §11.9.

  • AuthService is the only place a User row is ever constructed anywhere in the backend (verified by grep, not just inspection) — both the password and OAuth paths now funnel through the identical allowlist check, so neither can be a bypass of the other.
  • An approved email is not a secret (unlike an invite code) — the rejection message is intentionally specific ("this email hasn't been approved yet") rather than an anti-enumeration generic message, since knowing an email isn't approved yet doesn't leak anything sensitive about the system.
  • invite_tokens (historical redemption data) is preserved, not deleted — no data loss.
  • The migration's seed data adds exactly one row (the product owner's own email) with no other side effects.

Dependencies and blocked work

None.

Scope changes or remaining work

None — this fully replaces the invite-code registration path as scoped in #374. Note the one flagged gap above (no second "owner account" email could be identified to seed beyond parthrohit60@gmail.com) — this is a deliberate "don't guess" decision, not an oversight, and doesn't block the PR.

Contributor checklist

  • This PR targets dev
  • I claimed the issue and had it assigned or acknowledged before starting substantial work
  • The branch was created from an up-to-date upstream/dev
  • The branch is rebased on the latest upstream/dev
  • This PR addresses one clearly scoped issue
  • This PR is in scope: it advances a tracked issue (Scope section filled)
  • Every acceptance criterion I claim as complete is actually complete
  • Relevant tests pass
  • Documentation is updated for any user-visible change
  • No secrets, credentials, local env files, or generated artifacts are included
  • No unrelated files were changed
  • Closing syntax (Closes) is used only because the issue is fully resolved
  • Dependencies and follow-up work are linked (none)

…ail allowlist (#374)

Product decision: API access stays invite-only during the beta, but the
single-use invite-code system (#341) is retired in favor of an
admin-managed allowlist of approved email addresses. Both password
registration and OAuth first-time sign-in (#288) now check the exact same
gate; neither is a looser door into the product than the other.

Backend:
- New approved_emails table (migration 0016_approved_emails): email
  (unique), note, added_by, created_at, and (mirroring the invite_tokens
  audit trail's spirit) used_at/used_by_user_id -- purely informational,
  not a gate, since approval is not consumed by use the way an invite code
  was. The migration seeds parthrohit60@gmail.com so this change can never
  lock the product owner out. No other real account email could be
  identified anywhere in the codebase to also seed -- the only pre-existing
  seed user (system@partha.local) is an explicit non-login placeholder, not
  a real account, so it is deliberately not approved.
- AuthService.register() now checks the allowlist instead of an invite
  code, with a clear rejection message pointing at the waitlist. A new
  AuthService.register_oauth_user() shares the identical approval-gate and
  audit-trail code path for OAuth's brand-new-account case, so a verified
  provider email that's already approved may complete first-time sign-in
  via OAuth with no separate code needed -- OAuth still never creates an
  account for an email that isn't on the same allowlist.
- invite_tokens is left in place as a historical audit record, not dropped.
  scripts/issue_invite.py is removed (nothing can consume its output
  anymore); scripts/approve_email.py is the new v1 admin mechanism, the
  same script-only scope the retired one had (no admin UI/API exists for
  either).
- 24 new/updated backend tests (allowlist enforcement on both the password
  and OAuth paths -- including the one case OAuth account creation is now
  actually permitted, the admin script's idempotence and defaults, the
  migration's shape/seed/round-trip), full suite green on both SQLite and
  real PostgreSQL, ruff + mypy clean, migration rehearsal passes on both
  dialects.

Frontend:
- RegisterPage: invite code field removed; a plain-language note points an
  unapproved visitor at the same "Get in touch" link the field used to
  carry, and the backend's rejection message surfaces as the normal form
  error.
- OAuthCompletePage: the error-reason copy for an unapproved OAuth email
  matches the new registration message; the (now genuinely reachable)
  already-registered-by-email case has a message too.
- 4 new/updated frontend tests; tsc, eslint, and the full existing suite
  (436 tests) stay green; production build succeeds.

Security and data considerations: this changes who can create a PARTHA
account. AuthService is the single place a User row is ever constructed
(verified by grep, not just inspection), and every path into it -- password
register, OAuth first-time sign-in -- now shares one allowlist check with
no bypass. Verified manually in-browser (no console errors, correct copy)
in addition to the automated suite.
actually uses, not just a shape check -- the seeded owner email can
register through the real endpoint on a database built by nothing but
Alembic (no AUTO_CREATE_TABLES)."""
database_url, engine = approved_emails_migration_db
actually uses, not just a shape check -- the seeded owner email can
register through the real endpoint on a database built by nothing but
Alembic (no AUTO_CREATE_TABLES)."""
database_url, engine = approved_emails_migration_db
CI's Prototype Browser Acceptance job failed on this PR: the e2e fixture
seeder and surfaces.spec.ts's second-owner test both shelled out to
scripts/issue_invite.py, which this PR removes. Missed this dependency
when retiring the script.

Both now call scripts/approve_email.py instead (same CLI-invocation
pattern, just approving an email instead of minting a code) before
registering. Verified locally end to end with the exact command CI runs
(npm ci --prefix apps/frontend to match CI's install layout, then
node scripts/run-e2e-acceptance.mjs): all 22 tests pass, including the
second-owner registration case this fix specifically touches.
@parthrohit22
parthrohit22 merged commit 37dd2b5 into dev Aug 29, 2026
11 checks passed
@parthrohit22
parthrohit22 deleted the feature/374-approved-email-allowlist branch August 29, 2026 12:37
parthrohit22 added a commit that referenced this pull request Sep 1, 2026
…he email allowlist (#384, #388)

* fix(auth): dev-only bypass for the email allowlist so local development stays frictionless (#384)

The admin-managed allowlist (#374/#375) blocked normal local development --
a fresh checkout could no longer register/sign in without first running
scripts/approve_email.py by hand. Restores the pre-#374 frictionless
npm run dev experience in development specifically; production enforcement
is completely unchanged.

- AuthService._require_approval() now auto-creates a real, persisted
  ApprovedEmail row (added_by="dev-bypass") when app_env == "development"
  and no admin approval exists yet, instead of raising. Everything
  downstream (the audit trail, the uniqueness check, the OAuth path via
  register_oauth_user) behaves identically to a real approval -- this only
  changes what happens when nobody has approved the address yet, and only
  in development.

- Deliberately development-only, not the broader dev/test leniency pairing
  used elsewhere in Settings (AUTH_SECRET_KEY/AI_ENCRYPTION_KEY): the
  backend test suite currently runs under the same default app_env a real
  local dev server does (nothing had ever overridden APP_ENV for pytest),
  so reusing that exact pairing would have silently defeated every one of
  the allowlist-rejection tests. tests/conftest.py's client fixture (and
  test_approved_emails_migration.py's own fixture) now explicitly set
  APP_ENV=test, a real and meaningful distinction from "development" going
  forward, not just this fix's workaround. Fixed the four test_system.py
  assertions that had hardcoded the old default environment string.

- Two new tests (test_auth.py): the bypass actually auto-approves and opens
  a real session in development, and does NOT apply under test/staging/
  production -- both constructed directly against AuthService with an
  app_env-copied Settings, the same pattern
  test_register_commit_time_collision_is_reported_as_conflict already uses.

Verified for real, not just via the test suite: moved the local .env
aside, started the actual backend (npm run dev:backend, zero config) and
frontend (npm run dev:frontend) dev servers, and completed a real signup
in-browser for an email that was never pre-approved by anything --
POST /auth/register returned 201, landed on a real authenticated
Dashboard. Full backend suite green on both SQLite and real PostgreSQL;
ruff/mypy clean.

* feat(auth): first user on a fresh instance becomes its owner (#388)

The #384 dev-only bypass only helps when APP_ENV=development. A genuine
self-hosted deployment running in production mode still hit the allowlist
wall, since nobody is pre-approved except the hardcoded product-owner row
seeded by the #374 migration -- which is this project's own owner, not a
self-hoster's.

AuthService._require_approval now falls back to a new
_first_user_bootstrap check, in every environment the dev-only bypass
doesn't already cover unconditionally: if the users table has no real
account yet (excluding the permanent SEED_USER_ID placeholder every
database gets from migration 0002, which must never itself count as
"already claimed"), the registering email is auto-approved and becomes
the instance's owner -- the standard bootstrap pattern most self-hosted
software uses (first person to reach the setup wizard becomes the admin).
Every registration after that first real one goes through the normal
allowlist exactly as before. Applies identically to register() and
register_oauth_user(), since both share this same funnel.

The dev-only bypass is checked first, unchanged in behavior: in
development every registration is already frictionless regardless of
ordinal position, so that's the more specific and accurate reason to
attribute there. Bootstrap only ever fires as the fallback for
non-development environments.

Flagged, not silently resolved: the first-user check and the eventual
User insert are not atomic with each other, so two concurrent first-ever
registrations on the same fresh database could theoretically both observe
zero real users and both become "the owner". This window exists only for
the single moment between a fresh instance's first boot and its first
successful registration, closes permanently the instant one registration
commits, and never reopens or weakens the allowlist afterward. Closing it
completely would need a dedicated atomically-claimed mutex (a new table);
deliberately not added here given the narrow, single-operator nature of
the scenario -- see _first_user_bootstrap's docstring and the PR
description for the full tradeoff.

Updated tests whose fresh, otherwise-empty test databases were themselves
implicitly "the first user" and would now succeed via bootstrap instead
of exercising the rejection they were actually testing
(test_register_rejects_an_email_that_was_never_approved,
test_development_bypass_does_not_apply_outside_development,
test_brand_new_unapproved_identity_is_rejected_never_bypassing_the_allowlist,
test_brand_new_unapproved_identity_redirects_to_signup_required) to
register a baseline user first, closing the bootstrap window before
asserting the rejection. Added direct coverage for the new behavior at
the AuthService, OAuthService, and seed-placeholder-interaction levels.

Documented the behavior in README.md's security guidance, next to the
existing outside-dev/test requirements list, since that's where a
self-hoster preparing a real deployment would look.

Verified: ruff format/check and mypy clean; full backend suite green
(1076 passed, 13 skipped) on SQLite, plus the Postgres-gated migration and
concurrency suite green against a real local Postgres database. Real
manual verification against a genuine self-host boot: ran the actual
Alembic migration chain against a fresh SQLite file (not the test
fixture's create_all shortcut), started the real app with APP_ENV=production
and production-grade AUTH_SECRET_KEY/AI_ENCRYPTION_KEY, and confirmed over
real HTTP that the first registration succeeds (201, added_by =
"first-user-bootstrap" in the database) while a second, different
unapproved email is correctly rejected (422, "hasn't been approved")
immediately afterward.
parthrohit22 added a commit that referenced this pull request Sep 1, 2026
PARTHA stays self-hosted only for the foreseeable future, with no hosted
service planned -- there's no reason for a fresh guest on a self-hosted
instance to be funneled through a waitlist in the UI before they can even
try registering.

The landing page's unauthenticated "Analyze a Repository" hotspots
(nav, hero, and footer positions -- all three share the same analysisCta
handler) now link directly to /register, the same way the "Log In" nav
hotspot already links directly to /login. Removes the WaitlistModal import
and waitlistOpen state from LandingPage.tsx entirely.

Registration itself is unchanged: it still enforces the admin-managed
email allowlist (#374/#375) or the local development bypass (#384) exactly
as before. This only changes what the landing page's CTA points at, not
what completing registration requires.

Deliberately not touched, per the issue's own scope: the backend /waitlist
route, WaitlistEntry model, and the frontend's features/waitlist module
(WaitlistModal, useWaitlistForm) -- now unreferenced by the landing page
but still present and still covered by their own tests. Whether that
infrastructure should be removed entirely is a separate, larger,
cross-cutting question (flagged in PR #383, not decided yet).

Updated router.test.tsx's assertion to match the new link-based CTA
instead of the old waitlist-button one.

Verified: tsc/eslint clean, full vitest suite green (439/439, including
LandingPage.test.tsx, router.test.tsx, RegisterPage.test.tsx, and
WaitlistModal.test.tsx, which still passes since the component itself is
untouched), production build clean. Real in-browser click-through against
actual dev servers (backend + frontend) in both light and dark mode: "Log
In" opens the sign-in page, "Analyze a Repository" opens account creation,
no waitlist prompt anywhere in either path, and no "waitlist" text
anywhere on the rendered landing page.
parthrohit22 added a commit that referenced this pull request Sep 1, 2026
…page (#382)

* feat(marketing): free static marketing site with a scripted product simulation (#382)

Pauses the real Render/Neon backend deployment (no funding right now,
revisit later -- see #375/#377, left exactly as merged/paused, untouched
here) in favor of a free, static marketing/awareness push: a scripted
product simulation, a "run it yourself" CTA, and a working waitlist form,
none of it needing a live backend.

New, independent site (apps/marketing/), not a deploy of the existing
apps/frontend SPA: that app's other routes (login, register, dashboard)
all need a live backend this pivot explicitly doesn't have right now, and
shipping dead/broken auth flows to the public would undercut the point of
a clean marketing push. apps/frontend is completely untouched. Not part of
the root npm workspace either -- its own package.json/lockfile, so Vercel
can build it in total isolation with Root Directory set to apps/marketing.

- Hero, a scripted "Run the simulation" walkthrough, a "run it yourself"
  section, and a waitlist modal -- all hand-built React/Tailwind (not a
  deploy of the image-based main LandingPage), sharing the main product's
  color tokens/logo for brand consistency without any cross-package
  dependency.
- The simulation's canned data (src/data/sampleAnalysis.ts) uses the real
  product's actual Engineering Review category ids and labels (cross-
  checked against apps/backend/app/review/review_service.py's
  _CATEGORY_LABELS) and severity levels, and the real Insights response
  shape (cross-checked against the generated OpenAPI types) -- the
  repository and findings themselves are fictional, but the shape and
  vocabulary are not invented. Labeled persistently in the UI, in the hero,
  and in the waitlist copy as a scripted demo against a sample repository,
  never implied to be a live analysis.
- "Run it yourself": the exact commands from the main README's own "Run
  PARTHA locally" section (checked against it directly, and that section
  was independently verified accurate this session -- dev:backend/
  dev:frontend scripts confirmed to exist and match).
- Waitlist: api/waitlist.ts, a Vercel serverless function, appends
  submissions to a private GitHub Gist via a fine-grained PAT -- chosen
  over a new third-party form service since it needs no new account (only
  GitHub and Vercel, both already in use) and has no realistic volume
  limit. Validated email, a honeypot field, and a clear 503 (not a crash or
  silent drop) when the required env vars aren't set yet. 6 unit tests
  (api/waitlist.test.ts, node's test runner, fetch mocked -- no real
  network/GitHub call) cover method/validation/honeypot/dedup-append/
  upstream-failure paths.
- Typed against a minimal local request/response interface instead of
  importing @vercel/node: that package's own dependency chain
  (ajv/path-to-regexp/undici via @vercel/static-config) currently carries
  real advisories, for type declarations this file didn't need more than a
  few lines of. Zero npm audit findings as a result.

Verified, not just written: npm run typecheck/lint/test/build all pass;
production build inspected (four small output files, no errors); the
actual page loaded in-browser, the simulation run end to end (all 5 steps,
both the Review and Insights tabs, all 4 sample findings with correct
severity/category rendering), and the waitlist modal opened and submitted,
confirmed via zero console errors and full-page screenshots plus
get_page_text extraction -- not assumed from the code alone.

Deploy instructions (Vercel project creation, waitlist Gist/PAT setup) are
in apps/marketing/README.md -- I cannot create the Vercel account or
project myself.

* refactor(marketing): rebuild on the real landing page component (#382)

Replace the marketing site's custom-built Hero/SimulationDemo/RunItYourself
composition with the actual apps/frontend landing page as its visual basis:
the same 1728px authored artwork, light/dark/system theme system, brand
tokens, FAQ, and footer -- ported in directly rather than re-invented.

Two behavioral differences from the real app, since this standalone site
has no backend or accounts at all:

- The "Log In" nav hotspot has nothing to log in to, so it opens a scripted
  product simulation instead (DemoModal), using the same canned sample data
  as before.
- Every "Analyze a Repository" hotspot has no live backend to analyze
  against, so it opens fork/clone setup instructions instead
  (RunItYourselfModal), with a note encouraging visitors to star the repo
  and a path back to the waitlist for anyone who'd rather wait for a
  hosted beta.

Ported useLandingTheme/ThemeSwitcher/cn verbatim from apps/frontend, copied
the two landing SVG assets directly (no cross-package import, keeping this
project genuinely standalone), and rewrote globals.css/tailwind.config.ts
to match the real frontend's full token set including .landing-dark.
WaitlistModal keeps its ported visual design (literal light-mode hex
colors, tokenized dark mode) and now lives inside RunItYourselfModal as a
secondary path.

Removed the five now-superseded custom components (Hero, Footer,
SimulationDemo, RunItYourself, WaitlistForm).

Verified: typecheck/lint/test/build all clean; real in-browser
click-through in both light and dark mode covering every remapped hotspot
(Log In -> demo run-through on both result tabs, Analyze a Repository ->
run-it-yourself -> nested waitlist, FAQ, theme switcher, footer links and
notices).

* refactor(marketing): remove waitlist, demo the simulation from "See how it works" (#382)

Product-direction clarification: PARTHA stays self-hostable only for the
foreseeable future, with no hosted service planned. There's nothing to put
anyone on a waitlist for.

- Remove WaitlistModal and its useWaitlistForm hook entirely, and every
  reference/CTA to it in RunItYourselfModal and DemoModal's own comments.
- Remove the now-dead api/waitlist.ts serverless function and its test,
  along with the WAITLIST_GITHUB_TOKEN/WAITLIST_GIST_ID setup instructions
  in the README (the whole "Waitlist form setup" section).
- Drop the now-unused tsx devDependency and the test script that ran only
  against api/waitlist.test.ts.
- RunItYourselfModal no longer needs a dark prop now that it has nothing
  left to thread it to.

Also: "See how it works" now opens the scripted demo simulation directly
(DemoModal), same as "Log in" already does, instead of scrolling to an
anchor section -- there's nothing live to log into or a walkthrough to
scroll to, so both hotspots lead to the same demo. Everything else about
the page is untouched.

Verified: typecheck/lint/build clean; real in-browser click-through in
both light and dark mode confirming "See how it works" opens the demo and
"Analyze a Repository" no longer offers a waitlist path.

* refactor(marketing): slide-in side panel instead of centered popups (#382)

Replace the centered overlay-popup pattern used by DemoModal,
RunItYourselfModal, and the FAQ dialog with a shared slide-in drawer
(SlidePanel) that animates in from the right edge over part of the
screen, instead of appearing as a popup overlapping the page.

Purely a presentation/interaction change -- every dialog's actual content
and behavior is unchanged, only the container. Along the way, added two
behaviors the old centered popups didn't have: closing on Escape and on a
backdrop click (both standard for a drawer, neither carried over from the
previous pattern since it never had them).

Each dialog keeps its own width (DemoModal wider at max-w-3xl for its
two-tab results view, RunItYourselfModal and the FAQ panel narrower) and
its existing internal scroll/sticky-header behavior, now scoped to the
panel's own scroll container instead of the old centered box's.

Verified: typecheck/lint/build clean; real in-browser click-through in
both light and dark mode confirming all three panels slide in from the
right, the sticky header inside DemoModal stays pinned while its content
scrolls, the Close button works, and a backdrop click dismisses the
panel.
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.

feat(auth): replace invite-code registration with an admin-managed email allowlist

1 participant