signup

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package signup decides whether this instance admits new accounts, and admits them.

One idea runs through all of it. **`LINKCTRL_SIGNUP_MODE` is the mode, and the operator is the only one who sets it** (D38). There is no stored toggle, no permission, and no endpoint: changing how an instance admits accounts is an `.env` edit and a restart. What this package computes on top of that variable is one derivation and no policy — `open` with no mailer is `invite`, because there would otherwise be no way to verify an address.

Three consequences are worth stating before the code.

**`open` requires a configured mailer** (D1). Open registration proves the address before an account exists at all: the form writes a pending registration and mails a link, and the user, organization and workspace are created when the link is followed. With no relay configured there is nothing to prove an address with, so the effective mode drops to `invite` — and the signup page refuses on GET rather than letting somebody fill a form in and discover it at submit time.

**`closed` admits no new account by any path** (D7), which is why this package rather than the environment answers internal/invite's question about whether redemption may create an account. A mode that closed the signup form but left invitations creating accounts would make the word mean two things.

**A self-registered account gets its own organization and workspace** (D6), which is the opposite of an invited one. That difference is the whole reason this milestone ships after invitations, and the form says it in words.

Index

Constants

View Source
const ConsumedRetentionDays = 7

ConsumedRetentionDays is how long a spent registration row is kept before the sweep removes it. Short, because the account it produced is the durable evidence and the audit log holds the rest.

View Source
const MailKind = "verification"

MailKind names the mail template, which is also the outbox's `kind` column.

View Source
const MailKindExists = "account-exists"

MailKindExists is what a registration attempt on an address that already has an account sends instead. It exists so the *response* does not have to say so: the mail reaches the address, and only its owner reads it, where a status code reaches whoever typed the address into the form (F13).

View Source
const TokenBytes = 32

TokenBytes is the entropy in a verification token, matching an invitation's.

View Source
const VerificationTTL = 24 * time.Hour

VerificationTTL is how long an emailed verification link stays usable.

A constant rather than a variable, unlike INVITE_TTL. An invitation's window is an administrator's policy about somebody else's onboarding, and D29 made it tunable for that reason; this window is a person finishing something they started minutes ago, and one day is generous for that without being a credential anybody has to think about. Registering again supersedes the old link, so nobody is ever stuck waiting for this to lapse.

Variables

View Source
var (
	// ErrClosed is registration refused because the effective mode is not
	// `open`. Returned from both the form and the API, and from verification —
	// an operator who closes sign-ups stops the accounts that were half-way
	// through, because D7's bound is absolute rather than a moment.
	ErrClosed = errors.New("signup: this instance does not accept sign-ups")
	// ErrNotVerifiable is every failure to complete a verification: no such
	// token, expired, already spent. One error for all of them, because the
	// holder of a bad link learns nothing from which.
	ErrNotVerifiable = errors.New("signup: this link is no longer valid")
	// ErrEmailTaken is an address that already has an account.
	ErrEmailTaken = auth.ErrEmailTaken
)

Errors this package returns that a caller distinguishes.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Mode is LINKCTRL_SIGNUP_MODE, and it is the whole of the policy (D38).
	Mode Mode
	// AppURL is the origin a verification link points at.
	AppURL string
	// Hasher hashes the password chosen at the form, at the cost parameters the
	// operator configured. Required: a hasher this package invented for itself
	// would use costs nobody chose.
	Hasher *auth.Hasher
	// Mail delivers the verification link. Nil is an instance with no relay, and
	// it is what lowers an `open` mode to `invite` — there is no other way to
	// prove an address, and `open` without one would be an open door with a
	// verification step that never happens (D1).
	Mail Enqueuer
}

Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree.

type Enqueuer

type Enqueuer interface {
	Enqueue(ctx context.Context, to, kind string, data map[string]string) error
}

Enqueuer is internal/mail's writing half, as this package needs it.

