No description
  • JavaScript 91.8%
  • Go 7.5%
  • templ 0.5%
  • KCL 0.2%
Find a file Use this template
Hunter Egnatis 7c84e363ec feat(config): migrate to schema-driven configuration backed by KCL
Replace the hand-written Field registry with a schema-first workflow: the
config.Field registry (fields_gen.go) and typed Config struct (config.gen.go)
are now generated from internal/config/schema.k by kcl-generate (wired as
task generate-kcl, with a drift guard in task check).

- Author schema.k from the old fields_app.go (self-contained, so the runtime
  .k loader can import it); add kcl.mod; convert the dev config to a .k
  program (configs/app.example.k); blank-import toolbelt/config/kcl.
- Route 'config set' by extension: .k uses KCL OverrideFile, yaml keeps
  WriteConfigAs.
- Validate the fully resolved config against the schema at startup
  (config.Validate, called from initConfig) for .k and yaml config files.
- -v/-vv/-vvv verbosity ladder (warn/info/debug); explicit --log-level wins.
- Bump toolbelt pins to a7ac9fc (config/kcl + kcl-generate published).
- Update AGENTS.md/READMEs and the scaffolder (cmd/init) to the new layout;
  fix pre-existing lint debt so task check is green.
2026-08-06 08:48:49 -04:00
.agents/skills initial commit 2026-08-03 14:40:32 -04:00
.opencode/skills/live-reload-server docs: remove expense-project leftovers from the template 2026-08-03 14:55:02 -04:00
.task/checksum feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
api initial commit 2026-08-03 14:40:32 -04:00
cmd feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
configs feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
docs feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
internal feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
test initial commit 2026-08-03 14:40:32 -04:00
testdata initial commit 2026-08-03 14:40:32 -04:00
tools docs: remove expense-project leftovers from the template 2026-08-03 14:55:02 -04:00
web initial commit 2026-08-03 14:40:32 -04:00
.air.toml initial commit 2026-08-03 14:40:32 -04:00
.fga.yaml initial commit 2026-08-03 14:40:32 -04:00
.gitignore chore: use published toolbelt modules instead of sibling replace 2026-08-03 14:48:30 -04:00
.golangci.yml initial commit 2026-08-03 14:40:32 -04:00
AGENTS.md feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
go.mod feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
go.sum feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
kcl.mod feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
README.md feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00
skaffold.yaml initial commit 2026-08-03 14:40:32 -04:00
skills-lock.json initial commit 2026-08-03 14:40:32 -04:00
Taskfile.yaml feat(config): migrate to schema-driven configuration backed by KCL 2026-08-06 08:48:49 -04:00

go-template

A template repo for new projects on the shared Go platform: embedded NATS, embedded OpenFGA, embedded SQLite (sqlc), chi, SCS sessions backed by NATS KV, and DataStar SSE projections. The example application it ships with is an expense approval pipeline with role-based authorization.

Module layout

Module Path Role
Template git.animeteamspeak.moe/ginjiruu/go-template This repo: example features + startup wiring
Platform git.animeteamspeak.moe/ginjiruu/toolbelt db, embeddednats, embeddedfga, projector, events, sessions/natskv, cqrs, config, server, app (composition root + Feature)
Tools git.animeteamspeak.moe/ginjiruu/toolbelt/tools fga-generate, fga-coverage (own go.mod)

internal/app/app.go lists the features; everything else is provided by toolbelt/app. Adding a feature never grows the startup — see docs/FEATURE_ADDITION.md.

Scaffold a new project

task init NAME=myapp MODULE=git.animeteamspeak.moe/ginjiruu/myapp
cd ../myapp && go mod tidy && task check

task init copies this repo (minus git history, data, vendored tool deps, and the local go.work) and rewrites the module path, app name, and env prefix (MYAPP_*). The scaffolded project is self-contained: go.mod requires the published ginjiruu/toolbelt and ginjiruu/toolbelt/tools modules, and the codegen tasks use go tool fga-generate / go tool fga-coverage (pinned via the tool directive).


Example app: Expense Approval Pipeline

A self-contained Go application that provides an expense approval workflow with role-based authorization checks. Expenses flow through a multi-level approval pipeline backed by an embedded OpenFGA server for authorization decisions and SQLite for persistent metadata storage.

This is not a microservice. It is a single deployable unit that owns its own database, authorization store, and web UI. For multi-tenancy, deploy separate copies of this application — each instance manages its own data and authorization model independently.

Architecture

