terms

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

README

github.com/ubgo/auth/plugin/terms

Records which version of the app's terms each user accepted, and when, and lets the app's routes refuse users who have not accepted the version in force.

Install

go get github.com/ubgo/auth

The package is in the core module github.com/ubgo/auth, so it brings no extra dependency beyond github.com/ubgo/sqlb.

Wire it

Taken from the integration test TestTerms_AcceptAtSignUpAndOnBump:

tm := terms.New(terms.Current("2026-09"))
a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(email.New(email.Login(), email.Recover(), email.WithMailer(m)), password.New(), tm),
)
// then: a.Migrate(ctx), and mount authhttp.Handler(a, authhttp.Prefix("/auth"))

app := authhttp.Middleware(a)(authhttp.RequireUser(tm.RequireCurrent()(dashboard)))

RequireCurrent must sit inside authhttp.Middleware and after authhttp.RequireUser; on its own it answers a signed-out request with 401 auth.signed_out. It depends on no other plugin. auth.New fails without terms.Current, with terms.ErrNoVersion (terms: pass terms.Current with the version users must accept).

Acceptance is recorded two ways:

When How
At sign-up The sign-up request carries the header Terms-Version: 2026-09 (terms.TermsHeader). A data hook on user creation reads it, so every sign-up method (password, code, link, provider) records acceptance without knowing this plugin exists. The ready-made sign-up page of authui sets the header from its checkbox.
Later, after the version changes POST /auth/terms/accept {"version": "2026-09"}

A version other than the current one is never recorded: at sign-up it is ignored, and accept refuses it with ErrStale.

Options

Option What it does Default
terms.Current(version) The version users must accept, such as "2026-09"; compared exactly, so changing it makes every user accept again none; required

Routes and Go API

Route Go method Requirement Rate limit
POST /auth/terms/accept {version} Accept(ctx, version) SignedIn none

The operation id is terms.EndpointAccept (terms.accept). From Go:

Function Use
(*Plugin).Accepted(ctx, userID) Whether the user accepted the current version; auth.ErrUserNotFound for an unknown user
(*Plugin).Version() The current version, for a form that shows it
(*Plugin).RequireCurrent() Middleware, shown above
(*Plugin).History(ctx, userID) Every acceptance of the user, oldest first, as []Acceptance{Version, AcceptedAt}; never nil

Tables and columns

It adds two columns to auth_user, the latest acceptance:

Column Type Meaning
terms_version text, default '' The last version the user accepted; '' for never
terms_accepted_at timestamp, nullable When they accepted it

And it owns auth_terms_acceptance, alias tma, one row per acceptance, removed by DeleteUser:

Column Type Meaning
user_id key The user
version text The version accepted
accepted_at timestamp When

The key is all three columns, so a version accepted again after the current one changed back to it is a second row. Accepting the version the user already accepted adds nothing and keeps the first time. The migration that creates the table copies the acceptance already on auth_user, so a deployment that had the plugin before keeps it; earlier acceptances were overwritten before anything kept them. Typed columns: terms.TableAcceptance, terms.AcceptanceUserID, terms.AcceptanceVersion, terms.AcceptanceAt.

Typed columns: terms.Version, terms.AcceptedAt; terms.TermsColumns + terms.Bindings for a composite user or LoadUserColumns. These plugin columns are not in the stability contract (stability_test.go covers only the core's own columns).

Errors

Error variable Code HTTP When
ErrNotAccepted terms.not_accepted 403 RequireCurrent on a user who has not accepted the current version
ErrStale terms.stale_version 409 accept with a version other than the current one
ErrNoVersion (plain error, no code) n/a auth.New without terms.Current

Security notes

Property Pinned by
Acceptance sent with sign-up is recorded; a stale version is not TestTerms_AcceptAtSignUpAndOnBump
A page left open across a terms change cannot accept terms the user never saw (accept refuses a stale version) TestTerms_AcceptAtSignUpAndOnBump
RequireCurrent refuses until the current version is accepted, and refuses a signed-out request TestTerms_AcceptAtSignUpAndOnBump, TestOrgCov_TermsRefusalsAndFailures
A database failure while recording or checking acceptance is an error, never a pass TestOrgCov_TermsRefusalsAndFailures
Every acceptance is kept, oldest first; accepting twice adds nothing; the migration copies an earlier acceptance; DeleteUser removes the rows integration/TestR23_TermsHistory, plugin/terms/TestHistory_Wiring
auth.New refuses the plugin without a version TestNew_RequiresAVersion

Limits: only the latest acceptance is kept, not a history of every version a user accepted. The version is a label the app chooses; the plugin does not store or serve the terms text.

Example

Documentation

Overview

Package terms records which version of the terms each user accepted, and when, and lets the app refuse users who have not accepted the current one.

A sign-up form accepts in the same request by sending the version in the TermsHeader header; a data hook on user creation reads it, so every sign-up method — password, code, link, provider — records acceptance without knowing this plugin exists. Users who signed up before a new version accept it with the accept operation.

The latest acceptance sits on auth_user, for a cheap "has this user accepted" check; every acceptance is also kept, one row per version and time, in auth_terms_acceptance, read with History, for the record a regulator or a dispute asks for.

Index

Constants

View Source
const (
	ColVersion    = "terms_version"
	ColAcceptedAt = "terms_accepted_at"
)

Columns this plugin adds to auth_user.

View Source
const (
	//ds:def id=terms-tablenameacceptance-8wv3kq2n owner=@khanakia stability=stable
	TableNameAcceptance  = "auth_terms_acceptance"
	TableAliasAcceptance = "tma"

	ColAcceptanceUserID = "user_id"
	ColAcceptanceVer    = "version"
	ColAcceptanceAt     = "accepted_at"
)

The history table: one row per acceptance. The key is user, version and time, so a version accepted again after the current one changed back to it is a second row rather than a lost one. There is no foreign key (ADR-0002); DeleteUser removes a user's rows through UserTables.

View Source
const EndpointAccept auth.EndpointID = "terms.accept"

EndpointAccept records acceptance of the current version.

View Source
const PluginName = "terms"

PluginName is the plugin's permanent identity.

View Source
const TermsHeader = "Terms-Version"

TermsHeader carries the version a sign-up form showed and the user ticked.

Variables

TableAcceptance and its typed columns, for an app that reads or joins the history (reads are open; only the plugin writes).

Typed columns on auth_user, for an app that reports on acceptance in its own queries.

View Source
var (
	// ErrNotAccepted answers a user who has not accepted the current
	// version; its message tells the client to show the terms.
	ErrNotAccepted = auth.NewError("terms.not_accepted", http.StatusForbidden, "Accept the current terms to continue.")
	// ErrStale refuses acceptance of a version other than the current one:
	// the page the user read is out of date.
	ErrStale = auth.NewError("terms.stale_version", http.StatusConflict, "The terms changed. Read the current version and accept again.")
	// ErrNoVersion fails auth.New when Current was not passed: a terms
	// plugin with no version would accept nothing and refuse everyone.
	ErrNoVersion = errors.New("terms: pass terms.Current with the version users must accept")
)

Errors. The first two carry stable codes a client checks for.

Functions

func Bindings

func Bindings(c *TermsColumns) []sqlb.Binding

Bindings binds TermsColumns, for a composite user query or LoadUserColumns.

func Current

func Current(version string) func(*Plugin)

Current sets the version users must accept, such as "2026-09". Required. Any string works; it is compared exactly, so bumping it makes every user accept again.

Types

type AcceptRequest

type AcceptRequest struct {
	Version string `json:"version"`
}

AcceptRequest names the version the user read. A version other than the current one is refused, so a stale page cannot record acceptance of terms the user never saw.

type Acceptance

type Acceptance struct {
	Version    string    `json:"version"`
	AcceptedAt time.Time `json:"acceptedAt"`
}

Acceptance is one row of a user's history: a version and when it was accepted.

type Plugin

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

Plugin records terms acceptance. Build it with New and Current.

func New

func New(opts ...func(*Plugin)) *Plugin

New builds the plugin. Pass Current; auth.New fails with ErrNoVersion without it.

func (*Plugin) Accept

func (p *Plugin) Accept(ctx context.Context, version string) error

Accept records acceptance for the signed-in user.

func (*Plugin) Accepted

func (p *Plugin) Accepted(ctx context.Context, userID string) (bool, error)

Accepted reports whether userID accepted the current version.

func (*Plugin) Columns

func (p *Plugin) Columns() []auth.ColumnExtension

Columns adds the accepted version and time to auth_user.

func (*Plugin) DataHooks

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

DataHooks record acceptance for a user created by a request that sent the current version in TermsHeader. A stale version is not recorded: the user saw other terms than the ones in force.

func (*Plugin) History

func (p *Plugin) History(ctx context.Context, userID string) ([]Acceptance, error)

History returns every acceptance of userID, oldest first; never nil, so it encodes as []. Acceptances recorded before the history table existed are there too, copied from auth_user by the migration, but only the latest per user: earlier ones were overwritten before anything kept them.

func (*Plugin) Init

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

Init captures the core.

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) Operations

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

Operations lists the plugin's operation.

func (*Plugin) RequireCurrent

func (p *Plugin) RequireCurrent() func(http.Handler) http.Handler

RequireCurrent is middleware that refuses a signed-in user who has not accepted the current version with ErrNotAccepted. It must be inside authhttp.Middleware and after RequireUser.

func (*Plugin) Tables

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

Tables declares the history table, so New can check its alias and an app can rename it.

func (*Plugin) TypedMigrations

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

TypedMigrations creates the history table and backfills it from auth_user; the column migrations run before a plugin's own, so the columns exist by then.

func (*Plugin) UserTables

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

UserTables tells DeleteUser which rows are a user's: every history row, by user id.

func (*Plugin) Validate

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

Validate refuses a plugin built without Current.

func (*Plugin) Version

func (p *Plugin) Version() string

Version is the version users must accept, for a form that shows it.

type TermsColumns

type TermsColumns struct {
	TermsVersion    string
	TermsAcceptedAt *time.Time
}

TermsColumns is this plugin's flat part of a composite user. Flat because the columns live on auth_user itself (ADR-0001); an empty TermsVersion means the user never accepted any version.

Jump to

Keyboard shortcuts

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