Documentation
¶
Overview ¶
Package appximo is the public library surface of the Appximo engine (ADR-016 "Appximo as a Go library"). A developer imports this package, builds the engine with New, registers custom Class-1 handlers with (*App).Register, and runs it with (*App).Start — compiling a single static CGO-free binary. The pure binary that ships is exactly this with zero registered handlers.
EXPERIMENTAL: per ADR-016 Decision 5 the extension surface — Ctx, Claims, Route, Config, Handler, New, Register — will be frozen at the v1 major boundary. Until that promotion the interface may change between minor versions. Treat `grep UnsafeTx` as the complete audit of RBAC-bypass sites.
Index ¶
- Constants
- Variables
- func BackendSpec() string
- func BackofficeSpec() string
- func FrontendSpec() string
- func InstallPrompt() string
- func LifecycleSpec() string
- func LoadDotEnv() int
- func MasterPrompt() string
- func SafeParallel(ctx context.Context, limit int, tasks ...func(context.Context) error) error
- func ServeFleet(mf *fleet.Manifest, version string, debugTracesHTML []byte) error
- func StarterSchema() []byte
- type Allowlist
- type App
- type Claims
- type Config
- type CreatedUser
- type Ctx
- type ForeignKeyConflictError
- type Handler
- type InvalidTransitionError
- type QueryOpts
- type RateLimit
- type Registry
- func (r *Registry) AddApp(domains []string, app *compiledApp)
- func (r *Registry) RemoveApp(domains []string)
- func (r *Registry) Resolve(host string) *compiledApp
- func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request)
- func (r *Registry) Snapshot() map[string]string
- func (r *Registry) SwapApp(domains []string, app *compiledApp)
- type Route
- type ServeArgs
- type ServeFileOption
- type StaticMount
- type UniqueViolationError
- type ValidationError
Constants ¶
const CSPOff = "off"
CSPOff is the StaticMount.CSP sentinel that disables the header entirely.
const CacheControlImmutable = "public, max-age=31536000, immutable"
CacheControlImmutable is the aggressive-but-safe policy for a URL that embeds the file id: the store is content-addressed (an id's bytes never change), so a browser may cache it for a year and never revalidate. A changed image arrives under a NEW id — and therefore a new URL.
const DefaultStaticCSP = "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
DefaultStaticCSP is the BASE policy a StaticMount uses when CSP is unset: same-origin everything, no framing, no external script/connect targets.
⚠ The SERVED policy is usually STRICTER than this constant (SEC-2, hardenedStaticCSP): at boot the mount's index document is inspected and script-src is upgraded — no inline scripts → `script-src 'self'` only; inline bootstraps → pinned by 'sha256-…' hashes with 'unsafe-inline' DROPPED; only an unparseable shell keeps the permissive form, and then the reason is logged. The second field evaluation read this comment, expected 'unsafe-inline' on the wire, and measured the hardened form instead — the doc under-declared what the engine does. Consequence worth knowing: editing an inline script in the shell requires a restart (hashes are computed at boot).
script-src carries 'unsafe-inline' DELIBERATELY: SvelteKit's adapter-static shell boots hydration from an INLINE <script> (so do Next export and Astro islands), and the first strict draft of this default (script-src 'self', copied from the embedded admin UI, whose Vite build emits only external modules) blanked a SvelteKit app exactly the way the original ENG-5 bug did — caught by the commerce browser suite, invisible to curl. A default must run the mainstream bundlers' output; an app whose bundle has no inline scripts should tighten per mount:
StaticMount{CSP: strings.Replace(appximo.DefaultStaticCSP, "script-src 'self' 'unsafe-inline'", "script-src 'self'", 1)}
The load-bearing protections remain: no external script sources, no external connect targets (exfil), no framing, no foreign form action.
img-src carries blob: (FIELD-FEEDBACK-S1, FE4): the canonical image-preview pattern of every upload UI is URL.createObjectURL(file) — a blob: URL — and without it the preview silently doesn't render, visible only in the browser console (the curl-blind CSP class again). blob: relaxes nothing appreciable: a blob URL is same-origin and created by the document itself — it is not a third-party load channel or an exfil path.
const MaxBodyBytes = maxBodyBytes
MaxBodyBytes is the request-body cap Ctx.Bind / Ctx.BindResource / Ctx.RawBody enforce on a custom route — the same 1 MiB the generated REST and GraphQL handlers use. A body over it fails with ErrBodyTooLarge (→ 413).
const MinJWTSecretLen = 32
MinJWTSecretLen is the enforced floor for the HS256 signing secret (SEC-6). The docs said "at least 32 characters" while the engine booted with 5; a stated rule the engine does not enforce is the "accepts and continues" class (ADR-024 rule 8). Exported so a custom binary can reference the same number it will be held to.
Variables ¶
var ( // ErrEmailTaken: the email already has a user in this tenant. ErrEmailTaken = userauth.ErrEmailTaken // ErrInvalidEmail: the email failed the engine's format check. ErrInvalidEmail = errors.New("appximo: invalid email") // ErrWeakPassword: a non-empty password shorter than the engine's minimum. ErrWeakPassword = errors.New("appximo: password too short") // ErrUnknownRole: the role is not declared in the schema RBAC. ErrUnknownRole = errors.New("appximo: role not declared in the schema RBAC") // ErrBodyTooLarge: the request body exceeded MaxBodyBytes. Returned by // RawBody/Bind/BindResource; returning it from a Handler yields a 413. ErrBodyTooLarge = errors.New("appximo: request body too large") // ErrUpdateConflict: a Ctx.Update matched zero rows because the row changed // concurrently (its state fields already equal the requested values, so the // guard, not a bad transition, is what fired). Returning it from a Handler // yields a 409 — the caller should re-read and retry. ErrUpdateConflict = errors.New("appximo: the resource changed during the update; retry") // ErrFileNotFound: Ctx.ServeFile's uniform miss — a malformed id, an unknown // id and another tenant's id are deliberately indistinguishable (no // enumeration oracle on a download route). Returning it from a Handler // yields a 404 {"error":"not found"}. ErrFileNotFound = files.ErrNotFound )
Errors Ctx.CreateUser returns for the caller to branch on (map them to 409 / 422 / 400 with ctx.Error as fits the endpoint's contract).
Functions ¶
func BackendSpec ¶
func BackendSpec() string
BackendSpec returns the agent guide for building a complete backend. Paste it into your own agent (Claude Code, Cursor) alongside `appximo spec` and the agent has everything it needs to write handlers, hooks, auth and jobs safely.
func BackofficeSpec ¶ added in v0.1.3
func BackofficeSpec() string
BackofficeSpec returns the printable back-office contract.
func FrontendSpec ¶
func FrontendSpec() string
FrontendSpec returns the agent guide for building a production frontend. Paste it into your own agent (Claude Code, Cursor) alongside `appximo spec` and `appximo backend-spec` and the agent has the full stack: schema, backend, and the UI that consumes them.
func InstallPrompt ¶ added in v0.1.6
func InstallPrompt() string
InstallPrompt returns the paste-ready install prompt. Like MasterPrompt, the maintainer-facing HTML comment at the top of the source file is stripped so the printed text is exactly what a user should paste.
func LifecycleSpec ¶ added in v0.1.3
func LifecycleSpec() string
LifecycleSpec returns the printable operations contract.
func LoadDotEnv ¶ added in v0.1.3
func LoadDotEnv() int
LoadDotEnv loads a `.env` file from the current working directory into the process environment — field report F1: the missing-config message used to say "or a .env you source", but the binary never read one, `source` does not exist on Windows, and the shell workarounds introduced their own failure class (F1-bis: a PowerShell-written BOM glued itself to the first variable NAME, visually identical to the right one and broken).
Contract (deliberately boring):
- The REAL environment always wins: a variable already set is never overridden. `.env` fills gaps only.
- A UTF-8 BOM at the start of the file is stripped (F1-bis dies here).
- Lines: `KEY=VALUE`, optional `export ` prefix, blank lines and `#` comments ignored, CRLF tolerated, single or double quotes around the value removed (no escape processing — a literal file, not a shell).
- No file, or an unreadable file, is a no-op — nothing to load is a valid state, not an error.
It returns the number of variables actually set. The engine CLI calls it before every subcommand; ParseServeArgs calls it for consumer binaries — so `appximo serve` and a custom backend behave identically. It is exported for consumers that build their own flag handling and still want the behavior.
func MasterPrompt ¶ added in v0.1.5
func MasterPrompt() string
MasterPrompt returns the paste-ready master prompt. The source file opens with an HTML comment addressed to maintainers, not agents — it is stripped here so the printed text is exactly what a user should paste.
func SafeParallel ¶
SafeParallel runs tasks concurrently with at most `limit` of them in flight at once (backpressure — a handler never spawns an unbounded number of goroutines) and RECOVERS a panic in any task into an error, so one bad task can never crash the process. This is the sanctioned in-request fan-out primitive: a raw errgroup does NOT recover — a panicking errgroup goroutine takes the whole process down, exactly the failure mode Phase 0 (LIBRARY-HARDEN-S1) closes.
It waits for every task and returns the FIRST non-nil error (a recovered panic is reported as an error). ctx is the handler's context — cancel it (e.g. via Route.Timeout) to abort the tasks still running. limit <= 0 means unbounded (one goroutine per task); prefer a small bound sized to the work. Unlike Ctx.SafeGo (detached, fire-and-forget), SafeParallel's tasks share the request's lifetime and the handler waits for their results — so a task MAY use the handler's transaction, provided the tasks do not write it concurrently (pgx.Tx is not safe for concurrent use; parallelise reads or independent work, serialise writes on the tx).
func ServeFleet ¶
ServeFleet is the MT-STRUCT-S3 Option-B runtime: N DISTINCT apps compiled and served from ONE process, dispatched by Host through the S2 Registry.
Each app is a full *App instance — its own schema-compiled router, GraphQL, OpenAPI, pgx pool (its OWN database), response cache, SSE hub, rate limiter, observability stack and control-plane listener — with its middleware chain CLOSED OVER its own config: its JWT secret, its RBAC policy, its admin key.
The security ordering the design demands (app resolved BEFORE the JWT is validated, with THAT app's secret) holds by construction: the Registry resolves the Host to an app and only then does that app's chain — whose JWT middleware knows only that app's secret and whose RBAC middleware knows only that app's policy — run. A per-request "app from context" indirection was evaluated and rejected: N independent closure chains give the same property with ZERO added per-request work and a smaller blast radius (there is no shared auth state to mis-route; the one piece of cross-app shared state — the package-level claims cache — is keyed by (secret, token) since S3).
Unmatched Hosts do NOT fall into an arbitrary app (the single-app default would be a cross-app hole here): they get a process-level handler serving only the health probes and a clean 404 — the same contract as the fleet proxy (S1).
Deploy semantics in S3: `POST /admin/engine/schema` on an app persists THAT app's boot schema file and gracefully restarts the WHOLE process (all apps, ~6 s) — honest and safe; the per-app hot-swap without process restart is S4.
func StarterSchema ¶ added in v0.1.4
func StarterSchema() []byte
StarterSchema returns the embedded quickstart schema (todo-api): one `tasks` resource, two roles. It is what `appximo up` writes to ./schema.json when the project has none — a real, valid schema the user is meant to replace.
Types ¶
type Allowlist ¶
type Allowlist []string
Allowlist is the field projection permitted for the caller's role on a resource. An empty Allowlist means no restriction (every column is visible).
type App ¶
type App struct {
// contains filtered or unexported fields
}
App is a constructed Appximo engine: schema-derived REST + GraphQL + OpenAPI, multi-tenant, plus any custom Class-1 routes registered before Start. Build it with New, add routes with Register, run it with Start.
func New ¶
New builds an engine from cfg. SchemaPath is required; the DSN, JWT secret, admin key, port and env fall back to DATABASE_URL / JWT_SECRET / ADMIN_KEY / 8080 / APPXIMO_ENV when their Config fields are empty — so the pure binary and a custom binary boot identically. It performs the SAME initialization the `serve` command always has (pool, outbox table, hook runtime, observability, control plane, caches, rate limiter); the goroutines and HTTP listeners are started by Start.
func (*App) Pool ¶
Pool returns the engine's own PostgreSQL pool (LIBRARY-GAPS-S1) — the seam a framework-mode backend needs for boot-time work (its own DDL, seeds, a warm-up) without opening a SECOND pool from a DSN it re-parses itself, which can drift from the engine's configuration.
It is deliberately the raw pool, so it is NOT tenant-scoped and carries no RBAC: inside a request, use Ctx (whose transaction already has the tenant's search_path and the role's row filter). Outside a request, set the search_path transaction-locally as DATA, never by string concatenation:
tx.Exec(ctx, "SELECT set_config('search_path', $1, true)", pgSchema)
Do NOT Close it — the pool's lifetime belongs to the App (closed on shutdown). For the common case (boot DDL) prefer Config.BeforeStart, which hands you this same pool at exactly the right moment and fails the boot on error.
func (*App) Register ¶
Register adds a custom Class-1 route. It must be called BEFORE Start and is validated immediately for shape and collisions against the schema's generated routes — a bad route returns an error here, at boot, never at request time. Registration is boot-only (chi has no safe post-Start mutation), so calling Register after Start returns an error.
func (*App) Routes ¶
Routes returns the registered custom routes (read-only view, for OpenAPI/tests).
type Claims ¶
Claims is the authenticated identity the middleware chain already resolved from the request's JWT — a Class-1 handler never re-parses or re-verifies it.
type Config ¶
type Config struct {
// SchemaPath is the path to the schema JSON compiled at boot.
SchemaPath string
// DSN is the PostgreSQL connection string. Empty falls back to DATABASE_URL.
DSN string
// Port is the data-plane HTTP port. 0 falls back to 8080.
Port int
// Host is the data-plane bind address. Empty keeps the historical default
// (all interfaces). Set "127.0.0.1" for a loopback-only deployment — a dev
// box reached through an SSH tunnel, or a binary that only ever sits behind
// a local reverse proxy (LIBRARY-GAPS-S2, from the 105's port-exposure
// review: a public box's firewall should be the SECOND line, not the only
// one).
Host string
// ControlHost is the control-plane bind address. Empty keeps the historical
// default (all interfaces — relies on the firewall/AGENTS rule that :9090
// never reaches the internet). "127.0.0.1" enforces localhost-only at the
// socket, which is what the control plane's own documentation assumes.
ControlHost string
// ControlPort is the control-plane HTTP port (tenant registration,
// X-Admin-Key-gated — keep it off the internet). 0 falls back to
// APPXIMO_CONTROL_PORT, then 9090 (the historical fixed value, so a
// single-engine deployment boots byte-identically). Parameterized in
// MT-STRUCT-S1 so N engines can coexist on one box (`appximo fleet`).
ControlPort int
// ObsDBPath is the observability SQLite path. Empty falls back to
// OBS_DB_PATH, then the platform default (Linux /var/lib/appximo/obs.db;
// Windows %LOCALAPPDATA%\Appximo\obs.db; macOS ~/Library/Application
// Support/Appximo/obs.db — pkg/platformpath, W1). A Config field (not
// env-only) since MT-STRUCT-S3: N in-process apps share the process env,
// and each app needs its OWN obs store.
ObsDBPath string
// --- Self-monitoring of the engine's own resources (CENTINELA-C-S1, ADR-030) ---
//
// SelfMonDisabled turns the resource collector off (APPXIMO_SELFMON=off).
// On by default: one goroutine on a timer reads runtime/metrics, the
// process cgroup / PSI and pgxpool.Stat, and computes the attribution
// verdict served at /admin/resources and /debug/resources. The request
// path pays two atomic adds and one HDR record per request — measured on
// the proxies A-54 names (allocs/op, CPU-seconds, RSS), see docs/BENCHMARKS.md §4c.
SelfMonDisabled bool
// SelfMonInterval is the BACKGROUND cadence (default 10 s; env
// APPXIMO_SELFMON_INTERVAL, a Go duration). SelfMonLiveInterval is the
// cadence while the /admin correlation view is being polled (default 1 s;
// APPXIMO_SELFMON_LIVE_INTERVAL); it decays back after 60 s without a poll.
SelfMonInterval time.Duration
SelfMonLiveInterval time.Duration
// SelfMonHighP99Ms is the absolute "slow" floor of the attribution rules
// (default 50 ms; APPXIMO_SELFMON_P99_MS): "what is slow for MY app" is
// the one threshold an operator plausibly tunes. The relative rule (3× the
// healthy baseline) applies regardless.
SelfMonHighP99Ms float64
// BannerWriter is where Start's human boot banner ("Appximo serving on …",
// the foreground note, the Try-it line) is printed. nil keeps the historical
// os.Stdout. `appximo up` points it at io.Discard: up prints its own final
// card, and in --json mode stdout must carry EXACTLY one JSON object
// (ENG-38; the C1 rule — machine commands keep byte-clean stdout). Engine
// LOGS are unaffected (they follow the standard logger to stderr).
BannerWriter io.Writer
// JWTSecret signs/validates HS256 tokens. Empty falls back to JWT_SECRET.
JWTSecret string
// AdminKey gates the control plane (:9090) and /metrics, /debug, /admin on
// the data plane. Empty falls back to ADMIN_KEY.
AdminKey string
// Env mirrors APPXIMO_ENV ("development" enables GraphiQL + introspection +
// pprof). Empty falls back to the APPXIMO_ENV environment variable.
Env string
// GraphQLPlayground explicitly enables GraphQL introspection and the
// GraphiQL explorer (/graphiql) OUTSIDE development — the operator's opt-in
// for exploring/testing GraphQL in production without flipping the broader
// Env=development flag (which also enables pprof). False falls back to
// APPXIMO_GRAPHQL_PLAYGROUND (truthy); Env=="development" already implies
// this regardless (GRAPHQL-EXPLORER-S1).
GraphQLPlayground bool
// FilesDir is the root directory of the content-addressable file store
// (FILES-V1). Empty falls back to APPXIMO_FILES_DIR, then to the platform
// default (Linux /var/lib/appximo/files; Windows %LOCALAPPDATA%\Appximo\
// files; macOS ~/Library/Application Support/Appximo/files —
// pkg/platformpath, W1). The directory is created lazily on the first
// upload, so an engine that never serves /api/files touches no disk.
// Applies to the "local" files backend only.
FilesDir string
// --- File store backends (FILES-V2): BYOC storage, swappable by config ---
//
// FilesBackend selects where blobs live: "local" (default — the tenant's
// files on this VPS's disk, served by the engine with Range/ETag/sendfile)
// or "s3" (any S3-compatible provider: Cloudflare R2, DO Spaces, MinIO,
// AWS — served via short-lived presigned URL + 302 by default). Empty falls
// back to APPXIMO_FILES_BACKEND, then "local". Tenancy, RBAC, metadata
// and upload validation are IDENTICAL on both backends.
FilesBackend string
// FilesS3Bucket / FilesS3Endpoint / FilesS3Region / FilesS3AccessKey /
// FilesS3SecretKey configure the S3 backend provider-agnostically. Each
// falls back to its APPXIMO_FILES_S3_* env var (BUCKET, ENDPOINT, REGION,
// ACCESS_KEY, SECRET_KEY). Endpoint empty means AWS S3 proper; Region
// empty defaults to "auto" (R2's spelling, harmless elsewhere). With
// FilesBackend="s3", a missing bucket or credentials fails boot loudly.
FilesS3Bucket string
FilesS3Endpoint string
FilesS3Region string
FilesS3AccessKey string
FilesS3SecretKey string
// FilesS3ForcePathStyle addresses the bucket as <endpoint>/<bucket>
// (required by MinIO). Falls back to APPXIMO_FILES_S3_FORCE_PATH_STYLE
// (truthy).
FilesS3ForcePathStyle bool
// FilesS3Prefix namespaces keys inside the bucket. Empty falls back to
// APPXIMO_FILES_S3_PREFIX, then "tenants/".
FilesS3Prefix string
// FilesS3ServeMode is how GET /api/files/{id} delivers S3 bytes:
// "redirect" (default — 302 to a short-lived presigned URL; the engine
// authorizes, the bucket serves, zero engine bandwidth) or "proxy" (bytes
// stream through the engine; bucket never exposed). Falls back to
// APPXIMO_FILES_S3_SERVE.
FilesS3ServeMode string
// FilesTokenTTLSeconds bounds signed download URLs (both the engine-minted
// local tokens and S3 presigned URLs from /api/files/{id}/url). 0 falls
// back to APPXIMO_FILES_TOKEN_TTL (seconds), then 180.
FilesTokenTTLSeconds int
// FilesAllowedExt replaces the default upload extension ALLOWLIST
// (OWASP: allowlist, never denylist). Entries with or without the dot;
// the single value "*" disables the extension check (magic-byte checks
// still apply). Empty falls back to APPXIMO_FILES_ALLOWED_EXT
// (comma-separated), then to files.DefaultAllowedExtensions.
FilesAllowedExt []string
// BareDomains are hostnames that are THIS APP ITSELF (the fleet manifest's
// `domains`), not a tenant: a request whose Host equals one exactly carries
// no tenant label, so the tenant middleware passes it through with no
// TenantCtx instead of mis-reading the domain's first label as a tenant
// (the observability phantom-tenant bug, FLEET-CONSOLE-S2). Empty (the
// single-engine default) leaves the middleware byte-identical to before.
BareDomains []string
// BeforeStart runs ONCE at Start, after the engine is fully constructed (pool
// open, control-plane tables ensured, schema loaded and compiled) and BEFORE
// the data-plane listener accepts a single request — the seam framework-mode
// backends need for boot work (LIBRARY-GAPS-S1): their own DDL for what the
// schema grammar cannot express (a CHECK constraint, a generated column),
// seeds, or a cache warm-up.
//
// It receives the ENGINE'S OWN pool (the same *pgxpool.Pool as App.Pool()), so
// a backend no longer parses DATABASE_URL and opens a second pool that can
// drift from the engine's configuration. The pool is NOT tenant-scoped: set
// the search_path yourself, transaction-locally, exactly as the engine does —
//
// tx.Exec(ctx, "SELECT set_config('search_path', $1, true)", pgSchema)
//
// — never by string concatenation.
//
// A non-nil error ABORTS the boot: Start returns it and the listener never
// opens, so a backend whose invariants failed to install never serves traffic.
// The context is cancelled on SIGINT/SIGTERM, so a hung hook still drains.
BeforeStart func(ctx context.Context, pool *pgxpool.Pool) error
// OnTenantProvisioned runs INSIDE every tenant registration this app
// performs (control plane :9090/:9099 and the /admin API — both funnel
// through the same Service), after the engine has provisioned the tenant's
// tables (ENG-8, CONSUMER-PATH-S1). It is the per-tenant twin of
// BeforeStart: BeforeStart covers the tenants that exist AT BOOT; this hook
// covers every tenant created while the app is live — the normal flow of a
// multi-tenant SaaS. Without it, consumer DDL (generated columns, CHECKs,
// partial indexes) was missing from post-boot tenants until a restart.
//
// Same contract as BeforeStart: the engine's own pool, not tenant-scoped —
// scope the search_path transaction-locally. MUST be idempotent (BeforeStart
// typically re-applies the same DDL at every boot). An error FAILS the
// registration all-or-nothing: the tenant is rolled back, never left
// half-provisioned.
OnTenantProvisioned func(ctx context.Context, pool *pgxpool.Pool, tenantID, pgSchema string) error
// Static serves one or more file trees from THIS binary — the seam that makes
// "one binary = backend + frontend + admin + docs" real (LOOSE-ENDS-SWEEP-S1).
// Each mount is served outside /api/, with no tenant transaction, no RBAC
// evaluation and no response-cache buffering; a collision with an
// engine-owned prefix, a missing index document or a duplicated path is a
// BOOT error. See StaticMount for the full contract (including the PCI note
// on keeping a checkout page free of third-party scripts).
//
// Empty (the default, and the pure binary) mounts nothing and costs nothing.
Static []StaticMount
// AppThemeCSS re-skins the embedded generic back-office (/app) with the
// consumer's brand (DEMO-SHOWCASE-S1): the CSS text is served at
// /app/theme.css, linked after the panel's own stylesheet, and style.css
// exposes every color/radius/font as --app-* tokens on :root — so a few
// token overrides restyle the whole panel with no rebuild. Empty serves the
// embedded default (neutral look) and falls back to APPXIMO_APP_THEME_CSS,
// which names a FILE to read at boot.
AppThemeCSS string
// AppDemoRoles lists RBAC roles for which /app runs in DEMO MODE: the SPA
// simulates writes in a per-session in-memory overlay (a reload resets
// everything) and never sends them to the API. Pair it with a role whose
// policy is READ-ONLY — the overlay is visitor coherence, the RBAC is the
// security boundary; a hand-crafted write with that role's token is still
// a 403. Empty falls back to APPXIMO_APP_DEMO_ROLES (comma-separated).
AppDemoRoles []string
// AppBannerText / AppBannerHref put a one-line RETURN BAR above the
// embedded /app (login and panel): the consumer's text and one link back
// to its storefront or landing (ENG-46 — a public demo panel used to be a
// dead end for the hottest visitor). Text only, one href (http/https/
// mailto/tel or a same-site path; anything else renders as plain text).
// Empty falls back to APPXIMO_APP_BANNER_TEXT / APPXIMO_APP_BANNER_HREF.
AppBannerText string
AppBannerHref string
// Version is reported by /health and the synthetic monitor. Empty reports
// "dev"; the cmd binary passes its ldflags-injected build version.
Version string
// DebugTracesHTML is the embedded /debug/traces explorer page. The cmd
// binary injects its go:embed'd asset here; when nil the visual route is
// not mounted (the JSON debug APIs are unaffected). Optional engine wiring,
// not part of the day-to-day user surface.
DebugTracesHTML []byte
// AuthSignupRole is the RBAC role assigned to every PUBLIC signup
// (POST /auth/signup), the auth-as-product core (AUTH-CORE-V1). Empty
// DISABLES public signup (safe by default — no accidental self-service
// accounts). It must name a role declared in the schema's RBAC; New rejects
// an unknown role at boot. Empty falls back to APPXIMO_AUTH_SIGNUP_ROLE.
AuthSignupRole string
// AuthMinPasswordLength is the minimum accepted signup password length.
// 0 falls back to APPXIMO_AUTH_MIN_PASSWORD, then to 8.
AuthMinPasswordLength int
// AuthLoginAttemptsPerMinute / AuthLoginBurst bound login (and MFA-verify)
// attempts per (tenant, email): `burst` immediate attempts, then
// `per-minute` sustained, the 6th attempt in a minute answering 429
// (ENG-47, MOTOR-AUTORIZACION-S1). 0 falls back to
// APPXIMO_AUTH_LOGIN_ATTEMPTS_PER_MINUTE / APPXIMO_AUTH_LOGIN_BURST, then
// to the defaults 5 / 5 — UNCHANGED from before the knob existed. This is
// the online brute-force / credential-stuffing guard on a single account:
// RAISING IT WEAKENS THAT DEFENCE in proportion (at 60/min an attacker
// tries 86 400 passwords a day against one identity). Raise it only for
// a deliberately shared identity — a public read-only demo account —
// and keep the RBAC role of that identity read-only, since the limiter
// is then no longer protecting it. The engine logs a warning at boot
// whenever the value is above the default.
AuthLoginAttemptsPerMinute int
AuthLoginBurst int
// SafeGoTimeoutSeconds bounds a Ctx.SafeGo goroutine's context
// (LIBRARY-HARDEN-S1): the context is cancelled after this (fn must honor
// cancellation to actually stop — a deadline cannot forcibly kill a
// goroutine). 0 falls back to APPXIMO_SAFEGO_TIMEOUT (seconds), then to 30s.
// It does not affect the request-goroutine deadline, which is Route.Timeout.
SafeGoTimeoutSeconds int
// PublicRouteRPS / PublicRouteBurst tune the DEDICATED rate limit applied
// to PUBLIC custom routes (Route.Public — LIBRARY-EXTEND-S1), per
// (tenant, client IP), on top of the per-tenant limiter. Zero falls back to
// APPXIMO_PUBLIC_ROUTE_RPS / APPXIMO_PUBLIC_ROUTE_BURST, then to the
// deliberately conservative 5 rps / burst 10 — an anonymous endpoint is
// abuse surface, so the default protects it without configuration.
PublicRouteRPS float64
PublicRouteBurst int
// AuthRequireVerified, when true, blocks login for a user whose email is not
// yet verified (→ 403). Empty falls back to APPXIMO_AUTH_REQUIRE_VERIFIED
// ("true"/"1"/"on"). Default false (login works without verification).
AuthRequireVerified bool
// AuthBaseURL optionally overrides the origin used to build password-reset /
// email-verification links. Empty falls back to APPXIMO_AUTH_BASE_URL; if
// still empty the link origin is derived from the request Host (the
// multi-tenant-correct default). The link path is appended by the engine.
AuthBaseURL string
// OAuthCallbackURL is the FIXED public origin OAuth providers redirect back to
// (AUTH-OAUTH-V1), e.g. "https://auth.example.com" — it must be the redirect
// URI registered with each provider. Empty falls back to
// APPXIMO_OAUTH_CALLBACK_URL; if still empty it is derived from the request.
OAuthCallbackURL string
// OAuthDefaultRole is the role assigned to a user auto-created on first social
// login. Empty falls back to APPXIMO_OAUTH_DEFAULT_ROLE then to
// AuthSignupRole; if all empty, a brand-new social email is rejected (existing
// users still link/login). A configured role must exist in the schema RBAC.
OAuthDefaultRole string
// OAuthSuccessRedirect, when set, makes the OAuth callback 302 to
// "<url>#token=<jwt>" instead of returning JSON. Empty falls back to
// APPXIMO_OAUTH_SUCCESS_REDIRECT.
OAuthSuccessRedirect string
// OAuthProviders optionally sets the social-login provider credentials
// directly ("google"/"github"/"microsoft" → client id+secret). Nil falls
// back to the APPXIMO_OAUTH_{PROVIDER}_CLIENT_ID/_CLIENT_SECRET env vars.
// A Config field so the in-process fleet can give EACH APP its own
// providers (each app is a product with its own identity) instead of the
// process-wide env.
OAuthProviders map[string]userauth.OAuthProviderConfig
// MFAKey is the key material that ENCRYPTS users' TOTP secrets at rest
// (AUTH-MFA-V1, AES-256-GCM). Empty falls back to APPXIMO_MFA_KEY, then to
// the JWT secret. Set a dedicated key if you want to rotate it independently of
// JWT_SECRET (rotating it invalidates existing TOTP enrollments).
MFAKey string
// MFAIssuer is the issuer label shown in authenticator apps. Empty falls back
// to APPXIMO_MFA_ISSUER, then to "Appximo".
MFAIssuer string
// --- CORS (API-PRODUCTIVA-V1): cross-origin access for browser clients ---
// CORS is instance INFRASTRUCTURE config, not schema. An empty CORSAllowedOrigins
// DISABLES CORS (the safe default — no Access-Control-* headers, no preflight
// short-circuit); an operator opts in by listing browser origins. CORS applies
// ONLY to the public data-plane routes (/api, /auth, /graphql, /openapi), never
// to the control plane, /admin, /metrics or /debug.
//
// CORSAllowedOrigins is the exact origin allowlist, or the single literal "*"
// for any origin. Empty falls back to APPXIMO_CORS_ORIGINS (comma-separated).
CORSAllowedOrigins []string
// CORSAllowedMethods is echoed in preflight responses. Empty falls back to
// APPXIMO_CORS_METHODS, then to "GET,POST,PUT,PATCH,DELETE,OPTIONS".
CORSAllowedMethods []string
// CORSAllowedHeaders is echoed in preflight responses. Empty falls back to
// APPXIMO_CORS_HEADERS, then to "Authorization,Content-Type".
CORSAllowedHeaders []string
// CORSExposedHeaders lists response headers a browser script may read. Empty
// falls back to APPXIMO_CORS_EXPOSE_HEADERS, then to none.
CORSExposedHeaders []string
// CORSAllowCredentials sends Access-Control-Allow-Credentials: true (browser may
// send cookies/Authorization). Falls back to APPXIMO_CORS_CREDENTIALS (truthy).
// With credentials a literal "*" origin is reflected (the Fetch spec forbids "*").
CORSAllowCredentials bool
// CORSMaxAge bounds preflight caching (seconds). 0 falls back to
// APPXIMO_CORS_MAX_AGE, then to 600.
CORSMaxAge int
}
Config configures a New engine. SchemaPath and DSN are the only required fields; everything else falls back to the same defaults and environment variables the `appximo serve` command has always used, so the pure binary and a custom binary boot identically.
type CreatedUser ¶
CreatedUser is the identity Ctx.CreateUser returns — the same public shape the auth endpoints expose (never the password hash).
type Ctx ¶
type Ctx interface {
// Identity — already verified by the middleware chain.
Claims() Claims
Tenant() string // tenant id, e.g. "acme" (from the Host subdomain)
Role() string // the JWT "role" claim
// Allowlist returns the field projection the caller's role is granted on
// resource, and whether the role may read it at all (false ⇒ denied).
Allowlist(resource string) (Allowlist, bool)
// Tx is the transaction opened by the middleware with the tenant
// search_path already applied via set_config(...,true). Returning nil from
// the Handler commits it; returning an error rolls it back.
Tx() pgx.Tx
// UnsafeTx returns the SAME transaction but signals to the reader (and to
// `grep UnsafeTx`) that the RBAC-aware helpers are being bypassed. Tenant
// isolation STILL holds — the search_path is the same. There is no API that
// exposes the raw pool.
UnsafeTx() pgx.Tx
// RBAC-aware helpers — apply the role's row filter, validate against the
// compiled schema rules, and project the permitted fields. Use by default.
Query(resource string, opts QueryOpts) ([]map[string]any, error)
// Get loads ONE row by id, with the role's row-level condition applied and
// the role's field allowlist projected — the read counterpart of Update.
//
// It exists because `id` is NOT a filterable field: QueryOpts.Filters is
// validated against the resource's DECLARED fields, and the implicit primary
// key is not one of them, so `Query(r, QueryOpts{Filters: {"id": x}})` fails
// with `unknown filter field: id`. That cost a real integration a debugging
// round (docs/AUTHORING_JOURNEY.md 5-7), and the workaround people reach for —
// UnsafeTx plus a hand-written SELECT — silently drops the row rule.
//
// A row the role may not see is indistinguishable from one that does not
// exist: both return (nil, nil) — never a 403 — which is the same
// anti-enumeration contract Ctx.Update and the generated GET/PATCH/DELETE
// follow. A role denied `read` on the resource entirely is a forbidden error.
Get(resource, id string) (map[string]any, error)
Insert(resource string, data map[string]any) (map[string]any, error)
// Update is the generated PATCH, in a handler: partial semantics, the
// declarative rules + type check, the governed-field rule (id/auto → 422
// read_only), the declared state-machine transitions (in SQL, race-safe),
// the role's row condition (a row the role may not see → nil, nil) and
// field allowlist — and the identity-column rule (ADR-027): for a role
// whose row condition is bound to the caller, a data map that sets that
// column to anything but the caller's own id is a 403, exactly as the
// REST PATCH answers. Transferring a record to another user is done as an
// unscoped role or on UnsafeTx, never by passing a client body through.
Update(resource, id string, data map[string]any) (map[string]any, error)
// Bind JSON-decodes the request body (1 MiB cap) into dst. BindResource
// additionally validates the decoded body against the compiled schema rules
// for resource (the same rule engine REST and GraphQL use).
Bind(dst any) error
BindResource(resource string, dst any) error
// RawBody returns the request body's EXACT bytes, under the SAME 1 MiB cap
// Bind applies (MaxBodyBytes) — the handler never re-implements the limit.
//
// USE IT FOR WEBHOOKS. A gateway signature (Stripe, Wompi, GitHub…) is
// computed over the bytes as sent: parse-then-reserialize changes key order
// and whitespace and breaks every signature, so Bind is the WRONG tool for a
// signed payload. Verify over RawBody FIRST, then Bind (or unmarshal the same
// bytes) once the signature checks out — parsing before verifying is the #1
// documented payment-integration bug.
//
// The body is read ONCE and buffered: RawBody and Bind may be used together
// in any order and both see the whole body (a plain io.ReadAll on
// Request().Body would leave Bind an empty reader). The returned slice is the
// engine's buffer — treat it as read-only. A body over the cap returns
// ErrBodyTooLarge, which the middleware maps to 413 if the handler returns it.
RawBody() ([]byte, error)
// Enqueue writes an outbox job inside the current transaction (atomic with
// the business write). A Handler error rolls back the enqueue too.
Enqueue(topic string, payload any) (int64, error)
// SafeGo launches fn in a NEW goroutine — the ONLY sanctioned way to start a
// goroutine from a handler (LIBRARY-HARDEN-S1). A raw `go func(){…}()` whose
// body panics crashes the ENTIRE multi-tenant process: recover() never
// crosses a goroutine boundary, so the request-chain Recoverer cannot save a
// child goroutine. SafeGo wraps fn in recover() + a structured log (tenant +
// request id) + the goroutine_panics_total metric, so a panicking background
// task degrades to a logged incident instead of an outage for every tenant.
//
// The context passed to fn is a FRESH root — it carries NO request values (a
// detached copy of the request context would retain chi's pooled route
// context, which is recycled once the handler returns) — with an INDEPENDENT
// bounded deadline. fn MUST honor that deadline (return promptly once ctx is
// Done): a deadline cancels the context, it cannot forcibly stop a goroutine,
// so an fn that ignores cancellation still leaks. fn MUST NOT use the
// handler's transaction (Tx/UnsafeTx/Query/Insert/Update) — that transaction
// commits or rolls back as the handler returns, and a goroutine touching it
// races a closed tx. SafeGo is for post-response, non-transactional side
// effects where at-most-once is acceptable (fire-and-forget: a metric ping, a
// best-effort cache warm). For DURABLE, retryable work use Enqueue (the
// transactional outbox + worker); for parallel work whose result the response
// needs, use SafeParallel and wait for it.
SafeGo(fn func(context.Context))
// CreateUser creates an identity in THIS tenant's auth_users, inside the
// handler's transaction — if the handler later fails, the user rolls back
// with everything else (LIBRARY-EXTEND-S1). It applies the SAME rules as
// the admin API: the email is normalized + format-checked, the role must
// be declared in the schema RBAC (ErrUnknownRole — no privilege invention),
// the password is argon2id-hashed and must meet the engine's configured
// minimum length. An EMPTY password creates an invitation-style user that
// cannot password-login until a reset sets one (the OTP/invite gate — same
// contract as an OAuth-created user). Duplicate email in the tenant →
// ErrEmailTaken. Always scoped to Tenant(): a handler cannot create users
// in another tenant. Deliberately usable on a Public route — creating the
// user IS the point of a custom registration endpoint; the caller's
// anonymity is why the role comes from the handler's code, never from
// request input, and why every input must be validated by the handler.
CreateUser(email, password, role string) (CreatedUser, error)
// MintToken signs a session JWT for userID with the given role —
// byte-shape identical to what POST /auth/login issues (HS256, the app's
// JWT secret, THIS tenant, the standard 24 h TTL), so the token works on
// every generated /api route exactly like a logged-in session
// (FRESH-AGENT-GAPS-S1: Ctx.CreateUser could create the identity but no
// engine path could mint its session — a custom registration endpoint
// could not auto-login like the engine's own /auth/signup does).
//
// user, err := ctx.CreateUser(email, pass, "member")
// ...
// tok, err := ctx.MintToken(user.ID, user.Role)
// return ctx.JSON(201, map[string]any{"user": user, "token": tok})
//
// userID must be non-empty (an empty identity makes every $user_id row
// condition match nothing — the CLI-token footgun, refused here) and the
// role must be declared in the schema RBAC (ErrUnknownRole — a token with
// an undeclared role is denied everything with an unexplained 403). The
// role comes from handler code, never from request input.
MintToken(userID, role string) (string, error)
// ServeFile streams one of THIS tenant's stored files (the engine file
// store, pkg/files — the same store /api/files/{id} serves) as the route's
// response: stored Content-Type, strong content-hash ETag (If-None-Match →
// 304), Range → 206, and on the local backend sendfile zero-copy
// (FRONTEND-SPEC-S1). It is the seam for a PUBLIC or custom-authorized
// download route — the handler decides WHO may fetch the file (e.g. "it is
// the image of an active product"), the engine moves the bytes safely.
//
// Contract:
// - The route MUST declare ByteServing: true (rejected loudly otherwise):
// that flag is what routes the response around the response cache and
// the compression wrapper, which would otherwise buffer the whole blob
// in RAM, drop Content-Disposition/Range headers on a cache hit, and
// suppress sendfile.
// - Call it once, INSTEAD of JSON/Error, and return its error. The bytes
// are streamed after the transaction commits (same flush discipline as
// JSON). ctx.Error before it still works (the error response wins).
// - fileID must be one of this tenant's file ids. A malformed id, an
// unknown id, or ANOTHER tenant's id all yield the same uniform 404
// (ErrFileNotFound — isolation is structural: the metadata lives in the
// tenant's own schema).
// - The lookup reads committed state (the engine pool), not the handler's
// transaction: a file uploaded inside THIS tx is not yet servable.
// - Cache policy (FILES-2): by default no Cache-Control is set (browsers
// revalidate — cheap 304s via the strong ETag). Pass
// appximo.WithCacheControl(...) to declare one; the store is
// content-addressed, so a given file id's BYTES can never change —
// appximo.CacheControlImmutable ("public, max-age=31536000,
// immutable") is safe for any route whose URL embeds the file id (a
// product image, an avatar): a different image is a different id, so a
// stale cache is structurally impossible. Do NOT use it when the SAME
// URL can start serving a DIFFERENT file (e.g. /api/logo that follows a
// mutable pointer) — there, the default revalidation is the correct
// policy. The header is only sent on a successful stream, never on the
// 404/error paths.
ServeFile(fileID string, opts ...ServeFileOption) error
// JSON buffers a success response flushed AFTER the transaction commits, so
// a commit failure becomes a 500 rather than a false 200. Error buffers an
// error response and returns a non-nil error so the Handler can
// `return ctx.Error(...)`; the middleware rolls back and flushes it.
JSON(status int, v any) error
Error(status int, msg string, cause error) error
Request() *http.Request
Context() context.Context
}
Ctx is the single argument to a Class-1 Handler (ADR-016 Decision 3). It carries the request context fully resolved: identity, tenant, and a pgx.Tx already scoped to the tenant search_path. The handler writes business logic, not infrastructure — it never re-authenticates, re-scopes the tenant, or touches the raw connection pool.
EXPERIMENTAL surface — frozen at v1 (ADR-016 Decision 5).
type ForeignKeyConflictError ¶ added in v0.1.7
type ForeignKeyConflictError struct{ Message string }
ForeignKeyConflictError is returned by Ctx.Insert/Update on a referential conflict (ENG-42): a write referencing a row that does not exist ("invalid reference: no matching \"x\" record") or a change a RESTRICT FK refuses ("cannot delete: still referenced by …"). Message is the engine's safe, human-readable wording — the raw Postgres error never reaches a client. Returning it from a Handler yields the same 409 the generated path answers.
func (*ForeignKeyConflictError) Error ¶ added in v0.1.7
func (e *ForeignKeyConflictError) Error() string
type Handler ¶
Handler is a Class-1 custom endpoint (ADR-016 Decision 2). It receives a Ctx with identity, tenant, and a tenant-scoped transaction already resolved. Returning nil COMMITS the transaction (and flushes any Ctx.JSON response); returning an error ROLLS IT BACK. Use `return ctx.Error(...)` to send a specific error response, or return any error for a masked 500.
type InvalidTransitionError ¶
type InvalidTransitionError struct{ Message string }
InvalidTransitionError is returned by Ctx.Update when a schema-declared state machine refused the move (LIBRARY-GAPS-S2, ENG-7) — the same verdict, message and race-safety the generated PATCH produces. Returning it from a Handler yields the identical 422 response; branch on it with errors.As to customize.
func (*InvalidTransitionError) Error ¶
func (e *InvalidTransitionError) Error() string
type QueryOpts ¶
type QueryOpts struct {
Filters map[string]any
Limit int
OrderBy string
Desc bool
// Fields projects the SELECT list (MOTOR-FIELDS-S1, the `?fields=` of the
// generated list): only these columns (plus `id`, always) are READ — a
// large json/text value that lives in TOAST is not detoasted for a row
// that does not ask for it. nil = every column, as before. An unknown
// name is an error naming it; a name the role's allowlist hides is the
// same forbidden error `Filters` on a hidden column gets.
Fields []string
}
QueryOpts narrows a Ctx.Query. Filters are equality predicates keyed by field name (validated against the resource schema, bound as parameters). Limit caps the row count (clamped to the engine's per_page maximum); OrderBy + Desc sort by a single field. The role's row-level RBAC condition is ALWAYS applied on top — QueryOpts cannot widen what the role may see.
type RateLimit ¶
RateLimit is one route's token-bucket budget (Route.RateLimit): RPS sustained requests per second with Burst instantaneous, counted per (tenant, client IP). Both must be > 0 — a zero value is rejected at Register, so a half-filled struct can never silently disable the throttle.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
MT-STRUCT-S2 — the in-process app registry, the Option-B foundation (docs/design/MT-STRUCT.md §9 Stage 2).
An APP is one schema compiled into one API surface (router + GraphQL + RBAC + OpenAPI — exactly what buildRouter produces today); the registry maps a request's Host to the app that serves it. In S2 the registry holds ONE app — the boot schema — and every Host resolves to it, so behavior is identical to pre-registry: the layer exists, costs ~nothing (benched), and gives S3 (N apps + per-app middleware) and S4 (per-app hot-swap) their seam.
Read path is LOCK-FREE by construction: both fields are atomic.Pointer loads, never a mutex — the S4 hot-swap will publish a new map/app by pointer swap while in-flight requests keep the one they resolved.
Host-parse coordination (deliberate): the dispatch parses Host to answer "which APP?" (suffix walk over registered domains) and the tenant middleware — inside the app — keeps parsing it to answer "which TENANT?" (first label). Sharing one parse via context.WithValue would cost an allocation + escape per request (~50 B), an order of magnitude MORE than the second zero-alloc parse (~14 ns, measured in the design). Two cheap reads beat one expensive share; the clean ordering is app first, tenant second.
func NewRegistry ¶
NewRegistry builds a registry serving def for every unmatched Host, plus the optional domain table (keys must be lowercase hostnames). def must not be nil — S2 always has the boot app.
func (*Registry) AddApp ¶
AddApp registers a NEW app on domains (hot add — a domain not previously served starts routing to app). Same copy-on-write semantics as SwapApp.
func (*Registry) RemoveApp ¶
RemoveApp unregisters domains (hot remove — those Hosts fall back to the default/unmatched app). Same copy-on-write semantics.
func (*Registry) Resolve ¶
Resolve returns the app owning host (a request Host header, port allowed). Zero allocations on every path except the never-in-practice "uppercase Host that also matches a registered domain" retry. The suffix walk makes the LONGEST registered domain win (api.crm.example.com prefers crm.example.com over example.com) in O(labels) map probes.
func (*Registry) ServeHTTP ¶
func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request)
ServeHTTP dispatches the request to its app — the ONLY hot-path addition of S2 (benched: see docs/design/MT-STRUCT.md Stage 2).
func (*Registry) Snapshot ¶
Snapshot returns a read-only copy of the current domain table (domain → app name) — the fleet console's inventory view (MT-STRUCT-S5). It is a plain atomic load + copy, entirely OFF the request hot path (Resolve is untouched), and reflects hot-swaps/adds/removes at the moment of the call.
func (*Registry) SwapApp ¶
SwapApp atomically replaces the app served for each of domains with app — the per-app HOT-SWAP (MT-STRUCT-S4): a deploy recompiles ONE app's router from its new schema and publishes it here, leaving every OTHER app's entry byte-identical (their pointers are copied unchanged into the new map). No process restart, no effect on the other apps, lock-free reads throughout.
type Route ¶
type Route struct {
Method string // GET | POST | PUT | PATCH | DELETE
Path string // e.g. "/api/declarations/submit"
Handler Handler
// Description is an optional one-line summary published in the served
// OpenAPI document (ENG-33). Every registered route appears in
// /openapi.json regardless — method, path, auth mode (Public vs Bearer +
// the RBAC segment/action it demands), RequireRole and ByteServing are all
// facts the engine knows and publishes on its own; Description is the one
// thing only the author can add. Request/response SHAPES are deliberately
// not declarable here: a Go handler has no declared schema, and the app's
// contract sheet (backend-spec §3.6b) stays the authority for shapes — the
// OpenAPI is the authority for EXISTENCE. An empty Description publishes a
// generic summary; an invisible route was the problem, a tersely-documented
// one is not.
Description string
// RequireRole, when non-empty, demands the caller's JWT role equal it
// (else 403). This is in ADDITION to the path-based RBAC the middleware
// already applied; a Route with no RequireRole still gets deny-by-default
// from the policy when its path segment matches a policy rule.
RequireRole string
// Timeout bounds this endpoint's execution (LIBRARY-HARDEN-S1). When > 0 the
// handler's context — and the tenant transaction opened for it — is cancelled
// after Timeout: a slow query or a hung outbound call is aborted (the deadline
// propagates to pgx and to any downstream that honours the context), the
// transaction rolls back, and the caller gets a 500 instead of the request
// pinning a connection indefinitely. 0 uses the engine default (5s). It bounds
// the REQUEST goroutine only; a Ctx.SafeGo goroutine outlives the request and
// carries its own independent deadline.
Timeout time.Duration
// RateLimit overrides this endpoint's dedicated throttle, per (tenant, client
// IP) — the same bucket shape the public-route limiter uses, sized for THIS
// route (LIBRARY-GAPS-S1).
//
// The engine's default for a Public route (5 rps / burst 10) is calibrated for
// a public WRITE endpoint — a registration or a webhook, where 5 rps is
// generous and abuse is the real risk. A public READ endpoint (a storefront
// catalogue) has the opposite profile: legitimate traffic is bursty and much
// higher. That mismatch is what this field fixes, without touching the
// conservative default everyone else inherits.
//
// {Method: "GET", Path: "/api/catalogue", Public: true,
// RateLimit: &appximo.RateLimit{RPS: 200, Burst: 400}}
//
// Nil (the default) keeps today's behavior EXACTLY: a Public route uses the
// shared public-route limiter, a non-public route has no dedicated limit (only
// the per-tenant one). Set on a NON-public route, it adds a per-(tenant, IP)
// limit on top of the per-tenant limiter — useful for an expensive
// authenticated endpoint (a report, an export).
RateLimit *RateLimit
// Public marks this route as PRE-AUTHENTICATION (LIBRARY-EXTEND-S1): the
// JWT and path-RBAC middlewares skip it by EXACT method+path match, so a
// caller needs no Bearer token — the seam for a custom registration/webhook
// endpoint that must run before an identity exists.
//
// ⚠ A public route is ATTACK SURFACE. The engine keeps what it can by
// default — the tenant still resolves from the Host (per-tenant isolation
// holds), the shared per-tenant rate limit still applies, and a DEDICATED,
// far more aggressive public-route rate limit (per tenant+client IP,
// APPXIMO_PUBLIC_ROUTE_RPS/BURST, default 5 rps / burst 10 → 429) is
// enforced before the handler runs. Everything else is the handler's
// responsibility: validate EVERY input, and treat the caller as hostile.
//
// Authentication is OPTIONAL, not ignored (LIBRARY-GAPS-S2, ENG-6): with no
// Authorization header the handler sees Claims() zero (anonymous — the
// RBAC-aware helpers fail closed with forbidden; anonymous writes go
// through CreateUser or a deliberate, greppable UnsafeTx). With a VALID
// Bearer, Claims() is populated — one endpoint can serve guests and
// recognized users (a checkout that links the order to a logged-in
// customer). A present-but-invalid/expired/foreign-tenant Bearer is a 401:
// sent credentials never silently degrade to anonymous, so the client
// knows to re-authenticate or retry deliberately without the token. The
// path-RBAC still skips a Public route entirely — a populated Claims is
// input for the handler, never a new gate.
//
// Public routes must use literal paths (no chi {params} — the match is
// exact) and cannot combine with RequireRole (use Claims().Role in the
// handler if a public route wants to branch on identity). Only routes
// explicitly marked Public relax auth; every other route keeps
// deny-by-default.
Public bool
// ByteServing declares that this route streams a binary body (Ctx.ServeFile
// — a file download, a public product image) instead of buffered JSON
// (FRONTEND-SPEC-S1). It routes the response AROUND two wrappers that are
// right for JSON and wrong for a stream: the response cache (which would
// buffer the whole blob in RAM and strip Content-Disposition/Accept-Ranges
// on a hit) and the Compress middleware (whose writer lacks io.ReaderFrom
// and suppressed sendfile zero-copy — the FILES-BENCH finding, fixed for
// the engine's own file routes by the same bypass this flag extends to
// custom routes).
//
// Constraints (validated at Register): GET only, literal path (no chi
// {params}/wildcards — the bypass, like the Public skip, matches the exact
// path; pass the file id as a query parameter). Ctx.ServeFile refuses to
// run on a route without this flag. Everything else about the route is
// unchanged: auth/RBAC (or Public), RateLimit, Timeout, the tenant tx.
ByteServing bool
}
Route is a custom endpoint registered with (*App).Register before Start.
Path must begin with "/api/" so it flows through the SAME middleware chain as generated routes (tenant → rate limit → JWT → RBAC). The first path segment after "/api/" must NOT be a schema resource name — that space is owned by the generated CRUD routes, and registering under it is rejected at boot as a collision (deterministic, before chi can shadow it).
type ServeArgs ¶
type ServeArgs struct {
SchemaPath string
Port int
ControlPort int
// Static holds "[urlpath=]dir" mount specs from --static (repeatable), and
// SPA the --spa flag (PUBLIC-SURFACE-S1 Part A) — feed them through
// ParseStaticSpecs into Config.Static:
//
// mounts, err := appximo.ParseStaticSpecs(args.Static, args.SPA)
// … Config{Static: mounts}
//
// A binary that wires its frontend with go:embed simply ignores them.
Static []string
SPA bool
}
ServeArgs is the parsed serve configuration a consumer main feeds into Config (SchemaPath/Port/ControlPort). Zero values in the defaults you pass are replaced by the engine's conventions (schema.json / 8080 / 9090).
func ParseServeArgs ¶
ParseServeArgs processes os.Args for a consumer binary per the deployable contract (ADR-023):
- `version` (also -v / --version) prints "<name> <version> (commit <revision>) — built on the appximo framework" and exits 0 — install.sh identity-checks this, deploy-update.sh sanity-checks it, and /health should report the same string's version (pass it in Config.Version).
- a leading `serve` is accepted and skipped (the systemd unit the installer writes runs `<bin> serve --schema … --port …`).
- any OTHER leading word, and any argument left over after the flags, is a HARD error (exit 2) naming the offending argument — never a silent boot with defaults.
Typical consumer main:
var version, revision = "dev", "unknown" // ldflags -X main.version=…
func main() {
args := appximo.ParseServeArgs("myapp", version, revision,
appximo.ServeArgs{Port: 8099, ControlPort: 9099})
app, err := appximo.New(appximo.Config{
SchemaPath: args.SchemaPath, Port: args.Port,
ControlPort: args.ControlPort, Version: version,
})
…
}
type ServeFileOption ¶
type ServeFileOption func(*serveFileOpts)
ServeFileOption tunes one Ctx.ServeFile response (FILES-2). Options are applied at serve time (post-commit), and only on the success path.
func WithCacheControl ¶
func WithCacheControl(value string) ServeFileOption
WithCacheControl sets the Cache-Control header on the streamed response. The engine deliberately does not default this: it cannot know whether the ROUTE's URL is stable-per-content (immutable-safe) or a mutable pointer (must revalidate) — that is the handler's knowledge. For the common content-addressed case use CacheControlImmutable.
type StaticMount ¶
type StaticMount struct {
// Path is the URL prefix this tree is served at: "/" for the whole site, or
// a sub-path like "/app". It must not be under, or collide with, any prefix
// the engine owns (/api, /auth, /admin, /editor, /docs, /graphql, /graphiql,
// /openapi, /metrics, /debug, /healthz, /readyz, /health, /files, /fleet) —
// a collision is a BOOT error, never a silently shadowed route.
Path string
// FS is the file tree. An embed.FS sub-tree (fs.Sub) compiles the frontend
// INTO the binary; os.DirFS(dir) serves a directory from disk. Either way the
// handler can only ever read inside this FS: paths are cleaned and io/fs
// itself rejects anything that escapes the root, so traversal is impossible
// by construction rather than by filtering.
FS fs.FS
// SPA opts into client-side-routing fallback: a request that matches no file
// serves Index instead of 404, so /orders/42 reaches the router in the
// browser. It is OPT-IN because it is wrong for a plain static site, where a
// typo should 404. Requests under a prefix the engine owns are NEVER given
// the fallback — an unknown /api/… path stays a real 404.
SPA bool
// Index is the document served for the mount root and (when SPA) for
// unmatched client routes. Empty means "index.html".
Index string
// ImmutablePrefixes are path prefixes, RELATIVE to this mount, whose files
// carry content-hashed names and may be cached forever
// (Cache-Control: immutable). Empty means the Vite/webpack default
// ["assets/", "_app/", "static/"]. Everything else gets a short max-age, and
// the index document is ALWAYS no-cache — it names the current hashed
// bundles, so a stale copy would point at files a deploy already deleted.
ImmutablePrefixes []string
// CSP is this mount's Content-Security-Policy (LIBRARY-GAPS-S2, ENG-5).
//
// The static handler OWNS the header on everything it serves, for BOTH
// mount forms. Before this, the two forms diverged invisibly: a root mount
// is chi's NotFound handler, which chi copies into the API subrouter that
// lives inside the StrictCSP group — so the SPA shell shipped the API's
// `default-src 'none'` and every browser blocked the app's own scripts
// (a blank page curl can never see, because curl does not enforce CSP);
// a sub-path mount bypassed the group and shipped NO policy at all.
//
// "" (default) → DefaultStaticCSP: a same-origin SPA policy (the shape
// the engine's own embedded UIs use — external self scripts, inline
// styles allowed for component libraries, no framing).
// any other string → emitted verbatim, replacing the default (e.g. add
// img-src for a CDN).
// CSPOff ("off") → NO Content-Security-Policy header at all: the handler
// DELETES any policy inherited from the chain, so opting out is the
// same on a root and a sub-path mount. For apps that set their own
// policy via <meta http-equiv>.
CSP string
}
StaticMount serves a static file tree from the binary (LOOSE-ENDS-SWEEP-S1) — the seam that makes "one binary = backend + frontend + admin + docs" real.
Before this, a custom route (appximo.Route) had to live under /api/ AND ran inside a per-request tenant TRANSACTION, which is exactly wrong for an asset: a .js file needs no database. A StaticMount is therefore NOT a Route — it is mounted like the engine's own embedded UIs (/editor, /admin), outside /api/, on the static path: no transaction, no RBAC evaluation, no response-cache buffering.
//go:embed all:web/dist
var frontend embed.FS
sub, _ := fs.Sub(frontend, "web/dist")
app, err := appximo.New(appximo.Config{
SchemaPath: "schema.json",
Static: []appximo.StaticMount{{Path: "/", FS: sub, SPA: true}},
})
⚠ PCI / SAQ A (payments): if the app takes card payments through a hosted widget or an iframe (Stripe Elements, Wompi, Mercado Pago), the CHECKOUT page must stay free of third-party scripts — analytics, chat widgets, tag managers. A single extra script on that page moves the merchant from SAQ A to SAQ A-EP, a materially heavier compliance burden, because that script could read the cardholder data entry surface. Serve the checkout route from a bundle whose third-party dependencies you control, and keep the marketing tags on the pages that do not touch payment.
func ParseStaticSpecs ¶ added in v0.1.5
func ParseStaticSpecs(specs []string, spa bool) ([]StaticMount, error)
ParseStaticSpecs turns CLI/env mount specs into StaticMounts served from disk (PUBLIC-SURFACE-S1 Part A: Config.Static used to be reachable only from Go code — a `go get` away, but nothing said so, and the no-toolchain case had no path at all; `serve --static` is that path). Each spec is "[urlpath=]dir": a bare dir mounts at "/" (the whole site); "site=./dist" mounts at "/site". spa applies to every produced mount (the CLI's one flag mirroring StaticMount.SPA). The directory must exist NOW — a typo'd path is an error here, at boot, never a tree of silent 404s. Everything else (CSP, engine-prefix collisions, the index/SPA contract) is the SAME validateStaticMounts every mount goes through: this is a parser, not a second implementation.
type UniqueViolationError ¶ added in v0.1.7
type UniqueViolationError struct{ Field string }
UniqueViolationError is returned by Ctx.Insert/Update when the write collided with a unique constraint — a field's `unique: true` or a composite `unique` index (ENG-42). Field is the offending column, parsed from the constraint the same way the generated path does. Returning it from a Handler yields the IDENTICAL 409 the generated POST/PATCH answer: `field "x": value already exists`. Branch on it with errors.As when the endpoint wants its own wording — but prefer returning it verbatim: it is the error a form UI already knows how to present ("that value is taken — change it").
func (*UniqueViolationError) Error ¶ added in v0.1.7
func (e *UniqueViolationError) Error() string
type ValidationError ¶
type ValidationError struct{ Fields []schema.FieldRuleError }
ValidationError carries the per-field declarative-validation failures, in the same shape the 422 REST/GraphQL responses use.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
Source Files
¶
- app.go
- backendspec.go
- backofficespec.go
- canary.go
- cli.go
- config.go
- ctx.go
- deployed_surface.go
- dotenv.go
- editor_schema.go
- fleetpanel.go
- frontendspec.go
- installprompt.go
- lifecyclespec.go
- masterprompt.go
- multiapp.go
- openapi_serve.go
- registry.go
- restart.go
- restart_unix.go
- route.go
- runtime.go
- safego.go
- selfmon.go
- starter.go
- static.go
- static_csp.go
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
appximo
command
|
|
|
appximo-worker
command
Command appximo-worker is the outbox consumer (ADR-016 §Class 2): a SEPARATE process that drains public.outbox and runs each event through a Processor.
|
Command appximo-worker is the outbox consumer (ADR-016 §Class 2): a SEPARATE process that drains public.outbox and runs each event through a Processor. |
|
examples
|
|
|
backend-guide
command
Command backend-guide is the companion example to docs/BACKEND_SPEC_LLM.md — a complete, COMPILING Appximo backend built the library way (ADR-016): a schema (schema.json) for the declarative surface, plus custom Class-1 handlers for the logic a schema can't express (external calls, cross-resource transactions, parallel work).
|
Command backend-guide is the companion example to docs/BACKEND_SPEC_LLM.md — a complete, COMPILING Appximo backend built the library way (ADR-016): a schema (schema.json) for the declarative surface, plus custom Class-1 handlers for the logic a schema can't express (external calls, cross-resource transactions, parallel work). |
|
backend-guide/worker
command
Command worker is the FRAMEWORK-MODE outbox consumer for the backend-guide example — the other half of the async story in docs/BACKEND_SPEC_LLM.md §6.
|
Command worker is the FRAMEWORK-MODE outbox consumer for the backend-guide example — the other half of the async story in docs/BACKEND_SPEC_LLM.md §6. |
|
backoffice-guide
command
Command backoffice-guide is the runnable companion to docs/BACKOFFICE_SPEC_LLM.md (`appximo backoffice-spec`): ONE binary serving a back-office CRUD UI generated ENTIRELY from /openapi.json at runtime — zero resource-specific screens, zero hardcoded domain knowledge.
|
Command backoffice-guide is the runnable companion to docs/BACKOFFICE_SPEC_LLM.md (`appximo backoffice-spec`): ONE binary serving a back-office CRUD UI generated ENTIRELY from /openapi.json at runtime — zero resource-specific screens, zero hardcoded domain knowledge. |
|
custom-handler
command
Command custom-handler is the canonical example of the ADR-016 library model: import appximo, register a Class-1 custom handler, compile a single static CGO-free binary.
|
Command custom-handler is the canonical example of the ADR-016 library model: import appximo, register a Class-1 custom handler, compile a single static CGO-free binary. |
|
frontend-guide
command
Command frontend-guide is the runnable companion to docs/FRONTEND_SPEC_LLM.md (`appximo frontend-spec`): ONE binary serving a frontend + the generated API + one custom byte-serving route, exercising every pattern the spec teaches —
|
Command frontend-guide is the runnable companion to docs/FRONTEND_SPEC_LLM.md (`appximo frontend-spec`): ONE binary serving a frontend + the generated API + one custom byte-serving route, exercising every pattern the spec teaches — |
|
fullstack
command
Command fullstack is ONE BINARY that serves a frontend, an API, an admin panel and the API docs — the shape LOOSE-ENDS-SWEEP-S1 unlocked with Config.Static.
|
Command fullstack is ONE BINARY that serves a frontend, an API, an admin panel and the API docs — the shape LOOSE-ENDS-SWEEP-S1 unlocked with Config.Static. |
|
internal
|
|
|
Package migrations exposes the canonical control-plane bootstrap DDL as an embedded string, so Go callers (the fleet orchestrator's per-app database bootstrap) apply THE file — not a drifting copy.
|
Package migrations exposes the canonical control-plane bootstrap DDL as an embedded string, so Go callers (the fleet orchestrator's per-app database bootstrap) apply THE file — not a drifting copy. |
|
pkg
|
|
|
adminui
Package adminui embeds the SolidJS admin panel (ADMIN-UI-V1) and serves it from the engine binary under /admin.
|
Package adminui embeds the SolidJS admin panel (ADMIN-UI-V1) and serves it from the engine binary under /admin. |
|
aigen
Package aigen is the AI schema-generation layer: it turns a natural-language description into a VALID Appximo schema.
|
Package aigen is the AI schema-generation layer: it turns a natural-language description into a VALID Appximo schema. |
|
aigen/eval
Package eval is the scientific measurement instrument for the AI schema- generation layer (AI-F2-S1): a stratified NL→schema gold test set + a paired ablation harness + statistics with the rigor the research demands.
|
Package eval is the scientific measurement instrument for the AI schema- generation layer (AI-F2-S1): a stratified NL→schema gold test set + a paired ablation harness + statistics with the rigor the research demands. |
|
backofficeui
Package backofficeui embeds the generic back-office SPA and serves it from the engine binary under /app (ENG-38, the first-10-minutes path).
|
Package backofficeui embeds the generic back-office SPA and serves it from the engine binary under /app (ENG-38, the first-10-minutes path). |
|
consumers
Package consumers holds real outbox consumers — the business-logic Processors the worker runs (ADR-016 §Class 2).
|
Package consumers holds real outbox consumers — the business-logic Processors the worker runs (ADR-016 §Class 2). |
|
editorui
Package editorui embeds the visual schema editor (Appximo Studio, UI-F0-S1) and serves it from the engine binary under /editor.
|
Package editorui embeds the visual schema editor (Appximo Studio, UI-F0-S1) and serves it from the engine binary under /editor. |
|
events
Package events implements the in-process pub/sub hub behind the per-resource SSE subscription endpoints (GET /api/{resource}/events, S45).
|
Package events implements the in-process pub/sub hub behind the per-resource SSE subscription endpoints (GET /api/{resource}/events, S45). |
|
files
Package files is the engine's content-addressable file store (FILES-V1 core, FILES-V2 backends): the real source of the file_ref the XLSX consumer reads.
|
Package files is the engine's content-addressable file store (FILES-V1 core, FILES-V2 backends): the real source of the file_ref the XLSX consumer reads. |
|
fleet
Package fleet is the MT-STRUCT-S1 orchestrator: ONE server serving N DISTINCT apps (different schemas → different APIs) as N engine processes — the Option-A architecture of docs/design/MT-STRUCT.md.
|
Package fleet is the MT-STRUCT-S1 orchestrator: ONE server serving N DISTINCT apps (different schemas → different APIs) as N engine processes — the Option-A architecture of docs/design/MT-STRUCT.md. |
|
flowtest
Package flowtest is the multi-step flow-test engine (FLOWTEST-S1) — the last piece of the productivity-confidence layer: persisted, re-runnable scenarios ("login as role X → create → attach → assert") that a deploy can re-run for a PASS/FAIL regression verdict anchored to the schema version it ran against.
|
Package flowtest is the multi-step flow-test engine (FLOWTEST-S1) — the last piece of the productivity-confidence layer: persisted, re-runnable scenarios ("login as role X → create → attach → assert") that a deploy can re-run for a PASS/FAIL regression verdict anchored to the schema version it ran against. |
|
outbox
Package outbox implements the transactional outbox pattern (ADR-016 §Class 2).
|
Package outbox implements the transactional outbox pattern (ADR-016 §Class 2). |
|
platformadmin
Package platformadmin implements the BACKEND of the admin panel (ADMIN-API-V1): a platform super-admin that lives ABOVE the tenants, plus a consolidated admin API for managing tenants, their users, and their observability.
|
Package platformadmin implements the BACKEND of the admin panel (ADMIN-API-V1): a platform super-admin that lives ABOVE the tenants, plus a consolidated admin API for managing tenants, their users, and their observability. |
|
platformpath
Package platformpath resolves the platform-correct default data directory — ONE function every default path derives from (field report W1: the defaults were POSIX constants, so on Windows `/var/lib/appximo/files` silently resolved to `C:\var\lib\appximo\files`, a tree created at the drive root that the boot log announced in a format that does not exist on the system).
|
Package platformpath resolves the platform-correct default data directory — ONE function every default path derives from (field report W1: the defaults were POSIX constants, so on Windows `/var/lib/appximo/files` silently resolved to `C:\var\lib\appximo\files`, a tree created at the drive root that the boot log announced in a format that does not exist on the system). |
|
resilience
Package resilience provides circuit breaker, rate limiting, and query timeout utilities.
|
Package resilience provides circuit breaker, rate limiting, and query timeout utilities. |
|
schemadiff
Package schemadiff is the foundation of a real schema-migration engine for Appximo — the eventual replacement for the idempotent table "converger" in pkg/migration (which only ever runs CREATE TABLE / ADD COLUMN IF NOT EXISTS and therefore loses data on rename, ignores NOT NULL, no-ops a type change, and emits no foreign keys — see docs/MIGRATION_DIAG.md).
|
Package schemadiff is the foundation of a real schema-migration engine for Appximo — the eventual replacement for the idempotent table "converger" in pkg/migration (which only ever runs CREATE TABLE / ADD COLUMN IF NOT EXISTS and therefore loses data on rename, ignores NOT NULL, no-ops a type change, and emits no foreign keys — see docs/MIGRATION_DIAG.md). |
|
schemahistory
Package schemahistory is the append-only version history of tenant schemas (VERSION-S1) — the base of the productive trust layer.
|
Package schemahistory is the append-only version history of tenant schemas (VERSION-S1) — the base of the productive trust layer. |
|
shutdown
Package shutdown provides graceful HTTP server shutdown with readiness tracking.
|
Package shutdown provides graceful HTTP server shutdown with readiness tracking. |
|
worker
Package worker implements the outbox consumer (ADR-016 §Class 2): a SEPARATE process (cmd/appximo-worker) that drains rows the engine wrote to public.outbox and runs each through a Processor.
|
Package worker implements the outbox consumer (ADR-016 §Class 2): a SEPARATE process (cmd/appximo-worker) that drains rows the engine wrote to public.outbox and runs each through a Processor. |
|
Package scripts holds operational commands that run pg_dump and other external tooling on behalf of the appximo engine (CLI `appximo backup` and the admin /admin/backup endpoint).
|
Package scripts holds operational commands that run pg_dump and other external tooling on behalf of the appximo engine (CLI `appximo backup` and the admin /admin/backup endpoint). |
|
tools
|
|
|
capacity
command
Package main — the capacity laboratory: an open-model load generator, a Universal Scalability Law fit, and the translation of a throughput ceiling into concurrent users under a declared load profile.
|
Package main — the capacity laboratory: an open-model load generator, a Universal Scalability Law fit, and the translation of a throughput ceiling into concurrent users under a declared load profile. |
|
devhub
command
|
|
|
devhub/secrets
Package secrets is the DevHub's encrypted-at-rest secrets store (S47b).
|
Package secrets is the DevHub's encrypted-at-rest secrets store (S47b). |
|
devhub/sshx
Package sshx is the DevHub's outbound SSH client (S47).
|
Package sshx is the DevHub's outbound SSH client (S47). |
|
devhub/stats
Package stats provides the statistical primitives behind the DevHub benchmark engine (S42): robust summaries plus a two-sample significance test so a benchmark delta can be called improvement / regression / no_change with a p-value instead of eyeballing a single run.
|
Package stats provides the statistical primitives behind the DevHub benchmark engine (S42): robust summaries plus a two-sample significance test so a benchmark delta can be called improvement / regression / no_change with a p-value instead of eyeballing a single run. |
|
lab
command
Package main — `lab`, the ephemeral capacity laboratory (LAB-CAPACIDAD-S1).
|
Package main — `lab`, the ephemeral capacity laboratory (LAB-CAPACIDAD-S1). |
|
sseload
command
sseload opens N concurrent SSE connections against an Appximo events endpoint and holds them for a duration, counting received events and heartbeats and reporting dropped connections.
|
sseload opens N concurrent SSE connections against an Appximo events endpoint and holds them for a duration, counting received events and heartbeats and reporting dropped connections. |



