- JavaScript 91.8%
- Go 7.5%
- templ 0.5%
- KCL 0.2%
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. |
||
|---|---|---|
| .agents/skills | ||
| .opencode/skills/live-reload-server | ||
| .task/checksum | ||
| api | ||
| cmd | ||
| configs | ||
| docs | ||
| internal | ||
| test | ||
| testdata | ||
| tools | ||
| web | ||
| .air.toml | ||
| .fga.yaml | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| go.mod | ||
| go.sum | ||
| kcl.mod | ||
| README.md | ||
| skaffold.yaml | ||
| skills-lock.json | ||
| Taskfile.yaml | ||
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/toolbeltwith 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:
- Build defaults (from the schema, overridable with
-ldflags -X) - Config file (
--config app.k, seeconfigs/app.example.k) - Environment variables (prefixed with
GO_TEMPLATE_, dots replaced with underscores) - 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.Loggerin constructors, never create loggers inside services - Never log secrets — passwords, tokens, keys
- Never use bare
zap.L()orzap.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:
- Writes the
submittertuple derived from expense state - Calls FGA
ListUsersforcan_approveto derive approver tuples - 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.mdis 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: FGA→fga.*). - 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") becomeValidValues. Put cross-field invariants in acheck: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_id → FieldFgaStoreId):
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 configurationGroupLogging- Logging configurationGroupFGA- OpenFGA authorization configurationGroupOIDC- OIDC authentication configurationGroupSession- Session/cookie configurationGroupDev- 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*andDelete*functions incrud_main_*.gouseint64for theidparameter — 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 usestringfor IDs. Do not regeneratecrud_main_*.gofiles without fixing the codegen plugin. - Nullable columns map to Go pointers:
*stringfor TEXT nullable,*time.Timefor DATETIME nullable,*int64for INTEGER nullable.
Database Access
- Use
toolbelt/db.Databasetransaction helpers:db.ReadTX(ctx, fn)anddb.WriteTX(ctx, fn). - Inside transactions, the callback receives a
*sqlite.Conn. - Use
zz.Once*functions for all database operations.
OpenFGA
- Use
internal/fga.OpenFGAServerfor 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.