┌─────────────┐     HTTP/SSE+HTML       ┌──────────────────┐
│   Browser   │ ◄─────────────────────► │  expense-approval │
│  (PicoCSS)  │                         │      server       │
└─────────────┘                         └────────┬─────────┘
                                                  │
                                           ┌──────┴──────┐
                                           │   SQLite    │
                                           │  (FGA + DB) │
                                           └─────────────┘
                                                  ▲
                                                  │ JetStream KV
                                           ┌──────┴──────┐
                                           │   NATS      │
                                           │  (JetStream) │
                                           └─────────────┘
  • Web UI: templ templates rendered with PicoCSS (CDN), served at / and /expenses
  • Routes: chi router under /expenses/ with request ID, real IP, access logging, recovery, timeout, and CORS middleware
  • Real-time state: NATS JetStream Key-Value store for CQRS, with DataStar Server-Sent Events (SSE) for live UI updates
  • Authorization: embedded OpenFGA server (no external dependency required), model defined in internal/fga/model/
  • Metadata: SQLite via delaneyj/toolbelt with sqlc-gen-zombiezen codegen
  • Logging: go.uber.org/zap with configurable JSON or console output

Tech Stack

Component Library
HTTP framework github.com/go-chi/chi/v5
CORS github.com/go-chi/cors
HTML templating github.com/a-h/templ
CSS framework PicoCSS (CDN)
Authorization github.com/openfga/openfga (embedded), github.com/openfga/go-sdk
Database github.com/delaneyj/toolbelt (sqlite backend), zombiezen.com/go/sqlite
Codegen sqlc with custom sqlc-gen-zombiezen plugin
Messaging/State github.com/nats-io/nats.go (JetStream KV), github.com/nats-io/nats-server/v2 (embedded)
Real-time UI github.com/starfederation/datastar-go (SSE)
Authentication (prod) github.com/hashicorp/cap/oidc
Authentication (dev) github.com/zitadel/oidc/v3 (embedded zitadel OIDC server)
UUID github.com/google/uuid
Currency github.com/Rhymond/go-money
Logging go.uber.org/zap
Config github.com/spf13/viper
Validation github.com/go-playground/validator/v10
File watching github.com/fsnotify/fsnotify

Routes

Public (no auth)

GET  /                              Home page
GET  /health                        Health check

Authentication (OIDC)

GET  /login                         Redirect to OIDC provider (or dev OIDC on :9998)
GET  /callback                      OIDC callback — exchanges code for tokens, creates session cookie
GET  /logout                        Clears the session cookie

Web UI (DataStar SSE)

Authenticated via RequireAuth middleware. The /expenses/ endpoint returns an HTML page on first load and switches to Server-Sent Events (SSE) for live updates.

GET     /expenses/                  HTML page or SSE stream (detected by Accept header)
POST    /expenses/                  Create expense (DataStar signal)
DELETE  /expenses/{id}              Delete expense
POST    /expenses/{id}/approve      Approve expense at approval level-1
POST    /expenses/{id}/deny         Deny expense at approval level-1
POST    /expenses/{id}/filter/{filter}  Set filter: all | pending | approved | denied
POST    /expenses/{id}/sort/{field}     Set sorting: created_at | amount | title

Health

GET /health

Legacy JSON API (deprecated, not wired into the router)

Defined in internal/handler/ (removed). These endpoints exist in code but are not registered in the active router.

POST    /api/expenses/              Create expense (JSON)
GET     /api/expenses/              List expenses (JSON)
GET     /api/expenses/{id}          Get expense by ID
PATCH   /api/expenses/{id}          Update expense
DELETE  /api/expenses/{id}          Delete expense
GET     /api/expenses/{id}/approvals      List approvals
POST    /api/expenses/{id}/approve        Approve expense
POST    /api/expenses/{id}/reject         Reject expense
DELETE  /api/expenses/{id}/approvals/{level_id}  Delete approval
POST    /api/expenses/{id}/approver       Assign approver
DELETE  /api/expenses/{id}/approver/{user_id}   Remove approver
GET     /api/expenses/{id}/fully-approved Check if fully approved
GET     /api/check/{user}/{id}            Check authorization (any relation)

Setup

Prerequisites

  • Go 1.26+
  • sqlc CLI (with sqlc-gen-zombiezen plugin)
  • Taskfile runner (task)
  • FGA CLI (fga) for model validation (task fga-validate)

Configuration

