invite

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

github.com/ubgo/auth/plugin/invite

Invitation codes for users with usage tracking, and optionally invite-only sign-up: the Go port of better-invite. The guide is docs/plugins/invite.md.

Install

The package is in the core module github.com/ubgo/auth; it uses plugin/email for private invites, so auth.New refuses it with ErrNoEmail when no plugin delivers email, unless PublicOnly() turns private invites off.

What it does

  • A signed-in user creates a public invite (token and url answered once, maxUses, expiresIn) or a private one (mailed to one address, single use).
  • Every use is a row in auth_invite_use; the invite counts its uses and turns used when they run out.
  • A sign-up spends the invite it brings in the Invite-Token header or the __Host-auth_invite cookie that activate sets, in the sign-up's transaction; with Required() a client's sign-up without one is refused.
  • A role is stored and handed to OnAccepted, never applied by the plugin, and refused unless CanCreate is set.
Route Go method Requirement
POST /auth/invite/create Create SignedIn
GET /auth/invite/get?token= Get Public
GET /auth/invite/list List SignedIn
POST /auth/invite/cancel {id} Cancel SignedIn, creator
POST /auth/invite/reject {token} Reject SignedIn, the address's owner
POST /auth/invite/activate {token} Activate Public

Options

Required(), CanCreate(fn), OnAccepted(fn), WithTTL(d) (1 hour, at most 30 days), WithCookieTTL(d) (10 minutes), HideInviter(), Links(fn), PublicOnly() (no private invites: a create naming an email is 403 invite.forbidden, and the email plugin is not needed). An option that takes a func given nil fails auth.New with auth.ErrNilOption.

Tests

Unit tests in this package; the flows on SQLite, Postgres and MySQL, and every database failure, in integration/p3_community_test.go.

Documentation

Overview

Package invite is invitation codes for users, better-invite's plugin: a signed-in user creates an invite, either public (a link anyone holding it may use, up to a number of uses) or private (mailed to one address, used once), with an expiry and optionally a role; every use is recorded with who used it and when; and Required makes sign-up refuse anyone without a valid invite.

An invite is a token, stored as its hash. It reaches the sign-up in one of two ways, so every sign-up method works without knowing this plugin: the InviteHeader header on the sign-up request, or the invite cookie that activate sets for a signed-out visitor, which the provider callback of an OAuth sign-up carries too. A data hook on user creation spends it inside the sign-up's transaction, so an invite that runs out refuses the sign-up rather than leaving a user nobody invited.

The plugin stores a role but never grants one: OnAccepted runs in the same transaction with the role, for the app to apply (admin.SetRole, an organization membership, a plan). A role in an invite is refused unless the app passed CanCreate, so no user can mint an invite that makes its holder an admin.

Index

Constants

View Source
const (
	EndpointCreate   auth.EndpointID = "invite.create"
	EndpointGet      auth.EndpointID = "invite.get"
	EndpointList     auth.EndpointID = "invite.list"
	EndpointCancel   auth.EndpointID = "invite.cancel"
	EndpointReject   auth.EndpointID = "invite.reject"
	EndpointActivate auth.EndpointID = "invite.activate"
)

Operation ids, stable wire names.

View Source
const (
	// DefaultTTL is how long an invite works: one hour.
	DefaultTTL = time.Hour
	// DefaultCookieTTL is how long an activated invite waits in the cookie
	// for its visitor to sign up or sign in: ten minutes.
	DefaultCookieTTL = 10 * time.Minute
	// DefaultPage is the app page the link opens; it calls get to show the
	// invite and activate to use it.
	DefaultPage = "/invite"
	// MaxTTL bounds a requested expiry: an invite is a standing credential.
	MaxTTL = 30 * 24 * time.Hour
	// MaxListLimit bounds one page of list.
	MaxListLimit = 100
)

Defaults, better-invite's.

View Source
const (
	TableNameInvite  = "auth_invite"
	TableAliasInvite = "inv"
	TableNameUse     = "auth_invite_use"
	TableAliasUse    = "inu"

	ColID        = "id"
	ColTokenHash = "token_hash"
	ColCreatedBy = "created_by"
	ColEmail     = "email"
	ColRole      = "role"
	ColMaxUses   = "max_uses"
	ColUses      = "uses"
	ColStatus    = "status"
	ColExpiresAt = "expires_at"
	ColCreatedAt = "created_at"

	ColUseInvite = "invite_id"
	ColUseUser   = "user_id"
	ColUseAt     = "used_at"
)

The tables: invites, and one row per use. No foreign keys (ADR-0002).

View Source
const CookieName = "invite"

CookieName is the plugin cookie activate sets for a signed-out visitor, read when the account is created or the visitor signs in.

View Source
const InviteHeader = "Invite-Token"

