Skip to content

feat: let the instance owner require an invite to sign up - #403

Draft
tgolsen wants to merge 2 commits into
gethopp:mainfrom
tgolsen:feat/signup-requires-invite
Draft

tgolsen wants to merge 2 commits into
gethopp:mainfrom
tgolsen:feat/signup-requires-invite

Conversation

@tgolsen

@tgolsen tgolsen commented Oct 23, 2026 •

Copy link
Copy Markdown

Problem

Anyone who can reach a Hopp server can create an account, and a sign-up without a team invite creates a new team with that user as its admin. That is the right behaviour for Hopp Cloud, but a self-hosted server on the public internet has no way to say "only people we invite".

Change

An instance setting, off by default, so existing installs behave exactly as today. When on, an account can only be created with a valid team invite; otherwise sign-up returns 403 with a readable message (the web sign-up form already displays the server message).

  • Covers email/password sign-up and the new-user branch of social login. A refused social sign-up redirects to /login?error=invite_required and the login page shows a toast, rather than rendering raw JSON from the OAuth callback.
  • An unknown or stale invite UUID is refused rather than silently falling through to creating a new team.
  • While the instance has no accounts at all (a fresh database, or after a reset) the first account is exempt, otherwise nobody could ever create one. This check is not atomic with the insert, so simultaneous sign-ups on an empty instance can all pass; that state is only reachable before the first account exists.
  • Stored in a singleton instance_settings table, seeded by the migration (AutoMigrate adds it). No env var, per the request to manage it from the web interface.
  • GET / PUT /api/auth/instance-settings (OpenAPI + regenerated types for web-app and tauri). PUT rejects a missing or non-boolean field with 400 rather than defaulting to false.
  • Settings → Instance shows a toggle, only to the owner.
  • Docs: new "Restricting sign-up" section in the self-hosting guide, including how to find the owner and how to switch the setting off from the database.

Who can change it

is_admin is scoped to a user's own team, and every non-invited sign-up becomes an admin of its own team. If any admin could flip this, a stranger could reopen sign-up on a locked server. So only the instance owner, defined as the earliest account that still exists, can change it; everyone else gets 403. Hopp has no instance-wide role today, so ownership is positional rather than adding a column. It cannot be transferred and passes to the next-earliest account if the owner is deleted. Happy to move to an explicit is_instance_admin flag if you would rather have one.

Things worth a maintainer's attention

  • Hopp Cloud. This code also runs on the hosted service, where the earliest account would see a toggle that closes sign-up for everyone. I left that ungated: the backend has no explicit "self-hosted" setting today (it infers it from keys like STRIPE_SECRET_KEY being unset, which I did not want to build on), and I do not know how the hosted deployment differs. If you want the toggle limited to self-hosted installs, I would add an explicit env var (for example SELF_HOSTED=true, set in selfhost/compose.yml) rather than detect it from other features. Say the word and I will add it.
  • Invite links are the weak point, and that is pre-existing. They never expire, cannot be rotated or revoked, are visible to every member of a team (not just admins), and a link from any team lets someone sign up. So "invite-only" is only as strong as those links, and a server that was open for a while should review its existing accounts first. This is spelled out in the docs. Expiry/rotation would be a sensible follow-up.
  • On a fresh public server, whoever signs up first becomes the owner, so operators should create their own account before sharing the URL. Also in the docs.

Testing

  • Integration tests (backend/test/integration/signup_invite_test.go), including the social-login path via the existing MockSocialAuthProvider: first-account bootstrap; refused without an invite, with an unknown invite, and on the social path (no user or team left behind); accepted with a valid invite and joins the right team; open by default; existing accounts can still sign in; owner can toggle and it takes effect immediately; a non-owner team admin cannot; unauthenticated, missing-field and wrong-type requests are rejected.
  • Mutation checks in a throwaway copy: removing the manual gate, the social gate, the 403 unwrap, the redirect, the owner check, the bootstrap exemption, or the missing-field check each makes a test fail.
  • go test -tags integration -shuffle=on ./test/integration/ ./internal/..., gofmt, go vet pass. tsc --noEmit and eslint on the changed pages pass; changed files pass Prettier (Login.tsx already had unrelated Prettier differences at main, which I left alone).
  • Also exercised the settings read/upsert against PostgreSQL 16 (concurrent first reads, restart) during review.

Not covered

  • I did not click through the new Settings toggle in a browser.
  • golangci-lint was not available locally.

🤖 Generated with Claude Code

Anyone who can reach a Hopp server can create an account, and an account
without a team invite gets a team of its own. That is right for Hopp Cloud
but not for a self-hosted server on the public internet.

Add an instance setting, off by default so existing installs behave as
before, that rejects sign-ups without a valid team invite (403). It covers
email/password sign-up and the new-user branch of social login, and an
unknown invite UUID is refused rather than falling through to a new team.
The first account on an empty instance is exempt so a fresh install can be
bootstrapped.