All configuration is managed through a schema-driven system. The single source of truth is internal/config/schema.k (KCL): task generate-kcl turns it into the config.Field registry (fields_gen.go) and a typed Config struct (config.gen.go). Config files can be .k (KCL programs — the recommended, type-checked format), YAML, JSON, or TOML. Values can be set via:

  1. Build defaults (from the schema, overridable with -ldflags -X)
  2. Config file (--config app.k, see configs/app.example.k)
  3. Environment variables (prefixed with GO_TEMPLATE_, dots replaced with underscores)
  4. CLI flags (--log-level, -v/-vv/-vvv)

Precedence (highest to lowest): CLI flags > environment variables > config file > build defaults

Log level precedence: an explicit --log-level flag wins over the -v count ladder; -v/-vv/-vvv map to warn/info/debug. The resolved configuration is validated against the schema at startup, so unknown fields and violated invariants fail fast.

Viewing Configuration

# List all current configuration values
expense-approval config list

# List specific groups
expense-approval config list log
expense-approval config list fga
expense-approval config list oidc

# Describe configuration parameters with full documentation
expense-approval config describe

# Describe specific parameters
expense-approval config describe log.level fga.mode

# Show hidden/internal parameters
expense-approval config list --hidden
expense-approval config describe --hidden

Setting Configuration

# Set configuration values (saved to config file)
expense-approval config set --log.level debug
expense-approval config set --log.level info --log.format console
expense-approval config set --fga.mode external --fga.uri postgres://localhost:5432/openfga

# Use a specific config file
expense-approval --config /path/to/app.k config list

Running

# Generate SQL, templ, and FGA relation constants
task generate

# Run with built-in zitadel OIDC (no external OIDC provider needed)
task dev-auth

# Log in over real OIDC and curl authenticated endpoints
task dev-login

# Or build binary and run with serve subcommand
task build
./bin/server serve

# Run with verbose output (-v warn, -vv info, -vvv debug)
./bin/server -v serve

Migrations are applied automatically on server startup.

Environment Variables

All environment variables are prefixed with GO_TEMPLATE_. Dots in field names become underscores.

Server

Variable Default Field
GO_TEMPLATE_ADDR :8080 addr
GO_TEMPLATE_DB_PATH data/sqlite/go-template.db db.path

Logging

Variable Default Field
GO_TEMPLATE_LOG_LEVEL error log.level
GO_TEMPLATE_LOG_FORMAT json log.format

OpenFGA

Variable Default Field
GO_TEMPLATE_FGA_MODE embedded fga.mode
GO_TEMPLATE_FGA_DATASTORE sqlite fga.datastore
GO_TEMPLATE_FGA_URI file://data/fga/openfga.db fga.uri
GO_TEMPLATE_FGA_STORE_ID "" fga.store_id
GO_TEMPLATE_FGA_MODEL_ID "" fga.model_id
GO_TEMPLATE_TUPLE_FILE_PATH data/fga/tuples.json tuple.file.path

OIDC / Authentication

Variable Default Field
GO_TEMPLATE_OIDC_ISSUER "" oidc.issuer
GO_TEMPLATE_OIDC_CLIENT_ID "" oidc.client_id
GO_TEMPLATE_OIDC_CLIENT_SECRET "" oidc.client_secret
GO_TEMPLATE_OIDC_REDIRECT_URI "" oidc.redirect_uri
GO_TEMPLATE_OIDC_SUPPORTED_ALGS "" oidc.supported_algs
GO_TEMPLATE_OIDC_GROUP_SCOPE "" oidc.group_scope

Session

Variable Default Field
GO_TEMPLATE_SESSION_COOKIE_NAME session session.cookie_name
GO_TEMPLATE_SESSION_SUB_KEY sub session.sub_key
GO_TEMPLATE_SESSION_EMAIL_KEY email session.email_key
GO_TEMPLATE_SESSION_NAME_KEY name session.name_key

Note: The session.signing_key and session.secure fields were removed in the SCS migration. Session security is now handled by SCS with server-side storage in NATS JetStream KV.

Codebase Structure

cmd/server/main.go              Entry point: config -> FGA -> DB -> auth -> NATS -> router
cmd/server/dev.go               Dev build-tag setup (embedded zitadel OIDC server)
cmd/seed/main.go                Database seeding utility (demo users, groups, memberships)
tools/
  download/main.go              Downloads frontend assets (CSS, JS) from CDNs into web/
  fga-coverage/main.go          Reports FGA authorization model test coverage
  fga-generate/constants_gen.go Generates Go constants from OpenFGA model (task generate-fga)
