Skip to content

Architecture

Techeve Form Gateway compiles a Go backend (Fiber v3), a Svelte 5 admin frontend, and the client snippet into a single binary. The frontend is built by Vite into frontend/dist and embedded via go:embed (frontend/embed.go); the snippet serves the gateway under /form-gateway.js (client/embed.go).

  1. Public API (/v1/challenge, /v1/submit) — for the static customer sites, without auth. Security = verification pipeline (below).
  2. Admin API + UI (/api/v1/... + SPA) — real auth (argon2id, JWT, static RBAC), manages tenants, routes, quarantine, blocklist.
  3. Background worker (internal/worker) — send queue (every 15 s), nonce/queue cleanup (hourly), reputation refresh (daily). The ORM++-internal workers (projections, snapshots, archiving) are started separately by storage.Open.
HTTP request
Router + Middlewares internal/api/router, internal/api/middlewares
│ (JWT/API key auth, RBAC, logging, recover; CORS for /v1)
Controller internal/api/controllers
│ (parse JSON, validate, IDs as UUID strings via views)
Service internal/core/services
│ (business logic — knows nothing about HTTP;
│ GatewayService orchestrates the verification pipeline)
Repository / ORM++ internal/storage/repositories (CRUD)
│ orm.New/Load/Query directly for event sourcing (Submission)
ORM++ SQLite (default) or PostgreSQL — config switch

Alongside this, deliberately free of DB and HTTP:

  • internal/pipeline — the pure verification layers: challenge token (HMAC), proof of work, input hardening, spam score, rate limiter. Fully unit-tested.
  • internal/reputation — IP/ASN lookup (iptoasn.com), Tor exits, mirrored admin blocklist (in-memory).
  • internal/mailer — SMTP sending (STARTTLS/implicit), route resolution (tenant route → shared default route), retry backoff via the DB queue.

Rules:

  • Controllers contain no business logic and no DB access; orm.ID is always exposed externally as a UUID string (view structs).
  • Services never import fiber.
  • Repositories encapsulate CRUD; errors are normalized to repositories.ErrNotFound. For the event-sourced Submission model, services use the ORM++ API directly (orm.New/Load/Query within the tenant context).
  • Domain structs (internal/core/domain) have no dependencies other than ORM++ tags/types.

POST /v1/submit runs through GatewayService.Submit (gateway_service.go) — in “cheapest first” order (concept §5):

  1. In-memory rate limits: global → IP → site key
  2. Tenant lookup via site key + kill switch + origin allowlist (server-side!)
  3. Challenge token: HMAC signature, TTL, site/origin binding, minimum fill time
  4. Honeypot (hit: fake success, log internally as blocked)
  5. IP/ASN reputation (Tor, blocklist, ASN blocks)
  6. Proof-of-work verification (one hash)
  7. Atomically redeem the nonce (unique insert ⇒ replay protection)
  8. Input hardening (field/length limits, CRLF protection, reply-to validation)
  9. Email block patterns against reply-to
  10. Daily quota (persistent, counts pending + sent)
  11. Create the submission aggregate (received event) + spam score
  12. Score ≥ threshold ⇒ quarantined (the client still sees success), otherwise the send queue

Rejections are always 400 {"ok":false,"reason":"rejected"} on the outside — the real reason appears only in the log and (as a blocked submission) in the admin panel.

Gateway tenants (Tenant) are global configuration rows; their form data lives in its own dedicated ORM++ tenant (Tenant.OrmTenantID): submissions and nonces are thereby strictly separated, and GDPR export and purge are available per customer site via the ORM++ tenant registry. Submission is event-sourced — the history (received → scored → sent/quarantined → released/discarded) is the audit log. Details: database.md.

