No description
  • Go 94.9%
  • templ 4.2%
  • Shell 0.9%
Find a file
2026-08-03 22:25:08 -04:00
.agents/skills Initial commit 2026-08-03 19:12:49 +00:00
.githooks chore: enforce conventional commits with a commit-msg hook 2026-08-03 21:23:41 -04:00
.opencode/skills/live-reload-server docs: remove stale expense-template references 2026-08-03 21:18:47 -04:00
.task/checksum feat(campaign): TTRPG domain naming + FGA-gated HATEOAS actions 2026-08-03 20:19:13 -04:00
cmd fix(config): Update webui running port 2026-08-03 20:27:18 -04:00
configs fix(config): Update webui running port 2026-08-03 20:27:18 -04:00
docs chore: add agent configuration files for issue tracking in forge 2026-08-03 22:25:08 -04:00
internal Simplify architecture to resolve race condition 2026-08-03 21:14:42 -04:00
test feat(campaign): TTRPG domain naming + FGA-gated HATEOAS actions 2026-08-03 20:19:13 -04:00
testdata Initial commit 2026-08-03 19:12:49 +00:00
tools fix(config): Update webui running port 2026-08-03 20:27:18 -04:00
web initial refactor 2026-08-03 16:21:30 -04:00
.air.toml Initial commit 2026-08-03 19:12:49 +00:00
.fga.yaml Initial commit 2026-08-03 19:12:49 +00:00
.gitignore Initial commit 2026-08-03 19:12:49 +00:00
.golangci.yml Initial commit 2026-08-03 19:12:49 +00:00
AGENTS.md chore: add agent configuration files for issue tracking in forge 2026-08-03 22:25:08 -04:00
ARCHITECTURE.md Simplify architecture to resolve race condition 2026-08-03 21:14:42 -04:00
CONTEXT.md chore: add agent configuration files for issue tracking in forge 2026-08-03 22:25:08 -04:00
go.mod feat(campaign): TTRPG domain naming + FGA-gated HATEOAS actions 2026-08-03 20:19:13 -04:00
go.sum feat(campaign): TTRPG domain naming + FGA-gated HATEOAS actions 2026-08-03 20:19:13 -04:00
README.md docs: remove stale expense-template references 2026-08-03 21:18:47 -04:00
skaffold.yaml Initial commit 2026-08-03 19:12:49 +00:00
skills-lock.json Initial commit 2026-08-03 19:12:49 +00:00
Taskfile.yaml chore: enforce conventional commits with a commit-msg hook 2026-08-03 21:23:41 -04:00

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:

  1. Build defaults (hardcoded in internal/config/fields_app.go)
  2. Config file (YAML/JSON/TOML, passed via --config flag)
  3. Environment variables (prefixed with GO_TEMPLATE_, dots replaced with underscores)
  4. 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.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 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.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 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 configuration
  • GroupFGA - OpenFGA authorization configuration
  • GroupOIDC - OIDC authentication configuration
  • GroupSession - Session/cookie configuration
  • GroupNATS - NATS JetStream configuration
  • GroupGuild - Guild/tenant configuration

Add a new group constant if needed:

const GroupMyGroup = "My Group"

Code Conventions

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 the generated db/generated Once* functions for all database operations.
  • Never access config via struct fields — always config.ReadField*(config.FieldXxx).
  • Never use config.Load() — it no longer exists; use config.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.