internal/
  config/
    config.go                   Environment-based configuration with getEnv helpers
    config_test.go              Config tests
  server/
    server.go                   chi router, middleware, route registration, ExpenseRepository + ExpenseCQRS wiring
    access_logger.go            Custom access log middleware with status code + response body capture
  authn/
    config.go                   OIDC configuration with alg parsing
    session.go                  Session management (HMAC-SHA256 signed cookies, 8-hour expiry)
    handler.go                  OIDC auth code flow: Login, Callback (PKCE), Logout; upserts users to DB
    middleware.go               RequireAuth (redirect + 401 for JSON), DevAuthMiddleware (dummy alice session)
    context.go                  Context helpers: WithSession, SessionFromContext
    dev_oidc_server.go          //go:build dev — embedded zitadel OIDC server with 6 demo users
  authz/
    authorizer.go               Authorizer interface: Scope(), Check(), Enforce()
    fga_authorizer.go           Production: lists visible objects (ListObjects), Check, writes/revokes tuples
  feature/expense/
    service.go                  Application service: command orchestration (Check -> validate -> repo -> FGA -> projection)
    repository.go               SQLite persistence + approval-workflow invariants
    cqrs/                       Query service: per-entity projections, FGA scope gating, HATEOAS actions, SSE watchers
    handlers.go                 DataStar SSE handlers: ExpensesBase/Page/SSE, Create, Approve, Deny, Delete
    routes.go                   Route registration for /expenses
    types.go                    Expense domain types
    errors.go                   Domain errors: ErrExpenseNotFound, ErrAlreadyApproved, etc.
    components/
      expenses.templ            DataStar templ components: ExpensesView, ExpenseRow, ExpenseForm, FilterBar, StatusBadge
      detail.templ              Expense detail page components
      shared.templ              Shared UI components
  feature/graph/
    service.go                  Graph projection: BuildProjection + per-user FGA filtering, ECharts layout
    handlers.go                 HTTP handlers for /graph
    components/                 Graph visualization templ components
    types/                      Graph projection types
  feature/web/
    handler.go                  Web request handlers
    layouts/                    Shared layout templ components
    pages/                      Page templ components (Home)
  fga/
    server.go                   OpenFGAServer: embeds OpenFGA binary, SQLite/PostgreSQL backends, migration, store creation
    client.go                   External OpenFGA SDK client wrapper (for non-embedded mode)
    model/                      OpenFGA authorization model directory: multi-module FGA model (schema 1.2)
    model_bootstrapper.go       Ensures authorization model exists in OpenFGA
    sync_manager.go             Orchestrates multiple TupleSource implementations for concurrent sync
    tuple_source.go             TupleSource interface (Name, Sync, Stop) and TupleWriter interface
    file_source.go              File-based tuple source with fsnotify live reload
    fga_constants_gen.go        Generated Go constants for FGA types and relations
  nats/
    nats.go                     SetupNATS: embedded NATS server with JetStream KV, auto-assigns free port
  db/
    database.go                 SQLite via delaneyj/toolbelt with embedded migrations
    sqlc.yaml                   sqlc v2 config using sqlc-gen-zombiezen plugin
    seed.go                     SeedDemoData: inserts demo users, groups, memberships
    migrations/                 SQL migrations (3 forward, 3 backward)
    queries/                    SQL query files (source for sqlc)
    generated/                  Generated Go code from sqlc (CRUD, counts, reads)
web/
  assets/favicon.ico            Favicon
  assets/northstar.svg          SVG logo
  css/pico.min.css              PicoCSS framework
  datastar/datastar.js          DataStar JS library (SSE)
  echarts/echarts.js            Apache ECharts library
docs/templ-llms.md              Local copy of upstream templ docs for LLM context injection
docs/FEATURE_ADDITION.md        How to add a new feature (canonical guide)
Taskfile.yaml                   Build/dev tasks (go-task)
skaffold.yaml                   Container image build with ko (via skaffold)

Database Schema

expenses

Column Type Notes
id TEXT PRIMARY KEY UUID
title TEXT NOT NULL
amount INTEGER NOT NULL Minor units
currency TEXT Default 'USD'
submitter TEXT NOT NULL
status TEXT Default 'pending'
created_at DATETIME Row creation time
updated_at DATETIME Updated by app on each UPDATE

Indexes: status, submitter.

expense_approvals

Column Type Notes
expense_id TEXT FK to expenses(id), CASCADE delete
level_id TEXT
approved_by TEXT Nullable
rejected_by TEXT Nullable
approved_at DATETIME Nullable
rejected_at DATETIME Nullable

