Skip to content

v0.22.0

Release Date: 2026-10-25

Invite-gated sign-up lets a deployment grow on purpose: first the people its operators know by name, then cohorts invited with shareable codes, then people early members vouch for. It builds on invite-only sign-up and adds no breaking changes. Existing databases need migration 0011.

Highlights

  • Invite codes. An operator creates a code with a use limit, an optional expiry and a label naming the cohort, and shares it as a link (/signup?code=…). A code with N uses admits at most N accounts however many sign-ups race for it. Only a hash is stored, so a code is shown once.
  • Member invites. An operator gives a member an allowance, or every member a default one; members create single-use codes up to it. Codes already used, and codes still usable, count against it.
  • Attribution. Every account admitted by a code records which code and who created it; email invites record the principal who created them.
  • An invite API and commands. signupgate/signupapi serves operator routes (email invites, codes, the waitlist, allowances, joined accounts) and member routes (a person's own codes), behind SystemAuth's session or a host's own. New commands create codes, set allowances, invite from the waitlist, and grant the first platform administrator.

Invite codes

  • signupgate.CodeStore: CreateCode, ListCodes, RevokeCode, Lookup, Redemptions. Codes are 16 characters of Crockford base32 (80 bits), shown as XXXX-XXXX-XXXX-XXXX; NormalizeCode reads them back forgivingly.
  • The gate admits a verified email with a pending email invite, or else one bringing a usable code. It reserves a use with one conditional update before the account is created and gives it back through the new optional systemauth.SignupGateAborter if creation fails, for example for the sign-up age question. An email invite wins over a code.
  • systemauth.WithSignupCodes serves the sign-up page at /signup, keeps a usable code in an HttpOnly cookie for an hour, and accepts invite_code on /login/{provider}. The page is rate-limited per client address, sends no referrer, and never says why a code cannot be used.

Member invites

  • CodeStore.SetAllowance, Allowances, MemberInvites, CreateMemberCode and RevokeMemberCode, with signupgate.MemberOptions{DefaultAllowance, CodeTTL} (defaults 0 and 14 days). Creating a member code locks the member's allowance row, so concurrent requests cannot exceed it.

Invite API

signupapi.New(Options) takes the stores, how to find the signed-in person, and how to tell a platform administrator. JSON is snake_case, responses are not cached, cross-origin writes are refused, and every change is audited: signup_invite.created, signup_invite.revoked, signup_code.created, signup_code.revoked, signup_code.redeemed, signup_allowance.set, signup_waitlist.invited. See the guide for the routes.

SystemAuth service

New systemauthsvc.Options: SignupCodes (needs InviteOnly), SignupPage, MemberInvites, and SignupAPI (serves the API at /signup-invites/api for the SystemAuth session). Service.SignupCodes() returns the code store and Service.SignupAPI(principal) the API for a host to serve behind its own session.

Commands

  • systemauth code create|list|revoke|joined
  • systemauth allowance set <email> <count|default>, allowance list
  • systemauth waitlist invite <email>... | --all
  • systemauth admin grant|revoke|list (principal/admincmd), which creates the first platform administrator without editing the database

The waitlist CSV export gains an invited_at column.

Upgrading

Run migrations/0011_signup_invite_attribution.sql against an existing Postgres database: it adds sf_signup_invites.invited_by_principal_id and sf_signup_waitlist.invited_at. The new tables (sf_signup_codes, sf_signup_code_redemptions, sf_signup_allowances) are created by the schema migration and are never granted to application database logins.

Nothing else changes for existing deployments: codes, member invites and the API are off until their options are set.