Help us shape the future of Go! Take the 2026 Go Developer Survey

signupgate

package
v0.22.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package signupgate keeps platform sign-up invites and implements the systemauth.SignupGate that enforces them. With the gate installed a deployment creates accounts only for people who are invited; people who already have an account sign in as before.

There are two kinds of invite. An email invite (Store) names an address: the person proves control of it by signing in through the upstream provider, and the gate compares that verified address with the invites. An invite code (CodeStore) is shared as a link and admits up to its use limit, whoever holds it; operators create codes for cohorts, and members create single-use codes from their allowance. Either way a verified email is required.

Index

Constants

View Source
const (
	CodeActive  = "active"
	CodeUsedUp  = "used_up"
	CodeExpired = "expired"
	CodeRevoked = "revoked"
)

Code statuses, as returned by Code.Status.

View Source
const (
	EventInviteCreated   = "signup_invite.created"
	EventInviteRevoked   = "signup_invite.revoked"
	EventCodeCreated     = "signup_code.created"
	EventCodeRevoked     = "signup_code.revoked"
	EventCodeRedeemed    = "signup_code.redeemed"
	EventAllowanceSet    = "signup_allowance.set"
	EventWaitlistInvited = "signup_waitlist.invited"
)

Audit event types for sign-up invites.

View Source
const DefaultMemberCodeTTL = 14 * 24 * time.Hour

DefaultMemberCodeTTL is how long a member's invite code works when MemberOptions.CodeTTL is not set.

Variables

View Source
var (
	// ErrCodeInvalid means a code cannot be used: it is malformed, unknown,
	// used up, expired or revoked. The reason is not given, so a holder
	// cannot learn which codes exist.
	ErrCodeInvalid = errors.New("signupgate: invite code is not valid")

	// ErrCodeNotFound means no code has the given ID.
	ErrCodeNotFound = errors.New("signupgate: no such invite code")
)
View Source
var (
	// ErrInvalidEmail means the address is not a plain email address.
	ErrInvalidEmail = errors.New("signupgate: not a plain email address")

	// ErrAlreadyRedeemed means an account was already created with the
	// invite. Revoke it to invite the address again.
	ErrAlreadyRedeemed = errors.New("signupgate: invite already redeemed")

	// ErrNotFound means no invite exists for the address.
	ErrNotFound = errors.New("signupgate: no invite for that address")
)
View Source
var ErrAllowanceExhausted = errors.New("signupgate: no invites left")

ErrAllowanceExhausted means a member already holds as many invite codes as their allowance permits.

View Source
var ErrNoAccount = errors.New("signupgate: no account with that email address")

ErrNoAccount means no account has the given email address.

Functions

func CodeLink(signupURL, code string) string

CodeLink returns the invite link for a code: the sign-up page URL with the code as its code query parameter.

func FormatCode

func FormatCode(normalized string) string

FormatCode returns a normalized code in groups of four, as shown to people: XXXX-XXXX-XXXX-XXXX.

func NormalizeCode

func NormalizeCode(raw string) (string, error)

NormalizeCode returns the stored form of a code: upper-case, without dashes or spaces, with O read as 0 and I and L read as 1. It returns ErrCodeInvalid for anything that is not a code.

func NormalizeEmail

func NormalizeEmail(raw string) (string, error)

NormalizeEmail returns the stored form of an address: trimmed and lower-case. A display name, a comment or a missing domain is refused.

func WriteWaitlistCSV

func WriteWaitlistCSV(out io.Writer, entries []WaitlistEntry) error

WriteWaitlistCSV writes entries as CSV with a header row: email, joined and last submitted times (RFC 3339, UTC), the consent version, and when the address was invited (empty until then).

Types

type AddOptions

type AddOptions struct {
	// Note is free-text context for operators.
	Note string
	// InvitedBy labels the operator creating the invite.
	InvitedBy string
	// InvitedByPrincipal is the person creating the invite through the API.
	InvitedByPrincipal *uuid.UUID
	// ExpiresAt ends the invite; nil means it does not expire.
	ExpiresAt *time.Time
}

AddOptions are the optional parts of a new invite.

type Allowance

type Allowance struct {
	PrincipalID uuid.UUID
	Email       string
	// Granted is the allowance an operator set; nil means the default.
	Granted *int
	// Effective is Granted, or the deployment's default when it is nil.
	Effective int
	// Held is how many of the member's codes count against it.
	Held      int
	Note      string
	SetBy     *uuid.UUID
	UpdatedAt time.Time
}

Allowance is a member's invite allowance as listed to operators.

type Code