Composite PK on (expense_id, level_id). CHECK constraint enforces mutual exclusivity: each row is either pending (all null), approved (approved_by+approved_at set), or rejected (rejected_by+rejected_at set).

users

Column Type Notes
id TEXT PRIMARY KEY OIDC subject (sub)
email TEXT NOT NULL UNIQUE
first_name TEXT
last_name TEXT
created_at DATETIME CURRENT_TIMESTAMP

groups

Column Type Notes
id TEXT PRIMARY KEY
name TEXT NOT NULL UNIQUE
display_name TEXT
created_at DATETIME CURRENT_TIMESTAMP

group_memberships

Column Type Notes
user_id TEXT FK to users(id), CASCADE
group_id TEXT FK to groups(id), CASCADE

Composite PK on (user_id, group_id). Populated by SeedDemoData and synced via FGA tuple source.

OpenFGA Model

The embedded OpenFGA server loads its authorization model from internal/fga/model/:

fga.mod    Module manifest (schema 1.2) — lists all .fga files in load order
core.fga   Base types: user, organization, department, group, employee
conditions.fga  Custom conditions used in typed relations
approval-workflow.fga  Workflow, approval_request, approval_group, task, etc.
delegation.fga  Delegation types
notifications.fga  Notification, reminder, escalation
documents.fga  Attachment, comment
audit.fga  Audit events and decision traces
policy-rules.fga  Policy, rule, expression, decision_table, etc.
extensions.fga  Extension points for customer custom types
finances/finances.fga  Budget, vendor, contract, expense, invoice, etc.
assets/assets.fga  Asset (with kind relation for subtypes)
hr/hr.fga  Timeoff request/balance, job requisition, candidate, offer, etc.
travel/travel.fga  Trip, expense_report, corporate_card_transaction, etc.
it/it.fga  IT request (with kind relation), role_assignment, etc.
facilities/facilities.fga  Facility request (with kind relation), meeting_room, etc.
legal/legal.fga  NDA, contract_review, legal_request (with kind relation), etc.
security/security.fga  Security review, incident, remediation, etc.

Each .fga file declares a module and may extend type from other modules. The fga.mod file lists all modules in dependency order. The transformer.TransformModuleFilesToModel function merges all modules into a single authorization model, resolving extend type declarations across module boundaries.

Total: 70 types, 126 relations

The kind Tuple Pattern

To avoid exceeding OpenFGA's type limit (default: 100), narrow subtypes that share identical permission structures are merged into a single parent type using a kind relation. The subtype identity is stored as a tuple on the kind relation of the parent type.

Example: The assets.fga module defines a single asset type with a kind relation:

type asset
  relations
    define organization: [organization]
    define owner: [user]
    define admin: admin from organization
    define kind: [user]
    # ... permissions ...

Subtypes (laptop, phone, software_license) are stored as tuples on the kind relation:

# Store a laptop asset with its kind
- user: user:laptop
  relation: kind
  object: asset:laptop-001

# Store a phone asset with its kind
- user: user:phone
  relation: kind
  object: asset:phone-001

This pattern is used in the following modules:

Module Parent Type Subtypes (stored as kind tuples)
assets asset laptop, phone, software_license
travel trip flight, hotel, rental_car
it it_request access, license, vm, dns, database, vpn, cloud, firewall, ssh_key, certificate, api_key
hr timeoff_request vacation, sick_leave, personal_day
hr hr_request job_requisition, promotion, salary_adjustment, training, certification, conference, travel, remote_work
legal legal_request policy_exception, compliance_exception, risk_acceptance
security security_request pen_test
facilities facility_request parking, desk_reservation, renovation, maintenance, furniture_request, visitor_request

Querying by subtype: To check if a user can perform an action on a specific subtype, use FGA's ListUsers API with a filter on the kind relation:

// Check if bob can view the laptop with ID "laptop-001"
users, err := fgaClient.ListUsers(ctx, &openfga.ListUsersRequest{
    Type: "asset",
    Object: openfga.Object{Type: "asset", Id: "laptop-001"},
    Relation: "can_view",
    ContextualTuples: &openfga.ContextualUserTuples{
        TupleKeys: []openfga.TupleKey{
            {User: "user:laptop", Relation: "kind", Object: "asset:laptop-001"},
        },
    },
})

This approach keeps the model under the type limit while preserving the ability to distinguish subtypes at runtime.

Multi-Tenancy

