kereru-forms/docs/ARCHITECTURE.md

11 KiB

Architecture

Overview

┌──────────────────────────────┐      ┌──────────────────────────────────────────┐
│ React SPA (Vite, Tailwind)   │─────▶│ kereru-forms (Go, Gin)                   │
│ - form filling               │ HTTP │  ┌────────────┐  ┌────────────────────┐  │
│ - management                 │      │  │ OpenAPI    │  │ Strict handlers    │  │
│ - light/dark/system theme    │      │  │ generated  │─▶│ (validation, PDF,  │  │
└──────────────────────────────┘      │  │ router     │  │  access codes)     │  │
                                      │  └────────────┘  └─────────┬──────────┘  │
                                      │                            │             │
                                      │  ┌────────────┐  ┌────────▼──────────┐  │
                                      │  │ Telemetry  │  │ Store interface   │  │
                                      │  │ (OTLP)     │  │  SQLite adapter   │  │
                                      │  └────────────┘  └────────┬──────────┘  │
                                      └───────────────────────────│──────────────┘
                                                                  ▼
                                                         SQLite database file

The API is mounted both at the root (for Kubernetes probes and direct clients) and under /api (used by the SPA). The backend binary can also serve the built SPA (KERERU_STATIC_DIR) with history fallback, so a single container is enough for most deployments.

Backend layout

backend/
├── openapi/openapi.yaml        Contract: paths, schemas, enums
├── oapi-codegen.yaml           Code generation settings
├── cmd/kereru-forms/main.go    Wiring, listeners, graceful shutdown
└── internal/
    ├── api/                    Generated server + strict handlers + router + auth middleware
    ├── auth/                   Local password and OIDC token verification, roles
    ├── config/                 YAML/JSON + .env + env + flags, precedence
    ├── database/               Store interface, SQLite implementation, migrations
    ├── logging/                zerolog setup + Gin access logging
    ├── pdf/                    PDF rendering of a form definition
    └── telemetry/              OpenTelemetry traces and metrics (OTLP/HTTP)

Request flow

  1. Gin middleware: telemetry span (otelgin), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS, then authentication/authorisation.
  2. api.RegisterHandlers routes to the generated strict handler, which performs OpenAPI request decoding/validation.
  3. api.Server methods validate business rules and call the database.Store.
  4. Responses are mapped back to generated types; errors use the Error schema ({error, code}).

Authentication

The API uses bearer JWTs with no server-side session state. internal/auth validates two token types:

  • OIDC: discovery is performed at startup against the configured issuer. ID tokens are verified by go-oidc (signature, issuer, audience, expiry), and roles are read from a configurable claim path (for example roles, groups or realm_access.roles).
  • Local password fallback: used only when no OIDC issuer is configured. The password is compared with bcrypt (or constant-time plain comparison) and the server issues a short-lived HS256 JWT. This mode grants both admin and view roles for local testing.

The SPA performs the authorization-code flow with PKCE and sends the ID token as Authorization: Bearer …. It stores the token in sessionStorage and clears it on a 401. Because the SPA is a public client, no client secret is supported.

Authorisation is enforced by api/auth_middleware.go before the strict handlers:

Capability Required role
Form CRUD, activation, versioning, access-code management admin
List/read submissions and download offline PDFs view
List/open/submit forms and download form PDFs when requireSubmissionAuth is enabled any authenticated user

Admin-only users cannot view submissions by design. /auth/config, /auth/login, /auth/me, /auth/logout and /health remain public; /auth/me optionally authenticates so the SPA can distinguish signed-out from signed-in.

Configuration