InviteHeader carries an invite token on a sign-up request. ds:def id=inviteheader-b6pq8rb7 owner=@khanakia stability=stable

View Source
const PluginName = "invite"

PluginName is the plugin's permanent identity and route prefix.

View Source
const PurposeInvite auth.Purpose = "invite.user"

PurposeInvite is the message a private invite sends.

Variables

View Source
var (
	TableInvite = sqlb.Table{Name: TableNameInvite, Alias: TableAliasInvite}
	TableUse    = sqlb.Table{Name: TableNameUse, Alias: TableAliasUse}

	InviteID        = sqlb.Col[string](TableInvite, ColID)
	InviteTokenHash = sqlb.Col[[]byte](TableInvite, ColTokenHash)
	InviteCreatedBy = sqlb.Col[string](TableInvite, ColCreatedBy)
	InviteEmail     = sqlb.Col[*string](TableInvite, ColEmail)
	InviteRole      = sqlb.Col[string](TableInvite, ColRole)
	InviteMaxUses   = sqlb.Col[int64](TableInvite, ColMaxUses)
	InviteUses      = sqlb.Col[int64](TableInvite, ColUses)
	InviteStatus    = sqlb.Col[Status](TableInvite, ColStatus)
	InviteExpiresAt = sqlb.Col[time.Time](TableInvite, ColExpiresAt)
	InviteCreatedAt = sqlb.Col[time.Time](TableInvite, ColCreatedAt)

	UseInviteID = sqlb.Col[string](TableUse, ColUseInvite)
	UseUserID   = sqlb.Col[string](TableUse, ColUseUser)
	UseAt       = sqlb.Col[time.Time](TableUse, ColUseAt)
)

The tables and their typed columns, for an app that reads them.

View Source
var (
	ErrInvalid   = auth.NewError("invite.invalid", http.StatusBadRequest, "This invitation is not valid or has expired.")
	ErrRequired  = auth.NewError("invite.required", http.StatusForbidden, "An invitation is required to sign up.")
	ErrForbidden = auth.NewError("invite.forbidden", http.StatusForbidden, "You cannot create this invitation.")

	ErrNoBase  = errors.New("invite: links need auth.WithBaseURL or invite.Links")
	ErrBadTTL  = errors.New("invite: WithTTL and WithCookieTTL need a positive duration, WithTTL at most MaxTTL")
	ErrNoEmail = errors.New("invite: private invites mail an address, so the email plugin must be wired (or pass invite.PublicOnly)")
)

Errors. ErrInvalid covers an unknown, expired, spent, canceled and rejected invite alike.

Functions

func DefaultText

func DefaultText(inviter, link string) string

DefaultText is the English body of a private invite's mail when the app's mailer has no template for PurposeInvite.

Types

type Accepted

type Accepted struct {
	InviteID  string
	UserID    string
	CreatedBy string
	Role      string
	// NewAccount is true when the use created the account.
	NewAccount bool
}

Accepted is what OnAccepted receives when a user uses an invite.

type Action

type Action string

Action says what activate did.

const (
	// ActionAccepted: the signed-in caller used the invite.
	ActionAccepted Action = "accepted"
	// ActionSignInRequired: the invite waits in the cookie for the visitor
	// to sign up or sign in.
	ActionSignInRequired Action = "sign_in_required"
)

The actions, better-invite's.

type ActivateResult

type ActivateResult struct {
	Action Action `json:"action"`
}

ActivateResult is the answer of activate.

type Created

type Created struct {
	ID        string         `json:"id"`
	Token     auth.WireToken `json:"token,omitempty"`
	URL       string         `json:"url,omitempty"`
	ExpiresAt time.Time      `json:"expiresAt"`
}

Created is the answer of create. Token and URL are given for a public invite only; a private one goes by mail.

type IDRequest

type IDRequest struct {
	ID string `json:"id"`
}

IDRequest names an invite by id.

type Invite

type Invite struct {
	ID        string    `json:"id"`
	Email     string    `json:"email,omitempty"`
	Role      string    `json:"role,omitempty"`
	MaxUses   int64     `json:"maxUses"`
	Uses      int64     `json:"uses"`
	Status    Status    `json:"status"`
	ExpiresAt time.Time `json:"expiresAt"`
	CreatedAt time.Time `json:"createdAt"`
}

Invite is one invite in list: its use count, never its token.

type ListRequest

type ListRequest struct {
	Limit  int `json:"limit,omitempty"`
	Offset int `json:"offset,omitempty"`
}

ListRequest pages the caller's invites, newest first.

type ListResult

type ListResult struct {
	Invites []Invite `json:"invites"`
	Total   int64    `json:"total"`
}

ListResult is a page of list.

type Option

type Option func(*Plugin)

Option configures the plugin.

