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).
The binary’s three surfaces
Section titled “The binary’s three surfaces”- Public API (
/v1/challenge,/v1/submit) — for the static customer sites, without auth. Security = verification pipeline (below). - Admin API + UI (
/api/v1/...+ SPA) — real auth (argon2id, JWT, static RBAC), manages tenants, routes, quarantine, blocklist. - 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.
Layered architecture
Section titled “Layered architecture”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 switchAlongside 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.IDis 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-sourcedSubmissionmodel, services use the ORM++ API directly (orm.New/Load/Querywithin the tenant context). - Domain structs (
internal/core/domain) have no dependencies other than ORM++ tags/types.
The submit path (core of the product)
Section titled “The submit path (core of the product)”POST /v1/submit runs through GatewayService.Submit
(gateway_service.go) —
in “cheapest first” order (concept §5):
- In-memory rate limits: global → IP → site key
- Tenant lookup via site key + kill switch + origin allowlist (server-side!)
- Challenge token: HMAC signature, TTL, site/origin binding, minimum fill time
- Honeypot (hit: fake success, log internally as
blocked) - IP/ASN reputation (Tor, blocklist, ASN blocks)
- Proof-of-work verification (one hash)
- Atomically redeem the nonce (unique insert ⇒ replay protection)
- Input hardening (field/length limits, CRLF protection, reply-to validation)
- Email block patterns against reply-to
- Daily quota (persistent, counts pending + sent)
- Create the submission aggregate (
receivedevent) + spam score - 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.
Tenant data model
Section titled “Tenant data model”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.
Building a new feature — step by step
Section titled “Building a new feature — step by step”Always from the inside out (a complete example chain exists for
SmtpRoute):
-
Domain model —
internal/core/domain/xyz.go(ORM++ tags, see database.md) -
Register — in
internal/storage/database.go→registerModels(); incrementSchemaVersionfor non-additive changes -
Repository —
internal/storage/repositories/xyz_repository.go(template:smtproute_repository.go) -
Service —
internal/core/services/xyz_service.go; own errors asvar ErrXyz = errors.New(...) -
Controller —
internal/api/controllers/xyz_controller.gowith a view struct (template:route_controller.go) -
Route + permission — code in
domain/rbac.go+ role catalog, route inrouter.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. -
Frontend API class + page — see frontend_api.md (template:
api/routes.js+pages/Routes.svelte) -
Tests — service test against a temp SQLite (template:
gateway_service_test.go), route test inrouter_test.go
Special case: logic without a database (calculations, version info): omit the repository layer — chain is just controller → service. Reference:
SystemService/SystemController.
Directory structure
Section titled “Directory structure”cmd/app/main.go Entry point: config → DB → seeding → worker → serverclient/ Client snippet (form-gateway.js) + example pagedocs/ This documentationinternal/api/ HTTP transport (controllers, middlewares, router)internal/core/domain/ Entities (ORM++ models) + RBAC cataloginternal/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 processinginternal/worker/ Background jobs (sending, cleanup, refresh)internal/storage/ ORM++ connection, model registration, seeding, repositoriesinternal/config/ config.json managementfrontend/src/api/ API service classes (fetch abstraction)frontend/src/pages/ One Svelte file per routefrontend/embed.go go:embed of the dist folderagent.md Context for AI assistantsConfiguration & startup sequence
Section titled “Configuration & startup sequence”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.
Versioning & build number
Section titled “Versioning & build number”- Semantic version: file
VERSION— maintained by the CI pipeline’s release bot (Conventional Commits ⇒ next version), not by hand. - Build number:
.buildnumberlocally (Makefilebump-build), pipeline number in CI. - Both are injected via
-ldflags -Xinto internal/version.
Queryable via: ./form-gateway -version, GET /api/v1/system/info,
the web app footer, and make version.
Build & cross-compiling
Section titled “Build & cross-compiling”make build runs: npm audit → vite build → govulncheck →
go 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:
make build-linux # bin/form-gateway-linux-amd64make build-linux-arm64 # bin/form-gateway-linux-arm64make build-windows # bin/form-gateway-windows-amd64.exemake build-macos # bin/form-gateway-darwin-arm64 (Apple Silicon)make build-macos-intel # bin/form-gateway-darwin-amd64make build-all # all platformsOperations
Section titled “Operations”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 GatewayAfter=network.target
[Service]User=formgwWorkingDirectory=/opt/form-gatewayExecStart=/opt/form-gateway/form-gateway -data /var/lib/form-gatewayRestart=on-failure
[Install]WantedBy=multi-user.targetIn 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.