- Go 94.9%
- templ 4.2%
- Shell 0.9%
| .agents/skills | ||
| .githooks | ||
| .opencode/skills/live-reload-server | ||
| .task/checksum | ||
| cmd | ||
| configs | ||
| docs | ||
| internal | ||
| test | ||
| testdata | ||
| tools | ||
| web | ||
| .air.toml | ||
| .fga.yaml | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| ARCHITECTURE.md | ||
| CONTEXT.md | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
| skaffold.yaml | ||
| skills-lock.json | ||
| Taskfile.yaml | ||
VTT
A virtual table top built on the shared Go platform: embedded NATS, embedded
OpenFGA, embedded SQLite (sqlc), chi, SCS sessions backed by NATS KV, and
DataStar SSE projections. The DOMAIN event log is the source of truth; SQLite
and NATS KV are disposable projections. The shipped reference feature is the
campaign lifecycle (guild/campaign FGA model, SSE views, and
checkpoint/rewind). See ARCHITECTURE.md for the full design.
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: Campaign Lifecycle
A single deployable Go application that runs a tabletop RPG campaign service: create campaigns, join as players, manage them, and watch live updates over SSE. Authorization is enforced with an embedded OpenFGA server; SQLite keeps campaign history; a NATS JetStream DOMAIN log is the source of truth.
This is not a microservice. It owns its database, authorization store, and web UI in one process. For multi-tenancy, deploy separate copies — each instance manages its own data and authorization model independently.
Architecture
┌─────────────┐ HTTP + SSE (DataStar) ┌──────────────────┐
│ Browser │ ◄───────────────────────► │ go-template │
│ (PicoCSS) │ │ server │
└─────────────┘ └────────┬─────────┘
│
DOMAIN log (JetStream) ───────┤
│ projectors
┌────────────────────┼────────────────────┐
▼ ▼ ▼
SQLite OpenFGA NATS KV
(campaign history) (authorization) (read model / SSE)
- Write path: handlers publish full-state events to the DOMAIN log (the commit point, with optimistic concurrency control). Services never write SQLite, FGA, or KV directly.
- Projection: one durable consumer per feature applies each event in a fixed order — FGA, then SQLite, then the KV read model. The KV write is last, so an SSE update always implies committed authorization.
- Real-time state: NATS JetStream KV read model, streamed to the browser via DataStar Server-Sent Events (SSE).
- Authorization: embedded OpenFGA server (no external dependency),
model defined in
internal/fga/model/. - Logging: go.uber.org/zap with configurable JSON or console output.
Tech Stack
| Component | Library |
|---|---|
| HTTP framework | github.com/go-chi/chi/v5 |
| HTML templating | github.com/a-h/templ |
| CSS framework | PicoCSS (local copy) |
| 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 (dev) | github.com/zitadel/oidc/v3 (embedded zitadel OIDC server) |
| Logging | go.uber.org/zap |
| Config | github.com/spf13/viper |
| Validation | github.com/go-playground/validator/v10 |
Routes
Public (no auth)
GET / Home page
GET /health Health check
GET /web/* Static assets (CSS, JS)
Authentication (OIDC)
GET /login Redirect to OIDC provider (or dev OIDC)
GET /callback OIDC callback — exchanges code for tokens, creates session cookie
GET /logout Clears the session cookie
Campaigns (authenticated)
GET /campaigns and GET /campaigns/{id} return an HTML page on first load
and switch to a Server-Sent Events (SSE) stream when requested with
Accept: text/event-stream. Mutations are DataStar @post actions.
GET /campaigns List page or SSE stream
GET /campaigns/{id} Detail page or SSE stream
POST /campaigns Create campaign (DataStar signal: name)
POST /campaigns/{id}/join Join a campaign
POST /campaigns/{id}/leave Leave a campaign
POST /campaigns/{id}/end End a campaign (GM/owner)
POST /campaigns/{id}/checkpoint Snapshot KV state (returns the tick)
POST /campaigns/{id}/rewind/{tick} Restore state as of a tick
Setup
Prerequisites
- Go 1.26+
- Taskfile runner (
task) - FGA CLI (
fga) for model validation (task fga-validate)
Configuration
All configuration is managed through a Field-Driven Configuration system.
Each config parameter is defined as a Field struct containing its name, type,
default value, validation rules, and documentation. Configuration can be set
via:
- Build defaults (hardcoded in
internal/config/fields_app.go) - Config file (YAML/JSON/TOML, passed via
--configflag) - Environment variables (prefixed with
GO_TEMPLATE_, dots replaced with underscores) - CLI flags (e.g.,
--log.level debug)
Precedence (highest to lowest): CLI flags > environment variables > config file > build defaults
Viewing Configuration
# List all current configuration values
./bin/server config list
# List specific groups
./bin/server config list log
./bin/server config list fga
./bin/server config list oidc
# Describe configuration parameters with full documentation
./bin/server config describe
# Describe specific parameters
./bin/server config describe log.level fga.mode
# Show hidden/internal parameters
./bin/server config list --hidden
./bin/server config describe --hidden
Setting Configuration
# Set configuration values (saved to config file)
./bin/server config set --log.level debug
./bin/server config set --log.level info --log.format console
./bin/server config set --fga.mode external --fga.uri postgres://localhost:5432/openfga
# Use a specific config file
./bin/server --config /path/to/config.yaml 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
./bin/server --verbose 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 |
:9080 |
addr |
GO_TEMPLATE_DB_PATH |
data/sqlite/go-template.db |
db.path |
Logging
| Variable | Default | Field |
|---|---|---|
GO_TEMPLATE_LOG_LEVEL |
info |
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.
Guild / NATS
| Variable | Default | Field |
|---|---|---|
GO_TEMPLATE_GUILD_ID |
default |
guild.id |
GO_TEMPLATE_NATS_STORE_DIR |
data/nats |
nats.store_dir |
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
tools/
devlogin/main.go Dev OIDC login helper (writes a curl cookie jar)
download/main.go Downloads frontend assets (CSS, JS) from CDNs into web/
internal/
app/app.go Composition root: features + FGA model + migrations (stays tiny)
config/ Field-driven configuration framework + app fields
authn/ OIDC auth, sessions, dev OIDC server
authz/ Authorizer interface + FGA authorizer (Scope/Check/BatchCheck)
feature/campaign/
service.go Application service: FGA Check -> validate -> publish to DOMAIN log
projectors.go Single ordered projector: FGA -> SQLite -> KV read model
repository.go SQLite persistence (idempotent, transactional applies)
rewind.go Checkpoint/rewind subsystem (KV snapshots + forward replay)
handlers.go DataStar SSE handlers: list/detail pages + SSE streams, mutations
routes.go Route registration for /campaigns
cqrs/ Query service: KV read model, FGA scope gating, HATEOAS actions
components/ DataStar templ components (CampaignsView, CampaignRow, CampaignForm)
feature/web/ Home page and static assets
fga/ Embedded FGA model files + generated relation constants
db/ SQLite database, migrations, sqlc queries + generated code
web/
assets/ Favicon, logo
css/pico.min.css PicoCSS framework
datastar/datastar.js DataStar JS library (SSE)
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)
ARCHITECTURE.md Full system design (event sourcing, projections, rewind)
Taskfile.yaml Build/dev tasks (go-task)
skaffold.yaml Container image build with ko (via skaffold)
Database Schema
campaigns
| Column | Type | Notes |
|---|---|---|
id |
TEXT PRIMARY KEY | UUID |
guild_id |
TEXT NOT NULL | The guild (tenant) the campaign belongs to |
name |
TEXT NOT NULL | |
gm_id |
TEXT NOT NULL | The game master (adventurer id) |
status |
TEXT NOT NULL | active / ended |
players_json |
TEXT NOT NULL | JSON array of players |
created_at |
DATETIME NOT NULL | Row creation time |
updated_at |
DATETIME NOT NULL | Updated by app on each UPDATE |
Indexes: guild_id, status.
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 NOT NULL | CURRENT_TIMESTAMP |
OpenFGA Model
The embedded OpenFGA server loads its authorization model from
internal/fga/model/. The model is intentionally minimal — three types:
fga.mod Module manifest (schema 1.2) — lists core.fga + campaign.fga
core.fga Base types: adventurer, guild
campaign.fga The game unit: campaign relations
core-tuples.yaml Seed tuples: guild:default owner/member adventurers
The guild is the tenant (owner/member, can_create_campaign); the campaign
is the game unit (gm/player, re-exporting can_manage/can_view/can_play,
plus can_join/can_delete/can_leave). See internal/fga/model/README.md.
Total: 3 types, 16 relations.
Always use the generated constants in internal/fga/fga_constants_gen.go
(fga.CampaignRef(id), fga.CampaignCanView, fga.GuildCanCreateCampaign,
...) instead of string literals. Regenerate them with task generate-fga
whenever the model changes.
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/server/main.go)
├── "fga" — FGA server and migrations
│ └── "fga.tuple-sync" — Tuple synchronization
├── "database" — SQLite database
├── "nats" — NATS JetStream
├── "oidc" — OIDC provider
└── "campaign" — Campaign feature
├── "campaign.repo" — Campaign repository (SQLite writes)
├── "campaign.cqrs" — Campaign CQRS (NATS KV read model)
├── "campaign.projector" — Single projection pipeline (FGA → SQLite → KV)
├── "campaign.rewind" — Checkpoint/rewind subsystem
└── "campaign.service" — Application service
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:
featureLogger := logger.Named("my-feature")
repoLogger := featureLogger.Named("repo")
projectorLogger := featureLogger.Named("projector")
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 Projections
The campaign feature follows the platform's event-sourced CQRS model. The DOMAIN log (NATS JetStream) is the source of truth; SQLite and the KV read model are disposable projections rebuilt by replaying the log.
| Piece | Purpose |
|---|---|
events.Publisher |
The only write path: publishes full-state events with CAS |
projector.Projector |
Durable consumer with explicit acks; resumes from its ack floor |
ApplyCampaign |
Single ordered apply: FGA → SQLite → KV read model (idempotent) |
RewindService |
Checkpoint + forward replay for undo/redo |
Why one projector (not one per store)
The KV read model is the SSE source, and its rows are gated by FGA
(can_view). Two independent projectors would let the KV write race the FGA
write, so an SSE re-render could show a campaign before its authorization
tuples exist. A single consumer applying FGA → SQLite → KV in that order makes
the ordering guaranteed: a KV notification always implies committed
authorization. See ARCHITECTURE.md §3.
SSE Real-Time Updates
SSE handlers render the current KV read model as an initial DataStar patch,
then watch the campaigns KV bucket (WatchAll for the list, Watch({id})
for details). On every change they re-read the KV read model, re-render the
view, and push one HTML fragment patch. Client disconnects are handled
gracefully.
Conflict Retry
Mutations use optimistic concurrency control per aggregate subject
(Nats-Expected-Last-Subject-Sequence). A concurrent write surfaces 409 Conflict; the client resubmits.
Authorization
Permission checks happen in the service layer before any publish, via
authorizer.Check (single object) or authorizer.Scope (list rendering).
FGA tuples are written/revoked only by the projector, never by services.
HATEOAS actions shown in views come from item.Actions, computed in the CQRS
layer from the can_* relations plus resource state.
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
# Live development
task live:auth # Run templ + air watchers with hot reload (full auth flow)
# 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 --verbose serve # Start server with verbose config output
./bin/server --config app.yaml 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:9080/
# 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 |
go-template |
OAuth client ID |
OIDC_DEV_CLIENT_SECRET |
dev-client-secret |
OAuth client secret |
OIDC_DEV_REDIRECT_URI |
http://localhost:9080/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 Field-Driven Configuration system. All configuration is defined declaratively as Field structs. To add a new configuration variable:
1. Define the Field
Edit internal/config/fields_app.go and add a new var declaration for your field:
var FieldMyNewSetting = &Field{
Name: "my.new.setting", // Dot-separated key (becomes MY_NEW_SETTING env var)
Group: GroupServer, // Logical group for organization
Type: FieldTypeString, // string, bool, int, duration
Default: config.DefaultString("default-val"), // Use type-specific default helpers
Description: "What this setting does.",
Example: "value1, value2",
// Optional:
ValidValues: []any{"opt1", "opt2"}, // Enum validation
ValidateTag: "url", // go-playground/validator tag
Hidden: true, // Hide from `config list` (useful for secrets)
Shorthand: "m", // Short flag (e.g., -m)
Docstring: "Detailed documentation...", // Multi-line docs for `config describe`
}
2. Register the Field
Add your field to the init() function in fields_app.go:
func init() {
Register(
// ... existing fields ...
FieldMyNewSetting,
)
}
3. (Optional) Add a Default Variable
If the default value should be overridable at build time via -ldflags, add it to the var block at the top of fields_app.go:
var (
// ... existing defaults ...
DefaultMyNewSetting = "default-val"
)
Then use it in your field definition:
Default: config.DefaultString(DefaultMyNewSetting),
4. (Optional) Add a Validator
Validators live in ginjiruu/toolbelt/config and are re-exported by
internal/config (ValidateURI, ValidateFGAURI, ValidateLogLevel,
ValidateLogFormat, ValidateDuration, ValidateBoolString,
ValidateConfigFile). Use them via ValidateTag or ValidateFunc.
5. Use the Configuration
In your code, read the value using the appropriate ReadField* function:
import "git.animeteamspeak.moe/ginjiruu/go-template/internal/config"
// In your server startup or handler:
myValue := config.ReadFieldString(config.FieldMyNewSetting)
myBool := config.ReadFieldBool(config.FieldMyNewSettingBool)
myInt := config.ReadFieldInt(config.FieldMyNewSettingInt)
6. Verify
Run the following to verify your new config variable:
# 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
| Type | Default Helper | Env Var Example |
|---|---|---|
string |
config.DefaultString(val) |
GO_TEMPLATE_MY_SETTING |
bool |
config.DefaultBool("true") |
GO_TEMPLATE_MY_SETTING |
int |
config.DefaultInt("42") |
GO_TEMPLATE_MY_SETTING |
duration |
config.DefaultDuration("15m") |
GO_TEMPLATE_MY_SETTING |
Groups
Organize fields into logical groups using the existing group constants:
GroupServer- Server/listening configurationGroupFGA- OpenFGA authorization configurationGroupOIDC- OIDC authentication configurationGroupSession- Session/cookie configurationGroupNATS- NATS JetStream configurationGroupGuild- Guild/tenant configuration
Add a new group constant if needed:
const GroupMyGroup = "My Group"
Code Conventions
Database Access
- Use
toolbelt/db.Databasetransaction helpers:db.ReadTX(ctx, fn)anddb.WriteTX(ctx, fn). - Inside transactions, the callback receives a
*sqlite.Conn. - Use the generated
db/generatedOnce*functions for all database operations. - Never access config via struct fields — always
config.ReadField*(config.FieldXxx). - Never use
config.Load()— it no longer exists; useconfig.Init()(called by cobra).
OpenFGA
- The embedded OpenFGA server lives in
ginjiruu/toolbelt/embeddedfga. fgaServer.Check(ctx, Tuple)performs authorization checks.fgaServer.Write(ctx, tuples, ignoreExisting)writes tuples to OpenFGA.fgaServer.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.CampaignCanView
// Wrong — runtime bug if the model changes:
Relation: "can_view"
Constants are grouped by type: TypeAdventurer, TypeGuild, TypeCampaign
for object types, and CampaignCanView, CampaignCanManage,
GuildCanCreateCampaign, etc. for relations. 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.
Field Naming
- Generated FGA constants use CamelCase:
CampaignRef,AdventurerRef,GuildRef. - Database model fields use the sqlc-generated naming (
ModelId, etc.); all handler references must use the generated field names.