type Code struct {
	ID                 uuid.UUID
	Kind               CodeKind
	Hint               string // last four characters
	Label              string
	Note               string
	CreatedByPrincipal *uuid.UUID
	CreatedByLabel     string
	MaxUses            int
	UseCount           int
	CreatedAt          time.Time
	ExpiresAt          *time.Time
	RevokedAt          *time.Time
}

Code is an invite code as listed to operators and members. It never carries the code itself, which is shown only once, when created.

func (Code) Status

func (c Code) Status(now time.Time) string

Status is CodeRevoked, CodeExpired, CodeUsedUp or CodeActive at the given time, in that order of precedence.

type CodeFilter

type CodeFilter struct {
	// Kind keeps codes of one kind.
	Kind CodeKind
	// Label keeps codes with this label.
	Label string
	// CreatedBy keeps codes created by this principal.
	CreatedBy *uuid.UUID
	// ActiveOnly keeps codes that can still be used.
	ActiveOnly bool
}

CodeFilter narrows ListCodes. The zero value lists every code.

type CodeKind

type CodeKind string

CodeKind says who created a code.

const (
	// CodeOperator is a code an operator created; it may admit many people.
	CodeOperator CodeKind = "operator"
	// CodeMember is a code a member created from their allowance; it admits
	// one person.
	CodeMember CodeKind = "member"
)

type CodeOptions

type CodeOptions struct {
	// Kind is CodeOperator or CodeMember.
	Kind CodeKind
	// Label names the cohort, such as "launch-week".
	Label string
	// Note is free-text context for operators.
	Note string
	// CreatedByPrincipal is the person creating the code through the API.
	CreatedByPrincipal *uuid.UUID
	// CreatedByLabel labels an operator creating the code from the command
	// line.
	CreatedByLabel string
	// MaxUses is how many accounts the code may create; at least 1.
	MaxUses int
	// ExpiresAt ends the code; nil means it does not expire.
	ExpiresAt *time.Time
}

CodeOptions describe a new code.

type CodeStore

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

CodeStore reads and writes invite codes, their redemptions and member allowances.

func NewCodeStore

func NewCodeStore(client *ent.Client, member MemberOptions) *CodeStore

NewCodeStore returns a CodeStore over client. Member invites use the given options, with defaults for zero fields.

func (*CodeStore) Allowances

func (s *CodeStore) Allowances(ctx context.Context) ([]Allowance, error)

Allowances returns the allowances operators have set, with each member's email and held count, ordered by email.

func (*CodeStore) CreateCode

func (s *CodeStore) CreateCode(ctx context.Context, opts CodeOptions) (Code, string, error)

CreateCode creates a code and returns it with the code itself, formatted for people. The code is not stored and cannot be shown again.

func (*CodeStore) CreateMemberCode

func (s *CodeStore) CreateMemberCode(ctx context.Context, principalID uuid.UUID) (Code, string, error)

CreateMemberCode creates a single-use code for a member, expiring after the member code TTL, and returns it with the code itself, shown once. It returns ErrAllowanceExhausted when the member holds their allowance already. The member's allowance row is written first inside the transaction, which locks it, so concurrent requests cannot both pass.

func (*CodeStore) GetCode

func (s *CodeStore) GetCode(ctx context.Context, id uuid.UUID) (Code, error)

GetCode returns the code with the given ID, or ErrCodeNotFound.

func (*CodeStore) ListCodes

func (s *CodeStore) ListCodes(ctx context.Context, f CodeFilter) ([]Code, error)

ListCodes returns codes, newest first.

func (*CodeStore) Lookup

func (s *CodeStore) Lookup(ctx context.Context, code string) (Code, error)

Lookup returns the code a person holds, whatever its status, or ErrCodeInvalid when it is malformed or unknown.

func (*CodeStore) MemberInvites

func (s *CodeStore) MemberInvites(ctx context.Context, principalID uuid.UUID) (MemberSummary, error)

MemberInvites returns a member's allowance, how many codes they hold, and their codes.

func (*CodeStore) MemberOptions

func (s *CodeStore) MemberOptions() MemberOptions

MemberOptions returns the member invite settings the store applies.

func (*CodeStore) PrincipalByEmail

func (s *CodeStore) PrincipalByEmail(ctx context.Context, email string) (uuid.UUID, error)

PrincipalByEmail returns the principal ID of the account with the email address, or ErrNoAccount.

func (*CodeStore) Redeem

func (s *CodeStore) Redeem(ctx context.Context, id, principalID uuid.UUID, email string) error

Redeem records that the account was created with a code reserved by Reserve.

func (*CodeStore) Redemptions

func (s *CodeStore) Redemptions(ctx context.Context, f RedemptionFilter) ([]Redemption, error)

Redemptions returns accounts created with codes, newest first.

func (*CodeStore) Release

