Skip to content

feat(auth): create accounts through the admin port; signUp: 'closed' - #299

Open
wmadden-electric wants to merge 6 commits into
mainfrom
claude/auth-admin-port-provisioning-4b6334
Open

wmadden-electric wants to merge 6 commits into
mainfrom
claude/auth-admin-port-provisioning-4b6334

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

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 over AuthStore like every other handler. Writes exactly what Better Auth's 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 Better Auth's default generator. Refuses a duplicate email (case-insensitive) by throwing. Sends no mail.
  • admin.setEmailVerified({ userId, emailVerified })user: null for an unknown id.
  • auth({ signUp: 'open' | 'closed' }), default 'open'. 'closed' sets emailAndPassword.disableSignUp and the magic-link plugin's disableSignUp, so an invite-only app needs no proxy filtering. startLocalAuthServer takes the same option.
  • Docs: README, the Composer skill, and the auth-module spec explain the provisioning path, why a Node script calling /api/auth/* gets 403 MISSING_OR_NULL_ORIGIN (Node's fetch sends Sec-Fetch-Mode; an Origin in trustedOrigins is required), and that a deployed stack's rpc ports are reachable only from inside its graph.

Nothing in the module's origin, CSRF, or trustedOrigins handling changed. authProxy is untouched.

Why

prisma/asks created its first operator account by POSTing to /api/auth/sign-up/email from Node through its authProxy. Better Auth answered 403 MISSING_OR_NULL_ORIGIN (its form-CSRF middleware origin-checks any request carrying a Sec-Fetch-* header, and Node's fetch sends 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 src in the auth package: 125 pass (unit + Postgres integration).
  • A user made with createUser and a password signs in through Better Auth's real /sign-in/email (local-server.integration.test.ts); duplicate email throws over real rpc; setEmailVerified flips and returns null for an unknown id.
  • New closed-sign-up.integration.test.ts: /sign-up/email400 EMAIL_PASSWORD_SIGN_UP_DISABLED; a magic link for an unknown email completes to error=new_user_signup_disabled with no user written; a created account still signs in by password and by magic link.
  • contract.test.ts / contract.test-d.ts cover the two new methods and the signUp literal.
  • Typecheck and build of @internal/auth and @prisma/composer-prisma-cloud; biome, depcruise, contract-snapshot, vocabulary, and skill-packaging checks pass.

Follow-up in prisma/asks

After release: replace scripts/create-operator-account.mjs and the outbox verification route with an allowlisted operator route that calls admin.createUser with emailVerified: true; set signUp: 'closed'; drop the sign-up filtering from apps/web/server.ts.

🤖 Generated with Claude Code

wmadden-electric and others added 3 commits September 17, 2026 10:33
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>
@prisma-gizmo

prisma-gizmo Bot commented Sep 17, 2026

Copy link
Copy Markdown

✅ Gizmo reviewed 49d88f3 — posted 0 inline comment(s) this pass.

Open findings: none

Change walkthrough

This PR adds an operator provisioning path to the auth module — admin.createUser and admin.setEmailVerified over the rpc admin port — plus a signUp: 'open' | 'closed' factory option that closes Better Auth's browser sign-up surface itself, so invite-only apps no longer filter sign-up in their proxy and server-to-server scripts stop fighting the 403 MISSING_OR_NULL_ORIGIN form-CSRF check.

Contract and handlers. contract.ts:235 adds the two rpc methods in the same contract() style as the existing admin methods: createUser validates the email shape and (in the incremental delta) the password bounds 8 <= string <= 128 in the contract input, so caller mistakes are 400s at the rpc boundary rather than retried 500s; the case-insensitive duplicate-email refusal stays a handler-thrown error. handlers.ts:137 implements both DB-direct over AuthStore like every other handler: it lowercases the email, mints 32-char ids via Better Auth's own generateRandomString, and hashes with hashPassword from better-auth/crypto — so the written rows are exactly what Better Auth's sign-up produces, minus any mail. The delta removed the earlier handler-level MIN/MAX_PASSWORD_LENGTH checks and their thrown messages, leaving boundary validation solely to the contract; every dispatch path (serve, the testing server, the deploy entrypoint) goes through contract validation, so nothing bypasses it.

Store and options plumbing. pg-auth-store.ts gains createUser (user + credential account rows in one transaction, null on duplicate) and setEmailVerified (null for an unknown id), with the file's "writes are confined to…" header updated to match. signUp rides the service input as a literal (the deliveryUrl pattern) from auth-module.ts through auth-service.ts into auth-options.ts, where it sets both emailAndPassword.disableSignUp and the magic-link plugin's disableSignUp; origin/CSRF/trustedOrigins handling is untouched.

Tests. Coverage follows the validation move: the handler-level bounds test was deleted from handlers.test.ts (its fake store gained createUser/setEmailVerified records, lowercasing, hash round-trip via verifyPassword, and the pinned duplicate message); contract.test.ts:153 pins the bounds at the schema; local-server.integration.test.ts:303 proves a short password is a 400 that writes nothing, alongside new integration tests for sign-in with a provisioned account, duplicate refusal over real rpc, and a new closed-sign-up.integration.test.ts covering both disabled surfaces.

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 signUp switch, and match the shipped behavior.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c42b96bf-9f17-449c-adf3-b01b68bf404b

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@299
npm i https://pkg.pr.new/@prisma/composer-cli@299
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@299

commit: 49d88f3

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New findings: 🟡 1 minor · trace

Comment thread packages/1-prisma-cloud/2-shared-modules/auth/src/handlers.ts
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>

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New findings: 🟡 1 minor · trace

Comment thread packages/1-prisma-cloud/2-shared-modules/auth/src/contract.ts Outdated
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>

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New findings: none · trace

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All Gizmo review threads are resolved and the head commit has been reviewed. Approving.

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