feat(auth): replace invite-code registration with an admin-managed email allowlist (#374) - #375
Merged
Merged
Conversation
…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.
This was referenced Sep 1, 2026
Closed
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.
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.
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
signup_requires_inviteoutcome (renamedemail_not_approved, and now genuinely reachable as a success path for an approved email).What changed
Backend
approved_emailstable (migration0016_approved_emails):email(unique),note,added_by,created_at, and — mirroring theinvite_tokensaudit 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).parthrohit60@gmail.comso 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_hashnull, 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. NewAuthService.register_oauth_user()shares the identical approval-gate-and-audit-trail code path for OAuth's brand-new-account case (verified bygrep -rn "User(id=" app/— there is exactly one other call site, both funnel through the same gate).invite_tokensis left in place as a historical audit record — not dropped, matching this codebase's existing survives-deletion audit conventions.scripts/issue_invite.pyis removed (nothing can consume its output anymore);scripts/approve_email.pyis 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
approved_emailstable with note, added-by, and used-at/by audit fields.POST /auth/registerchecks the allowlist; a non-allowlisted email is rejected with a clear message pointing at the waitlist.AuthService.register_oauth_user), otherwise refused — same non-bypass guarantee as before, now gated by the allowlist instead of nothing.invite_tokensdata preserved, issuing script retired.scripts/approve_email.pyas the v1 admin mechanism.Testing performed
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.
AuthServiceis the only place aUserrow is ever constructed anywhere in the backend (verified bygrep, 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.invite_tokens(historical redemption data) is preserved, not deleted — no data loss.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
devupstream/devupstream/devCloses) is used only because the issue is fully resolved