Inside a generated app
The functions that make up a Full app's composition root, internal/app, and its commands: what each does, what it takes and returns, its side effects and errors, what it touches in the database, and why it's built that way. Read with examples/full-single/internal/app open. For the library's packages, see the generated package reference; for one request's path, life of a request.
internal/app is the only package that reads the environment and the only one that knows every module. Modules receive what they need as constructor arguments (ADR-0020, ADR-0022).
Startup sequence#
cmd/api/main.go run()
│
├─ LoadConfig(config.OS) environment → Config, every error at once
│ ├─ loadKeyring AUTH_ENCRYPTION_KEYS
│ ├─ loadWebAuthnConfig WEBAUTHN_*
│ ├─ loadSocialConfig GOOGLE_*, APPLE_*, APP_PUBLIC_URL
│ └─ loadMailConfig RESEND_API_KEY or SMTP_* (infra_mail.go)
│
├─ subcommand? openapi · roles · grant-role · revoke-role · reset-mfa · rotate-auth-keys · auth-providers
│
├─ New(ctx, cfg)
│ ├─ newBase telemetry (logger, tracer, meter), health checker, cleanup stack
│ └─ build
│ ├─ postgres.Open pool; pings; readiness check
│ ├─ auditpg.NewStore audit recorder
│ ├─ declareSettings → settings.NewStore loads values, starts nothing yet
│ ├─ newMailSender → jobs.AddMailWorker
│ ├─ defineJobs → jobs.New → jobs.NewManager
│ ├─ mail.WithDefaults(jobs.AsyncSender) the mailer modules use
│ ├─ authmodule.New users, sessions, 2FA, passkeys, Google, Apple
│ ├─ releases.NewTracker / NewStore
│ └─ buildHTTP mapper, Huma API, modules, health, docs, middleware
│
└─ Run(ctx)
├─ reportSignInMethods
└─ lifecycle.Run(server, settings, jobs, jobsManager, releases)
config.go#
LoadConfig(src config.Source) (Config, error)#
| Purpose | Build the whole boot configuration from environment variables |
| Input | config.Source: config.OS in commands; a map in tests |
| Output | Config with defaults applied, or invalid configuration: joining every problem |
| Side effects | None: reads only. _FILE variants read files |
| Validation | Types and ranges; production-only requirements; partial sign-in configuration; mail provider credentials when delivery is provider; parses the encryption keys and the Apple key |
| Why | A misconfigured deploy should fail on its first start with every problem listed, not one per restart, and never half-start with a feature silently off (ADR-0045) |
DATABASE_URL is read but not required here: New, Migrate and Seed require it, and WriteOpenAPI doesn't need a database.
Config.Production() reports APP_ENV == "production". Config.returnOrigins() is APP_PUBLIC_URL plus APP_CORS_ORIGINS: where Google and Apple web sign-in may send the browser back.
app.go#
New(ctx, cfg Config) (*App, error)#
| Purpose | Construct every component in dependency order |
| Errors | DATABASE_URL is required; postgres: connect: …; any constructor's error. On failure, everything already built is closed (errors.Join(err, cleanup.Close)) |
| Database | Connects and pings; settings.NewStore and jobs.NewManager read their tables, so an unmigrated database fails here with relation "…" does not exist |
| Concurrency | Starts no goroutines that outlive it: long-running work only starts in Run |
| Why | Constructors do setup and fail fast; runners do work (ADR-0017). Tests call New and Workers directly |
Components register cleanups as they're created (cleanup.Add("postgres", …)), so shutdown closes them in reverse order.
newBase(ctx, cfg) (*App, error)#
Sets up OpenTelemetry (telemetry.Setup: text logs in development, JSON in production, OTLP export when OTEL_EXPORTER_OTLP_ENDPOINT is set), the health checker and the cleanup stack. Needs no infrastructure, which is why WriteOpenAPI uses it alone.
(*App).build(ctx) error#
The wiring, in order, with the reason for the order:
- Pool first: everything below needs it. Adds
postgres.HealthCheckto/readyz. - Audit recorder: settings, jobs and auth record events.
- Settings store: its typed values (
appSettings) feed the mailer and auth durations. - Mail sender and worker: the worker must be registered before the job client is created.
- Job definitions, client, manager:
defineJobsreceivesauthCleanupas a closure overa.auth, which is built next but before any job can run. - Mailer:
mail.WithDefaults(jobs.AsyncSender(a.jobs), …)so modules send email by queueing a job, with the sender filled from settings.warnDefaultSenderlogs in production whenmail.from_emailis still the placeholder. - Auth module, with providers, keyring, passkeys, catalog, emails and the settings-backed durations.
- Release tracker and store.
buildHTTPwith theservicesbusiness modules need.
(*App).Run(ctx) error#
Creates the HTTP server, prints or logs the sign-in methods, logs starting, and hands the server and Workers() to lifecycle.Run, which runs them until the context ends or a signal arrives, then shuts down: health reports shutting down, drain delay (5 s in production, none in development), graceful stop, cleanup. Returns the first runner error, such as listen tcp …: bind: address already in use.
(*App).Workers() []lifecycle.Runner#
settings (LISTEN/NOTIFY and resync), jobs (River client), jobsManager (definition changes across instances), releases (heartbeat). Exposed so tests can start them without an HTTP server.
WriteOpenAPI(ctx, cfg, w) error#
Builds the HTTP layer with a static ping message and no database, and writes the OpenAPI 3.1 document. Used by api openapi and the drift check; it's why buildHTTP must not need a live pool.
Auth(), Handler(), Close(ctx)#
Accessors for tests and commands: the auth use case service, the full middleware-wrapped handler, and cleanup without running.
routes.go#
(*App).buildHTTP(svc services) error#
Creates the error mapper and installs Huma's error hooks and pagination mappings; creates the Huma API on a ServeMux with bearer auth documented; registers GET /version and every module (registerModules); mounts /livez, /readyz, /docs (when enabled), the .well-known files and the problem 404; then builds the middleware chain. Returns mapping and CORS configuration errors. Order and each middleware: life of a request.
authLimitKey(r) string#
Returns the client IP for requests the sign-in rate limit applies to (non-GET under /v1/auth/, plus provider start and callback), and "" for everything else, which the rate limiter skips. Keyed on RemoteAddr; see production.
exceptCrossSitePosts(protect) Middleware#
Applies cross-origin protection to every request except POST /v1/auth/apple/callback and POST /v1/auth/apple/notifications, which Apple posts from its own origin by design. They're protected instead by the single-use state and __Host-oauth cookie, and by Apple's signature.
modules.go#
registerModules(api, mapper, svc) error#
Calls each module's register<Name> and joins their errors. aps gen resource inserts a line after //aps:anchor modules. Each module_<name>.go builds the module from services, registers its operations and its error mappings: error codes there are public API.
jobs.go, job_<name>.go#
defineJobs(defs, deps jobDeps)#
Declares every job with its code defaults; aps gen job adds a line after //aps:anchor jobs. Operators override enabled, schedule, timeout, attempts, queue and priority at runtime in /ops/jobs, stored in jobs_definitions (ADR-0033).
| Job | Defined in | Does |
|---|---|---|
heartbeat |
job_heartbeat.go |
Logs a heartbeat every hour; an example to copy or remove |
auth_cleanup |
job_auth_cleanup.go |
Daily at 03:30 UTC: deletes expired sessions, codes and second-factor challenges, and purges accounts deleted longer ago than auth.deleted_account_retention (audit action auth.accounts.purged). Returns the counts as authdomain.CleanupResult |
auth_revoke_tokens |
job_auth_revoke_tokens.go |
Every minute: revokes up to 20 Apple refresh tokens queued in auth_token_revocations by unlinking or account deletion; failures back off from 1 minute to 6 hours and are abandoned after 10 attempts (audit action auth.identity.revocation_abandoned). Returns the counts as authdomain.RevocationResult |
apistock.mail.send |
jobs.AddMailWorker in app.go |
Delivers queued email; 8 attempts; mail.ErrRejected cancels |
jobDeps holds what workers may use. Add a store or client there when a job needs one.
settings.go#
declareSettings(reg) appSettings#
Declares every runtime setting with type, default, bounds and description, and returns typed handles (config.Value[T]) that read the current value on each use. Values live in settings_values only when changed; changes arrive on every instance through LISTEN/NOTIFY (ADR-0031).
| Key | Type | Default |
|---|---|---|
example.ping_message |
string | pong |
mail.from_name |
string | the app's name |
mail.from_email |
string | no-reply@example.com |
mail.reply_to |
string | empty |
auth.session_idle_ttl |
duration | 14 days |
auth.session_absolute_ttl |
duration | 90 days |
auth.verification_code_ttl |
duration | 15 minutes |
auth.reset_code_ttl |
duration | 30 minutes |
auth.deleted_account_retention |
duration | 30 days |
Multi-tenant apps add orgs.invitation_url, orgs.invitation_ttl and orgs.deleted_org_retention.
appSettings.mailDefaults() turns the mail.* values into mail.Defaults for mail.WithDefaults.
permissions.go#
declarePermissions() *authlib.Catalog#
The permission catalog: every permission modules check, and the platform roles that grant them. platform_admin grants every ops.* permission; ops_viewer the read ones; both require two-factor authentication (RequireMFA). api roles prints it. Roles are stored per user in auth_user_roles and read on every request. Multi-tenant apps also declare organisation permissions after //aps:anchor org-permissions.
keys.go#
loadKeyring(keys config.Secret, production bool) (*authlib.Keyring, error)#
Parses AUTH_ENCRYPTION_KEYS with authlib.ParseKeyring. Empty: nil in development (authenticator apps off, 503 mfa_unavailable), an error in production. Config.keyring() returns the parsed keyring for New, Seed and the commands.
passkeys.go#
loadWebAuthnConfig(get, production) (webAuthnConfig, []error)#
Reads WEBAUTHN_*. Development with RP ID and origins empty uses localhost and http://localhost:8080, http://localhost:3000. Errors: RP ID missing while other values are set; origins missing with an RP ID; non-https origins in production; malformed iOS or Android entries. service() builds the passkey.Service (relying party name ServiceName), or nil when off.
(*App).mountWellKnown(mux)#
When WEBAUTHN_APPLE_APP_IDS is set, serves GET /.well-known/apple-app-site-association with {"webcredentials": {"apps": […]}}; when WEBAUTHN_ANDROID_APPS is set, GET /.well-known/assetlinks.json with a delegate_permission/common.get_login_creds statement per app and its fingerprints. JSON is built once at start.
social.go#
loadSocialConfig(get, secret, production) (socialConfig, []error)#
Reads Google and Apple. Rules: a Google client ID needs its secret, and mobile client IDs need the web client ID; any Apple value requires Team ID, Key ID, private key, and a Services ID or bundle IDs, and the key must parse; Google or Apple web sign-in requires APP_PUBLIC_URL as scheme and host, https in production. Defaults APP_PUBLIC_URL to http://localhost:8080 in development.
(socialConfig).providers(ep) (google, apple *social.Provider)#
Builds the social.Providers, or nil for a provider that's off. ep overrides endpoints in tests (socialtest). Google's native audiences are the iOS and Android client IDs, besides the web client ID.
mail.go, infra_mail.go#
newMailSender(cfg) (mail.Sender, error)#
Returns an SMTP sender to Mailpit (MAILPIT_SMTP_ADDR, no auth, no TLS) when delivery is mailpit, and the provider's sender from infra_mail.go otherwise. aps add mail replaces infra_mail.go (and its loadMailConfig and newProviderSender) to switch between Resend and SMTP.
mailInfo(cfg, settings) opsusecase.MailInfo#
What GET /ops/mail shows: provider, delivery, whether credentials are set (never their values), and the sender settings.
providers.go#
(Config).signInMethods() []opsdomain.SignInMethod#
For each sign-in method, whether it's on and, if not, which variables turn it on. Shared by GET /ops/auth/providers, WriteSignInMethods (the auth-providers command and the development start banner) and reportSignInMethods (a log line in production). Never includes values.
migrate.go#
Migrate(ctx, cfg, w) error#
| Purpose | Bring the schema up to date |
| Steps | Open a pool; postgres.Migrate(ctx, pool, migrations.FS) under an advisory lock, printing applied migration <version>; then jobs.Migrate for River, printing applied job queue migration <n> |
| Errors | Connection errors; the SQL error of a failing migration, which stops there. Goose runs each migration in a transaction unless annotated otherwise, so a failing one leaves nothing half-applied |
| Concurrency | Safe to run from several places at once: the advisory lock serializes them |
| Why | Schema changes are a release step, never a side effect of starting an instance (ADR-0017) |
seed.go#
Seed(ctx, cfg, email, w) error#
| Purpose | Give a development database an administrator and example data (ADR-0042) |
| Refuses | APP_ENV=production; no AUTH_ENCRYPTION_KEYS |
| Idempotent | If email exists, prints ✓ Seed data is in place and changes nothing |
| Creates | The user with a crypto/rand.Text() password and verified email; platform_admin; a TOTP enrollment with 10 recovery codes; three projects owned by the administrator |
| Output | Password, 2FA key, otpauth:// URI and recovery codes, once. Only hashes and the encrypted secret are stored |
| Why through use cases | Password policy, hashing, encryption and audit events apply exactly as for real sign-ups, as the seed system actor |
commands.go, admin.go, admin_mfa.go#
Operator commands share openCommandDeps(ctx, cfg, name), which connects with application name <ServiceName>-<name> (visible in pg_stat_activity) and builds the audit recorder and auth service. Commands use a stub email sender (noEmails) rather than the job queue.
| Function | Command | Does | Audit |
|---|---|---|---|
WriteRoles(w) |
api roles |
Prints roles, descriptions and permissions from declarePermissions |
— |
GrantRole(ctx, cfg, email, role, w) |
api grant-role <email> <role> |
Adds a platform role to the account; unknown roles and accounts are errors | auth.role.granted |
RevokeRole(ctx, cfg, email, role, w) |
api revoke-role <email> <role> |
Removes it; revoking a role the user doesn't have changes nothing | auth.role.revoked |
ResetMFA(ctx, cfg, email, w) |
api reset-mfa <email> |
Deletes the account's TOTP secret and recovery codes | auth.mfa.reset |
RotateAuthKeys(ctx, cfg, w) |
api rotate-auth-keys |
Decrypts every TOTP secret with whichever key it names and re-encrypts it with the first key in AUTH_ENCRYPTION_KEYS |
auth.keys.rotated, with the key ID and count |
WriteSignInMethods(w, cfg) |
api auth-providers |
Prints the sign-in methods | — |
Commands#
| Command | Package main |
Calls |
|---|---|---|
cmd/api |
run(ctx, args) |
LoadConfig, then a subcommand or New + Run. Errors print as <app>: <error> with exit code 1 |
cmd/migrate |
run(ctx) |
LoadConfig, Migrate. Errors: migrate: <error> |
cmd/seed |
run(ctx) |
LoadConfig, Seed(DefaultSeedEmail). Errors: seed: <error> |
Each reads the process environment through config.OS; none reads .env.