appximo

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 70 Imported by: 0

README

Appximo

A JSON schema in. A production multi-tenant REST + GraphQL + OpenAPI server out. One ~64 MB static Go binary, on your own server. Apache 2.0.

CI Docker Go License

You don't write handlers, models, or migrations. You write this:

{
  "$schema": "https://appximo.com/schema/v1",
  "version": "1",
  "name": "todo-api",
  "resources": {
    "tasks": {
      "fields": {
        "title":  { "type": "string", "required": true },
        "status": { "type": "string", "enum": ["open", "done"] },
        "due":    { "type": "time" }
      }
    }
  },
  "rbac": {
    "roles": {
      "admin":  { "resources": "*", "actions": ["*"] },
      "viewer": { "resources": ["tasks"], "actions": ["read"], "fields": ["id", "title", "status"] }
    }
  }
}

and the engine serves — per isolated tenant, from one process:

  • GET/POST/PUT/PATCH/DELETE /api/tasks with typed filters, sort, keyset pagination
  • a GraphQL endpoint with the same data ({ tasks { data { id title } } })
  • an OpenAPI 3.0 spec (appximo openapi schema.json)
  • declarative validation (422 listing every invalid field at once)
  • live updates over SSE (GET /api/tasks/events)
  • JWT auth + RBAC enforced on every request, deny by default — that viewer role really is read-only and never sees the due field

It's a code generator without the generated code: the schema is compiled at boot, not scaffolded into files you then maintain.

Quick start (~30 s with the image pull)

⚠ The neodevtrix/appximo Docker image publishes automatically on every green CI run of main — if the pull fails, the image simply hasn't landed yet. From a clone, the two files are at the repo root — skip the downloads. No Docker? Build from source: make install (Go 1.25), then appximo serve --schema examples/quickstart/schema.json.

mkdir appximo && cd appximo
curl -O https://raw.githubusercontent.com/appximo/appximo/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/appximo/appximo/main/.env.example
cp .env.example .env          # set JWT_SECRET (≥32 chars), ADMIN_KEY, DB_PASSWORD
docker compose up -d          # multi-arch image (amd64+arm64), ~22 MB pull
curl localhost:8080/health    # {"status":"ok",...}

On our test box, up -d to healthy takes ~9 s plus the image pull. The image boots with exactly the schema shown above, so your first request is four copy-paste commands against it (verified on a clean machine, virgin DB):

set -a; source .env; set +a

