API reference
The supported public surface: the root package, model, db, auth, accounts, i18n, and admin in full; migration primarily through its CLI workflow, and shell for tango shell. Anything not listed here that happens to be exported should be treated as an implementation detail that may change without notice. Generated package documentation (go doc ./... locally, or pkg.go.dev once published) complements this page with full doc comments, but this page is the map of what's actually meant for you to use.
For coding-agent-oriented recipes, see the root AGENTS.md and docs/agents/. Those files are compact task checklists and pointers to canonical examples, not a separate API surface.
Root package (github.com/angvp/tango)
| Symbol | What it's for | See also |
|---|---|---|
type App interface{ Name() string; Register(*Registry) error } |
The contract every installable app satisfies — local or reusable. | project structure, reusable apps |
func NewApp(name string, registerFn func(*Registry) error) App |
Build an App from a name and a registration closure — the pattern used throughout the tutorial and examples. |
tutorial part 1 |
type Config struct{ InstalledApps []App; Addr string; Middleware []Middleware; MiddlewareScope MiddlewareScope; NotFound, MethodNotAllowed View } |
Minimal project configuration. Middleware wraps every compiled route globally, first entry outermost; MiddlewareScope decides whether it also wraps Unmatched requests; NotFound/MethodNotAllowed optionally replace the router's 404/405 (with Allow set for 405). |
configuration, routing |
type MiddlewareScope int; MiddlewareScopeDefault, MiddlewareScopeRoutes, MiddlewareScopeAll |
What Config.Middleware wraps. Routes wraps each matched route. All wraps the whole router, resolving each request's route first and reporting an Unmatched request as "(unmatched)". The zero value is the framework's default (Routes), and an unknown value fails BuildRegistry. |
middleware scope |
func LoadConfigFromEnv(...ConfigOption) Config, func WithPortFromEnv() ConfigOption |
Config with Addr from TANGO_ADDR, else :8000. With WithPortFromEnv, PORT (as ":"+PORT) is the fallback before :8000. |
configuration |
func LoadDBConfigFromEnv() (db.DSN, error), func LoadEnvFile(path string) error |
Optional environment helpers used by generated projects. LoadDBConfigFromEnv parses TANGO_DB_DSN (default sqlite://app.db) with db.ParseDSN. |
configuration |
func BuildRegistry(config Config) (*Registry, error) |
Registers every Config.InstalledApps entry into a new Registry. Fails on ErrDuplicateApp. |
|
func Check(config Config) error |
Runs registration, compiles the route tree, and enforces app-contributed checks; the logic behind tango check's -check flag. |
app checks |
func DispatchFlags(config Config, sqlDB *sql.DB, dialect db.Dialect, migrations []migration.Migration) (bool, error) |
Handles the stable app-side flags (-check, -tango-dump-models, -tango-status, -tango-openapi, -migrate, -migrate -down) and reports whether serving should stop. |
project structure |
func Serve(config Config, sqlDB *sql.DB, dialect db.Dialect) error |
Builds the registry, runs registration, wires the db.Store, compiles routes, and serves. Blocks forever — delegates to ServeContext(context.Background(), ...), so there is no caller-triggered shutdown path. dialect must match whatever sqlDB was opened with — pass the same value given to DispatchFlags. |
project structure, application lifecycle |
func ServeContext(ctx context.Context, config Config, sqlDB *sql.DB, dialect db.Dialect, opts ...ServeOption) error |
Like Serve, but additionally starts every registered Lifecycle around the HTTP server's lifetime and stops gracefully when ctx is canceled: drains in-flight requests (force-closing, and still reporting the drain-timeout error, if the drain deadline is hit — errors.Is(err, context.DeadlineExceeded)), then stops every started Lifecycle in reverse order against one shared timeout budget. A Start failure, or cancellation while a Start is running (the Application context is canceled promptly so a cooperative Start can return; ServeContext always waits for it before proceeding, so no goroutine is leaked), rolls back already-started components and never starts the HTTP server. |
application lifecycle |
type ServeOption func(*serveConfig), func WithShutdownTimeout(d time.Duration) ServeOption, func WithReadHeaderTimeout(d time.Duration) ServeOption, func WithLogger(*slog.Logger) ServeOption, func WithRecorder(observability.Recorder) ServeOption |
Configure shutdown budgets, the request-header timeout (default 10s, 0 disables it, negative is an error), and framework-owned logging/metrics. WithLogger(nil) is invalid; omitted logging uses slog.Default(). Metrics are no-op unless configured. |
application lifecycle, observability |
type DBPool struct{ MaxOpen, MaxIdle int; MaxLifetime, MaxIdleTime time.Duration }, func WithDBPool(DBPool) ServeOption |
The database pool limits ServeContext applies to its *sql.DB. A zero field leaves that setting as the *sql.DB has it, so WithDBPool(DBPool{}) keeps the database/sql behavior; negative values are an error. Without the option, ServeContext raises the idle limit from 2 to 32 unless the app already bounded the pool with SetMaxOpenConns. |
configuration |
func DumpModels(config Config) ([]migration.Model, error) |
Registered models in the dialect-agnostic shape tango makemigrations diffs against; backs -tango-dump-models. |
migrations |
func DumpOpenAPI(config Config) ([]byte, error), type OpenAPIProvider interface{ OpenAPI() ([]byte, error) }, ErrNoOpenAPI |
The OpenAPI document of the installed openapi app; backs -tango-openapi and tango openapi. |
OpenAPI |
func Status(ctx, config, sqlDB, dialect, migrations) ProjectStatus |
Registration/database/migration status as one payload; backs -tango-status and tango tui. |
|
type Registry |
The app/model/route/admin registry apps register into. Key methods: Register(App) error, RunRegistration() error, Models() *model.Registry, Routes() *RouteRegistry, Admin() *admin.Registry, SetStore/Store, Checks() []AppCheck, RegisterLifecycle(Lifecycle) error, Lifecycles() []Lifecycle. |
|
type AppCheck struct{ Description string; Err error }, type Checker interface{ Checks() []AppCheck } |
Optional advisory checks an App may contribute. |
app checks |
type Lifecycle struct{ Name string; Start, Stop func(context.Context) error }, ErrDuplicateLifecycle |
A named component with optional startup/shutdown hooks, registered via Registry.RegisterLifecycle inside App.Register — never started implicitly. At least one of Start/Stop must be set; Name must be non-empty and unique (ErrDuplicateLifecycle, checkable via errors.Is). |
application lifecycle |
type Job struct{ Name string; Interval time.Duration; Run func(context.Context) error }, func (*Registry) RegisterJob(Job) error |
A named, interval-based piece of recurring work, recorded from an app's Register and run by ServeContext's scheduler (not by Serve). The first run is one interval after startup, a run that overlaps its next tick makes that tick skip, and an error or panic is logged as tango.scheduler.job_failed without stopping later ticks. Name must be non-blank and unique, Interval positive, Run non-nil. In-process only: no cron syntax, retries or persistence. |
background jobs |
type Middleware func(http.Handler) http.Handler, func Recoverer(...RecoveryOption) Middleware, func RequestID(...RequestIDOption) Middleware, func AccessLogger(...AccessLogOption) Middleware |
Standard Go middleware plus opt-in recovery, correlation IDs, and access logging. | routing, observability |
func MaxBodySize(n int64) Middleware |
Caps request bodies at n bytes: 413 with {"error":"request body too large"}, before the View when Content-Length is too large, else when a View returns the read's *http.MaxBytesError. The most restrictive applicable limit wins; n must be positive. |
request body limits |
func RequestIDFromContext(context.Context) (string, bool), func WithTrustedHeader(string) RequestIDOption |
Read the generated request ID or explicitly trust one validated inbound header. | observability |
EventViewError, EventPanic, EventAccessLog, EventJobFailed, EventRequestIDGenerationFailed |
Stable structured-log event names. | observability |
func Path(method, pattern string, view View, opts ...RouteOption) Route, func Name(name string) RouteOption, func Use(...Middleware) RouteOption, type URLs []Route |
Declaring routes, including optional route-scoped middleware. | routing |
func (*RouteRegistry) Include(prefix string, routes []Route, opts ...IncludeOption) error, func WithMiddleware(...Middleware) IncludeOption |
Mounting routes under a namespace prefix, including optional group-scoped middleware. | routing |
func (*RouteRegistry) Handler() (http.Handler, error), func (*RouteRegistry) Reverser() (Reverser, error) |
Compiling the route tree for serving / reverse lookup. Both require RunRegistration to have completed. |
routing |
func (*RouteRegistry) IncludedNames() []string, func (*RouteRegistry) IncludedPatterns() []string |
The fully-qualified names and the mounted path patterns of every route included so far, in inclusion order. Mainly for tests of how Include namespaces routes; Described is the fuller view for tools. |
routing |
type RouteDoc struct{ Summary, Description string; Tags []string; Body, Query any; Responses map[int]any; Bearer, Deprecated, Hidden bool }, func Doc(RouteDoc) RouteOption, type DescribedRoute, func (*RouteRegistry) Described() []DescribedRoute |
Optional documentation of a route for tools that read the route table, such as openapi. |
OpenAPI |
type Params map[string]string, type Reverser interface{ Reverse(name string, params Params) (string, error) } |
Reverse URL lookup. | routing |
type Context, type View func(*Context) error |
The request/response wrapper every view receives: Param, Query, Bind, JSON, Redirect, HTML, Request, ResponseWriter, Context, Logger. Logger is request-scoped and enriched with route, method, and request ID when present. |
Context and binding, observability |
ErrDuplicateApp, ErrMalformedPattern, ErrDuplicateRouteName, ErrRegistrationNotComplete, ErrUnknownRouteName, ErrMissingParam |
Sentinel errors, checkable via errors.Is. |
observability (github.com/angvp/tango/observability)
| Symbol | What it's for |
|---|---|
type Recorder interface{ AddCounter(name string, delta int64, attrs ...slog.Attr); ObserveHistogram(name string, value float64, attrs ...slog.Attr) }, type NopRecorder struct{} |
Backend-neutral counter/histogram recording. Implementations must be concurrency-safe; NopRecorder's zero value is ready to use. |
MetricHTTPRequestDuration, MetricSchedulerJobInvocations, MetricRealtimeRoomEvents |
Stable adapter-facing metric names. Durations use seconds; labels use bounded vocabularies. |
type Snapshotter interface{ Snapshot() (Snapshot, error) }, type Snapshot, type RequestSummary, type JobSummary |
The read side of activity numbers, separate from Recorder. A Snapshot carries the source's Scope label, Since (when its window began), Window, request counts by status class with p50/p95/p99 latency in seconds, and job counts by outcome. A source that can fail returns the error. |
func NewWindow(...WindowOption) *Window, func WithClock(func() time.Time) WindowOption |
An opt-in, bounded, in-memory Recorder and Snapshotter: 60 one-minute buckets, global aggregates only (HTTP requests by status class and latency, job invocations by outcome), no route, method, status code or job name kept, reset on restart, scope "this instance only". Snapshot never fails. |
func Multi(...Recorder) Recorder |
Forwards every metric to each recorder in order, skipping nil ones. It does not recover panics. |
See structured logging and observability.
openapi (github.com/angvp/tango/openapi)
| Symbol | What it's for |
|---|---|
func New(Options) tango.App, func Dev(Options) tango.App, const EnvVar, type Options |
An opt-in app that serves the OpenAPI 3.1 document of every included route at Options.Path (default /openapi.json), and an optional UI at Options.UIPath (default /docs/). Dev serves them only when TANGO_OPENAPI is true. Its Checks fail tango check for a route that cannot be described. |
func Document([]tango.DescribedRoute, Options) (map[string]any, error), const Version |
The document as a value; deterministic. |
type UI interface{ Routes(base, specPath string) []tango.Route } |
A page that renders the document. |
swaggerui.New() (github.com/angvp/tango/openapi/swaggerui) |
Swagger UI 5.17.14 embedded in the binary; no network request. About 1.7 MB. |
See OpenAPI documentation and tango openapi.
model (github.com/angvp/tango/model)
| Symbol | What it's for |
|---|---|
type ModelMeta struct{ Name string; App string; Type reflect.Type; Fields []FieldMeta } |
Introspected metadata for one registered model. Name is the exact Go type name; App records which app registered it. |
type FieldMeta struct{ Name string; Type reflect.Type; PrimaryKey, Unique, Indexed, Editable bool; ForeignKey string; MaxLength int; JSON bool } |
One field's metadata, derived from its type and tango tag. ForeignKey is the target model name from tango:"fk=X", or "". MaxLength is the limit from tango:"varchar=n" in runes, or 0 for an unbounded string and every other kind. JSON is true for a string tagged tango:"json". |
type Registry, func (*Registry) Register(value any) error, func (*Registry) Get(name string) (ModelMeta, bool), func (*Registry) All() []ModelMeta, func (*Registry) ValidateForeignKeys() error |
The model registry, reached via tango.Registry.Models(). ValidateForeignKeys checks every fk= target is registered; tango.Check calls it automatically. |
ErrDuplicateModel, ErrNoPrimaryKey, ErrMultiplePrimaryKeys, ErrEmbeddedField, ErrUnsupportedField, ErrUnknownForeignKeyTarget, ErrInvalidFieldTag |
Sentinel errors from Register/ValidateForeignKeys. ErrInvalidFieldTag is a malformed varchar=n or text tag, named by model and field. |
MaxVarcharLength |
The largest varchar=n a tag may declare, 10,485,760 (PostgreSQL's own limit). |
See models and tags for the full tag/field-kind reference, and relationships and admin foreign keys for fk=.
db (github.com/angvp/tango/db)
| Symbol | What it's for |
|---|---|
type Dialect, SQLite, Postgres, MySQL |
Which SQL dialect a Store generates for. |
func NewStore(sqlDB *sql.DB, dialect Dialect) *Store |
Construct a Store. |
func (*Store) Create/Update(ctx, meta, dest) error, func (*Store) Get(ctx, meta, ...) error, func (*Store) List(ctx, meta, Query, dest) error |
Metadata-driven CRUD. When UseModels has been called, Create/Update also validate every set foreign key field against an existing related row (referential-integrity validation), failing with ErrInvalidForeignKey. See relationships and admin foreign keys. |
func (*Store) Delete(ctx, meta, pk) error, func (*Store) UseModels(models *model.Registry) |
Deletes the matching row; when UseModels has been called (tango.Registry.SetStore does this automatically), also cascades — deleting every row of every other registered model that references it via a foreign key field first, recursively, in one transaction. See relationships and admin foreign keys. |
type Query struct{ Where, Any []Condition; Limit, Offset int; OrderBy []string } |
Single-model filtering, pagination, and ordering for List; Where uses AND, Any is one OR group. |
type Condition struct{ Field string; Op Op; Value any }, type Op, OpEq, OpNe, OpGt, OpGte, OpLt, OpLte, OpLike |
Validated comparisons in Query; see the persistence guide for field kinds, SQL patterns, and nil handling. |
func (*Store) InTx(ctx, fn func(tx *Store) error) error |
Runs fn with a Store bound to one transaction: commits on nil, rolls back on an error (returned as is), rolls back and re-panics on a panic. Nested calls join the outer transaction (flat, no savepoints). See persistence. |
func (*Store) Count(ctx, meta, Query) (int, error) |
Count rows matching Where and Any, ignoring pagination and ordering. |
func (*Store) Query/QueryRow(ctx, dest, sql string, args ...any) error |
Raw-SQL escape hatches, reusing CRUD's scan-into-dest machinery — where Store stops and hand-written SQL begins for joins, aggregates, or cross-model filtering. |
func ColumnName(name string) string |
The snake_case table/column name derivation, exposed for anything that needs to match it (e.g. building raw SQL by hand). |
type DSN struct { Dialect Dialect; Driver, Source string } |
A parsed DSN: the dialect for NewStore, and the driver name and data source name for sql.Open. |
func ParseDSN(dsn string) (DSN, error) |
Turns a scheme-qualified DSN (sqlite://app.db, sqlite:///abs/path.db, sqlite://:memory:, postgres://…, mysql://user:pass@host:3306/db) into a DSN; any other form is an error. Applies SQLiteForeignKeysDSN for SQLite and a 5-second busy timeout unless the DSN sets one; for MySQL it sets UTC times, matched-row counts, utf8mb4_bin, a strict SQL mode and READ COMMITTED unless the DSN's own options say otherwise. Never registers a driver. See configuration. |
func SQLiteForeignKeysDSN(dsn string) string |
Appends the SQLite driver's _foreign_keys=on DSN parameter, so foreign key constraints are actually enforced on connections opened with the result. |
ErrNotFound |
Sentinel for a missing row on Get/Update/Delete. |
ErrValueTooLong, type ValueTooLongError struct{ Model, Field string; Max, Got int } |
Create/Update refuse a bounded string longer than its varchar=n limit, before any SQL. The error matches ErrValueTooLong; Max is the limit and Got the length, both in runes. Raw SQL is not checked. |
func IsUniqueConstraintViolation(err error) bool |
Reports whether an error from Create or Update is a unique-constraint violation, on any dialect. Attempt the write and let the database's constraint decide a duplicate, rather than a SELECT-then-INSERT check that two concurrent writers can both pass. |
ErrInvalidJSON, type InvalidJSONError struct{ Model, Field string; Err error } |
Create/Update refuse text that is not valid JSON in a field tagged tango:"json", before any SQL. The error matches ErrInvalidJSON; Err says where the syntax failed and never contains the submitted text. The empty string is valid (unset). Reads and raw SQL are not checked. |
ErrInvalidForeignKey |
Sentinel for referential-integrity validation failing on Create/Update — a set foreign key field references a row that doesn't exist. Distinct from model.ErrUnknownForeignKeyTarget, which validates the related model, not a specific row. |
See persistence CRUD and raw SQL and SQLite/PostgreSQL setup.
auth (github.com/angvp/tango/auth)
| Symbol | What it's for |
|---|---|
func HashPassword(password string) (string, error), func VerifyPassword(hash, password string) bool |
Thin bcrypt helpers for application-owned user models. |
func ValidateSessionModel(sessionMeta model.ModelMeta) error |
Checks auth's fixed session-model convention: primary key, Token string, UserID tagged as a foreign key, and ExpiresAt time.Time. |
func CreateSession(ctx, store, sessionMeta, userID, duration) (token, expiresAt, error) |
Creates a crypto-random, cookie-safe session token row for an app-owned session model. |
func SessionUser(ctx, store, sessionMeta, token) (userID any, ok bool, err error) |
Returns the stored user id for a valid, unexpired session token. |
func DeleteSession(ctx, store, sessionMeta, token) error |
Deletes session rows matching a token; missing tokens are a no-op. |
func CurrentUserID(ctx *tango.Context, store, sessionMeta, cookieName) (userID any, ok bool, err error) |
Reads a request cookie and resolves the current session's user id without hydrating the user model. |
func RequireLogin(store, sessionMeta, cookieName, loginPath, allowedPrefix, next) tango.View |
Wraps a tango.View, redirecting unauthenticated requests to loginPath with a next value validated against allowedPrefix. |
func DeriveCSRFToken(secret string) string, func VerifyCSRFToken(submitted, secret string) bool |
Form CSRF helpers for application login/logout forms. |
func SafeRedirect(raw, allowedPrefix, fallback string) string, func IsSafeRedirect(raw, allowedPrefix string) bool |
Same-app redirect validation helpers for application login flows. |
ErrInvalidSessionModel |
Sentinel for an app session model that does not match auth's fixed convention. |
See application auth. This package is primitives-only: no default User model, no signup view, no login view, and no sharing with admin auth.
auth/jwt (github.com/angvp/tango/auth/jwt)
| Symbol | What it's for |
|---|---|
type Claims struct{ Subject, Issuer, Audience string; IssuedAt, ExpiresAt time.Time } |
tanGO-owned, registered-claims-only decoded token payload. Subject is opaque application identity. |
type Key struct{ ID string; Secret []byte } |
One HS256 key identified by kid; secrets must contain at least MinimumSecretBytes bytes. |
func NewService(active Key, verificationKeys []Key, issuer, audience string, opts ...ServiceOption) (*Service, error) |
Constructs an immutable issuer/verifier. The active key signs and verifies; retired keys verify only. |
func WithMaxTTL(time.Duration) ServiceOption, func WithClockSkew(time.Duration) ServiceOption |
Configure issuance lifetime limits and up to five minutes of verification leeway. Defaults are DefaultMaxTTL (24 hours) and DefaultClockSkew (zero). |
func (*Service) Issue(subject string, ttl time.Duration) (string, error), func (*Service) Verify(token string) (Claims, error) |
Issue an HS256 token or validate its algorithm, key, signature, issuer, audience, and timestamps without a database lookup. |
type Extractor func(*http.Request) (string, error), BearerToken, QueryToken(name) |
Read a token from the standard bearer header or, when necessary, a named query parameter. |
func (*Service) Middleware(Extractor) tango.Middleware |
Optional-auth HTTP middleware: missing tokens pass; supplied invalid/expired tokens return a generic JSON 401; valid claims enter the request context. |
func FromContext(context.Context) (Claims, bool), func Require(tango.View) tango.View |
Read verified claims and require them for a view. Require always rejects if middleware did not install claims first. |
ErrMissingToken, ErrInvalidToken, ErrExpiredToken |
Sentinel errors checkable with errors.Is; wire responses never reveal the distinction. |
See JWT authentication and the runnable examples/jwt-api.
realtime (github.com/angvp/tango/realtime)
| Symbol | What it's for |
|---|---|
type Principal struct{ UserID string }, type Peer interface{ Send(context.Context, []byte) error; Close() error } |
The stable actor identity and one live, replaceable transport connection for it. Peer implementations must be comparable (a pointer type); Join rejects a nil interface, a typed-nil pointer inside a non-nil Peer interface, or a non-comparable dynamic type, all with ErrInvalidPeer. |
type EventKind, type Event struct{ Kind EventKind; RoomID string; Principal Principal; Timer string; Payload []byte } |
EventAction/EventTimer/EventJoin/EventLeave. Payload is opaque — never parsed by realtime itself, and always copied before outbound delivery, so mutating a caller's buffer after Dispatch/DispatchPeer returns is safe. |
type Logic interface{ Handle(*RoomContext, Event) error; Snapshot(*RoomContext, Principal) ([]byte, error) }, type Factory func(roomID string) Logic |
Host-supplied domain behavior for one room; one Hub always uses the same Factory. |
type RoomContext with Broadcast, BroadcastExcept(userID string, ...), SendUser(userID string, ...), ResetTimer(name string, duration time.Duration) error |
Valid only inside one Handle/Snapshot call, on that room's own event-loop goroutine. |
type Options struct{ ReconnectWindow time.Duration; PeerQueue, RoomQueue int; Recorder observability.Recorder } |
Defaults: DefaultReconnectWindow (30s), DefaultPeerQueue (16), DefaultRoomQueue (64), and no-op metrics. A negative numeric field is rejected at NewHub. |
type Coordinator interface{ Join, Leave, DispatchPeer }, func NewHub(Factory, Options) (*Hub, error) |
*Hub implements Coordinator. Coordinator deliberately excludes Hub.Dispatch — a transport adapter must always act through DispatchPeer, bound to its own live connection. |
func (*Hub) Join(context.Context, roomID string, Principal, Peer) error, func (*Hub) Leave(...) error |
Block until the room's single-owner event loop has actually applied the operation and run Logic.Handle for the corresponding EventJoin/EventLeave — a Handle error there is returned to the caller, but membership/delivery already applied is never rolled back. A stale Leave (a Peer no longer current) is an idempotent no-op that emits no EventLeave. |
func (*Hub) Dispatch(context.Context, Event) error |
Host/bot-facing: delivers into Logic.Handle without requiring membership. Requires Event.Kind == EventAction. Not part of Coordinator. |
func (*Hub) DispatchPeer(context.Context, Event, Peer) error |
Transport-facing: like Dispatch, but only succeeds if peer is exactly the currently installed connection for Event.Principal in that room — a stale, replaced, overflow-removed, or unknown peer gets ErrStalePeer before Logic.Handle ever runs. What realtime/websocket.View calls. |
func (*Hub) SendUser(ctx context.Context, userID string, payload []byte) error |
Delivers to userID's live peer in every room the Hub currently tracks where they have one. |
func (*Hub) Close(ctx context.Context) error |
Idempotent: rejects new Join/Dispatch/DispatchPeer with ErrClosed, stops timers, closes peers, waits for every room loop to exit (or ctx to be canceled). An op already queued, or blocked trying to enqueue, when Close began also resolves to ErrClosed — never ErrRoomNotFound, which stays reserved for an actual room eviction. |
ErrRoomNotFound, ErrClosed, ErrStalePeer, ErrInvalidPeer |
Sentinel errors checkable with errors.Is. |
See realtime and WebSockets, and the runnable examples/realtime-chat.
realtime/websocket (github.com/angvp/tango/realtime/websocket)
| Symbol | What it's for |
|---|---|
type Authenticate func(*http.Request) (realtime.Principal, error) |
Resolves the requesting Principal before any WebSocket upgrade is attempted; a failure never touches the handshake. |
func View(coordinator realtime.Coordinator, authenticate Authenticate, roomID func(*tango.Context) (string, error), opts ...ViewOption) tango.View |
Mount as an ordinary route. Blocks on coordinator.Join before starting the read loop; dispatches every inbound message through coordinator.DispatchPeer, carrying its own connection, never the unrestricted Hub.Dispatch; calls Leave exactly once when the connection ends, including a best-effort Leave after a failed Join (which has no rollback and can leave membership installed). |
type ViewOption, func WithOriginPatterns(patterns ...string) ViewOption, func WithInsecureSkipVerify() ViewOption |
Options for View. By default the upgrade accepts only a same-origin Origin header and answers 403 to any other. WithOriginPatterns authorizes other hosts (a frontend on another port or subdomain; a bare host, optionally with a scheme, with * inside a segment). Never pass * alone. WithInsecureSkipVerify turns the origin check off entirely, for deployments with no real origin to check (native clients, or infrastructure that already enforces it); prefer patterns. |
DefaultMaxMessageSize |
32 KiB inbound message cap; an oversized frame closes the connection rather than being buffered. |
Wraps github.com/coder/websocket; no third-party type appears in this package's own public API. See realtime and WebSockets.
ratelimit (github.com/angvp/tango/ratelimit)
| Symbol | What it's for |
|---|---|
type Decision struct{ Allowed bool; Limit, Remaining int; RetryAfter time.Duration } |
The outcome of one Limiter.Take call — enough to build custom headers/body without reaching into Limiter's internal state. |
type Options struct{ Limit int; Refill time.Duration; Clock func() time.Time }, func NewLimiter(Options) (*Limiter, error) |
Configures a token bucket's capacity/refill rate; Limit/Refill must be positive. Clock defaults to time.Now, injectable for deterministic tests. |
func (*Limiter) Take(ctx context.Context, key string, now time.Time, cost int) (Decision, error) |
Concrete, in-memory, per-key token bucket, safe for concurrent use. A key seen for the first time starts full (allows an initial burst up to Limit). cost must be positive and must not exceed Limit — a request that could never succeed is rejected outright, not given a finite RetryAfter. No storage interface. Opportunistically evicts a key's bucket once it's been idle long enough to have fully refilled anyway (Limit * Refill), so long-running processes with many distinct keys don't retain them forever — this never changes any key's observed rate-limit behavior. |
type KeyFunc func(*http.Request) (string, error), func RemoteIPKey(trustedProxies ...*net.IPNet) KeyFunc |
Caller-supplied key extraction — ratelimit never imports auth/auth/jwt/accounts. RemoteIPKey uses only RemoteAddr by default; with trusted CIDRs it reads X-Forwarded-For from the right, skipping trusted proxies, and only from an immediate peer inside a trusted CIDR. X-Real-IP is never read. |
type LimitedHandler func(http.ResponseWriter, *http.Request, Decision), type ErrorHandler func(http.ResponseWriter, *http.Request, error) |
Respond to a rejected Decision, or to a KeyFunc failure — kept distinct; an extraction failure is never treated as "over budget." |
func Middleware(limiter *Limiter, key KeyFunc, opts ...MiddlewareOption) tango.Middleware, WithLimitedHandler, WithErrorHandler, WithCost(int) |
Ordinary tango.Middleware composition, usable at any attachment tier and on a WebSocket upgrade route. Default rejection: 429, JSON, Retry-After (refill-derived, rounded up to whole seconds), {"error":"rate limit exceeded"}. Default extraction failure: 400, JSON, {"error":"rate limit key extraction failed"}. |
Independent of admin/accounts' existing failed-login-attempt limiter, which counts authentication failures in a sliding window rather than every request — the two are not merged. See rate limiting and the runnable example wired into examples/notes-starter.
mail (github.com/angvp/tango/mail)
| Symbol | What it's for |
|---|---|
type Sender interface{ Send(ctx context.Context, message Message) error } |
Delivers one message. Send is synchronous and truthful: nil only once the message is handed on. An interface because real alternatives exist from day one: SMTP, a provider's HTTP API, and a test double. |
type Message struct{ From, To, Subject, Text string; Attachments []Attachment } |
One plain-text email to one recipient. From and To are single addresses, optionally with a display name. |
type Attachment struct{ Filename, ContentType string; Data []byte } |
One in-memory file attached to a Message. A message with attachments is sent as multipart/mixed, with base64 attachments; without, it stays plain text. An empty ContentType is sent as application/octet-stream; empty Data is valid. |
var ErrInvalidMessage |
A message no sender will send: a missing or malformed address, or a line break in a header value (header injection). Nothing is sent. |
func WriterSender(w io.Writer, opts ...Option) Sender |
Writes each message to w in the exact wire format SMTP would send, for development. Nothing falls back to it automatically; don't point it at production logs, since messages carry live links. |
var ErrInvalidAttachment |
An attachment with an empty filename, a control character in its filename, or a malformed content type. Also matches ErrInvalidMessage. |
var ErrMessageTooLarge, const DefaultMaxAttachmentSize, func WithMaxAttachmentSize(n int64) Option |
A message whose attachments together exceed the limit, 10 MiB of raw bytes by default, fails with ErrMessageTooLarge, which doesn't match ErrInvalidMessage. Base64 makes attachments about a third larger on the wire; the limit bounds what is sent, not memory already allocated. WithMaxAttachmentSize panics unless n is positive. |
func NewSMTPSender(rawURL string, opts ...Option) (*SMTPSender, error), type SMTPSender |
Delivers through an SMTP server, one connection per message. The scheme picks the security: smtp:// requires STARTTLS (a server without it is refused before any credentials are sent), smtps:// is TLS from the first byte, and smtp+insecure:// is plaintext, accepted only for localhost or a loopback IP, never with credentials and only with an explicit port, for tools such as Mailpit. smtp and smtps default to ports 587 and 465. Credentials in the URL are sent with AUTH PLAIN. Errors never contain the URL, credentials, recipient or message; a server's refusal is reported by stage and reply code only, any other failure by server address and stage only, and errors.Is still matches the cause. |
func SMTPSenderFromEnv(opts ...Option) (*SMTPSender, error), const SMTPURLEnv, var ErrNotConfigured |
NewSMTPSender for TANGO_SMTP_URL, or ErrNotConfigured when it's unset or empty, so main decides what "no mail" means. |
const DefaultSMTPTimeout |
One SMTP exchange is bounded by Send's context deadline, or 30 seconds when it has none; cancelling the context ends it at once. |
type Option |
Configures a sender. |
mail/mailtest (github.com/angvp/tango/mail/mailtest)
| Symbol | What it's for |
|---|---|
type Sender struct{ Err error }, func (*Sender) Send(ctx, mail.Message) error, func (*Sender) Messages() []mail.Message |
A mail.Sender for tests that records messages instead of delivering them. It rejects invalid messages exactly as real senders do; with Err set, every Send returns it. Safe for concurrent use. |
storage (github.com/angvp/tango/storage)
| Symbol | What it's for |
|---|---|
type Store interface{ Put; Stat; Open; Delete } |
The object store. Put(ctx, r io.Reader, PutOptions) (Object, error) streams r in atomically and create-only under a generated key; Stat(ctx, key) (Info, error); Open(ctx, key, offset, length) (io.ReadCloser, error) reads a range (a negative length reads to the end); Delete(ctx, key) (a missing object is not an error). No listing. Every method refuses a key not made by NewKey with ErrInvalidKey. |
type PutOptions struct{ MaxSize int64; AllowedTypes []string }, type Object struct{ Key string; Size int64; SHA256, ContentType string }, type Info struct{ Key; Size; SHA256; ContentType; ETag; ModTime } |
MaxSize is required and positive. AllowedTypes lists sniffed media types (image/png, image/*). Object is what the host copies into its own model; ContentType is always sniffed from the bytes. |
func NewKey() string, func ValidKey(key string) bool, func ETagFor(sha256hex string) string |
A key is 160 random bits as 32 lowercase base32 characters. It is opaque and never derived from a filename. |
func Inspect(r io.Reader, PutOptions) (*Inspector, error) |
For Store implementers: sniffs, caps and hashes a write the same way in every adapter. |
func Upload(store Store, r *http.Request, UploadOptions) (UploadedFile, error), type UploadOptions struct{ Field string; PutOptions }, type UploadedFile struct{ Object; Filename, DeclaredType string } |
Reads exactly one multipart file (field file unless set) into store.Put, with bounded memory. Filename is a sanitized display name and DeclaredType the client's claim, both plain metadata. Refuses with ErrNotMultipart, ErrNoFile, ErrMultipleFiles, ErrTooManyParts, ErrTooLarge (also for a body cut by MaxBodySize) or ErrTypeNotAllowed. Writes no response and logs nothing. |
func Serve(w, r, store Store, key string, ServeOptions) error, type ServeOptions struct{ Filename string; InlineTypes []string; CacheControl string } |
Delivers an object the View has already authorized: GET/HEAD, one Range, If-None-Match, If-Modified-Since, If-Range, ETag, nosniff, the sniffed type. Attachment unless the type is in InlineTypes, and HTML, SVG, XML and script types are always attachments. Cache-Control defaults to private, no-store. A missing or invalid key is 404. Returns an error only for an unexpected store failure, without having written a response. |
ErrNotFound, ErrExists, ErrTooLarge, ErrTypeNotAllowed, ErrInvalidKey, ErrInvalidRange, ErrInvalidOptions; *TooLargeError{Max}, *TypeNotAllowedError{Type} |
Match with errors.Is; the typed errors give the limit or detected type through errors.As. |
See the uploads guide.
storage/local (github.com/angvp/tango/storage/local)
| Symbol | What it's for |
|---|---|
func New(dir string) (*Store, error), func (*Store) Close() error |
A filesystem storage.Store rooted at dir (directories 0700, files 0600), reached only through os.Root. An object is a directory published by one rename, so a crash never leaves a partial object visible; stale temporary directories are swept when it opens. New fails, naming the directory and the chmod 700 to run, if an existing root, objects or tmp directory grants group or other access; it never changes one for you. For development, tests and one host with a persistent volume. |
storage/s3 (github.com/angvp/tango/storage/s3, its own module)
| Symbol | What it's for |
|---|---|
func New(ctx, Config) (*Store, error), type Config struct{ Bucket, Region, Endpoint string; PathStyle bool; Prefix string; Credentials *Credentials; TempDir string; PartSize int64 }, type Credentials struct{ AccessKeyID, SecretAccessKey, SessionToken string } |
A storage.Store on Amazon S3, Cloudflare R2 or another S3-compatible provider (Endpoint, PathStyle). Nil Credentials uses the SDK's standard chain. Create-only writes use a conditional put. Put spools to TempDir before uploading, since the SHA-256 is metadata fixed when the upload starts. Its own module, so no cloud SDK reaches the root go.mod. |
storagetest (github.com/angvp/tango/storagetest)
| Symbol | What it's for |
|---|---|
func NewMemory() *Memory, func NewMemoryWithKeys(next func() string) *Memory |
An in-memory storage.Store for tests; the second forces key collisions. |
func Run(t *testing.T, f Factory), type Factory struct{ New; NewWithKeys } |
The conformance suite every Store passes: round trip, ranges, missing and invalid keys, size and type refusals storing nothing, create-only, concurrency, cancelled contexts. NewWithKeys is optional and enables the checks that need a controlled key. |
cache (github.com/angvp/tango/cache)
| Symbol | What it's for |
|---|---|
type Store interface{ Get; Set; Delete } |
The cache. Get(ctx, key) ([]byte, bool, error): a miss is ok == false with a nil error, an error means the backend failed, the slice is the caller's. Set(ctx, key, value, ttl) error: replaces, keeps none of the caller's memory, ttl is the longest the value may be served (never counted as elapsed sooner than asked) and not a promise of retention: eviction, restart or Delete can make a later Get miss. Delete(ctx, key) error: a missing key is not an error. |
func ValidKey(key string) bool, func CheckTTL(ttl time.Duration) error, const MaxKeyLength = 200, const MaxTTL |
A key is 1 to 200 bytes of printable ASCII with no spaces; a TTL is 0 < ttl <= MaxTTL (30 days). Violations are ErrInvalidKey and ErrInvalidTTL, before any request. |
func Prefix(store Store, prefix string) Store |
Namespaces every key, validating the final key. Bump a version in the prefix to invalidate in bulk. |
func GetJSON[T](ctx, store, key) (T, bool, error), func SetJSON[T](ctx, store, key, value, ttl) error |
Typed values as JSON. A value that does not decode is ErrCorrupt. |
func FetchJSON[T](ctx, store, key, ttl, load func(context.Context) (T, error), opts ...FetchOption) (T, error), func OnError(func(op string, err error)) FetchOption, OpGet, OpDecode, OpEncode, OpSet |
Fail-open read-through: a cache failure goes to the hook and the loader still runs. A loader error is returned as is. No in-process coalescing. |
ErrInvalidKey, ErrInvalidTTL, ErrTooLarge, ErrCorrupt, *CorruptError{Key, Err} |
Match with errors.Is; errors.As gives the key and decode error of a corrupt value. |
See the caching guide.
cache/local (github.com/angvp/tango/cache/local)
| Symbol | What it's for |
|---|---|
func New(Config) (*Store, error), type Config struct{ MaxEntries int; MaxBytes int64; Now func() time.Time } |
A bounded in-process cache.Store. MaxEntries is required and positive; MaxBytes (full key plus value) is optional and non-negative; least-recently-used eviction after expired entries; no goroutine. An entry larger than MaxBytes is ErrTooLarge. Per process. |
cache/redis (github.com/angvp/tango/cache/redis, its own module)
| Symbol | What it's for |
|---|---|
func New(ctx, Config) (*Store, error), type Config struct{ URL, Username, Password string; TLSConfig *tls.Config; DialTimeout, ReadTimeout, WriteTimeout time.Duration; PoolSize int }, func (*Store) Close() error |
A cache.Store on Redis or Valkey. URL is required (redis:// or rediss://; the database is the URL path; a TLSConfig needs rediss://). Explicit credentials override the URL's. New pings and closes the client it created if the ping fails. |
func NewFromClient(goredis.UniversalClient) *Store |
For Cluster, Sentinel, dynamic credentials or a Unix socket. It does not ping and its Close does nothing: the host closes the client it supplied. |
cache/memcache (github.com/angvp/tango/cache/memcache, its own module)
| Symbol | What it's for |
|---|---|
func New(Config) (*Store, error), type Config struct{ Servers []string; Timeout time.Duration; MaxIdleConns int; TLSConfig *tls.Config }, func (*Store) Close() error |
A cache.Store on Memcached. No authentication; TLS only through the dialer; Timeout (default 500 ms) bounds a call because an in-flight call cannot be cancelled; several servers shard keys without replication; expiry is rounded up to whole seconds. |
cachetest (github.com/angvp/tango/cachetest)
| Symbol | What it's for |
|---|---|
func Run(t *testing.T, f Factory), type Factory struct{ New func(*testing.T) cache.Store } |
The conformance suite every cache.Store passes: round trip, miss versus error, ownership of both slices, key and TTL rules, expiry, cancelled contexts, an oversized value, concurrent use. |
accounts (github.com/angvp/tango/accounts)
| Symbol | What it's for |
|---|---|
func New(store *db.Store, opts ...Option) tango.App |
Constructs the accounts application, installable via InstalledApps like any reusable app. Mounts a fixed /accounts/register/, /accounts/login/, /accounts/logout/. New(store) with no options defaults to signup enabled, a 30-day session in a tango_account_session cookie. |
type Account struct{ ID, Email, PasswordHash, Active, CreatedAt, EmailVerifiedAt }, type AccountSession struct{ ID, Token, UserID, ExpiresAt } |
The Application user/session model pair New registers. Deliberately no permission-shaped field on Account — see the accounts guide. EmailVerifiedAt is when the owner proved they receive mail at Email; the zero time means unverified, and it gates nothing by itself. |
type AccountToken struct{ ID, TokenHash, AccountID, Purpose, AddressHash, ExpiresAt }, type TokenPurpose, PurposePasswordReset, PurposeEmailVerification |
One outstanding emailed token, registered by New with the other models. Only hashes are stored: of the token, and of the address it was sent to. |
func WithTrustedProxies(...*net.IPNet) Option |
Names the reverse proxies in front of the app so the failed-attempt limiters (login, registration, password reset, verification resend) count the forwarded client address instead of the proxy's. Without it, no forwarded header is read. |
func WithSignupDisabled() Option |
Closes self-service registration; /accounts/register/ still returns a clear closed-registration response, never 404. Signup is enabled by default. |
func WithSessionDuration(d time.Duration) Option |
Overrides the default 30-day session duration. |
func WithSessionCookieName(name string) Option, DefaultSessionCookieName |
Overrides (or names) the session cookie, default "tango_account_session". |
func WithMail(config MailConfig) Option, type MailConfig struct{ Sender mail.Sender; From, BaseURL string; Logger *slog.Logger } |
Turns on password reset and email verification. BaseURL is the Public base URL links are built on: an absolute https:// origin, or http:// for localhost; links never come from the request's Host. Validated at registration, so -check fails on a bad configuration. Emails go through an in-memory outbox of 100 drained by one worker registered as a Lifecycle component; a full outbox drops the email. Without WithMail, the mail routes aren't mounted. |
GET/POST /accounts/password-reset/ (with WithMail) |
Asks for a reset link. Every syntactically valid address gets the same response, whether or not it has an account, is active, or is in its cooldown: the outbox, not the request, looks the account up, and only an active account is emailed. An empty or malformed address is a 400 form error. One email per address every five minutes (a dropped email gives its cooldown back), and five requests per client IP a minute. The link lasts one hour. |
GET/POST /accounts/password-reset/confirm/?token=… (with WithMail) |
GET shows the new-password form and never uses the token, so mail scanners following the link change nothing; POST sets the password under registration's rules. Success deletes every token and session of the account, sets EmailVerifiedAt if empty, and redirects to the login page. Both send Referrer-Policy: no-referrer and Cache-Control: no-store. An unknown, expired, used, replaced or wrong-purpose token, an inactive account, or an account whose email changed since the link was sent all get one 400 page. |
GET/POST /accounts/verify/?token=…, POST /accounts/verify/resend/ (with WithMail) |
Registration queues a verification email; its 24-hour link shows a "Confirm my email" button on GET, and only POST sets EmailVerifiedAt. The same headers and single invalid-link page as password reset. Resend is for a logged-in account, needs the CSRF token the RequireVerified page carries, allows five requests per client IP a minute, and shares the five-minute per-address cooldown. |
func RequireVerified(store *db.Store, cookieName, loginPath string, next tango.View) tango.View |
Like RequireLogin, and also requires a Verified email: a logged-in but unverified account gets a 403 HTML page with a button to send a new link (which needs WithMail). Verification never stops a login; this guard is the only thing that acts on it. |
func WithJSON(config JSONConfig) Option, type JSONConfig struct{ Auth JSONAuth; VerifyURL, ResetURL string; OnRegister func(ctx, tx *db.Store, Registration) error; ResolveIdentifier func(ctx, *db.Store, identifier string) (int64, bool, error) } |
Mounts six bearer-only JSON endpoints under /accounts/api/: register/, login/, password-reset/, password-reset/confirm/, verify/ and verify/resend/. Requires WithMail and a valid config, checked at registration. VerifyURL/ResetURL are absolute http(s) templates with {token} exactly once, used only for the mail these endpoints trigger. OnRegister runs in the account's transaction; ResolveIdentifier lets login take a non-email identifier (which must not contain @). See the accounts guide. |
type JSONAuth interface{ Issue(ctx, Account) (AuthGrant, error); Authenticate(ctx, *http.Request) (int64, bool, error) }, type AuthGrant struct{ AccessToken, TokenType string; ExpiresIn time.Duration } |
The host's side of the bearer contract: Issue receives the account with PasswordHash withheld; Authenticate names the account behind a request's token, with ok=false for a missing or invalid one and err for an operational failure. TokenType defaults to "Bearer"; ExpiresIn is rendered in whole seconds. |
type Registration struct{ AccountID int64; Email string; Profile json.RawMessage }, type FieldError struct{ Field, Message string } |
What OnRegister is told (the request's profile object is untouched, capped at 4 KiB), and the error it returns, alone or joined with errors.Join, for a 422 invalid_field that names a public JSON field. A *db.ValueTooLongError or *db.InvalidJSONError from tx.Create is also a 422, keyed by the Go field name. |
const EventMailFailed, const EventMailDropped |
"tango.accounts.mail_failed" (attributes purpose, error) and "tango.accounts.mail_dropped" (purpose). The error is redacted: never the address, the link or the message. |
func RequireLogin(store *db.Store, cookieName, loginPath string, next tango.View) tango.View |
View wrapper for a host's own routes, mirroring auth.RequireLogin's shape. Active is re-checked on every request through an already-valid session, not just at login. |
func CurrentAccountID(ctx *tango.Context, store *db.Store, cookieName string) (int64, bool, error), func CurrentAccount(...) (Account, bool, error) |
Thin, Active-aware sugar over auth.CurrentUserID, for a host's own Views. Not a route — there is no /accounts/me/ page. |
See the accounts guide. Built entirely by composing auth's primitives — no new primitives added to auth itself; HTML by default; JSON endpoints only with WithJSON (otherwise only a rejected CSRF token's 403 and rate limiting's 429 answer with a JSON {"error": …}), no CLI, no template-override hook.
i18n (github.com/angvp/tango/i18n)
| Symbol | What it's for |
|---|---|
func T(ctx context.Context, key, fallback string, args ...any) string |
Looks up a symbolic translation key using the locale carried by ctx, falling back to the inline English fallback. If args are present, formats the chosen string with fmt.Sprintf. |
func RegisterCatalog(locale string, catalog map[string]string) |
Merges a Go-source catalog into the named locale. Multiple calls for one locale merge per key; later values win on key collision. |
type LocaleResolver func(*http.Request) string |
Host-provided per-request locale selection hook. |
func DefaultLocaleResolver(r *http.Request) string |
Parses Accept-Language and returns the best registered locale using tanGO's simplified exact-then-base-language matching. |
func Middleware(resolver LocaleResolver) tango.Middleware |
Resolves the locale once per request and stores it on the standard request context.Context; pass nil to use DefaultLocaleResolver. |
func WithLocale(ctx context.Context, locale string) context.Context, func Locale(ctx context.Context) string |
Low-level context helpers used by the middleware and useful in tests or custom middleware. |
See i18n and l10n. Admin is converted to call i18n.T, but no first-party app wires the middleware automatically — translation is always a host-level opt-in.
admin (github.com/angvp/tango/admin)
| Symbol | What it's for |
|---|---|
func New(store *db.Store, opts ...Option) tango.App |
Constructs the admin app. Add it to InstalledApps after every app registering models with it; tango check fails and names any model registered after it. Mounts /admin/, /admin, /admin/login/, and /admin/logout/ alongside each model's routes. Also registers AdminUser/AdminSession as ordinary models. New(store) with no options is unchanged. |
type AdminUser struct{ ID, Username, PasswordHash, Active, IsStaff, IsSuperuser, CreatedAt }, type AdminSession struct{ ID, Token, UserID, ExpiresAt } |
The Admin account and session models New registers. Never registered with the admin's own CRUD registry — manage accounts via the tango admin CLI, not the admin UI. IsStaff gates admin-panel access on an already-authenticated session (403 if false); IsSuperuser ships now but is currently inert. See admin registration. |
func CreateAccount(ctx, store, username, password string, opts ...AccountOption) error, func ResetPassword/Deactivate(ctx, store, username, ...) error |
The account-lifecycle functions behind tango admin create/resetpassword/deactivate — the only code paths that ever write a password, always bcrypt-hashed. CreateAccount defaults to IsStaff=true, IsSuperuser=true; pass WithoutStaff()/WithoutSuperuser() to opt out. |
func GrantStaff/RevokeStaff/GrantSuperuser/RevokeSuperuser(ctx, store, username string) error |
The account-flag functions behind tango admin grant-staff/revoke-staff/grant-superuser/revoke-superuser — each changes exactly the one flag it names, leaving Active, the password, and the other flag untouched. |
func HandleCLI(ctx, store, args, stdin, stdout, stderr) (bool, error) |
Handles the -tango-admin-create/-resetpassword/-deactivate/-grant-staff/-revoke-staff/-grant-superuser/-revoke-superuser app-side flags (plus create's -tango-admin-no-staff/-tango-admin-no-superuser opt-outs) a generated main.go calls alongside tango.DispatchFlags. |
type Options struct{ ListDisplay, Search, Ordering []string; Label string; Labels, HelpText map[string]string; ReadOnly, FieldOrder []string; Widgets map[string]Widget; Filters []string; Display map[string]DisplayFunc } |
Per-model admin configuration, validated against the model's actual fields at registration time. Label names the field shown wherever this model is displayed as a related object. Labels/HelpText/ReadOnly/FieldOrder/Widgets customize the create/edit form — see admin registration. Filters and Display are covered; the other presentation fields are best-effort — see what is not covered. Filters adds boolean and foreign key filters to the list (changelist filters); Display computes list cells (computed changelist cells); Labels also names the changelist column of a field and is required for a synthetic Display column. |
type Widget interface{ Render(FieldContext) template.HTML; Parse(FieldContext, FieldValues, reflect.Value) error }, type FieldContext struct{...}, type FieldValues interface{ Get, Has(string) ... } |
The field-widget contract set via Options.Widgets. FieldContext (field name, label, help text, value, read-only flag, related-model select options, optional related create URL) is passed to both methods, so a widget derives its own submitted form key(s) from FieldContext.Name identically on both sides. See admin registration. Best-effort, like Options above. |
func Textarea() Widget |
tanGO's one built-in widget beyond the default five (text/number/date-time/checkbox/foreign-key-select) — a multi-line text input for a string field. |
type DisplayFunc func(context.Context, any) Display, type Display struct{ Text, Href string } |
Covered. A host-owned changelist cell: any is a pointer to the model row. Text is always escaped; Href is used only for a relative or http/https URL. An empty Display is an empty cell; a panic is not recovered. |
func ByteSize(int64) string, func ShortHash(string) string |
Best-effort presentation helpers for Display functions: a size in binary units and a shortened digest. |
func JSON() Widget |
Covered. A widget for a string field holding JSON: syntax validation on save, the exact submitted text stored, and a browser-side Formatted tab that shows the text indented. See JSON fields. |
func Markdown(...MarkdownOption) Widget, type MarkdownOption, func WithMarkdownPreview(MarkdownPreview) MarkdownOption, type MarkdownPreview func(context.Context, string) (template.HTML, error) |
Covered. A Markdown textarea with a character count and, when a host renderer is supplied, a sandboxed preview. The host owns rendering and sanitization. See Markdown fields. |
type FieldError struct{ Message string } |
Covered. A Widget.Parse error that admin shows beside its own field (directly, wrapped, or inside errors.Join) with aria-invalid and aria-describedby. |
type PreviewWidget interface{ Preview(context.Context, string) (template.HTML, error) } |
Best-effort, like Widget. The optional interface behind the one admin-owned preview route. |
type Branding struct{ Name, LogoURL string }, func WithBranding(Branding) Option |
The sidebar's only two customization slots — a brand name and logo — passed to New. Best-effort, like Widget above. See admin registration. |
func WithDashboard(DashboardConfig) Option, type DashboardConfig struct{ Status func(context.Context) (DashboardStatus, error); Activity observability.Snapshotter }, type DashboardStatus |
Turns /admin/ into an operational dashboard when at least one source is set; otherwise the landing page is unchanged. Status is supplied by the host (for example from tango.Status) and returns plain display data only: database state, dialect, applied and pending migrations, tracking-table state, release and process start time. An error or panic in either callback makes only that card "unavailable"; the error text never reaches the page. |
func WithTrustedProxies(...*net.IPNet) admin.Option |
Names the reverse proxies in front of the app so the login limiter counts the forwarded client address instead of the proxy's. Without it, no forwarded header is read. |
func WithMiddleware(...tango.Middleware) Option |
Wraps only admin's internally contributed routes, inside global Config.Middleware and before admin Views. |
type Registry, func NewRegistry(models *model.Registry) *Registry, func (*Registry) Register(value any, opts Options) error |
The admin sub-registry, reached via tango.Registry.Admin(). |
| POST /admin/theme/ | The Light, Dark and System switch. Staff session and CSRF token required; form fields theme (light, dark or system; anything else is a 400) and next (a path under /admin/; any other value redirects to /admin/). light and dark set the tango_admin_theme cookie (path /admin/, HttpOnly, SameSite Lax, one year, Secure when the request arrived over HTTPS); system deletes it. Best-effort, like the rest of admin's presentation. See admin registration for the token classes widgets should use. |
The default admin theme (sidebar navigation across every registered model, humanized field labels, a truncated paginator with a true total count) is applied automatically — there's nothing to opt into or configure beyond Options and WithBranding. Authentication is a real session-cookie login (not HTTP Basic Auth): every form carries a CSRF token, and failed login attempts are rate-limited — see admin registration for the full model.
migration (github.com/angvp/tango/migration)
The supported way to use this package is the CLI workflow — tango makemigrations, tango migrate, tango migrate down — documented in the migrations guide. Most exported symbols exist so generated migration files, an app's generated main.go, and the tango CLI can share types across packages.
For reference, what a generated migration file's Go literal actually looks like:
type Migration struct {
App string
Name string
Up, Down []Step
Reversible bool
}
Step is implemented by CreateTable, DropTable, AddColumn, DropColumn, AlterColumnUnique, CreateIndex, DropIndex, RenameColumn, RenameTable and AlterColumnType — each a plain struct describing one dialect-agnostic schema operation. Treat these as "what generated code looks like," not a hand-authored API.
Stable runtime contract:
| Symbol | What it's for |
|---|---|
func ApplyPending(ctx, sqlDB, dialect, migrations) error |
Backs -migrate. Runs pending migrations by name, each app's in sequence, and a migration referencing another app's table after the one creating it — see migrations. |
func RollbackLast(ctx, sqlDB, dialect, migrations) error |
Backs -migrate -down. Returns ErrNoAppliedMigrations or ErrIrreversibleMigration for their respective cases. |
func AppliedMigrations(ctx, sqlDB) (map[MigrationKey]bool, error) |
Reads the applied migration keys from an existing tango_migrations table. |
type MigrationKey struct{ App, Name string } |
The app-scoped identity used by tango_migrations. |
func IsMissingTrackingTable(error) bool |
Lets status/checking code treat a missing tango_migrations table as "no migrations applied" without creating the table. |
type Migration |
The generated migration value passed to ApplyPending and RollbackLast. |
CLI-internal exported surface. It is not covered by the compatibility promise, except as the Generated-file contract protects files tango makemigrations wrote:
| Symbol | What it's for |
|---|---|
type Step, type Column, CreateTable, DropTable, AddColumn, DropColumn, AlterColumnUnique, CreateIndex, DropIndex, RenameColumn, RenameTable, AlterColumnType |
Generated migration-file representation; don't hand-author these in application code. Column.Length is a varchar column's limit and AlterColumnType.FromLength/ToLength carry the limits of a type change; all three are 0 for every other type, and a generated file that omits them means what it always meant. Column.Default is a raw SQL literal (e.g. "TRUE"), not a typed Go value — when set, AddColumn (and only AddColumn) emits NOT NULL DEFAULT <Default> so pre-existing rows backfill instead of going NULL; CreateTable ignores it, since a freshly created table has no existing rows to backfill. tango makemigrations never produces one from a model's struct tags; it only ever survives on a migration where it was hand-set (e.g. admin's IsStaff/IsSuperuser columns). Not a general default-value system for model fields. |
type Model, func ModelsFromMeta(...) |
The -tango-dump-models JSON bridge used by tango makemigrations; Model.Struct and Model.Fields carry the Go names its errors use. |
func Replay(...), type SchemaState, type TableState, type ColumnState |
Schema reconstruction for tango makemigrations. |
func Diff(...), func DiffModels(..., renames ...Rename), type Rename, ErrUnsupportedChange, ErrInvalidRename |
Diff engine behind tango makemigrations. A Rename is one --rename mapping (Column empty for a model); ErrUnsupportedChange names every change no step can express (a non-widening type change, a primary-key or foreign-key-target change, a default that can't convert), ErrInvalidRename every mapping that doesn't fit history and the models. Either way no migrations are returned. |
func ApplyStep(...) |
Shared DDL translator used by migration runners and tests; use tango migrate / ApplyPending instead. |
shell (github.com/angvp/tango/shell)
The console behind tango shell. A project's shell/main.go calls Run; everything else in the package is internal. Importing it links the Yaegi interpreter, so a server's main package should not.
| Symbol | What it's for | See also |
|---|---|---|
func Run(ctx context.Context, config tango.Config, store *db.Store, args []string, opts Options) int |
Registers config's apps against store without serving anything, then runs a shell session for args (os.Args[1:]) and returns the process exit code: 0 for a clean end or --help, 1 for the first error of a non-interactive session or a project that fails to boot, 2 for a command line it does not understand, 130 when Ctrl-C ends a running evaluation. Accepts -c EXPR, --readonly and --help. |
shell guide |
type Options struct{ ReadOnly bool; Helpers map[string]any; DatabaseLabel string } |
ReadOnly makes every helper that writes fail (as does --readonly). Helpers are the project's own functions, called in the session as project.Name; each name must be an exported Go identifier, and only plain values (primitives, maps, slices, error) are supported across the boundary. DatabaseLabel is display-only text shown at startup, which the project derives from its database configuration after removing every credential; it is never a DSN. |
shell guide |
Inside a session the helpers are Models, Describe, Get, List, Count, Create, Update, Delete and Context, plus help(), exit() and quit(); the shell guide lists their arguments.
testdb (github.com/angvp/tango/testdb)
For tests only. The run's Test dialect comes from TANGO_TEST_DSN, with the same grammar as TANGO_DB_DSN: unset or sqlite://:memory: is in-memory SQLite, any other sqlite:// path is a fresh SQLite file per test (the path itself is not used), postgres://…/postgresql://… is a fresh schema per test, and mysql://… is a fresh database per test (the account needs the CREATE and DROP privileges), each dropped when the test ends. On MySQL, raw SQL written with $1, $2, … placeholders is rewritten to ?.
| Symbol | What it's for |
|---|---|
func Open(t testing.TB) (*sql.DB, db.Dialect) |
A fresh database for this test, closed by t.Cleanup. An unsupported scheme or an unreachable PostgreSQL or MySQL server fails the test; it never skips. |
func Store(t testing.TB) *db.Store |
db.NewStore over Open(t). |
func Dialect() db.Dialect |
The run's Test dialect, for per-dialect raw SQL inside a test. Panics on an unsupported TANGO_TEST_DSN, with the message Open fails the test with. |
func SQLiteOnly(t testing.TB, reason string), func PostgresOnly(t testing.TB, reason string), func MySQLOnly(t testing.TB, reason string) |
Skip the test on the other dialects, with a reason, so every exemption is explicit and greppable. |
func NotMySQL(t testing.TB, reason string) |
Skip the test on MySQL, with a reason. |