This application is designed as a single deployable unit. Each deployment owns its own SQLite database and embedded OpenFGA store. To support multiple tenants, deploy separate instances — each with its own DB_PATH, FGA_URI, and FGA_STORE_ID. There is no shared database or multi-tenant routing within a single instance.

Logging

This project uses go.uber.org/zap for structured logging with named loggers for service identification.

Logger Hierarchy

root logger (cmd/serve.go)
  ├── "fga"                — FGA server and migrations
  │   └── "fga.tuple-sync" — Tuple synchronization
  ├── "database"           — SQLite database
  ├── "nats"               — NATS JetStream
  ├── "oidc"               — OIDC provider
  └── "expense"            — Expense domain
      ├── "expense.fga"    — FGA tuple processor
      └── "expense.approval" — Approval initializer processor

Adding a New Service Logger

When introducing a new service, create a named child logger at the wiring layer:

// In a feature's Register (the wiring layer)
myLogger := logger.Named("my-service")
svc := mypackage.NewService(myLogger)

Sub-components use dot-separated namespaces:

expenseLogger := logger.Named("expense")
fgaLogger := expenseLogger.Named("fga")
approvalLogger := expenseLogger.Named("approval")

Logging Patterns

// Startup/shutdown
logger.Info("service started", zap.String("port", port))
logger.Info("shutting down", zap.String("reason", reason))

// Debug traces
logger.Debug("processing request", zap.String("id", reqID))

// Errors
logger.Error("failed to connect", zap.Error(err))

// Warnings
logger.Warn("optional feature disabled", zap.String("feature", "oauth"))

Rules

  • Use zap exclusively — never log/slog
  • Accept *zap.Logger in constructors, never create loggers inside services
  • Never log secrets — passwords, tokens, keys
  • Never use bare zap.L() or zap.S() — always use injected named logger

Architecture Patterns

Event Sourcing with Chronicle

The expense domain uses chronicle for event sourcing. Expenses and approvals are modeled as aggregates with typed events stored in NATS JetStream streams.

File Purpose
internal/expense/aggregate/expense.go Expense aggregate: events, state, commands (NewExpense, Update, Approve, Deny, Delete)
internal/expense/aggregate/approval.go Approval aggregate: tracks individual approval decisions per user
internal/expense/eventlog.go NATS JetStream event log setup (one stream per aggregate)
internal/expense/fga_processor.go Transactional processor: syncs OpenFGA tuples after each expense save
internal/expense/approval_initializer.go Transactional processor: creates approval records for authorized users
internal/expense/service.go Business logic layer wrapping chronicle repositories
internal/expense/handler/handlers.go HTTP handlers using DataStar SSE for real-time updates

Architecture

┌──────────────────────────────────────────────────────────────┐
│                        Handlers                               │
│  ExpensesPage / ExpenseDetailPage / SSE streams              │
│  Approve / Deny / Create / Update / Delete                   │
└──────────────────────┬───────────────────────────────────────┘
                        │
           ┌────────────┼────────────┐
           ▼            ▼            ▼
┌──────────────┐ ┌──────────┐ ┌─────────────────┐
│ Expense      │ │ Approval │ │ FGA Processor   │
│ Aggregate    │ │ Aggregate│ │ (Transactional  │
│ Commands:    │ │ Commands │ │  Processor)     │
│ Create,      │ │ Approve, │ │ Writes FGA      │
│ Update,      │ │ Deny,    │ │ tuples in same  │
│ Approve*,    │ │ Resubmit │ │ transaction as  │
│ Deny*,       │ │          │ │ events. Ensures │
│ Delete       │ │          │ │ FGA is in sync  │
└──────┬───────┘ └────┬─────┘ └─────────────────┘
        │               │
        ▼               ▼
┌──────────────────────────────────────────┐
│       chronicle TransactionalRepository  │
│  Save() → AppendEvents (+ FGA tuples)    │
│  Get()   → Replay events → Apply()       │
│  ConflictError → retry with backoff      │
└──────────────────┬───────────────────────┘
                    │
           ┌────────┼────────┐
           ▼        ▼        ▼
┌────────────┐ ┌─────────┐ ┌────────────────┐
│ NATS       │ │ OpenFGA │ │ SSE            │
│ JetStream  │ │ tuples  │ │ handlers watch │
│ streams    │ │ source  │ │ stream subjects│
│ (per agg)  │ │ of truth│ │ for real-time  │
└────────────┘ └─────────┘ └────────────────┘

Key principle: chronicle is the source of truth. SQLite (users/groups tables) is read-only reference data. FGA tuples are derived from events via the transactional processor.