# 1. register a tenant (its isolated Postgres schema + tables are created now)
curl -X POST http://localhost:9090/tenants \
  -H "X-Admin-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d "{\"tenant_id\":\"acme\",\"display_name\":\"Acme\",\"email\":\"a@acme.com\",\"plan\":\"free\",\"schema\":$(docker compose exec engine cat /etc/appximo/schema.json)}"

# 2. mint a JWT for the "admin" role the schema defines (helper ships in the image)
TOKEN=$(docker compose exec engine appximo token --secret "$JWT_SECRET" --tenant acme --role admin 2>/dev/null | tail -1)

# 3. write a task
curl -X POST http://localhost:8080/api/tasks \
  -H "Authorization: Bearer $TOKEN" -H "Host: acme.localhost" -H "Content-Type: application/json" \
  -d '{"title":"ship the launch","status":"open"}'

# 4. read it back, filtered (curl -g: brackets need globbing off)
curl -g "http://localhost:8080/api/tasks?filter[status][eq]=open&per_page=20" \
  -H "Authorization: Bearer $TOKEN" -H "Host: acme.localhost"

To serve your own model, mount your schema over /etc/appximo/schema.json (or --schema yours.json on the binary). Tenants are addressed by Host subdomain: acme.localhost → Postgres schema tenant_acme.

Building with an AI agent? Start with three commands

The engine prints its own agent-facing contract — no repo access needed:

appximo spec           # 1. the schema grammar (the declarative 90% of an app)
appximo backend-spec   # 2. custom Go handlers, hooks, auth, background jobs
appximo frontend-spec  # 3. the API contract a UI consumes, errors→screens, files
appximo specs          # …or all three at once (one paste = the whole contract)

Paste them into your own Claude Code / Cursor in that order (schema → backend → frontend), describe your app, and the agent generates against the real grammar, self-correcting with appximo validate --json <schema> as the oracle. A running app additionally serves its complete live surface — generated and custom routes — at /openapi.json (interactive at /docs). The long-form docs behind each command: SCHEMA_SPEC_LLM · BACKEND_SPEC_LLM · FRONTEND_SPEC_LLM.

See it live

Two real apps — schema + custom Go logic + embedded frontend, each one binary on a $16/mo VPS — run in production today:

(The demo domains still carry the project's pre-rename name; they move to appximo.com next.)

Where it sits

Honest comparison — these are different tools that overlap on "I need an API":

Appximo NestJS / Express / Rails Supabase PocketBase
You write a JSON schema application code SQL + RLS policies + client code collections config + Go/JS hooks
API surface REST + GraphQL + OpenAPI, generated whatever you build PostgREST + client SDKs REST + realtime
Multi-tenancy first-class: schema-per-tenant isolation, subdomain routing you build it you build it (RLS) one DB per app
Database your PostgreSQL any bundled Postgres (its platform) embedded SQLite
Runtime one static Go binary, ~24 MB RSS idle Node/Ruby + deps a service fleet (or their cloud) one Go binary
Custom logic Go in-process (framework mode: appximo.Route + Ctx, same process & transaction) + sandboxed JS/WASM hooks unlimited (it's your code) edge functions, triggers Go/JS hooks

What they do better: frameworks give you unlimited logic with no ceremony — Appximo's custom logic is a bounded framework surface (in-process Go routes sharing the engine's transaction and RBAC, plus sandboxed hooks), not an anything-goes app server. Supabase has auth providers, storage, realtime channels and a massive ecosystem. PocketBase is even simpler to run (no Postgres needed). Appximo's lane is: several isolated tenants on one cheap box, talking to a Postgres you control, with the API contract generated and enforced from a schema file — plus the custom 10 % as Go in the same process and transaction.

Performance

On a $16/mo 2-vCPU droplet with JWT + RBAC + multi-tenancy + validation + rate limiting all active, measured from an external load generator over a real network: 2,000 req/s sustained, p50 1.60 ms (CI95 [1.57, 1.67]), 0 errors in 597k requests — re-measured 2026-08-01 on the same hardware and reproducing the original figure (1.58 ms [1.52, 1.62], 2026-06-10). With the response cache fully bypassed, every request reaching PostgreSQL: p50 2.44 ms at 500 req/s.

Read this before reproducing it. 2,000 req/s against a single tenant needs the per-tenant rate limiter raised — RATE_LIMIT_RPS=3000 RATE_LIMIT_BURST=300, the configuration the benchmark declares. On the defaults (1000 rps / 100 burst) roughly half of a 2,000 rps single-tenant load is answered 429, by design. The limit is per tenant, so real multi-tenant traffic is not affected the same way.

Full methodology — every limitation, the cache asymmetry, the statistical treatment (Mann-Whitney, bootstrap CIs), and raw per-run data — ships in the repo; reproduce it on your own hardware with make bench-protocol (scripts/bench-protocol.sh: warmup + N runs + a statistical verdict, not a one-shot number).

That benchmark measures the engine. For the whole production stack — Caddy terminating real Let's Encrypt TLS → the engine under systemd → native PostgreSQL, with a million rows — see docs/BENCHMARKS.md: the production layers cost about +1.2 ms p50 (re-measured 2026-08-01), the box sustains 500 req/s with every request reaching PostgreSQL (knee at 750, 2026-07), a filtered page over 1M rows answers in ~4.2 ms end-to-end (re-measured 2026-08-01), a real consumer app's whole stack runs at ~186 MiB PSS under load (2026-07-31; the original ~109 MiB idle figure predates the box serving two apps and is kept in BENCHMARKS with its date), and the resilience matrix (kill the engine, kill Caddy, stop PostgreSQL, deploy under load, reboot) is measured rather than asserted. Better still, don't take those numbers: scripts/verify-production/ runs the same suite against your server and prints your report.

What's in the box (all verified by the test suite)

  • CRUD: list/get/create/replace/patch/delete per resource; typed filters (eq everywhere; partial/start on strings; gt/gte/lt/lte on numbers and time; after/before on time), single-field sort (?sort=created_at&order=desc), keyset pagination (?after=<uuid>, no OFFSET)
  • Aggregation: count/sum/avg/min/max + group_by per resource (GET /api/{resource}/aggregate and <resource>Aggregate in GraphQL), plus opt-in ?count=true total on lists — all scoped by the SAME RBAC row condition, field allowlist and filters as a read (a row-scoped role aggregates only its own rows; a hidden field can't be summed)
  • Atomic transactions: POST /api/transaction runs many create/update/delete ops across resources in one Postgres transaction — all-or-nothing (a transfer, a checkout). Every op is authorized (per-resource RBAC) and validated like its single-op counterpart, outbox events emit in the same tx, and an optimistic-lock guard (compare-and-set) gives race-safe conditional writes; failures name the offending operation. The single-op write path is unchanged
  • State machines: a status field can declare its state_machine (initial state(s) + allowed transitions); the engine forces the lifecycle — create only in an initial state, update only along a declared transition, a terminal state is immutable (append-only). Enforced race-safely in the UPDATE itself, on REST, GraphQL, and inside a transaction
  • Queryable documents: a jsonb field is a real Postgres document — declare {"fields":["attributes"],"method":"gin","opclass":"jsonb_path_ops"} and containment (attributes @> {...}) is an index lookup, not a sequential scan. Merchant-defined attributes without EAV, and without hand-written DDL
  • Declarative validation: required/enum/type rules compiled from the schema; one 422 lists every failing field; field default values applied on insert (literals + "now" for time); a unique/composite-unique collision is a clean 409 Conflict (REST create & update + GraphQL), never a raw DB error
  • Declarative relations: has_many / belongs_to / many_to_many served nested in one round-trip (json_agg + LATERAL, no N+1) on opt-in ?include=, RBAC compiled into the SQL; FK columns auto-indexed
  • Referential integrity (complete FK coverage): a field with relation creates a real foreign key with declarative on_delete and on_update (restrict/cascade/set_null — a blocked delete is a clean 409, never a silent orphan), pointing at the target's id or a unique non-id column (references); genuine composite multi-column FKs are a resource-level foreign_keys block. All applied safely (NOT VALID/VALIDATE, no long lock) and enforced on REST + GraphQL — adding on_update to an existing schema causes zero migration churn
  • Multi-tenancy: schema-per-tenant Postgres isolation (SET LOCAL search_path), subdomain → tenant routing, per-tenant rate limiting. One API structure for all tenants, compiled at boot: per-tenant migrations apply live (a new column is readable/writable immediately — the DB is the source of truth for write keys), while everything derived from the compiled definition — validation rules, filters, GraphQL fields, /docs, and new resources — activates on a graceful self-restart (POST /admin/engine/schema, super-admin-gated: validated + atomic boot-schema persist with a .bak rollback, drain via /readyz→503, re-exec — one click from the visual editor, ~6 s, no terminal; see docs/MENTAL_MODEL.md)
  • RBAC: JSON policies — per role, per resource, per action, per field, plus dynamic row conditions (operator_id = $user_id); deny by default. Field allowlists and row conditions are enforced on create as well as read/update/delete — an owner-scoped role can only create rows attributed to itself (no mass-assignment), on both REST and GraphQL. Row conditions are equality (field = $user_id), validated at load (a condition can't declare an operator the engine wouldn't apply), and enforced uniformly on every read path — including ?include= embeds and the relation read subroute (GET /api/{res}/{id}/{rel} scopes the referenced resource, not just the parent). Conditions can be role-global or per-resource (a permissions map): one role can scope each resource by its own column (projects.owner_id, documents.created_by), leave some resources unscoped, and even "read all, write own" via condition_actions — unlocking workspace/owner scoping that role-global conditions couldn't express. A routes block grants the custom endpoints a Go backend registers (a virtual segment, boot-validated against the routes that actually exist), so "owner-scoped end users plus a custom action like checkout" is expressible at last
  • Auth (password identity): in-engine signup/login/refresh (POST /auth/*), multi-tenant-aware — users live in the tenant's own schema, so email is unique per tenant, not globally (the same email is a distinct account in two tenants). argon2id hashing, anti-enumeration, per-identity login throttling; the issued JWT is the same one the engine validates. Public signup is opt-in + role-gated. Password reset + email verification via single-use tokens, delivered async through the outbox + email worker (the email never blocks the request). Social login (OAuth2: Google / GitHub / Microsoft) — tenant carried in a signed state, identity linked by stable provider id, no new dependency. TOTP MFA (opt-in, RFC 6238) — encrypted secret, one-time backup codes, two-step login; the secret never password-degrades the request hot path
  • Admin panel: a platform super-admin (in a system schema, above all tenants, with its own login + MFA) plus a consolidated /admin/* API to manage tenants, their users, and their observability — inheriting the schema RBAC + tenant isolation (not a second permission system); the X-Admin-Key still works for machine callers. Bootstrap with appximo admin create. A SolidJS UI is embedded in the binary and served at /admin — login + MFA, tenant management, per-tenant user management, read-only data navigation, and an observability dashboard (ECharts latency + SLO burn-rate charts, a trace-span waterfall, and the z-score anomaly + error views) (built with make admin-ui before go build)
  • Real-time: per-resource SSE streams with RBAC applied at delivery
  • Webhooks: HMAC-SHA256-signed, async, retries with backoff, SSRF-guarded
  • Extensions: JS sandbox (Goja, watchdog-interrupted) with built-in helpers — including Colombian DIAN tax compliance (CUFE SHA-384, NIT mod-11) — plus a WASM runtime (Wazero, no CGO)
  • GraphQL: queries (with nested relation embeds) + create/update/delete mutations, selection-count limits (alias-amplification guard), introspection off in production by default. GraphiQL — the visual schema explorer — is served at /graphiql (autocomplete, run queries/mutations, a Headers editor for testing with a real token) whenever introspection is allowed: dev, or the explicit APPXIMO_GRAPHQL_PLAYGROUND=on opt-in for production, per-app in the fleet
  • API contract, served: an OpenAPI 3.0 spec generated from the schema — including the auth + file-store endpoints — served at /openapi.json (and /openapi.yaml), with Swagger UI at /docs for interactive exploration
  • CORS: configurable cross-origin access for browser SPAs on another origin (APPXIMO_CORS_ORIGINS), disabled by default, scoped to /api,/auth, /graphql,/openapi — never the control plane or /admin
  • Safe schema evolution: a real diff-based migration engine — renames preserve data, NOT NULL is enforced faithfully, all DDL runs under lock_timeout+retry with CONCURRENTLY indexes. Additive by default (it never drops); a destructive drop (removing a field/resource) needs a two-step approval gate — a dry-run reports exactly what would be lost (rows affected), and the drop runs only when you enumerate it explicitly. No accidental data loss; automated workers never auto-approve. Rolling a change out to every tenant is a resumable fan-out (migrate --all-tenants): one tenant at a time under its advisory lock, resilient to partial failure (a broken tenant is recorded and the rest continue) and resumable (re-run skips the converged ones, retries the failed) — the multi-tenant migration story Prisma and django-tenants don't have. Every deployed schema is recorded in an append-only version history, and rollback to any prior version re-deploys it through the same gate: a dry-run shows exactly what reverting destroys (measured rows lost), nothing drops without enumeration, and the rollback itself becomes a new version — browsable timeline + rollback UI in the visual editor ("History"). And the trust loop closes with persisted flow tests: multi-step scenarios (login as a role → create → attach → assert, state chained between steps) run server-side against the live app with live PASS/FAIL output — re-run after a deploy as a regression suite whose verdict is anchored to the schema version it ran against ("Flows" in the visual editor)
  • Ops: Prometheus /metrics, per-request trace ring with stage breakdown, SLO burn-rate alerts (Slack), graceful drain on SIGTERM, circuit breaker (verified open/recover with toxiproxy), zero-downtime additive migrations — the full observable surface (trace explorer, per-tenant debug, health probes) is mapped in docs/EXPLORE.md
  • Security hardening: HS256-pinned JWT (alg-confusion rejected), sanitized identifiers everywhere, masked DB errors, 1 MB body cap, fuzzed parsers (0 crashers), per-tenant cache isolation

Full capability inventory (and the honest not-yet list): docs/CAPABILITIES.md.

Production deploy

The official path — an empty VPS to a live HTTPS API in one command (docs/PRODUCTION.md). The stack is native PostgreSQL + the engine under systemd + Caddy (automatic Let's Encrypt TLS); Docker would eat 300–400 MB on a 1 GB box, so it's a documented variant, not the default.

curl -fsSL https://raw.githubusercontent.com/appximo/appximo/main/scripts/install.sh \
  | sudo bash -s -- --domain api.example.com --email you@example.com

The installer asks one thing — your domain — then generates every secret, writes the systemd unit + Caddyfile, installs PostgreSQL and Caddy, and brings the API up on HTTPS. Updates are one script (scripts/deploy-update.sh, atomic swap + auto-rollback), backups another (scripts/backup.sh, pg_dump + rotation).

Prefer containers or a PaaS? The Docker paths still work:

Goal Path
Try it in ~9 s docker compose up — the quick start above
Production, Docker docker-compose.prod.yml + Caddy: automatic Let's Encrypt TLS, engine and Postgres on the internal network only
Production, native (max throughput) the installer above, or docs/PRODUCTION.md / docs/DEPLOY.md Level 3 by hand — the configuration the benchmark measured

Full guide — updates, backups, framework mode, serving your frontend, the complete env-var table, security checklist, troubleshooting: docs/PRODUCTION.md.

Status — what's real and what's missing

Production-ready and test-backed (make test-all: unit + integration + E2E + resilience against real Postgres in Docker): everything in the feature list above. It serves our own production workload today.

Known limits, honestly:

  • Single node. No HA/clustering story; scale is vertical (the benchmark shows how far one cheap box goes).
  • Observability is Prometheus + an internal trace ring — no OTLP export.
  • No hosted/SaaS version. Self-hosted only, by design, for now.

Beyond the API surface, the binary also embeds Appximo Studio at /editor — a visual schema designer (full ERD over the schema grammar, visual RBAC, state machines, relations, plus a Code view: the raw schema in an assisted JSON editor with live engine validation, every error on its line) that deploys/migrates tenants, restarts the engine in one click when a new resource needs it, and manages tenant files — plus the admin panel at /admin. Both are static SPAs compiled into the binary (make editor-ui admin-ui before go build; the published image ships them).

And you don't have to write the schema by hand: appximo ai-generate "<description>" turns a natural-language app description into a valid schema (validator-guided loop, ~$0.006/schema), or — with your own agent (Claude Code, Cursor) — appximo spec prints the LLM-distilled grammar so your subscription generates it and self-corrects against appximo validate --json, at zero API cost. The flow: docs/SCHEMA_SPEC_LLM.md.

And you don't have to stop at the schema. For the logic a schema can't express — custom endpoints with the tenant transaction + RBAC in-process, hooks, auth flows, background jobs — appximo backend-spec prints the agent guide for building a complete backend: the decision framework for where each piece of logic goes, the whole custom-handler surface with compiling examples, and the safety rules that keep the in-process model robust (a panicking background goroutine can never take the process down). Paste spec + backend-spec into your agent and it can build the whole backend. The guide, with its runnable example: docs/BACKEND_SPEC_LLM.md + examples/backend-guide/.

The trilogy closes with the part users touch: appximo frontend-spec prints the agent guide for building a production frontend — where the frontend lives (embedded in the same binary via Config.Static by default: one artifact, same origin, no CORS), the recommended stack and why (SvelteKit + adapter-static as a pure SPA — no Node at runtime), the exact API contract a UI consumes, the error→screen-state mapping (the multi-field 422, the work-preserving 409, the honest 503), the files/images pattern end to end (upload with progress → attach via a file field → display, including PUBLIC images through a byte-serving custom route), and the traps only a real browser reveals. Distilled from a production storefront, with a runnable no-build example: docs/FRONTEND_SPEC_LLM.md + examples/frontend-guide/. Give an agent all three — spec, backend-spec, frontend-spec (or appximo specs, which prints the trilogy in one stream) — and it can build the full stack.

Configuration

Setting Kind Required Description
DATABASE_URL env yes PostgreSQL connection string
JWT_SECRET env yes HS256 signing secret (≥ 32 chars)
ADMIN_KEY env yes X-Admin-Key for /metrics, /debug, /admin, control plane
RATE_LIMIT_RPS / RATE_LIMIT_BURST env no per-tenant token bucket (default 1000/100)
APPXIMO_MAX_TX_OPS env no max operations per POST /api/transaction (default 100)
APPXIMO_FILES_BACKEND env no file-store storage: local (default, this box's disk) or s3 (R2/Spaces/MinIO/AWS). See docs/FILES.md
APPXIMO_FILES_S3_* env with s3 BUCKET,ENDPOINT,REGION,ACCESS_KEY,SECRET_KEY,FORCE_PATH_STYLE,PREFIX,SERVE — provider-agnostic S3 config
APPXIMO_FILES_DIR / APPXIMO_FILES_MAX_BYTES / APPXIMO_FILES_TOKEN_TTL / APPXIMO_FILES_ALLOWED_EXT env no local blob root; upload cap (256 MiB); signed-URL TTL (180 s); upload extension allowlist
APPXIMO_PUBLIC_ROUTE_RPS / APPXIMO_PUBLIC_ROUTE_BURST env no dedicated rate limit for public custom routes (appximo.Route{Public: true} in the library model), per tenant+client IP; default 5 rps / burst 10
APPXIMO_AUTH_SIGNUP_ROLE env no role assigned to public signup; set it to enable POST /auth/signup (empty = signup disabled). Must be a schema role
APPXIMO_AUTH_MIN_PASSWORD env no minimum signup password length (default 8)
APPXIMO_AUTH_REQUIRE_VERIFIED env no block login until the user's email is verified (default off)
APPXIMO_AUTH_BASE_URL env no origin for reset/verify email links (else derived from the request Host)
APPXIMO_OAUTH_{GOOGLE,GITHUB,MICROSOFT}_CLIENT_ID / …_CLIENT_SECRET env no enable social login per provider (unset = provider not offered)
APPXIMO_OAUTH_CALLBACK_URL / APPXIMO_OAUTH_DEFAULT_ROLE env no fixed OAuth redirect origin; role for auto-created social users (falls back to signup role)
APPXIMO_MFA_KEY / APPXIMO_MFA_ISSUER env no TOTP-secret encryption key (falls back to JWT_SECRET); authenticator-app issuer label
APPXIMO_CORS_ORIGINS env no comma-separated browser origins allowed cross-origin (or *); empty = CORS disabled (safe default). Scoped to /api,/auth,/graphql,/openapi
APPXIMO_CORS_METHODS / APPXIMO_CORS_HEADERS / APPXIMO_CORS_EXPOSE_HEADERS / APPXIMO_CORS_CREDENTIALS / APPXIMO_CORS_MAX_AGE env no CORS preflight tuning (see docs/DEPLOY.md)
APPXIMO_GRAPHQL_PLAYGROUND env no allow GraphQL introspection + serve the GraphiQL explorer at /graphiql outside development; empty = off (the safe default — APPXIMO_ENV=development already enables both). Per-app in the fleet
APPXIMO_PLATFORM_SUPER_ADMIN_ROLE / APPXIMO_PLATFORM_MFA_ISSUER env no admin API: platform super-admin role marker (default platform_super_admin); platform authenticator label. Bootstrap the first super-admin with appximo admin create
OBS_DB_PATH env no observability SQLite path; default /var/lib/appximo/obs.db (persistent — survives restarts). See docs/DEPLOY.md
DB_MAX_CONNS, GOMAXPROCS, SLACK_WEBHOOK_URL, REDIS_URL env no see docs/DEPLOY.md
--schema flag yes path to the JSON schema
--port flag no data-plane port (default 8080)
--control-port flag no control-plane port (default 9090; also APPXIMO_CONTROL_PORT) — parameterized so several engines can share one box (appximo fleet, docs/FLEET.md)

The control plane (tenant admin) listens on 9090 by default — keep it off the internet.

Development (from a clone)

One command per task (make help lists them all):

make dev         # build the Studio SPA + engine, load dev secrets, serve on :8080
                 # boots a BLANK app — open http://localhost:8080/editor and
                 # load/paste your schema; or: make dev SCHEMA=mine.json PORT=9000
make dev-fast    # same, skipping the SPA rebuild (when you didn't touch the editor)
make stop        # stop the dev server by its exact PID (make stop PORT=9000)
make spec        # regenerate appximo-spec.md — the LLM grammar pack for your
                 # agent (docs/SCHEMA_SPEC_LLM.md)
make install     # install the version-stamped `appximo` CLI into /usr/local/bin
                 # (may need sudo) — then `appximo validate --json x.json` works anywhere
make fleet-init  # scaffold a working FLEET (N distinct apps on one port): manifest +
                 # generated secrets (gitignored) + starter schema + databases
make fleet       # serve every app on :8080 with the unified console at /fleet —
                 # per-app Studio//admin//docs by domain (docs/FLEET.md)

make dev reads the env-file at DEV_ENV (default .env.dev, gitignored) for the three required vars — DATABASE_URL, JWT_SECRET, ADMIN_KEY — and loads them only into the launched process; it tells you exactly what to create if the file is missing.

Testing

make test        # unit, -race, no Docker needed (~7 s warm)
make test-all    # + integration + E2E + resilience (real Postgres, toxiproxy)

License & contributing

Apache 2.0 — LICENSE · NOTICE. Issues and PRs welcome, especially: benchmark-baseline improvements, DNS modules for the Caddy wildcard setup, and schema features you're missing. Security reports: SECURITY.md.


Dedicated to my MVC: Máximo, Valentina and Cristina — Model, View, Controller.

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

View Source
const CSPOff = "off"

CSPOff is the StaticMount.CSP sentinel that disables the header entirely.

View Source
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.

View Source
const DefaultStaticCSP = "default-src 'self'; img-src 'self' data:; 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 policy a StaticMount serves when CSP is unset: same-origin everything, no framing, no external script/connect targets.

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.

View Source
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).

Variables

View Source
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 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 SafeParallel

func SafeParallel(ctx context.Context, limit int, tasks ...func(context.Context) error) error

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

func ServeFleet(mf *fleet.Manifest, version string, debugTracesHTML []byte) error

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.

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

func New(cfg Config) (*App, error)

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

func (a *App) Pool() *pgxpool.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

func (a *App) Register(rt Route) error

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

func (a *App) Routes() []Route

Routes returns the registered custom routes (read-only view, for OpenAPI/tests).

func (*App) Start

func (a *App) Start() error

Start builds the data-plane router (the shared middleware chain + custom routes + generated routes), launches the background goroutines and the control-plane listener, and serves until SIGINT/SIGTERM, running the graceful shutdown sequence. It blocks until shutdown completes.

type Claims

type Claims struct {
	UserID           string
	Role             string
	TenantID         string
	ExternalClientID string
}

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 package default (/var/lib/appximo/obs.db).
	// 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

	// 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
	// /var/lib/appximo/files. 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

	// 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

	// 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

type CreatedUser struct {
	ID            string
	Email         string
	Role          string
	EmailVerified bool
}

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(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)

	// 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 Handler

type Handler func(ctx Ctx) error

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
}

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

type RateLimit struct {
	RPS   float64
	Burst int
}

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

func NewRegistry(def *compiledApp, domains map[string]*compiledApp) *Registry

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

func (r *Registry) AddApp(domains []string, app *compiledApp)

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

func (r *Registry) RemoveApp(domains []string)

RemoveApp unregisters domains (hot remove — those Hosts fall back to the default/unmatched app). Same copy-on-write semantics.

func (*Registry) Resolve

func (r *Registry) Resolve(host string) *compiledApp

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

func (r *Registry) Snapshot() map[string]string

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

func (r *Registry) SwapApp(domains []string, app *compiledApp)

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
}

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

func ParseServeArgs(name, version, revision string, defaults ServeArgs) ServeArgs

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.

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

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.
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.
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).
db
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.
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
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.
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.

Jump to

Keyboard shortcuts

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