A contract-first REST API for an experimental Go game web server. Players use the
API to write their own clients, so the API contract is the product — there is
no official frontend. api/openapi.yaml is the source of truth for the transport
contract; the Go server is generated from it and implements the behavior behind it.
The stack is deliberately boring and inspectable:
- Go standard-library
net/http(Go 1.22+ pattern routing) - OpenAPI 3.0.3 as the public API contract
oapi-codegenfor generated DTOs and a strict server interface- JWT Bearer authentication, with application-level authorization in Go
- SQLite (pure-Go
zombiezen.com/go/sqlite) with append-only migrations
RPC, gRPC, Connect, and GraphQL are intentionally out of scope. REST is preferred because players can exercise it with common HTTP tools and write clients in any language — Go, Python, JavaScript, shell, a spreadsheet — without adopting an RPC toolchain. The contract and its examples should be clear enough to authenticate, manage games and rosters, and fetch turns without reading server source.
Authentication, account administration, and game management — creating games,
their lifecycle (draft → recruiting → active → paused → complete → archived),
and roster/membership management — are implemented and in the MVP. The game
engine (turns, order validation, and submission) is intentionally still
stubbed: those routes answer 501 Not Implemented. The OpenAPI spec's
info.description walks through the create → recruit → activate workflow, and the
game-management authorization model (roles, status chain, visibility) is
documented in CLAUDE.md.
Agents working in this repo should also read CLAUDE.md, which covers
the architecture internals and coding conventions in more depth.
api/openapi.yaml Public API contract (source of truth)
api/oapi-codegen.yaml oapi-codegen configuration
internal/api/ Generated code only (do not hand-edit)
internal/handlers/ Adapts HTTP/API DTOs to services; implements the strict server
internal/auth/ JWT issue/verify + bearer middleware
internal/store/ Typed data-access layer over the SQLite pool
internal/database/ Database create/open + append-only migrations
internal/cli/ game-server command tree (serve + database/account/game verbs)
cmd/game-server/ Thin process shell over internal/cli
cmd/earl/ curl-like smoke-testing client for a running server
docs/ curl examples, MVP status, background notes
make install-tools # one-time: go install oapi-codegen
make generate # regenerate internal/api/openapi.gen.go from the spec
make test # go test ./...
make build # build to bin/game-server
make run # run the serverThe server listens on :9987 by default. For a live-rebuild dev loop, run air
(config in .air.toml), which also serves on :9987.
curl http://localhost:9987/healthz
curl http://localhost:9987/openapi.yamlSee docs/curl-examples.md for login and authenticated request examples.
cmd/game-server is an ff-based command
tree. With no subcommand it runs the server. Subcommands:
game-server version # print the version
game-server database create <PATH> # create ecv4.db in an existing dir (or :memory: to verify migrations)
game-server database account create --email <e> [--is-admin] [--is-inactive] [--secret <s>]
game-server database account update --email <e> [--is-active[=false]] [--is-admin[=false]] [--secret <s> | --generate-secret]
game-server database account reset-password --email <e> [--secret <s> | --generate-secret] # generates one if omitted
game-server database account list # print all accounts (id, active, admin, email); read-only, no server needed
game-server database game create --code <CODE> --name <name> [--description <text>] # create a game (draft, active)
game-server database game list # print all games incl. hidden (id, active, status, code, name); read-only
game-server database game add-member --code <CODE> --email <e> [--handle <h>] [--is-gm] # seed a roster member
game-server database game assign-gm --code <CODE> --email <e> [--handle <h>] # add a GM (alias for add-member --is-gm)The database game verbs are an offline admin bootstrap — they seed games and
rosters directly against the database file with no running server and no
authorization gate (only store-level integrity is enforced), the direct-DB analog
of database account create.
The shared --development flag enables the POST /admin/shutdown route when
serving and seeds a known admin when used with database create. The separate
--allow-openapi-docs flag serves the embedded Swagger UI at /docs when
serving.
Config comes from flags or ECV4_-prefixed environment variables. .env files
load before flags are parsed, selected by ECV4_ENV (default development). Key
variables:
ECV4_DB_DIR— directory holdingecv4.dbECV4_JWT_SECRET— HMAC signing key, must be ≥32 bytes for HS256. Required whenECV4_ENV=production— startup fails if it is unset there. In any other environment an unset secret yields a random ephemeral one that invalidates all tokens on restart.ECV4_DEVELOPMENT,ECV4_DEVELOPMENT_ADMIN_EMAIL,ECV4_DEVELOPMENT_ADMIN_SECRET— control development mode and the optional seeded admin.
The OpenAPI file is edited first, then code is regenerated to match:
$EDITOR api/openapi.yaml # 1. change the contract (keep operationId names stable)
make generate # 2. regenerate transport code
go test ./... # 3. fix handler compile errors and implement behavior
# 4. commit the contract and generated code togetherCommitting generated code alongside the spec keeps API diffs easy to review.
The contract declares Bearer JWT auth (Authorization: Bearer <token>), enforced
by middleware that verifies the token and attaches claims to the request context.
The spec's security requirements are the single source of truth for which routes
need a token; public routes opt out with security: [].
Authentication proves who you are; authorization is object-level and lives in handlers/services, not in generated code or the middleware:
- Can this user see this game?
- Can this user submit orders for this faction?
- Can this GM close this turn?
Access tokens are short-lived (15m); refresh tokens (24h) are persisted, rotated on
/auth/refresh, and revoked on /auth/logout. Presenting an already-rotated
refresh token revokes the whole token family as a theft signal.
See LICENSE. Copyright © 2026 Michael D Henderson.