feat(auth): create accounts through the admin port; signUp: 'closed' - #299
wmadden-electric wants to merge 6 commits into
Conversation
Operator provisioning has no path today: the admin port cannot create a user, and the browser sign-up surface refuses Node's fetch by design (Better Auth origin-checks any request carrying a Sec-Fetch-* header, and Node's fetch always sends one). prisma/asks hit exactly this: its account script POSTed to /api/auth/sign-up/email, got 403 MISSING_OR_NULL_ORIGIN, and printed success anyway. The admin port gains createUser and setEmailVerified, implemented like every other handler: DB-direct over AuthStore, authorized by wiring, never through auth.api.*. createUser writes exactly what Better Auth's own sign-up writes in one transaction - the user row (email lowercased) and, with a password, a credential account hashed by Better Auth's own hashPassword from better-auth/crypto, ids from its default generator. A duplicate email (case-insensitive) throws, so a typed rpc client cannot mistake a refusal for success. No mail is sent. The local-server suite proves the rows and the hash are what Better Auth expects: a created account signs in through the real /sign-in/email. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
An invite-only application has had to stay invite-only by filtering
/api/auth/sign-up/* in its own proxy. auth({ signUp: 'closed' }) closes it
inside Better Auth instead: emailAndPassword.disableSignUp and the
magic-link plugin's disableSignUp both follow the setting, which rides the
service input as a literal (email's deliveryUrl pattern). Default 'open',
so existing applications behave as today. Nothing about origin, CSRF, or
trustedOrigins changes.
startLocalAuthServer takes the same option; a new integration suite shows
Better Auth refusing /sign-up/email, a magic link for an unknown email
completing to new_user_signup_disabled with no user written, and an
admin-created account still signing in by password and by magic link.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…n check The README and the Composer skill now say where operator-created accounts come from (admin.createUser from a service wired to the admin port), why a Node script calling /api/auth/* gets 403 MISSING_OR_NULL_ORIGIN (Node's fetch sends Sec-Fetch-Mode, so it must send an Origin in trustedOrigins), that a deployed stack's rpc ports are reachable only from inside its graph, and how signUp: 'closed' behaves. The auth-module spec records the same amendments against the admin port, the module factory, and the pinned Better Auth options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
|
✅ Gizmo reviewed 49d88f3 — posted 0 inline comment(s) this pass. Open findings: none Change walkthroughThis PR adds an operator provisioning path to the auth module — Contract and handlers. contract.ts:235 adds the two rpc methods in the same Store and options plumbing. Tests. Coverage follows the validation move: the handler-level bounds test was deleted from Docs. The spec amendment, the README's new "Provisioning accounts" section (README.md:108), and the SKILL.md gotcha all document the provisioning path, the Node-fetch origin refusal, and the |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
commit: |
…t-provisioning-4b6334
Better Auth's sign-up rejects a malformed address; the admin port took any string, so a typo'd email with emailVerified: true would have looked fully provisioned and never been able to sign in or receive mail. The contract input is now string.email, which serve() enforces at the rpc boundary as a 400 (no retries, nothing written), and the spec and README name it as the second sign-up check createUser shares. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
The email shape was a 400 at the rpc boundary while the password bounds lived in the handler, so a 7-character password was a 500 the typed client retried with backoff before throwing. Both are now contract input constraints (string.email, 8 <= string <= 128): the same caller mistake gets the same answer, nothing is written, and nothing is retried. The handler loses its length checks; the spec and README say where the two sign-up checks live. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
Summary
An account an operator creates goes through the auth module's admin port, server to server, never through the browser sign-up surface.
admin.createUser({ email, name, password?, emailVerified? })— DB-direct overAuthStorelike every other handler. Writes exactly what Better Auth's sign-up writes, in one transaction: theuserrow (email lowercased) and, with a password, acredentialaccount hashed by Better Auth's ownhashPasswordfrombetter-auth/crypto; ids from Better Auth's default generator. Refuses a duplicate email (case-insensitive) by throwing. Sends no mail.admin.setEmailVerified({ userId, emailVerified })—user: nullfor an unknown id.auth({ signUp: 'open' | 'closed' }), default'open'.'closed'setsemailAndPassword.disableSignUpand the magic-link plugin'sdisableSignUp, so an invite-only app needs no proxy filtering.startLocalAuthServertakes the same option./api/auth/*gets403 MISSING_OR_NULL_ORIGIN(Node'sfetchsendsSec-Fetch-Mode; anOriginintrustedOriginsis required), and that a deployed stack's rpc ports are reachable only from inside its graph.Nothing in the module's origin, CSRF, or
trustedOriginshandling changed.authProxyis untouched.Why
prisma/askscreated its first operator account by POSTing to/api/auth/sign-up/emailfrom Node through itsauthProxy. Better Auth answered403 MISSING_OR_NULL_ORIGIN(its form-CSRF middleware origin-checks any request carrying aSec-Fetch-*header, and Node'sfetchsends one on every request), and the script printed success anyway. The admin port had no way to create a user, a created user still needed its verification mail read from the outbox, and sign-up could not be closed without app-side filtering.Test plan
bun test srcin the auth package: 125 pass (unit + Postgres integration).createUserand a password signs in through Better Auth's real/sign-in/email(local-server.integration.test.ts); duplicate email throws over real rpc;setEmailVerifiedflips and returnsnullfor an unknown id.closed-sign-up.integration.test.ts:/sign-up/email→400 EMAIL_PASSWORD_SIGN_UP_DISABLED; a magic link for an unknown email completes toerror=new_user_signup_disabledwith no user written; a created account still signs in by password and by magic link.contract.test.ts/contract.test-d.tscover the two new methods and thesignUpliteral.@internal/authand@prisma/composer-prisma-cloud; biome, depcruise, contract-snapshot, vocabulary, and skill-packaging checks pass.Follow-up in
prisma/asksAfter release: replace
scripts/create-operator-account.mjsand the outbox verification route with an allowlisted operator route that callsadmin.createUserwithemailVerified: true; setsignUp: 'closed'; drop the sign-up filtering fromapps/web/server.ts.🤖 Generated with Claude Code