appximo

package module
v0.1.16 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 75 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 Release Docker Go License

One sentence in. A running, multi-tenant app out — verified and serving at 0:22, unedited:

Open the player to pause, rewind, change speed or copy the text — this GIF plays at real speed and can't be stopped. Or replay it in your own terminal: asciinema play docs/demo/appximo-new.cast.
The two moments worth stopping at: 0:17 the AI-generated schema validates on the first try · 0:22 the app is running — request verified through the full auth chain, URLs and credentials printed. The take runs to 0:47 because it ends with the graceful Ctrl+C. One real run: schema, Postgres, tenant, first admin, server. The untouched recording, the browser tour and the exact steps to reproduce and re-time it: docs/demo/.

That command (appximo new) is the shortcut. The contract underneath is what matters: 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, "minLength": 1, "maxLength": 200 },
        "status": { "type": "string", "enum": ["open", "done"], "default": "open" },
        "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.

The layers — everything below ships in the one binary

Each of these is usually its own service, dependency, or SaaS. Here they are compiled surfaces of the same process, all derived from the schema:

REST + GraphQL + OpenAPI, generated per resource Auth as a product: signup/login/refresh, argon2id, password reset + email verify, OAuth (Google/GitHub/Microsoft), TOTP MFA RBAC per role/resource/action/field + row conditions, deny-by-default, the identity column server-owned on create AND update
Migrations with a conscience: diff-based, data-preserving renames, destructive drops behind a dry-run + approval gate, resumable multi-tenant fan-out, version history + rollback Multi-tenancy: schema-per-tenant Postgres isolation, subdomain routing, per-tenant rate limits File store: content-addressed, OWASP upload validation, signed URLs, local disk or any S3
/app — a back-office CRUD UI generated at runtime from your API's own OpenAPI /admin — tenants, users, an observability dashboard (latency, SLO burn rate, trace waterfalls) and the engine's own Resources view: RAM/CPU/GC/pool/PSI with a deterministic bottleneck verdict under load ("the database, not Appximo") /editor — Appximo Studio: a visual ERD schema designer that deploys, migrates and restarts the engine
State machines enforced race-safely in SQL Atomic multi-resource transactions with compare-and-set guards SSE real-time, signed webhooks, JS/WASM sandboxed hooks, Prometheus + trace ring

The same generated schema from the demo, seen through three of those surfaces:

A record in /app — its foreign keys resolved to NAMES, status offering only the transitions its state machine allows The model, in /editor The platform, in /admin
the ERD in Appximo Studio the admin panel

Who it's for — and who it's NOT for

For you if: you want several small-to-medium apps (or tenants) on a box you control, with the API contract generated and enforced from a schema file, the custom 10% as plain Go in the same process, and an AI agent doing most of the typing against a printable spec.

NOT for you if —

  • You need horizontal scale or HA. Appximo is deliberately single-node; scale is vertical. The benchmark shows how far one cheap box goes — that's the honest ceiling.
  • Your domain logic outgrows a bounded framework surface. Custom logic is in-process Go routes + sandboxed hooks — powerful, but it is not an anything-goes app server. If most of your app is bespoke logic, use a framework; this engine's bet is that most of most apps isn't.
  • You want a managed cloud. Self-hosted only, by design, for now.
  • You need NoSQL, MySQL, or SQLite. It's PostgreSQL, period (jsonb covers the document-shaped corners).

Quick start (~30 s with the image pull)

With the binary, it's ONE command. appximo up in an empty directory starts Postgres (Docker, or your DATABASE_URL), writes+loads the secrets, registers your app with its schema, creates the first admin, serves — and prints the URLs, the credentials (once), a dev token and a curl that already works. Your app is browsable at /app (a back-office generated live from your API's own contract), documented at /docs, manageable at /admin, editable at /editor. appximo new "<your idea>" does the same with the schema AI-generated from one sentence. The full walkthrough — install, schema, first call, first user, custom Go, frontend, production with HTTPS, backup — with a manual track AND an AI-agent track for every step, is docs/QUICKSTART.md.

⚠ 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), ~41 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? Two prompts, then five specs

The product ships its own prompts. Paste them into YOUR agent (Claude Code, Cursor, Copilot) — they carry the whole journey, with executable checklists:

