Add self-service sign-up and invite links #19

Merged
niklas merged 1 commits from signup-invites into main 2026-09-21 08:42:08 +00:00
Owner

First half of #7. The login page has been telling people to "ask an admin" since before any of this work started — workable for one operator, impossible for a team.

Two modes, not three

invite_only (default) and open, chosen by an administrator in the settings table. The domain-restricted third mode from the original plan is dropped: with no email in this server there's nothing to verify an address against, so it would only check the domain of a string somebody typed.

The default is the closed door, and so is the fallback for an unrecognised value — a typo in this setting should fail shut.

Invites are links, not email

Adding SMTP to send one message would be a subsystem to run, secure and monitor. The person inviting sends the link however they already talk to the person they're inviting.

A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody — not a state to invite somebody into. Single-use by default, seven-day expiry, revocable before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log.

The uses counter increments inside the sign-up transaction, guarded by uses < max_uses, so two people redeeming the last use at once cannot both get in. There's a test for exactly that.

Details worth the space

  • GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password. It gives one answer for expired, revoked, used up and never existed — telling a stranger which it was tells them something about links they don't hold.
  • Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession.
  • Its own rate limiter, per address, separate from login's: a burst of sign-ups must not lock somebody out of logging in.
  • The settings table grew a second shape. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything — a request setting two values with one wrong changes neither.

Verified

make fmt lint test helm-lint green with -race against Postgres 17, plus eight new tests: the closed default, an invite landing somebody in the right team with the right role, single-use enforcement, revocation, open mode creating a team, the mode being admin-only and taking effect at once, invites being owner-only, and sign-up validating passwords and duplicate usernames like the rest of the server.

Still to come in #7

The sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema already carries onboarding_dismissed_at for the checklist, and users.invited_via records how an account arrived — the answer to "how did this account get here" should outlive the link that made it.

https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7

First half of #7. The login page has been telling people to "ask an admin" since before any of this work started — workable for one operator, impossible for a team. ### Two modes, not three `invite_only` (default) and `open`, chosen by an administrator in the settings table. The domain-restricted third mode from the original plan is **dropped**: with no email in this server there's nothing to verify an address against, so it would only check the domain of a string somebody typed. **The default is the closed door**, and so is the fallback for an unrecognised value — a typo in this setting should fail shut. ### Invites are links, not email Adding SMTP to send one message would be a subsystem to run, secure and monitor. The person inviting sends the link however they already talk to the person they're inviting. A link carries the **team and the role**, because an account in no team sees an empty queue and can be paged by nobody — not a state to invite somebody into. Single-use by default, seven-day expiry, revocable before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter increments **inside the sign-up transaction**, guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. There's a test for exactly that. ### Details worth the space - **`GET /api/signup`** reports the mode and whether a link is usable, so the form can say "this link has expired" *before* somebody picks a password. It gives **one answer** for expired, revoked, used up and never existed — telling a stranger which it was tells them something about links they don't hold. - **Sign-up signs you in.** The alternative is a form that says "now go and log in", which is the same credential typed twice. `login` and `signup` now share `startSession`. - **Its own rate limiter**, per address, separate from login's: a burst of sign-ups must not lock somebody out of logging in. - **The settings table grew a second shape.** It held only durations; `signup_mode` is a word from a fixed list, so the admin endpoint now validates everything before writing anything — a request setting two values with one wrong changes neither. ### Verified `make fmt lint test helm-lint` green with `-race` against Postgres 17, plus eight new tests: the closed default, an invite landing somebody in the right team with the right role, single-use enforcement, revocation, open mode creating a team, the mode being admin-only and taking effect at once, invites being owner-only, and sign-up validating passwords and duplicate usernames like the rest of the server. ### Still to come in #7 The sign-up and invite-redemption **pages**, the first-run **checklist**, and the in-app **integration instructions**. The schema already carries `onboarding_dismissed_at` for the checklist, and `users.invited_via` records how an account arrived — the answer to "how did this account get here" should outlive the link that made it. https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
niklas added 1 commit 2026-09-21 07:07:05 +00:00
Add self-service sign-up and invite links
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 2m30s
d827ceedff
First half of #7. Until now the only way to get an account was for
somebody who already had one to create it, and the login page told people
to "ask an admin" -- workable for one operator, impossible for a team.

Two modes, chosen by an administrator in the settings table: invite_only,
which is the default, and open. A third domain-restricted mode was
considered and dropped, because with no email in this server there is
nothing to verify an address against and it would only check the domain
of a string somebody typed.

The default is the closed door. An install that gets a public hostname
before anybody has thought about sign-up should not be collecting
accounts from the internet, and the failure mode of a typo in the setting
is invite_only rather than open.

An invite is a link, not an email. Adding SMTP to send one message would
be a subsystem to run, secure and monitor; the person inviting sends the
link however they already talk to the person they are inviting. A link
carries the team and the role, because an account in no team sees an
empty queue and can be paged by nobody -- that is not a state to invite
somebody into. Links are single-use by default, expire after seven days,
and can be revoked before that: a link that works forever is a credential
nobody remembers issuing, sitting in a chat log.

The uses counter is incremented inside the sign-up transaction and
guarded by `uses < max_uses`, so two people redeeming the last use at
once cannot both get in.

GET /api/signup reports the mode and whether a link is usable, so the
form can say "this link has expired" before somebody picks a password
rather than after. It gives one answer for expired, revoked, used up and
never existed: telling a stranger which it was tells them something about
links they do not hold.

Sign-up signs you in. The alternative is a form that says "now go and log
in", which is the same credential typed twice. login and signup now share
startSession rather than each minting a cookie.

Rate-limited per address on its own limiter, not login's: a burst of
sign-ups must not lock somebody out of logging in.

The settings table grew a second shape for this. It held only durations;
signup_mode is a word from a fixed list, so the admin endpoint now
validates everything before writing anything -- a request that sets two
settings and gets one wrong changes neither.

Still to come in #7: the sign-up and invite-redemption pages, the
first-run checklist, and the in-app integration instructions. The schema
carries onboarding_dismissed_at for the checklist already.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
niklas merged commit 19f168ab7e into main 2026-09-21 08:42:08 +00:00
Sign in to join this conversation.