invite

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: 15 Imported by: 0

Documentation

Overview

Package invite issues, revokes and redeems organization invitations.

An invite is a single-use, revocable, expiring grant of one membership in one organization, and three decisions shape everything here.

**It is bound to an address, not to whoever holds it** (D27). Redemption compares the redeeming account's address against the address invited, so a link forwarded into a group chat cannot add a stranger. That comparison is a new place that could answer "does this address have an account", so every refusal in Redeem is the same refusal, spends argon2 work before returning, and says nothing about which check failed. The reason is logged, never returned. What that work is and is not equal across is Redeem's own doc.

**It may carry any role at or below the inviter's own rank** (D28), and no more than editor when an API key issued it (D43). Those are two axes, not one ceiling twice: the first bounds the invitation against whoever sent it, the second against the *kind of credential* that sent it. D28 read the first as sufficient and it is not, because redemption turns an invitation into an interactive account — one that requireSessionActor no longer refuses, that holds whatever its role holds, and that revoking the key does not revoke.

**It expires** (D29), from creation rather than from delivery: mail leaves through the outbox on the scheduler's tick (D23), so there is no send moment to start the clock at.

Redemption creates a membership and nothing else (D6). No personal organization, no workspace — the invited person is a colleague in somebody else's organization, and the "one personal org per user" invariant is broken here deliberately.

Index

Constants

View Source
const (
	StatusPending  = "pending"
	StatusExpired  = "expired"
	StatusRevoked  = "revoked"
	StatusRedeemed = "redeemed"
)

Invitation statuses.

View Source
const DefaultRole = "viewer"

DefaultRole is the role an invitation carries when none is named.

The least powerful of the four, because this is the value a caller gets by not thinking about it and the failure mode of the alternative is somebody admitted with more authority than the request asked for.

View Source
const MailKind = "invitation"

MailKind names the mail template, which is also what lands in the outbox's `kind` column. It is the filename in internal/ui/templates/mail, without the extension.

View Source
const PermWrite = "members.write"

PermWrite guards issuing, listing and revoking invitations. M27 is its first enforcement; the permission itself has been seeded since Phase 1.

Delegable to an API key, and that is a recorded conclusion rather than an omission (D28, applying D18): a key may bring collaborators in. What makes it safe is not D28's rank ceiling — that argument was wrong, and F29 is what it cost — but auth.KeyIssuableRoles, which bounds the role a key may put somebody at. auth.NonDelegableScopes governs what a key may *hold*; D43 governs what it may *make*, and this permission needs both. The two live side by side in internal/auth, because this permission is not the only door D43 has to cover: team.ChangeRole and team.Grant assign the same roles to somebody already admitted.

View Source
const TokenBytes = 32

TokenBytes is the entropy in an invitation token, matching a session token. Well beyond guessing range, and short enough that the resulting link fits in a chat message without wrapping.

Variables

View Source
var ErrNotRedeemable = errors.New("invite: this invitation cannot be redeemed")

ErrNotRedeemable is every redemption failure.

One error for all of them, deliberately: no such token, expired, revoked, already spent, wrong address, unknown address on a closed instance, wrong password, already a member. Distinguishing any of them would answer a question about somebody else's account (D27, and the milestone's no-enumeration bullet). The real cause is logged.

Functions

This section is empty.

Types

type Config

type Config struct {
	// AppURL is the origin an invitation link points at.
	AppURL string
	// TTL is how long an invitation stays redeemable, from creation (D29).
	TTL time.Duration
	// NewAccounts says whether redemption may create an account that does not
	// exist yet. False under LINKCTRL_SIGNUP_MODE=closed, where the environment
	// ceiling is absolute and an invite may only add users who already exist
	// (D7).
	NewAccounts bool
	// Hasher verifies the password of an account being joined, and hashes the
	// one for an account being created. The service's own, so the cost
	// parameters an operator configured are the ones that apply here.
	Hasher *auth.Hasher
	// Lockout is the same policy Login applies, and it is here for the same
	// reason Hasher is: redemption verifies a password, so it is a second place
	// an account's password can be guessed at. Without it the lockout an
	// operator configured covered one of the two doors (F51). The zero value
	// disables lockout, which is what a threshold of zero means everywhere else.
	Lockout auth.LockoutPolicy
	// Audit records the three lifecycle events. Nil records nothing.
	Audit audit.Recorder
	// Notify tells the inviter their invitation was accepted. Nil tells nobody.
	Notify notify.Notifier
	// Mail queues the invitation message. Nil is an instance with no relay,
	// where the copyable link is the whole delivery path.
	Mail Enqueuer
	Log  *slog.Logger
}

Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree: the package doing the work does not read the environment.

type CreateInput

type CreateInput struct {
	Email string
	// Role is a built-in role slug. Empty means DefaultRole.
	Role string
}

CreateInput describes a new invitation.

type Created

type Created struct {
	Invitation
	// URL is the copyable link, and the only time the raw token is available.
	// It exists on every path, mailer or no mailer, because on a default
	// instance the mailer is off (D1) and this is the whole delivery mechanism.
	URL string `json:"url"`
	// Emailed says whether a message was queued for delivery. False on an
	// instance with no relay configured, which is not a failure.
	Emailed bool `json:"emailed"`
}

Created is a new invitation plus the two things that exist only in the response that made it.

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 a test satisfies it with a slice, and so "no mailer configured" is a nil interface rather than a flag every call site has to remember to check.

type Invitation