appximo prompt --install   # 1. gets the right binary onto this machine (any OS,
                           #    any starting state — fresh, outdated, or current)
appximo prompt             # 2. one idea in a sentence → schema → local checklist
                           #    green → production with HTTPS, zero questions

(Readable versions: docs/INSTALL_PROMPT.md · docs/MASTER_PROMPT.md.) Underneath them, the engine prints its complete 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 backoffice-spec  # 4. a CRUD admin UI generated from /openapi.json
appximo quickstart       # 5. OPERATING it: install → tenant → users → production
appximo specs            # …or all five at once (one paste = the whole contract)

Paste them into your own Claude Code / Cursor in that order (schema → backend → frontend → back-office → operations), 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.

The proof, not the adjective

Every claim in this README traces to a measurement or a third party — the inventory of what we assert, with conditions, is docs/CERTIFICATION_2026-08-01.md (+ dated addendums). When an audit kills a claim, the correction stays visible in the docs — that's the method, not an apology. The strongest evidence, in order:

Independent builds. Four field evaluations from outside the project — no repository access, real apps built from the public docs and printed specs alone; one of them was driven end to end by the evaluator's AI agent — each answered finding-by-finding in docs/FIELD_FEEDBACK_RESPONSE.md. A fifth field report — a real migration (Symfony 7.2, 23 tables, 46,119 rows, 1.2 GB of JSON, five sessions) — is answered the same way in §5 of that document, including the three points where the report's own diagnosis was wrong:

  • atina — the case study, open today at atina.appximo.com: a multi-client recruiting SaaS — 32 resources, 48 custom Go routes, a 30+-screen embedded SPA, matching engine, kanban, consent-by-link, scheduled jobs, mail worker — in production with HTTPS, built by an external developer with no direction from us. The largest third-party build we know of, and the only one a reader can walk through; counts verified in its public /openapi.json.
  • VecinGo — the case study: a neighborhood-association platform (18 resources, 8 state machines, 13 custom Go handlers, weighted quorum voting, a 13-screen embedded SPA) to production with HTTPS in ~3–3.5 h. Verdict: "as a consumer, I would do it again."
  • crisblogs — a complete blog (roles, cover uploads, publish lifecycle) built by an outside evaluator's agent ONLY from the distributed binary + appximo specs, on infrastructure that isn't ours.
  • A gym-bookings app, install → production with HTTPS in under 24 h of part-time evaluation — the report that opened this feedback loop.

Fresh-agent runs (agents with zero repo access, only the public docs, all disclosed in full): the QUICKSTART checklist 0→green in 1m53s (measured); a sports-court app — schema valid first try, Go compiling first try, zero blockers; two master-prompt runs reaching a local green checklist in 3m28s and HTTPS installs on a disposable test box (a simulated VPS — systemd container with a local CA, disclosed, not hidden) in ~17–22 min, zero questions asked.

Live demos — schema + custom Go + embedded frontend, one binary each on a $16/mo VPS:

  • tiendita.appximo.com — a commerce storefront (catalog, cart, checkout with an atomic stock transaction, order tracking, image uploads).
  • petfriendly.appximo.com — a pet-services app born from the AI authoring flow; try the demo panel or browse its generated API docs.

Demo data is public: the store's checkout genuinely writes (payments run in mock mode), the demo panels are read-only (the server rejects writes — RBAC is the boundary, not the UI), and everything resets nightly.

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 — the numbers WITH their conditions

No number below is valid without its condition column. That is deliberate.