Declared here rather than imported so "no mailer configured" is a nil interface rather than a flag every call site has to remember to check, and so a test satisfies it in four lines.

type Mode

type Mode string

Mode is how open this instance is to new accounts.

Its own type rather than internal/config's, for the reason every service package here declares its own configuration: the package that does the work does not read the environment. The values are the same three words, so the wiring converts with a string conversion and no table of equivalences.

const (
	// Closed admits no new account by any path, invitations included (D7).
	Closed Mode = "closed"
	// Invite admits an account only through a redeemed invitation, where an
	// administrator named the address first.
	Invite Mode = "invite"
	// Open additionally admits anybody through the signup form, once they have
	// proven the address.
	Open Mode = "open"
)

func (Mode) AdmitsNewAccounts

func (m Mode) AdmitsNewAccounts() bool

AdmitsNewAccounts reports whether an account may be created at all — the question internal/invite asks before letting a redemption create one.

func (Mode) Valid

func (m Mode) Valid() bool

Valid reports whether m is one of the three modes.

type RegisterInput

type RegisterInput struct {
	Email    string
	Name     string
	Password string
}

RegisterInput is somebody filling in the signup form.

type Registered

type Registered struct {
	// Email is the normalized address the link was sent to. Echoed back so the
	// page can say which inbox to look in.
	Email string
	// ExpiresAt is when the link stops working.
	ExpiresAt time.Time
}

Registered is what a caller tells the person afterwards.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service answers what the instance's signup mode is, and runs the two halves of an open registration.

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) (*Service, error)

func (*Service) Configured

func (s *Service) Configured() Mode

Configured is `LINKCTRL_SIGNUP_MODE` as the operator set it, before the mailer is taken into account. Logged at boot beside Effective, so "you configured `open` but there is no relay" is one line in the log rather than a mystery at the signup form.

func (*Service) Effective

func (s *Service) Effective() Mode

Effective is the mode that actually applies.

`LINKCTRL_SIGNUP_MODE`, lowered to `invite` when no mailer is configured. That is the one derivation this package performs, and it exists because open registration verifies an address by email (D1): with no relay, `open` would be an open door with a verification step that never runs.

No context and no error, because there is nothing to read. The mode is fixed for the life of the process, which is what makes "no session or API call can change it" a property of the shape rather than of a check.

func (*Service) MailerConfigured

func (s *Service) MailerConfigured() bool

MailerConfigured reports whether this instance can prove an address.

func (*Service) PurgeLapsed

func (s *Service) PurgeLapsed(ctx context.Context) (int64, error)

PurgeLapsed removes registrations nobody completed and spent rows past the short retention window, reporting how many went. Called by the maintenance job, for the reason the outbox has a purge: a waiting room with no sweep is the one table that grows forever with nothing watching it.

func (*Service) Register

func (s *Service) Register(ctx context.Context, in RegisterInput) (*Registered, error)

Register starts an open-mode registration.

It creates no account. Under D1 the address is proven first, so this writes a pending registration and queues the mail that carries the link; the user, the organization and the workspace are written by Verify, in one transaction, when somebody demonstrates they read mail at the address.

func (*Service) Verify

func (s *Service) Verify(ctx context.Context, token string) (*Verified, error)

Verify completes a registration, creating the account it was waiting on.

D6 in one function: a self-registered account gets its own organization and its own workspace, and owner membership in it — which is exactly what an invited account does not get. auth.ProvisionOrganization is called rather than reimplemented, so there is one statement of what provisioning tenancy means.

The effective mode is checked again here, and that is not belt-and-braces. A link lives for a day and an operator can close the instance inside that window — an `.env` edit and a restart — so a registration started while sign-ups were open must not still be able to land afterwards. D7's bound is a state the instance is in, not a moment a request passed through.

type Verified

type Verified struct {
	UserID      uuid.UUID
	Email       string
	Name        string
	WorkspaceID uuid.UUID
	OrgID       uuid.UUID
}

Verified is a completed registration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL