Skip to content

Latest commit

 

History

History
265 lines (200 loc) · 12.9 KB

terms.md

File metadata and controls

265 lines (200 loc) · 12.9 KB

Terms of service

You will have an app that records which version of its terms each user accepted and when, takes that acceptance from the sign-up form without any change to the sign-up code, asks existing users to accept a new version, and refuses a page to anyone who has not accepted the version in force.

Before you start

  • An app with sign-in, as in your first app. The plugin works with every sign-in method, because it hooks user creation rather than any one form.
  • The example is examples/terms: email and password sign-in, a public route that tells the form which version to show, and a dashboard behind the check. go run ./terms in examples/ serves it on localhost:8081; go test ./terms runs the flow below.
  • What the plugin stores: two columns on auth_user, terms_version and terms_accepted_at. It keeps only the latest acceptance, not a history, and it does not store or serve the text of the terms; the version is a label the app chooses.

Step 1 — Wire the plugin with the current version

terms.Current is the one option and it is required: a terms plugin with no version would accept nothing and refuse everyone, so auth.New fails without it. Pick a label that changes whenever the terms do; the plugin compares it exactly.

newAuth · examples/terms/main.go:98-106

func newAuth(db *sql.DB, m email.Mailer) (*auth.Auth, *terms.Plugin, error) {
	tm := terms.New(terms.Current(currentTerms))
	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),
	)
	return a, tm, err
}

Check: auth.New returns an error if you forget the version: auth: plugin "terms": terms: pass terms.Current with the version users must accept.

Step 2 — Tell the sign-up form which version to show

The form must show the terms and send back the version it showed. Give it a public route for that, so a deploy that bumps the version needs no frontend change:

curl -s localhost:8081/terms
{"version":"2026-09","header":"Terms-Version"}

The header name is Terms-Version (terms.TermsHeader). The ready-made pages in authui set it from their own checkbox when this plugin is registered.

Step 3 — Accept at sign-up

The form sends the version in the Terms-Version header of the sign-up request. Nothing else changes: a data hook on user creation reads the header and records the acceptance in the same transaction, so every sign-up method (password, code, link, provider) records it without knowing the plugin exists.

Plugin.DataHooks · plugin/terms/terms.go:211-220

func (p *Plugin) DataHooks() auth.DataHooks {
	return auth.DataHooks{User: auth.TableHooks[auth.User]{
		AfterCreate: func(ctx context.Context, _ sqlb.DBTX, u *auth.User) error {
			if auth.RequestHeader(ctx, TermsHeader) != p.current {
				return nil
			}
			return p.record(ctx, u.ID)
		},
	}}
}

Alice's form showed the current version:

O='Origin: http://localhost:8081'
curl -s -c alice.txt -X POST localhost:8081/auth/password/sign-up -H "$O" -H 'Terms-Version: 2026-09' \
  -d '{"identifier":"alice@example.com","password":"correct horse battery staple","name":"Alice"}'
{"user":{"id":"usr_nrmkocl77dsmozgzrgovkydp","name":"Alice","image":""},"verificationRequired":false,"secondFactorRequired":false}

A stale version is not recorded: the user saw other terms than the ones in force. Bob's form was deployed before the bump:

curl -s -c bob.txt -X POST localhost:8081/auth/password/sign-up -H "$O" -H 'Terms-Version: 2025-01' \
  -d '{"identifier":"bob@example.com","password":"correct horse battery staple","name":"Bob"}'
{"user":{"id":"usr_vo4z4bgr4zgzoczkrywx7fmm","name":"Bob","image":""},"verificationRequired":false,"secondFactorRequired":false}

Check from Go, with Accepted:

Plugin.Accepted · plugin/terms/terms.go:307-313

func (p *Plugin) Accepted(ctx context.Context, userID string) (bool, error) {
	var c TermsColumns
	if err := p.core.LoadUserColumns(ctx, userID, Bindings(&c)...); err != nil {
		return false, err
	}
	return c.TermsVersion == p.current, nil
}

Step 4 — Refuse a page until the current version is accepted

RequireCurrent is middleware for the app's own routes. It sits inside authhttp.Middleware and after authhttp.RequireUser, and answers 403 terms.not_accepted until the user has accepted the version in force; a signed-out request gets 401 auth.signed_out first.

newHandler · examples/terms/main.go:113-119

func newHandler(a *auth.Auth, tm *terms.Plugin) http.Handler {
	mux := http.NewServeMux()
	mux.Handle(authPrefix+"/", authhttp.Handler(a, authhttp.Prefix(authPrefix)))
	mux.Handle("GET /terms", termsHandler(tm))
	mux.Handle("GET /dashboard", authhttp.Middleware(a)(authhttp.RequireUser(tm.RequireCurrent()(http.HandlerFunc(dashboard)))))
	return mux
}

Plugin.RequireCurrent · plugin/terms/terms.go:320-335

func (p *Plugin) RequireCurrent() func(http.Handler) http.Handler {
	return authhttp.RequireFunc(func(r *http.Request) error {
		u, ok := auth.CurrentUser(r.Context())
		if !ok {
			return auth.ErrSignedOut
		}
		yes, err := p.Accepted(r.Context(), u.ID)
		if err != nil {
			return err
		}
		if !yes {
			return ErrNotAccepted
		}
		return nil
	})
}

By the time the handler runs, both checks have passed:

dashboard · examples/terms/main.go:145-147

func dashboard(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, dashboardResponse{Hello: authhttp.MustUser(r).Name})
}

Alice reaches the dashboard; Bob is refused:

curl -s -b alice.txt localhost:8081/dashboard
curl -s -b bob.txt localhost:8081/dashboard
curl -s localhost:8081/dashboard
{"hello":"Alice"}
{"error":{"code":"terms.not_accepted","message":"Accept the current terms to continue."}}
{"error":{"code":"auth.signed_out","message":"Sign in to continue."}}

A frontend that sees terms.not_accepted shows the terms and the accept button; the message says so: "Accept the current terms to continue."

Step 5 — Accept later

An existing user accepts through the plugin's one route, terms.accept, POST /auth/terms/accept with {"version": "…"}. The version must be the current one: a page left open across a terms change cannot record acceptance of terms the user never saw.

curl -s -b bob.txt -X POST localhost:8081/auth/terms/accept -H "$O" -d '{"version":"2025-01"}'
curl -s -b bob.txt -X POST localhost:8081/auth/terms/accept -H "$O" -d '{"version":"2026-09"}'
curl -s -b bob.txt localhost:8081/dashboard
{"error":{"code":"terms.stale_version","message":"The terms changed. Read the current version and accept again."}}
{"ok":true}
{"hello":"Bob"}

From Go: tm.Accept(ctx, version), where ctx carries the request (a.WithRequest) so the plugin knows who is signed in. Accepting the version the user already accepted is a no-op that keeps the first time.

The acceptance history

auth_user holds only the latest acceptance. Every acceptance is also kept, one row per version and time, in the plugin's table auth_terms_acceptance, for the record a regulator or a dispute asks for. tm.History(ctx, userID) returns it oldest first, as []terms.Acceptance{Version, AcceptedAt}; there is no route, so expose it from your own admin page. DeleteUser removes the rows. On a deployment that had the plugin before the table, the migration copies each user's latest acceptance from auth_user; the ones before it were overwritten before anything kept them.

Options you may want

Option What it does
terms.Current(version) The version users must accept. Required. Bump it to make every user accept again
tm.Version() The current version, for a form or a route like GET /terms above
tm.Accepted(ctx, userID) Whether a user accepted the current version, for a job or an admin page
tm.History(ctx, userID) Every version the user accepted and when, oldest first
terms.TermsColumns + terms.Bindings The two columns in a composite user query, or through a.LoadUserColumns (databases)

The plugin has no rate limit of its own and depends on no other plugin.

From TypeScript

Use this when the sign-up form shows the terms and a page asks an existing user to accept a new version. The typed client has the one route as client.terms.accept; list terms() in plugins. Acceptance at sign-up is not a call of its own: the form sends the version it showed in the Terms-Version header of the sign-up call, through the per-call headers option, and any sign-up method records it:

signUpAcceptingTerms · clients/ts/examples/plugin-headers.ts:60-66

export async function signUpAcceptingTerms(address: string, secret: string, shownVersion: string): Promise<boolean> {
  const { error } = await auth.password.signUp(
    { identifier: address, password: secret },
    { headers: { "Terms-Version": shownVersion } },
  );
  return error === null;
}

An existing user accepts the version in force:

acceptCurrentTerms · clients/ts/examples/plugin-headers.ts:71-75

export async function acceptCurrentTerms(version: string): Promise<"accepted" | "show-new-terms" | string> {
  const { error } = await auth.terms.accept({ version });
  if (!error) return "accepted";
  return error.code === "terms.stale_version" ? "show-new-terms" : error.message;
}

The codes a frontend handles: terms.stale_version from accept (the terms changed while the page was open; show the current ones and ask again), and terms.not_accepted (403) from your app's own routes behind RequireCurrent (Step 4), which the page answers by showing the terms and calling accept. Those routes are not part of the client; read the code from their JSON body the same way. More in What can go wrong.

There is no runnable TypeScript example for terms; the blocks above are type-checked by task ts:check, and Steps 3 to 5 show the same calls with curl.

Pitfalls:

  • Read the current version from the server, never hard-code it in the frontend. Step 2's public route answers it, so a deploy that bumps the version needs no frontend change; a form that sends a stale version signs the user up without recording an acceptance.
  • A sign-up with a stale or missing version still succeeds; the user is then stopped by terms.not_accepted on the first guarded route. Handle that code in the page, not only in the form.

Every method with its route and types: TypeScript client reference › terms.

What can go wrong

  • auth: plugin "terms": terms: pass terms.Current with the version users must accept — terms.New() without terms.Current. Fix: pass the version.
  • 403 terms.not_accepted ("Accept the current terms to continue.") — the user has not accepted the current version. Show the terms and call POST /auth/terms/accept.
  • 409 terms.stale_version ("The terms changed. Read the current version and accept again.") — accept was sent a version other than the current one. Fetch the current version and show it again.
  • 401 auth.signed_out from a route behind RequireCurrent — the middleware is outside authhttp.Middleware, or the request has no session. Put it inside, after RequireUser.
  • Acceptance sent with sign-up was not recorded — the header carried a version other than the current one, which is by design, or the header name is misspelled; it is Terms-Version.

Next

  • Ready-made pages: a sign-up page with the terms checkbox built in.
  • Databases: reading terms_version and terms_accepted_at in your own queries.
  • The plugin's README is the reference for its routes, columns and errors.