Invitations and signup
The two ways anyone other than the first operator gets an account, and why one of them is off by default.
Invitations
An owner invites someone by email address. The invitation carries a single-use token, expires
after AUTH_INVITATION_TTL_HOURS (168 hours — one week), and is listed on the organization's
page while pending.
| Status | |
|---|---|
PENDING | Issued, not yet accepted. An owner can cancel it. |
ACCEPTED | Consumed. |
REJECTED | Declined. |
CANCELLED | Withdrawn by an owner. |
Accepting
POST /invitations/accept is public — the one unauthenticated route that creates a user.
Accepting collects a name and a password, creates the user and their membership in one
transaction, consumes the token, and signs the invitee in.
Acceptance refuses an address that already has an account
It fails with an error saying so, rather than quietly creating a second membership. See one user, one organization.
The token is single-use, enforced by an atomic delete-on-consume, and stored as an HMAC digest rather than as itself.
Signup
POST /signup creates a user and a new organization, with the user as its owner. Unlike
first-run setup it never closes.
It is gated on SIGNUP_ENABLED, which defaults to false.
Why it is off by default
The control plane holds the Docker socket, so anyone with admin access to the portal has root on the server. Open registration on a self-hosted box turns "who has root on this machine" into "whoever finds the URL".
Cloud mode — where the control plane is infrastructure somebody else runs — turns it on.
When it is off, the route answers 404 — indistinguishable from a route that does not exist,
not a 403 that would advertise the feature to whoever is probing for it.
What signup creates
In one Serializable transaction: a User, an Organization, and a Member row with role
OWNER. Serializable because the email and slug uniqueness checks are claims about absent
rows, which a weaker isolation level would let two concurrent requests both believe.
It does not create a subscription row. An organization with no subscription is treated as being on the default plan — see plans and limits.
Unlike a first-run account, a signed-up user's emailVerified starts false. The first-run
account is verified at creation because the operator made it at the console and there is no
one else to verify it against; that justification does not transfer to a web form.
Comparison
/setup | /signup | /invitations/accept | |
|---|---|---|---|
| Public | Yes | Yes, when enabled | Yes |
| Creates an organization | Yes | Yes | No — joins an existing one |
| Role | OWNER | OWNER | Whatever the invitation says |
| Closes | Permanently, after one use | Never | Per invitation |
| Default | — | Off | — |
emailVerified | true | false | true |
An invitee is verified on acceptance because receiving the token is proof of control over the address. A self-service signup has proved nothing, which is why it starts unverified.