is_admin is scoped to a user's own team, so every stranger who signs up is
an admin and could otherwise reopen sign-up. The setting can therefore only
be changed by the instance owner, defined as the earliest account that still
exists. GET/PUT /api/auth/instance-settings expose it, and Settings shows a
toggle to the owner only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tgolsen
tgolsen requested a review from konsalex as a code owner October 23, 2026 17:00
@netlify

netlify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for hoppdocs ready!

Name Link
🔨 Latest commit f62c4b8
🔍 Latest deploy log https://app.netlify.com/projects/hoppdocs/deploys/6adb96a47ff4427bc59efffb
😎 Deploy Preview https://deploy-preview-403--hoppdocs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 07af3d39-3dd0-4ca2-9135-2d308c78a91e
📥 Commits

Reviewing files that changed from the base of the PR and between 2390461 and c8abc9e.

📒 Files selected for processing: 10
  • backend/api-files/openapi.yaml
  • backend/internal/handlers/handlers.go
  • backend/internal/handlers/instanceSettings.go
  • backend/internal/models/instance_settings.go
  • backend/internal/server/server.go
  • backend/test/integration/signup_invite_test.go
  • docs/src/content/docs/open-source/self-hosting.md
  • tauri/src/openapi.d.ts
  • web-app/src/openapi.d.ts
  • web-app/src/pages/Settings.tsx

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

📝 Walkthrough

The change adds an instance-level setting that can require valid team invites for new sign-ups. It adds authenticated owner controls, enforces the setting for manual and social sign-ups, and documents the behavior.

Changes: Invite-Required Sign-Up

Layer / File(s) Summary
Instance settings storage and API
backend/internal/models/instance_settings.go, backend/internal/handlers/instanceSettings.go, backend/internal/server/server.go, backend/api-files/openapi.yaml, tauri/src/openapi.d.ts, web-app/src/openapi.d.ts, backend/test/integration/signup_invite_test.go
Adds the settings model and owner lookup, authenticated settings endpoints, migration and route wiring, API declarations, and integration coverage for settings access and owner updates.
Invite checks during sign-up
backend/internal/handlers/handlers.go, backend/internal/handlers/instanceSettings.go, backend/api-files/openapi.yaml, tauri/src/openapi.d.ts, web-app/src/openapi.d.ts, backend/test/integration/signup_invite_test.go, docs/src/content/docs/open-source/self-hosting.md
Manual and social sign-up check whether an invite is required before creating a new team. Tests and documentation cover first-account creation, valid invites, default behavior, and rejected sign-ups.
Owner controls in web settings
web-app/src/pages/Settings.tsx
Fetches instance settings and displays an owner-only switch to update the invite requirement.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  actor Owner
  participant SettingsPage
  participant AuthHandler
  participant InstanceSettings
  Owner->>SettingsPage: Opens instance settings
  SettingsPage->>AuthHandler: GET /api/auth/instance-settings
  AuthHandler->>InstanceSettings: Read setting and owner ID
  InstanceSettings-->>AuthHandler: Return setting and owner ID
  AuthHandler-->>SettingsPage: Return instance settings
  Owner->>SettingsPage: Changes signup_requires_invite
  SettingsPage->>AuthHandler: PUT /api/auth/instance-settings
  AuthHandler->>InstanceSettings: Persist setting
  InstanceSettings-->>AuthHandler: Return updated setting
  AuthHandler-->>SettingsPage: Return updated instance settings
Loading
Merge Risk: ⚪ Minimal · up to c8abc

No actionable merge-blocking risk remains after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 8 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: allowing the instance owner to require an invite for sign-up.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 31.58% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 8 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@tgolsen
tgolsen marked this pull request as draft October 23, 2026 17:04
- Cover the social-login gate with tests (refused without invite, refused
  with a stale invite, accepted with a valid invite, existing users can
  still sign in) using the existing MockSocialAuthProvider. Mutation checks
  confirm they fail if the gate, the 403 unwrap or the redirect is removed.
- Send a refused social sign-up back to /login?error=invite_required and
  show a toast, instead of rendering raw JSON from the OAuth callback.
- Seed the settings row in the migration and read it without writing.
- Reword the bootstrap exemption: it only applies while the instance has no
  accounts, and is not atomic with the insert.
- Validate the PUT body: a missing or non-boolean field is a 400 and never
  silently turns the setting off. Drop an unused validate tag.
- Tests: give the test owner a real team, assert the 403 message and PUT
  body, and cover the 400 cases.
- Docs: describe the owner accurately (earliest account that still exists),
  how to find them and recover, and that invite links do not expire, are
  visible to every team member, and work from any team.
- Web: tie the toggle's description to it with aria-describedby.

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

This branch has not been deployed

No deployments
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.

2 participants