func (s *CodeStore) Release(ctx context.Context, id uuid.UUID) error

Release gives back a use taken by Reserve, when the account it was reserved for was not created.

func (*CodeStore) Reserve

func (s *CodeStore) Reserve(ctx context.Context, code string) (uuid.UUID, error)

Reserve takes one use of a code and returns the code's ID. It is one conditional update, so a code with N uses is reserved at most N times however many sign-ups race for it. It returns ErrCodeInvalid when the code cannot be used, for any reason.

func (*CodeStore) RevokeCode

func (s *CodeStore) RevokeCode(ctx context.Context, id uuid.UUID) error

RevokeCode revokes a code, so it admits nobody else. Accounts already created with it are not touched. Revoking a revoked code changes nothing. It returns ErrCodeNotFound when there is no such code.

func (*CodeStore) RevokeMemberCode

func (s *CodeStore) RevokeMemberCode(ctx context.Context, principalID, id uuid.UUID) error

RevokeMemberCode revokes one of a member's own codes. A code that was never used gives its slot back. It returns ErrCodeNotFound for a code the member did not create.

func (*CodeStore) SetAllowance

func (s *CodeStore) SetAllowance(ctx context.Context, principalID uuid.UUID, granted *int, note string, setBy *uuid.UUID) error

SetAllowance sets how many invite codes a member may hold. A nil granted returns the member to the deployment's default.

func (*CodeStore) SignupCodeUsable

func (s *CodeStore) SignupCodeUsable(ctx context.Context, code string) (string, bool, error)

SignupCodeUsable implements systemauth.SignupCodeLookup: it returns the code in its stored form and whether it can be used now.

type Gate

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

Gate is the systemauth.SignupGate over a Store and, optionally, a CodeStore. A sign-up is allowed when the login's verified email holds a pending invite, or else when the person brought an invite code with a use left. An email invite wins: a person invited by address does not use up a code they also hold.

func NewGate

func NewGate(store *Store, opts ...GateOption) *Gate

NewGate returns a gate that allows a sign-up when the login's verified email holds a pending invite, or with WithCodes, when it brings a usable invite code.

func (*Gate) AllowSignup

func (g *Gate) AllowSignup(ctx context.Context, login systemauth.ExternalLogin) error

AllowSignup implements systemauth.SignupGate. Only a verified email can sign up: an unverified address is never trusted, and the directory refuses to create an account for one anyway. A code is reserved here, one use, so a code's limit holds however many sign-ups race for it.

func (*Gate) SignedUp

func (g *Gate) SignedUp(ctx context.Context, login systemauth.ExternalLogin, principalID uuid.UUID) error

SignedUp implements systemauth.SignupGate by consuming what admitted the sign-up: the email invite, or the reserved code use.

func (*Gate) SignupAborted

func (g *Gate) SignupAborted(ctx context.Context, login systemauth.ExternalLogin)

SignupAborted implements systemauth.SignupGateAborter by giving back a reserved code use when the account was not created, for example because the person has yet to answer the sign-up age question.

type GateOption

type GateOption func(*Gate)

GateOption configures a Gate.

func WithCodes

func WithCodes(codes *CodeStore) GateOption

WithCodes lets the gate admit people who bring an invite code from codes.

func WithGateAudit

func WithGateAudit(emitter audit.Emitter) GateOption

WithGateAudit emits signup_code.redeemed through emitter when a code admits an account.

type Invite

type Invite struct {
	Email     string
	Note      string
	InvitedBy string
	// InvitedByPrincipal is the person who created the invite through the
	// API; nil for invites created from the command line.
	InvitedByPrincipal *uuid.UUID
	CreatedAt          time.Time
	ExpiresAt          *time.Time
	RedeemedAt         *time.Time
	RedeemedBy         *uuid.UUID
}

Invite is a sign-up invite as listed to operators.

func (Invite) Status

func (i Invite) Status(now time.Time) string

Status is "redeemed", "expired" or "pending" at the given time.

type MemberOptions

type MemberOptions struct {
	// DefaultAllowance is how many invite codes a member may hold when an
	// operator has not set their allowance. The default is 0: members invite
	// nobody until an operator allows it.
	DefaultAllowance int
	// CodeTTL is how long a member's invite code works. Zero means
	// DefaultMemberCodeTTL.
	CodeTTL time.Duration
}

MemberOptions configure member invites.

type MemberSummary

type MemberSummary struct {
	Allowance int
	Held      int
	Remaining int
	// Codes are the member's codes, newest first, whatever their status.
	Codes []Code
	// CodeTTL is how long a new member code works.
	CodeTTL time.Duration
}

MemberSummary is what a member sees of their own invites.

type Redemption