func CanCreate

func CanCreate(fn func(ctx context.Context, inviter auth.User, in Request) error) Option

CanCreate decides who may create which invite: return an error (such as ErrForbidden) to refuse. Without it every signed-in user may create an invite with no role, and any role is refused.

func HideInviter

func HideInviter() Option

HideInviter leaves the inviter's name out of get, better-invite's shareInviterName false.

func Links(fn func(t auth.Token) string) Option

Links builds the URL of an invite from its token, for an app whose page is not DefaultPage.

func OnAccepted

func OnAccepted(fn func(ctx context.Context, a Accepted) error) Option

OnAccepted runs in the transaction that records a use, to apply the role or anything else the invite stands for; an error undoes the use (and the sign-up it came with).

func PublicOnly

func PublicOnly() Option

PublicOnly turns private invites off: a create that names an Email is refused with ErrForbidden, and the email plugin is no longer needed. Without it a private invite is mailed, so auth.New refuses the plugin with ErrNoEmail unless a plugin that delivers email (plugin/email) is wired, rather than the first private invite failing.

func Required

func Required() Option

Required refuses a sign-up by a client (a public operation) that brings no valid invite. Users an admin or the app creates from Go are not refused.

func WithCookieTTL

func WithCookieTTL(d time.Duration) Option

WithCookieTTL replaces DefaultCookieTTL.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL replaces DefaultTTL, an invite's default life.

type Plugin

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

Plugin is the invite plugin. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Activate

func (p *Plugin) Activate(ctx context.Context, tok auth.Token) (ActivateResult, error)

Activate uses the invite of tok for the signed-in caller, or keeps it in the cookie for a signed-out one.

func (*Plugin) Cancel

func (p *Plugin) Cancel(ctx context.Context, id string) error

Cancel cancels one of the signed-in user's own invites.

func (*Plugin) Create

func (p *Plugin) Create(ctx context.Context, in Request) (Created, error)

Create creates an invite as the signed-in user.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks spend the invite a request brings: on user creation, inside the sign-up's transaction, where a bad invite refuses the sign-up; and on session creation for an existing user who activated an invite while signed out, where a bad one is ignored so it never blocks a sign-in. ds:def id=plugin-datahooks-8kkfp2xr owner=@khanakia stability=stable

func (*Plugin) Get

func (p *Plugin) Get(ctx context.Context, tok auth.Token) (View, error)

Get shows the invite of tok.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core and fills the default link.

func (*Plugin) List

func (p *Plugin) List(ctx context.Context, in ListRequest) (ListResult, error)

List returns the signed-in user's invites.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the six operations.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds lookups of a token and invites created per IP.

func (*Plugin) Reject

func (p *Plugin) Reject(ctx context.Context, tok auth.Token) error

Reject declines a private invite addressed to the signed-in user.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the two tables.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the two tables. A use's key is invite and user, so one user uses one invite once.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables tells DeleteUser which rows are a user's: the invites they created, which stop working with them, and their uses.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate refuses lifetimes that are not positive, or longer than MaxTTL.

type Request

type Request struct {
	// Email makes the invite private: mailed to this address and usable
	// once; only the account with this address may reject it. The token is
	// the proof, as better-invite's: whoever holds the mailed link may use
	// it, which is how an invite to a work address can create an account
	// under another. Empty makes a public invite whose token the creator
	// shares.
	Email string `json:"email,omitempty"`
	// Role is stored and handed to OnAccepted; refused unless CanCreate is
	// set.
	Role string `json:"role,omitempty"`
	// MaxUses bounds a public invite's uses; 0 means unlimited. A private
	// invite is used once.
	MaxUses int64 `json:"maxUses,omitempty"`
	// ExpiresIn is seconds until the invite stops working; 0 means the
	// plugin's TTL. At most MaxTTL.
	ExpiresIn int64 `json:"expiresIn,omitempty"`
}

Request is the input of create.

type Status

type Status string

Status is an invite's state.

const (
	StatusPending  Status = "pending"
	StatusUsed     Status = "used"
	StatusCanceled Status = "canceled"
	StatusRejected Status = "rejected"
)

The states, better-invite's names. An invite whose uses ran out is used; an expired one keeps its last state and is refused by its expiry.

type TokenRequest

type TokenRequest struct {
	Token auth.WireToken `json:"token"`
}

TokenRequest carries an invite token.

type View

type View struct {
	Status      Status    `json:"status"`
	ExpiresAt   time.Time `json:"expiresAt"`
	Role        string    `json:"role,omitempty"`
	InviterName string    `json:"inviterName,omitempty"`
	// Private is true for an invite mailed to one address.
	Private bool `json:"private"`
}

View is what get shows the holder of a token.

Jump to

Keyboard shortcuts

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