Skip to content

feat(backend)!: close public sign-up and require a one-time setup token - #149

Merged
patrickbrandao merged 5 commits into
mainfrom
feat/close-public-signup
Oct 14, 2026
Merged

patrickbrandao merged 5 commits into
mainfrom
feat/close-public-signup

Conversation

@patrickbrandao

@patrickbrandao patrickbrandao commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

This is the backend half of #29, following the maintainer's 2026-10-10 decision on the issue: minimal lock in v0.1, a one-time setup token printed in the logs, and the provisional THINKER_ALLOW_SIGNUP with default false.

  • Public sign-up closed (auth/signup-policy.ts, auth/auth.instance.ts)
    • databaseHooks.user.create.before refuses every user creation (email/password, social, plugins) with 403 { code: "SIGNUP_DISABLED" }.
    • A hooks.before on /sign-up/email refuses earlier. It is needed because, with requireEmailVerification on, Better Auth 1.7 turns a 403 thrown during user creation into a fake success (anti-enumeration).
    • Social providers get disableSignUp, so social login still signs in and links existing accounts but never creates one.
  • First user only through the wizard. createFirstAdmin runs auth.api.signUpEmail inside runAsServerUserCreation, an AsyncLocalStorage mark that only in-process code can enter. No header or body can set it. A direct POST /api/auth/sign-up/email on an empty database is refused. The dev-only seeder (SEED_DATA) uses the same mark.
  • One-time setup token (setup/setup-token.service.ts)
    • With no user at boot, the backend generates 24 random bytes (base64url), keeps only the SHA-256 in memory and logs: No admin account exists yet. Open /setup and enter this one-time setup token: <token>.
    • POST /api/v1/setup/admin takes setupToken. A missing or wrong token gets 403, compared in constant time. If no token is pending (for example, the database was wiped after boot), a new one is printed.
    • The token is consumed when the admin is created. Under the existing advisory lock 9001, two concurrent setups still yield exactly one admin (one 201, one 409).
    • Limitation: each process has its own token, so run the first setup against a single replica. This is documented.
  • THINKER_ALLOW_SIGNUP=true (provisional, removed in v0.3) reopens email and social sign-up, but never for the first account.
  • GET /api/v1/setup/status now includes signupEnabled. It is true only when the valve is open and setup is done.
  • Docs: docs/dev/manifest-guide.md (Better Auth Setup and env vars) and packages/backend/.env.example.