Conflict Retry

Expenses use optimistic concurrency control via chronicle's built-in version tracking. The repository is wrapped with NewEventSourcedRepositoryWithRetry (3 attempts, exponential backoff) to handle concurrent save conflicts.

SSE Real-Time Updates

SSE handlers subscribe directly to NATS stream subjects (expense.events.<id>, expense.events.>). When events are appended to an expense's stream, the subject fires and the SSE handler re-renders the view. No separate polling or KV watch layer needed.

Authorization

Authorization tuples (submitter, can_approve, can_view, can_delete) are managed by the FGA tuple processor running inside chronicle's transactional repository. After each expense save, the processor:

  1. Writes the submitter tuple derived from expense state
  2. Calls FGA ListUsers for can_approve to derive approver tuples
  3. On delete, revokes all tuples for the expense

This ensures FGA is always in sync with the event stream — no tuple drift.

Authorization Abstraction

The Authorizer interface (internal/authz/authorizer.go) abstracts authorization decisions, backed by the embedded OpenFGA server (authz.FGAAuthorizer). Mutations run an FGA Check; reads filter through a Scope (ListObjects).

FGA Tuple Sync

Seed tuples (organization, group memberships) are loaded from the YAML files in internal/fga/model/ by the embedded FGA tuple-sync machinery in ginjiruu/toolbelt/embeddedfga (TupleSource/SyncManager), which supports live reload via fsnotify.

Adding a new type or relation to the FGA model directory requires running task generate-fga to regenerate constants. Tuple validity is verified by task fga-validate and task test-fga-models using the FGA CLI.

Development Workflow

# Code generation
task generate        # Run all generators: sqlc + templ + FGA constants
task generate-sql    # Regenerate sqlc helpers
task generate-templ  # Regenerate _templ.go (templ)
task generate-fga    # Regenerate fga_constants_gen.go from the FGA model directory

# Quality
task check       # Run lint, vet, test, and build (full suite)
task lint        # Run golangci-lint
task vet         # Run go vet
task test        # Run all tests
task fmt         # Run gofmt -w
task fga-validate # Validate the OpenFGA model file

# Build
task build       # Build binary to bin/server
task build-dev   # Build with dev build tags (includes zitadel OIDC server) to bin/server-dev
task image       # Build container image with ko (via skaffold)

# Run (all commands use 'serve' subcommand)
task dev-auth    # Run with built-in zitadel OIDC server (dev build tag, full auth flow)
task dev-login   # Log into the running dev server over real OIDC and write a curl cookie jar

# Direct binary usage
./bin/server serve                    # Start server with defaults
./bin/server -v serve                    # Start server with verbose config output
./bin/server --config app.k serve        # Start server with config file
./bin/server config list              # List all config values
./bin/server config describe          # Show full config documentation
./bin/server config set --log.level debug  # Set config value

# Maintenance
task tidy        # Tidy Go modules
task seed        # Seed database with demo data
task clean       # Delete all local data (FGA, SQLite, NATS)
task clean-fga   # Delete OpenFGA data (forces model re-initialization)
task clean-sqlite # Delete SQLite database
task clean-nats  # Delete NATS JetStream data

Migrations are applied automatically on server startup.

Local OIDC Development

The dev-auth task includes a built-in OIDC provider (zitadel/oidc) compiled via the dev build tag. No external OIDC server required:

task dev-auth
# OIDC provider available at http://localhost:9998/
# App available at http://localhost:8080/
# Login with: alice/alicepass or bob/bobpass

Configure via environment variables:

Variable Default Description
OIDC_DEV_PORT 9998 Port for the dev OIDC server
OIDC_DEV_CLIENT_ID expense-approval OAuth client ID
OIDC_DEV_CLIENT_SECRET dev-client-secret OAuth client secret
OIDC_DEV_REDIRECT_URI http://localhost:8080/callback Callback URL

Documentation Notes

  • docs/templ-llms.md is kept as a local copy of upstream templ docs for LLM context injection. Do not delete even if it appears to be out-of-date documentation.

Adding a New Configuration Variable

This project uses a schema-driven configuration system: internal/config/schema.k (KCL) is the single source of truth. To add a new configuration variable:

1. Add the Attribute to the Schema

Edit internal/config/schema.k. Add the attribute to the schema that owns its prefix (create a sub-schema and compose it in Config if it needs its own group/prefix):

schema Approval:
    """Approval"""
    #@kcl:example = "managers, approvers"
    default_group: str = "managers"
    """Default group used as the approval assignee for new expenses."""