Always from the inside out (a complete example chain exists for SmtpRoute):

  1. Domain modelinternal/core/domain/xyz.go (ORM++ tags, see database.md)

  2. Register — in internal/storage/database.goregisterModels(); increment SchemaVersion for non-additive changes

  3. Repositoryinternal/storage/repositories/xyz_repository.go (template: smtproute_repository.go)

  4. Serviceinternal/core/services/xyz_service.go; own errors as var ErrXyz = errors.New(...)

  5. Controllerinternal/api/controllers/xyz_controller.go with a view struct (template: route_controller.go)

  6. Route + permission — code in domain/rbac.go + role catalog, route in router.go:

    xyz := api.Group("/xyz")
    xyz.Get("/", middlewares.RequirePermission(domain.PermXyzRead), ctrl.List)

    Important (Fiber v3): handlers run in the order they are listed — middlewares come before the controller handler, otherwise the route is unprotected. Regression test: TestRBACMiddlewareRunsBeforeHandler.

  7. Frontend API class + page — see frontend_api.md (template: api/routes.js + pages/Routes.svelte)

  8. Tests — service test against a temp SQLite (template: gateway_service_test.go), route test in router_test.go

Special case: logic without a database (calculations, version info): omit the repository layer — chain is just controller → service. Reference: SystemService/SystemController.

cmd/app/main.go Entry point: config → DB → seeding → worker → server
client/ Client snippet (form-gateway.js) + example page
docs/ This documentation
internal/api/ HTTP transport (controllers, middlewares, router)
internal/core/domain/ Entities (ORM++ models) + RBAC catalog
internal/core/services/ Business logic (incl. GatewayService = pipeline orchestration)
internal/pipeline/ Pure verification layers (token, PoW, hardening, spam, rate limits)
internal/reputation/ IP/ASN/Tor data (in-memory, daily refresh)
internal/mailer/ SMTP sending + retry queue processing
internal/worker/ Background jobs (sending, cleanup, refresh)
internal/storage/ ORM++ connection, model registration, seeding, repositories
internal/config/ config.json management
frontend/src/api/ API service classes (fetch abstraction)
frontend/src/pages/ One Svelte file per route
frontend/embed.go go:embed of the dist folder
agent.md Context for AI assistants

On startup, the binary looks for a config.json in the data directory (-data <dir> or FORMGW_DATA; default: the binary’s directory). If it is missing, it is generated with secure random values — including the JWT secret and the field encryption key (encryption_key, back it up!). Details: security.md.

After that: open ORM++ (SQLite or database_dsn = PostgreSQL), migration + ORM++ worker, idempotent seeding (system and admin users; the generated admin password appears once in the console), mirror the blocklist into the reputation store, start the gateway worker, bind the server.

An installation consists of three files: the binary, config.json, and gateway.db — the companion files are created automatically on first start.

  • Semantic version: file VERSION — maintained by the CI pipeline’s release bot (Conventional Commits ⇒ next version), not by hand.
  • Build number: .buildnumber locally (Makefile bump-build), pipeline number in CI.
  • Both are injected via -ldflags -X into internal/version.

Queryable via: ./form-gateway -version, GET /api/v1/system/info, the web app footer, and make version.

make build runs: npm auditvite buildgovulncheckgo build. If a security check fails, the build aborts. ORM++ is a private module: run once go env -w GOPRIVATE=gitlab.techeve.de (locally; CI uses the job token).

CGO-free ⇒ cross-compiling without toolchains:

Terminal window
make build-linux # bin/form-gateway-linux-amd64
make build-linux-arm64 # bin/form-gateway-linux-arm64
make build-windows # bin/form-gateway-windows-amd64.exe
make build-macos # bin/form-gateway-darwin-arm64 (Apple Silicon)
make build-macos-intel # bin/form-gateway-darwin-amd64
make build-all # all platforms

Docker: a hardened Alpine image (non-root, read-only) with a ./data volume — guide: docker.md. Short form: make docker-build && docker compose up -d.

systemd (/etc/systemd/system/form-gateway.service):

[Unit]
Description=Techeve Form Gateway
After=network.target
[Service]
User=formgw
WorkingDirectory=/opt/form-gateway
ExecStart=/opt/form-gateway/form-gateway -data /var/lib/form-gateway
Restart=on-failure
[Install]
WantedBy=multi-user.target

In front of this belongs a reverse proxy (nginx) with TLS; the public API and the admin UI run over the same port. The binary handles SIGINT/SIGTERM with a graceful shutdown (also stopping the workers cleanly).

Operational prerequisite for sending: set up at least one SMTP route in the admin panel — typically the shared default route through your own mail server with a fixed, DKIM-/SPF-signed sender domain (e.g. forms.techeve.de). The test-send button checks the connection and login.