Frontend change needed (not in this PR; the frontend agent is on #25 and owns these files)

Until this lands the dashboard wizard cannot complete, because it does not send the token. Merge this together with, or right after, the frontend PR.

  1. pages/Setup.tsx: add a required "Setup token" field with this hint: "Printed in the server log: docker compose logs thinker". Send it as setupToken in POST /api/v1/setup/admin. On 403, show the server message.
  2. services/setup-status.ts: add signupEnabled: boolean to the status type.
  3. pages/Register.tsx: redirect to /login when signupEnabled is false.
  4. pages/Login.tsx and components/SocialButtons.tsx: hide the "Create account" links when signupEnabled is false.
  5. Social callback errors: Better Auth redirects with error=signup_disabled (provider sign-up disabled) or unable_to_create_user (hook refusal). The login page should show a friendly "Sign-up is closed on this instance" message.

Also out of scope here: docker/install.sh and the PT-BR install docs should tell operators where to find the token. Those files are owned by devops and docs-ptbr, and release notes are owed in docs/releases/0.1.0.md.

Tests

  • Unit:
    • signup-policy.spec.ts: valve parsing, default refusal without a DB query, first user refused even with the valve, the trusted mark scoped across awaits.
    • setup-token.service.spec.ts: boot issue/skip, verify, consume, re-issue.
    • setup.service.spec.ts: user created inside the trusted context, token consumed, 403 for a missing or wrong token with a re-print, token kept on failure, isSignupEnabled.
    • setup.controller.spec.ts: signupEnabled.
    • auth.instance.spec.ts: hooks refuse by default, /sign-up/email middleware, trusted context allowed, valve semantics, social disableSignUp.
  • E2e test/signup-lock.e2e-spec.ts (new) boots the real src/main.ts twice, default and THINKER_ALLOW_SIGNUP=true, on one disposable DB:
    • empty DB: direct sign-up gets 403 with no user row, also with the valve open;
    • wizard without a token, with a wrong token, or with another process's token gets 403;
    • two concurrent setups with the right token give [201, 409] and one verified admin;
    • a consumed token is refused;
    • the admin can sign in;
    • after setup: sign-up gets 403 by default, the wizard gets 409, and with the valve sign-up gets 200;
    • signupEnabled is reported correctly throughout.
  • test/setup.e2e-spec.ts: status keys include signupEnabled; 403 without a token or with a wrong one; 400 for a non-string token.

Local runs:

  • npm run lint (0 errors), npm run typecheck, npm run build: pass
  • npx jest (backend): 477 suites, 9111 tests passed
  • TZ=UTC npm run test:e2e:docker --workspace=packages/backend: 72 suites, 612 tests passed

qa-docker: required (runtime and auth behavior changed). The acceptance criteria in #29 are:

  • on a new container, POST /api/auth/sign-up/email before setup is refused;
  • setup works with the token from the logs;
  • after setup, API sign-up is refused;
  • with a social provider configured, a new email gets no account.

Until the frontend change above lands, run the setup step through the API.

Closes #29

Dashboard (included in this PR)

  • /setup: new Setup token field, with help text pointing at the server log line "one-time setup token" (e.g. docker compose logs thinker). Sent as setupToken to POST /api/v1/setup/admin; a 403 shows a friendly "token invalid or missing" message.
  • services/setup-status.ts: signupEnabled (false unless the backend reports true), checkSignupEnabled(), and SetupError carrying the HTTP status.
  • /register redirects to /login (query kept) when sign-up is disabled.
  • /login hides the "Sign up" link when sign-up is disabled and shows a friendly message for the social callback errors signup_disabled and unable_to_create_user. (SocialButtons has no sign-up link, so it needed no change.)
  • Frontend checks: lint (0 errors), typecheck, build, design-token check and the full unit suite (201 files, 4186 tests) pass.

🤖 Generated with Claude Code

Public sign-up is closed. Better Auth's databaseHooks.user.create.before
refuses every user creation (email/password, social, plugins) with
403 SIGNUP_DISABLED, and a hooks.before on /sign-up/email refuses it
before the endpoint runs. Social providers get disableSignUp, so social
login only signs in or links existing accounts.

The first admin comes only from the setup wizard: createFirstAdmin runs
signUpEmail inside runAsServerUserCreation, an AsyncLocalStorage mark
that no HTTP request can set. With no user at boot, the backend prints a
one-time setup token to the log and keeps only its SHA-256 in memory.
POST /api/v1/setup/admin requires it (setupToken) and the token is
consumed when the admin is created.

THINKER_ALLOW_SIGNUP=true (provisional, removed in v0.3) reopens sign-up
once the first admin exists. GET /api/v1/setup/status exposes
signupEnabled.

Closes #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Setup: one-time setup token field (help points at the "one-time setup
  token" server log line), sent as setupToken; friendly message on 403.
- setup-status: signupEnabled (false unless the backend says true),
  SetupError carries the HTTP status.
- Register redirects to /login when sign-up is disabled; Login hides the
  sign-up link and explains signup_disabled / unable_to_create_user
  social callback errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Review of PR #149: close public sign-up and require a one-time setup token

Reviewed git diff origin/main...206e46658d9dbd873270a6748bc8b8626dd23ca5.

Blockers

  1. CI red: Compose smoke test. .github/workflows/docker-smoke.yml:237 still calls POST /api/v1/setup/admin without setupToken. The endpoint now returns 403 (curl: (22) ... 403). That 403 is this PR working as intended, but the smoke test has to be updated in the PR. Suggested fix: read the token from docker compose logs (grep one-time setup token:) and send it in the body. Note: that edit changes .github/workflows/** outside the CI mínima provisória #38 Escopo, so once it lands the PR becomes risco alto on that criterion too.
  2. CI red: Unit (frontend). Login.tsx:8,49 now imports checkSignupEnabled. tests/pages/Login-validation.test.tsx (and any other test that mocks setup-status.js and renders Login) mocks the module without that export. Vitest reports 15 unhandled errors and the job fails. Fix: add checkSignupEnabled: vi.fn().mockResolvedValue(false) to those vi.mock factories.

Security analysis (no bugs found)

  • Bypass paths.
    • databaseHooks.user.create.before (auth.instance.ts) covers every path that goes through Better Auth's internal adapter: email, social and plugins.
    • The hooks.before on /sign-up/email blocks the fake success that requireEmailVerification returns.
    • disableSignUp on the social providers is redundant defense, evaluated at boot.
    • The AsyncLocalStorage mark can't be set from HTTP.
    • With the valve open and no users, decideUserCreation still refuses, so the first account always comes from the wizard. Good.
  • Token.
    • 192 bits (randomBytes(24)), base64url.
    • Only the SHA-256 is kept in memory.
    • timingSafeEqual compares fixed-length digests.
    • It is invalidated by consume() and, above all, by count > 0 under the advisory lock. The race is covered by e2e.
    • The plain token appears only in the WARN log, which is the intended design.
    • ensureIssued does not regenerate a pending token, so failed attempts don't rotate it.
  • Multi-replica. Each process has its own token, and a request that lands on another replica gets 403. This is documented in the service. After setup, count > 0 neutralizes the leftover tokens. Acceptable for v0.1. Should-fix (doc): mention it in the user docs (docs/) / release notes, not only in the code comment.

Should-fix

  • setup.service.ts recovery branch (count === 1 with an unverified user): it marks emailVerified=true without a token, before the token check. Today the only way to get a single unverified user is a setup that crashed (and that one already presented the token), so the impact is low. Still, moving the verify() before the recovery, or requiring it there too, closes the path for good. Add a unit test.
  • Multi-replica limitation in the PT-BR docs (see above).

Nit

  • assertUserCreationAllowed runs twice on /sign-up/email (hook plus DB hook). This is harmless, and with the valve open it costs one extra query.

Tests

The e2e tests in signup-lock.e2e-spec.ts cover direct sign-up, the valve, a missing/wrong token, the race, reuse, and the post-setup state. There are unit tests for the policy and the token. qa-docker: no run is reported in the PR, and it is required (runtime change).

CI on the SHA

Lint, Typecheck, Build, Unit backend/shared/cli, both Backend e2e jobs, Docker build amd64/arm64, install.sh, CodeQL and Changeset all pass. Failures: Unit (frontend) and Compose smoke test (read-only).

High risk

Even after the blockers are fixed, the PR changes auth (closes sign-up, adds the setup token): risco alto per ADR 0002. Fixing the smoke test also touches .github/workflows/**. Under aprovado the should-fix items would have to be resolved or ticketed, but here the maintainer decides.

Head revisado: 206e466
Veredito: bloqueado

patrickbrandao and others added 3 commits October 14, 2026 10:25
- docker-smoke: read the one-time setup token from the container logs and
  send it to the setup wizard endpoint.
- install.sh: tell the operator where the setup token is printed.
- Frontend tests that mock setup-status now provide checkSignupEnabled.
- createFirstAdmin requires the setup token before completing a
  half-created (unverified) admin too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Re-review of PR #149 (closed sign-up + setup token)

What I reviewed:

  • The delta since the previous verdict: git diff 206e466 3a2e495.
  • The merge commit 00ed9e3, which brings main into the branch.
  • The full git diff origin/main...00ed9e31f8e0ed5c19ef9fa0886d754f11502ed0.

Previous blockers: resolved

  • Compose smoke test. docker-smoke.yml now reads the token from docker compose logs, checks it is non-empty, masks it with ::add-mask::, and sends it in setupToken. The job is green.
  • Unit (frontend). vi.mock now has checkSignupEnabled in the six affected test files. The job is green.

Previous should-fix items

Merge with main

New findings

None. The security analysis from the previous review still holds: hooks on every creation path, a 192-bit token kept as a hash, constant-time comparison, single use guaranteed by count > 0 under the advisory lock.

CI on 00ed9e3

All jobs passed, including Compose smoke, Unit frontend/backend, both e2e jobs, Docker amd64/arm64, install.sh, CodeQL and Lint/Typecheck/Build.

qa-docker

The PR body says qa-docker is required, but no run is reported yet. It is a merge gate.

High risk

These criteria apply:

  • Auth change: sign-up is closed and the setup token is new.
  • .github/workflows/docker-smoke.yml change outside the CI mínima provisória #38 Escopo.

The maintainer decides the merge (ADR 0002). Any authorization from the maintainer has to come from the maintainer directly, not through an agent.

Head revisado: 00ed9e3
Veredito: risco alto (aguardar mantenedor)

@patrickbrandao

Copy link
Copy Markdown
Owner Author

The multi-replica setup-token limitation (docs should-fix) is tracked in #152.

@patrickbrandao

Copy link
Copy Markdown
Owner Author

QA Docker (qa-docker): PR #149

Image thinker:qa-3270522045 (arm64, 427 MB, build 5m37s), Postgres postgres:16-alpine@sha256:7218...80ea, run in an isolated Compose project with volumes renamed by override (removed with labeled down -v, 0 left over). Mock provider only, THINKER_CATALOG_SYNC=false.

Check Result
1. Fresh container: sign-up 403, 0 user rows PASS
2. "one-time setup token" in log; /setup/admin 403 without/wrong token, 201 with right, second call 409 PASS
3. Sign-up after setup: 403 SIGNUP_DISABLED PASS
4. THINKER_ALLOW_SIGNUP=true then restart: sign-up works PASS only with an extra QA override; FAIL via docker/docker-compose.yml (see below)
5. Social login SKIPPED (no real providers)
6. SPA: Setup chunk has the token field and the invalid-token message; Register chunk redirects to /login when signupEnabled is false PASS (static JS inspection)
7. Smoke: thk_ key, /v1/models, /v1/chat/completions, /v1/messages, /v1/responses via mock PASS
8. Contracts (9/9), backend e2e (74 suites, 624 tests, 3m25s) PASS

Finding (should-fix)

docker/docker-compose.yml does not pass THINKER_ALLOW_SIGNUP to the app container (the environment: list has no such entry; grep ALLOW_SIGNUP docker/ finds nothing). Setting it in .env and restarting left sign-up at 403, and env in the container showed no such var. Adding THINKER_ALLOW_SIGNUP=true through a QA-only override made sign-up return 200 and signupEnabled:true. Compose users therefore cannot use the valve. Add - THINKER_ALLOW_SIGNUP=${THINKER_ALLOW_SIGNUP:-} (and document it in docker/.env.example).

Minor

The setup page hint says docker compose logs thinker, but the Compose service is still named manifest.

Tested SHA: 00ed9e3

@patrickbrandao
patrickbrandao marked this pull request as ready for review October 14, 2026 14:21
@patrickbrandao
patrickbrandao merged commit e25af7e into main Oct 14, 2026
20 checks passed
@patrickbrandao
patrickbrandao deleted the feat/close-public-signup branch October 14, 2026 14:22
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.

Fechar o cadastro público depois do primeiro admin

1 participant