Conventions:

  • Attribute path becomes the field name (fga: FGAfga.*).
  • Schema docstrings ("""...""") become groups; attribute docstrings FOLLOW the attribute and their first line becomes the description.
  • #@kcl: directives PRECEDE the attribute and carry Go-only metadata: example, hidden = "true", validate = "ValidateFGAURI", type = "duration".
  • KCL types map to field types: str→string, bool→bool, int→int, float→float; literal unions ("a" | "b") become ValidValues. Put cross-field invariants in a check: block.

2. Regenerate

task generate-kcl   # rewrites internal/config/fields_gen.go + config.gen.go

3. Use the Configuration

The Go identifier is derived from the field name (fga.store_idFieldFgaStoreId):

import "git.animeteamspeak.moe/ginjiruu/go-template/internal/config"

myValue := config.ReadFieldString(config.FieldMyNewSetting)
myBool := config.ReadFieldBool(config.FieldMyNewSettingBool)
myInt := config.ReadFieldInt(config.FieldMyNewSettingInt)

4. (Optional) Custom Validation

For one-field validation, reuse a toolbelt validator via a #@kcl:validate = "ValidateX" directive (the function must live in the config package). For invariants spanning several fields, add a check: block to the owning schema — it is enforced at startup for every config source.

5. Verify

# List all config (your field should appear)
./bin/server config list

# Describe your field (should show docs, defaults, examples)
./bin/server config describe my.new

# Set your field
./bin/server config set --my.new.setting new-value

# Use environment variable
GO_TEMPLATE_MY_NEW_SETTING=from-env ./bin/server config list

Field Types and Defaults

KCL type Field Type Env Var Example
str string GO_TEMPLATE_MY_SETTING
bool bool GO_TEMPLATE_MY_SETTING
int int GO_TEMPLATE_MY_SETTING
float float GO_TEMPLATE_MY_SETTING
str + #@kcl:type = "duration" duration GO_TEMPLATE_MY_SETTING

Groups

Organize fields into logical groups using the existing group constants:

  • GroupServer - Server/listening configuration
  • GroupLogging - Logging configuration
  • GroupFGA - OpenFGA authorization configuration
  • GroupOIDC - OIDC authentication configuration
  • GroupSession - Session/cookie configuration
  • GroupDev - Development mode configuration

Add a new group constant if needed:

const GroupMyGroup = "My Group"

Code Conventions

Generated Code (zz/)

  • Once* list functions return ([]*Model, error) — slice of pointers, not pointer to slice.
  • Generated ReadByID* and Delete* functions in crud_main_*.go use int64 for the id parameter — this is a codegen mismatch with TEXT primary keys. The other generated files (read_expense_by_id.go, delete_expense_by_id.go, etc.) correctly use string for IDs. Do not regenerate crud_main_*.go files without fixing the codegen plugin.
  • Nullable columns map to Go pointers: *string for TEXT nullable, *time.Time for DATETIME nullable, *int64 for INTEGER nullable.

Database Access

  • Use toolbelt/db.Database transaction helpers: db.ReadTX(ctx, fn) and db.WriteTX(ctx, fn).
  • Inside transactions, the callback receives a *sqlite.Conn.
  • Use zz.Once* functions for all database operations.

OpenFGA

  • Use internal/fga.OpenFGAServer for embedded OpenFGA operations.
  • OpenFGAServer.Check(ctx, Tuple) performs authorization checks.
  • OpenFGAServer.Write(ctx, tuples, ignoreExisting) writes tuples to OpenFGA.
  • OpenFGAServer.Close() shuts down the embedded server.

FGA Relations (Generated Constants)

FGA relation names are defined in internal/fga/model/ and compiled to Go constants via task generate-fga. Always use the generated constants instead of hardcoded strings:

// Correct — compile-time verified:
Relation: fga.ExpenseCanView

// Wrong — runtime bug if the model changes:
Relation: "can_view"

Constants are grouped by type: TypeUser, TypeOrganization, TypeExpense, TypeBudget, etc. for object types, and ExpenseCanView, BudgetCanApprove, GroupMember, etc. for relations. The fga.ValidRelations map contains all valid relation strings across all 70 types and 126 relations defined in the model.

If you add a new type or relation to the FGA model directory, run task generate-fga to regenerate the constants. Tuple validity is verified by task fga-validate and task test-fga-models using the FGA CLI.

Field Naming

  • Generated types use CamelCase with lowercase first letter: ModelId, ExpenseId, LevelId.
  • All handler references must use the generated field names.