type Invitation struct {
	ID    uuid.UUID `json:"id"`
	Email string    `json:"email"`
	Role  string    `json:"role"`
	// InvitedBy is the address of whoever sent it, empty once that account is
	// gone. A label rather than an id, for the reason the audit log keeps one.
	InvitedBy string `json:"invited_by"`
	// Status is one of pending, expired, revoked, redeemed. Derived rather than
	// stored, because expiry is a comparison against the clock and a stored copy
	// would need a job to keep it true.
	Status     string     `json:"status"`
	CreatedAt  time.Time  `json:"created_at"`
	ExpiresAt  time.Time  `json:"expires_at"`
	RevokedAt  *time.Time `json:"revoked_at,omitempty"`
	RedeemedAt *time.Time `json:"redeemed_at,omitempty"`
}

Invitation is an invite as an administrator sees it. The token is absent by construction — only its hash is stored, so it cannot be listed.

type Offer

type Offer struct {
	OrganizationName string
	Role             string
	ExpiresAt        time.Time
}

Offer is what the redemption page may show somebody holding a link.

Deliberately not the invited address. Showing it would tell whoever picked the link up exactly which address to type, which is the one thing D27's binding is there to prevent — so the page says which organization, and the person redeeming supplies the address they were invited at.

type RedeemInput

type RedeemInput struct {
	Token string
	// Email must be the address the invitation was issued to (D27). It is asked
	// for rather than taken from a session, because the invitation names an
	// address and the account signed in elsewhere in this browser may not be it.
	Email string
	// Name is used only when the account is being created, and defaults to the
	// local part of the address.
	Name string
	// Password authenticates an existing account, or becomes the password of the
	// account being created.
	Password string
}

RedeemInput is an attempt to join.

type Redeemed

type Redeemed struct {
	UserID           uuid.UUID
	Email            string
	OrganizationID   uuid.UUID
	OrganizationName string
	Role             string
	// Created is true when the account did not exist and was made here. False
	// means an existing account gained a membership.
	Created bool
}

Redeemed is the outcome of a successful redemption.

type Role

type Role struct {
	Slug        string `json:"slug"`
	Name        string `json:"name"`
	Description string `json:"description"`
	Rank        int32  `json:"rank"`
}

Role is one choice in the invite form.

type Service

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

Service issues and redeems invitations.

func NewService

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

func (*Service) AdmitsNewAccounts

func (s *Service) AdmitsNewAccounts() bool

AdmitsNewAccounts reports whether redemption may create an account that does not exist yet.

It exists so the question has **one** answer. The redemption page used to derive it a second time, from `Config.Auth.SignupMode != SignupClosed`, while enforcement read `cfg.NewAccounts` — set from `signupSvc.Effective().AdmitsNewAccounts()`, which is the *effective* mode rather than the configured one. Those agree today and are not the same expression: `open` with no mailer degrades to `invite`, and anything that made the effective mode diverge further would move the enforcement and leave the page behind, offering a form whose submission is refused (F19).

A nil receiver answers false, which is what a caller with no invite service configured should show: no form for an account it cannot create.

func (*Service) Create

func (s *Service) Create(ctx context.Context, actor *auth.Identity, in CreateInput) (*Created, error)

Create issues an invitation and returns the link, which is the only time the token exists.

Issuing one is an organization-wide act, because what redemption writes is an organization-wide membership — Redeem sets no workspace_id, deliberately, so somebody who accepts is in the organization rather than in one corner of it. The authority to do that therefore has to come from an organization-wide membership too (D44, M28's reopening): a workspace-scoped admin resolves inside their own workspace holding members.write, and admitting a new organization-wide member with it would hand out reach they do not have (F27).

func (*Service) List

func (s *Service) List(ctx context.Context, actor *auth.Identity) ([]Invitation, error)

List returns the organization's invitations, newest first.

Gated on the same organization-wide authority Create is, and not merely because symmetry is tidy. Every row discloses an invitee's address, the role they were offered and who offered it, for an object nobody scoped to one workspace has any reach over. Refusing the page whole is also the only honest shape: gating Revoke alone would draw a list of rows whose only button answers 403.

func (*Service) Offer

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

Offer describes a redeemable invitation to whoever holds its link.

ErrNotRedeemable for anything that is not currently redeemable, so a spent, revoked, expired or invented token are one answer. Reads without locking: a GET must not let a stranger hold a write lock on a row by opening a page.

func (*Service) Redeem

func (s *Service) Redeem(ctx context.Context, in RedeemInput) (*Redeemed, error)

func (*Service) Revoke

func (s *Service) Revoke(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

Revoke ends an invitation that has not been redeemed.

An id belonging to another organization answers not-found, the same as one that never existed, so ids cannot be probed. So does an invitation that was already revoked or already redeemed: a redeemed invite has produced a member, and reporting "revoked" for it would claim something that did not happen.

The organization-wide gate matters most here of the four, because this verb is the irreversible one. A revoked invitation cannot be un-revoked and Create refuses a workspace-scoped actor a replacement, so without it somebody with reach over one workspace could stop an owner staffing their own organization.

func (*Service) Roles

func (s *Service) Roles(ctx context.Context, actor *auth.Identity) ([]Role, error)

Roles lists the roles this actor may invite at: their own rank and below (D28), most powerful first.

Read from the seeded rows rather than listed in Go, so the four built-in roles have one definition and a form cannot offer something the ceiling check will then refuse. An actor whose role did not resolve carries auth.NoRoleRank and is offered nothing, which is the direction this fails in.

The rank is read from the organization-wide authority, exactly as Create's ceiling is. Reading it from the identity was the same mistake one step earlier: the identity's rank can be borrowed from a workspace-scoped membership, so an organization-wide viewer who is an admin in one workspace was offered admin here and refused it at the ceiling.

The D43 cap is applied for the same reason — the list is what a control renders, and offering a key a role Create will refuse is the same disagreement in the other direction.

Jump to

Keyboard shortcuts

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