internal/config implements a deterministic precedence chain: defaults → YAML/JSON file (--conf) → .env (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The --conf flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently.

The auth block selects local-password or OIDC mode (oidc.issuerUrl non-empty), maps role claims, optionally requires authentication for submissions, and controls local token secret/TTL.

Data model

forms represents one concrete version. form_group_id groups all versions of the same logical form; (form_group_id, version) is unique. Creating a new version clones the current definition, increments version, activates the new row and inactivates the previous one. Historical submissions keep form_version and denormalised question_key values, so they remain readable after questions change.

Table Purpose
forms Form version metadata, contact details, state
questions Ordered questions with type-specific configuration (JSON options, bounds)
submissions Response header: user identifier, form version, server-captured tracking
answers One row per answered question, value stored as JSON
access_codes Single-use codes (used, used_at, owning submission)
schema_migrations Applied schema versions

All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).

Validation

Form definitions: non-empty title, unique keys ([A-Za-z][A-Za-z0-9_-]*), supported question types, select questions require unique non-empty option values, numeric bounds ordered.

Submissions: identifier type must be email|username|phone|guid; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.

Access codes are consumed in the same SQL transaction as the submission insert. The conditional UPDATE ... WHERE used = 0 makes reuse safe under concurrency; failure rolls back the submission.

Observability

  • Logs: zerolog, JSON or console, level configurable. Access logs include method, path, status, latency, client IP, user agent and X-Request-ID.
  • Traces/metrics: OpenTelemetry SDK with OTLP/HTTP exporters. Spans cover HTTP requests (otelgin) and individual use cases (GetForm, SubmitForm, GetFormPdf, …) with form/submission attributes. Metrics are exported every 15s. Telemetry is opt-in (KERERU_TELEMETRY_ENABLED).
  • Health: GET /health returns status, version and uptime for liveness/readiness probes.

PDF

internal/pdf renders the form definition with go-pdf/fpdf: title, description, introduction, contact block, numbered questions, type hints, options and answer lines. Built-in PDF fonts cover WinAnsi; macrons and common typographic characters are transliterated so the output is always valid. For full Unicode, embed a TTF font instead.

Frontend layout

frontend/src/
├── api/…            (reserved)
├── auth/            AuthProvider, useAuth, ManagementGate, AccessDenied
├── components/      Layout, ThemeToggle, QuestionField, StatusBadge, SignInPrompt
├── hooks/useTheme   light/dark/system with localStorage + matchMedia
├── lib/api.ts       Typed fetch client, bearer auth, ApiError, PDF download
├── lib/oidc.ts      Authorization code + PKCE callback handling
├── lib/session.ts   Token storage and auth change events
├── lib/types.ts     Mirrors the OpenAPI schemas
├── pages/           Home, FillForm, Login, OidcCallback, Manage, FormBuilder, FormDetail, SubmissionDetail
└── test/            Vitest + Testing Library

AuthProvider loads /auth/config and /auth/me on start, exposes password/OIDC login and logout, and refreshes on auth-change events. Management routes are wrapped in ManagementGate; the home and fill pages show a sign-in prompt when submission authentication is required.

Theming uses Tailwind's class dark mode. An inline script in index.html applies the stored preference before paint to avoid a flash. The useTheme hook keeps documentElement.dark, localStorage and the OS preference in sync.

Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF.

The fill flow asks for the respondent identifier and (optionally) an access code first, then renders the questions by type. On success the local answers are cleared and only the confirmation is shown — previously entered data is never re-presented. The management section lists active and inactive versions and supports activation, versioning, deletion, PDF download, access-code generation and submission review.

Extending the database

database.Store is the boundary used by the API layer. To add PostgreSQL or MariaDB:

  1. Add a new adapter implementing Store (or generalise the SQL adapter with a dialect for placeholders and upserts).
  2. Extend database.Open with the driver case and adjust migrations (TEXT timestamps and JSON strings map cleanly to timestamptz/jsonb and varchar/json).
  3. Keep transaction semantics for submission + access-code consumption.

Known limitations / next steps

  • OIDC assumes a public SPA client (no client secret) and an IdP that allows token-endpoint CORS; confidential-client flows are not implemented.
  • SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit.
  • Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets.
  • PDF output uses built-in fonts and does not embed form logos (see above).