type Redemption struct {
	ID          uuid.UUID
	CodeID      uuid.UUID
	PrincipalID uuid.UUID
	Email       string
	CreatedAt   time.Time
	// The code's kind, label, hint and creator, for attribution.
	CodeKind           CodeKind
	CodeLabel          string
	CodeHint           string
	CodeCreatedBy      *uuid.UUID
	CodeCreatedByLabel string
}

Redemption is an account created with a code.

type RedemptionFilter

type RedemptionFilter struct {
	// CodeID keeps accounts created with this code.
	CodeID *uuid.UUID
	// CreatedBy keeps accounts created with codes this principal created.
	CreatedBy *uuid.UUID
	// Label keeps accounts created with codes carrying this label.
	Label string
}

RedemptionFilter narrows Redemptions. The zero value lists every redemption.

type Store

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

Store reads and writes invites.

func NewStore

func NewStore(client *ent.Client) *Store

NewStore returns a Store over client.

func (*Store) Add

func (s *Store) Add(ctx context.Context, email string, opts AddOptions) (*Invite, error)

Add creates an invite, or updates the note, label and expiry of a pending one. It returns ErrAlreadyRedeemed for an address that already has an account created through its invite.

func (*Store) Allowed

func (s *Store) Allowed(ctx context.Context, email string) (bool, error)

Allowed reports whether the address holds an invite that is neither redeemed nor expired.

func (*Store) List

func (s *Store) List(ctx context.Context, includeRedeemed bool) ([]Invite, error)

List returns invites ordered by address. Redeemed invites are included only when includeRedeemed is set.

func (*Store) Redeem

func (s *Store) Redeem(ctx context.Context, email string, principalID uuid.UUID) error

Redeem marks the invite for an address as used by the principal. It does nothing, and reports no error, when the invite was already used, so a repeated call is harmless.

func (*Store) Revoke

func (s *Store) Revoke(ctx context.Context, email string) error

Revoke deletes the invite for an address, redeemed or not. Deleting a redeemed invite does not touch the account created with it; it only lets the address be invited again. It returns ErrNotFound when there is none.

type Waitlist

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

Waitlist stores the addresses that asked to be notified while a deployment is invite-only. It sends nothing itself: operators export it.

func NewWaitlist

func NewWaitlist(client *ent.Client) *Waitlist

NewWaitlist returns a Waitlist over client.

func (*Waitlist) Invite

func (w *Waitlist) Invite(ctx context.Context, invites *Store, emails []string, opts AddOptions) ([]Invite, error)

Invite creates an email invite in invites for each address on the waitlist and marks the entry invited; the entry stays listed. It returns the invites created and an error joining the failures, such as an address that is not listed or one whose invite was already redeemed.

func (*Waitlist) Join

func (w *Waitlist) Join(ctx context.Context, email, consentVersion, consentText string) error

Join adds an address, or refreshes the consent recorded for one already listed. Either way the caller learns nothing about whether it was listed before, so a public form built on it cannot be used to probe addresses.

func (*Waitlist) List

func (w *Waitlist) List(ctx context.Context) ([]WaitlistEntry, error)

List returns every entry, oldest first.

func (*Waitlist) Remove

func (w *Waitlist) Remove(ctx context.Context, email string) error

Remove deletes an address, for example on request. It returns ErrNotFound when the address is not listed.

type WaitlistEntry

type WaitlistEntry struct {
	Email          string
	ConsentVersion string
	ConsentText    string
	// CreatedAt is when the address first joined; UpdatedAt when it was
	// last submitted.
	CreatedAt time.Time
	UpdatedAt time.Time
	// InvitedAt is when an operator invited the address from the waitlist;
	// nil until then.
	InvitedAt *time.Time
}

WaitlistEntry is an address waiting to be told that sign-up is open.

Directories

Path Synopsis
Package signupapi serves the HTTP API for invite-gated sign-up: operator routes for email invites, invite codes, the waitlist, member allowances and the accounts invites admitted, and member routes for a person's own invite codes.
Package signupapi serves the HTTP API for invite-gated sign-up: operator routes for email invites, invite codes, the waitlist, member allowances and the accounts invites admitted, and member routes for a person's own invite codes.
Package signupcmd provides the operator commands for invite-only sign-up as cobra commands a host CLI mounts: "invite" (add, list, revoke), "code" (create, list, revoke, joined), "allowance" (set, list) and "waitlist" (list, invite, remove).
Package signupcmd provides the operator commands for invite-only sign-up as cobra commands a host CLI mounts: "invite" (add, list, revoke), "code" (create, list, revoke, joined), "allowance" (set, list) and "waitlist" (list, invite, remove).

Jump to

Keyboard shortcuts

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