Number What it measures Condition — read it
2,000 req/s, p50 1.60 ms (CI95 [1.57, 1.67]), 0 errors / 597k reqs the engine: JWT + RBAC + multi-tenancy + validation + rate limiting active, external load generator over a real network $16/mo 2-vCPU droplet; single-tenant load needs the per-tenant limiter raised (RATE_LIMIT_RPS=3000 RATE_LIMIT_BURST=300, as the benchmark declares — on the THEN-default 1,000 rps/tenant, ~half of 2,000 rps to ONE tenant was answered 429, by design (the default is derived from the box since MOTOR-PRODUCCION-S2)); re-measured 2026-08-01, reproducing 2026-06-10's 1.58 ms
p50 2.44 ms @ 500 req/s the same engine with the response cache fully bypassed — every request reaches PostgreSQL same box; the uncached truth next to the cached one
+1.2 ms p50 the full production stack's overhead: Caddy + Let's Encrypt TLS → systemd → native PostgreSQL re-measured 2026-08-01; see docs/BENCHMARKS.md
~4.2 ms end-to-end a filtered page over 1M rows, whole stack re-measured 2026-08-01
~186 MiB PSS under load a real consumer app's ENTIRE stack 2026-07-31, box serving two apps (the older ~109 MiB idle figure is kept in BENCHMARKS with its date)
22 s to a running, verified app · 47 s full take one sentence → appximo new → schema, Postgres, tenant, admin, server, one request verified through the full chain (0:22); the take continues to 0:47 only because it ends with the graceful shutdown one real recorded run; the cast carries its own timing and ships in the repo, with reproduction steps in docs/demo/. The AI step varies per run — this one validated first try (measured convergence: ~90% first-try)
1m53s fresh agent, QUICKSTART 0 → green checklist measured once, disclosed conditions in the doc

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), and ?fields=a,b — pushed into the SQL SELECT so an unlisted json/text document is never read from disk (a migrated system's page of 20: 961 KB / 53 ms → 3 KB / 1.2 ms, BENCHMARKS §4b); every generated read carries a Server-Timing header with the engine's stages
  • 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 — and the identity column is server-owned on update too: a body that reassigns or nulls it is the same 403 on REST, GraphQL, the batch transaction and Ctx.Update (a row can never be given to another user, ADR-027). 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) — the built assets ship in the module (ADR-025), so every go build includes the panel
  • 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, brings the API up on HTTPS, and verifies what it installed (binary checksum, /health version locally and through the proxy, the schema on disk) before saying so. 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 runs our own live apps today — deployed and operated through the same official path it documents — and the heaviest production build we know of is a third party's (atina, open at atina.appximo.com; VecinGo before it).

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.
  • Receiving an existing system has limits, written down: no COPY/file import (the bulk door is /api/transaction, 100 ops and 1 MiB per request — minutes for 46k rows, not hours); JSON numbers pass through float64 (integers past 2^53 lose digits, ENG-50); ?fields= projects the root row only (?include= embeds stay whole); ?page= is OFFSET. The full list, each with its tracker: GUIDE §9.

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 whose built assets ship in the module (ADR-025): any go build — including a consumer's custom binary via go get github.com/appximo/appximo — embeds working /admin and /editor.

And the schema speaks both directions: appximo explain schema.json --lang es|en reads a VALID schema back as plain-language prose for the app's owner (fields in words, lifecycles in flow order, each role's reach — deterministic, never guessed), so a non-programmer can confirm an AI-written schema models what they asked. And you don't have to write it 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 — enforced: the engine refuses to boot with a shorter one)
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 DERIVED: 350 rps × vCPU / 100 — docs/BENCHMARKS.md §4e)
APPXIMO_MAX_INFLIGHT env no admission control: max in-flight data-plane requests (auto = max(32, 4×(vCPU+pool)); 0 disables; excess → cheap early 429 + Retry-After)
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_LOGIN_ATTEMPTS_PER_MINUTE / APPXIMO_AUTH_LOGIN_BURST env no login (and MFA-verify) attempts per (tenant, email) before 429 — default 5 / 5, the online brute-force guard on every account. Raising it weakens that guard by the same factor; do it only for a deliberately shared, read-only demo identity (the engine warns at boot, and refuses a non-integer)
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
APPXIMO_SELFMON env no off disables the engine's own resource collector (runtime / cgroup / PSI / pool + the attribution verdict at /admin → Resources, /debug/resources, appximo_selfmon_* on /metrics). On by default; APPXIMO_SELFMON_INTERVAL (10s background), APPXIMO_SELFMON_LIVE_INTERVAL (1s while the view polls), APPXIMO_SELFMON_P99_MS (the verdict's "slow" floor, 50). See ADR-030
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. CONTRIBUTING.md has the ground rules (Conventional Commits, the PR gate, how the data-path binary-diff gate works); CI runs the full suite — unit + integration + E2E + resilience against real Postgres, lint, govulncheck, and a native Windows gate — on every push (workflows). 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: 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.

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

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

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

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.

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

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

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

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

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

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

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.

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

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

Jump to

Keyboard shortcuts

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