- JavaScript 52.7%
- Go 31.4%
- CSS 14.1%
- Nix 0.5%
- HTML 0.5%
- Other 0.8%
| cmd/lantern | ||
| db/queries | ||
| internal | ||
| k8s | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
| sqlc.yaml | ||
Lantern
A branching survey application for TTRPG groups. Go + Fiber v3, PostgreSQL, sqlc, Goose, and native web components. Frontend assets and migrations are embedded in one binary; PostgreSQL runs separately.
Local development
Install devenv with Nix, then:
cp .env.example .env
# Add your Google OAuth credentials to .env.
devenv up
Open http://localhost:8080. devenv up starts PostgreSQL 16 on loopback
port 55432, waits for it to become healthy, and starts the app. Migrations
run on app startup. Stop the foreground process with Ctrl-C, or use
devenv down after starting with devenv up -d.
The exact Go, Bun, sqlc, Goose, PostgreSQL, and Prettier packages are pinned by
devenv.lock. Go's automatic toolchain download is disabled. The browser runs
native ES modules: there is no frontend build or npm install. Bun runs the
frontend unit tests; Prettier is provided directly by Nix.
To enter the tool environment:
devenv shell
make generate # regenerate Go queries after editing SQL
make format # gofmt and Prettier
make check # go vet, sqlc vet, and frontend formatting check
make test # Go race tests and Bun frontend tests
make test-integration # needs the devenv PostgreSQL service running
make build # bin/lantern, with assets and migrations embedded
After changing Go or browser code, restart the server process to rebuild its
embedded assets: devenv processes restart server. There is no live-reload
server or authentication bypass.
Google sign-in
Create a Google OAuth client of type Web application. Register:
- Local redirect:
http://localhost:8080/auth/google/callback - Production redirect:
https://YOUR_HOST/auth/google/callback
Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env locally and your
secret manager in production. Configure Google's consent screen/audience for
the intended users; a Google app in testing mode may restrict access to test
accounts. Lantern itself allows any successfully authenticated Google account
to create surveys.
Only the stable Google subject identifier is stored for creators, not a password, profile, email address, or Google access token. Without credentials, the landing page is usable but creator sign-in is unavailable.
Color scheme
Light evokes a warm day with cream surfaces, golden yellow highlights, and burnt-orange accents. Dark evokes night with deep navy surfaces, cool blues, and aqua accents. The palette applies to controls, cards, results, and the landing illustration, not just the page background.
Click the header's sun/moon icon to reveal System, Light, and Dark choices; the menu stays hidden otherwise. It closes after a selection, when clicking outside it, or with Escape, and supports keyboard navigation. The icon reflects the current light/dark appearance, including when following the system. The control is available to creators and anonymous respondents. System is the default and follows OS color-scheme changes immediately. Explicit choices persist in browser-local storage and are shared across tabs, not stored on a creator account. If storage is unavailable, switching still works for the current page.
The preference is applied before styles load to avoid a light/dark flash. Changing it never reloads the page or discards draft edits or survey answers.
Project layout
cmd/lantern/ startup, graceful shutdown, JSON slog configuration
internal/config/ environment parsing and validation
internal/httpapp/ Fiber routes, auth, creator and respondent handlers
internal/domain/ survey graph and answer validation; no HTTP or SQL
internal/db/ sqlc-generated types and queries — do not edit by hand
db/queries/ SQL query source of truth
internal/migrations/ embedded, versioned Goose migrations and runner
web/app.js component registration only
web/core.js shared API, HTML escaping, formatting helpers
web/theme.js early browser-local color-scheme preference
web/theme.css semantic light/dark palette for all components
web/autosave.js debounced, serialized draft persistence
web/draft-questions.js question-editing operations for incomplete drafts
web/survey-api.js separate real-response and preview API adapters
web/survey.js pure browser-side path and answer checks
web/components/ focused native components and view templates
web/tests/ Bun unit tests
k8s/ example Kustomize base for GitOps overlays
Components use light DOM intentionally, so shared semantic controls and styling
stay simple. HTML templates escape user-supplied values with esc(). Split
stateful behavior from larger rendering templates rather than growing a
single application script. Comments explain module responsibilities and
non-obvious invariants; generated code stays untouched.
Database development
All application queries live in db/queries/; sqlc.yaml generates typed Go
methods in internal/db/. Schema changes are versioned Goose SQL migrations,
which sqlc also reads as its schema source. Do not put ad-hoc SQL in handlers.
Inside devenv shell:
goose -dir internal/migrations/sql create describe_change sql
# Edit the new migration, then update queries as needed.
make generate
make migrate
make migration-status
The embedded Goose runner uses a PostgreSQL advisory lock, so replicas can start concurrently. The runtime database role needs permission to apply migrations. Existing versioned migrations must not be edited once applied; add a new migration instead. Database backups remain the operator's responsibility, including recovery of soft-deleted data.
Survey behavior
- Draft edits autosave after a short pause (400 ms). A visible status confirms when changes are saved; failed saves keep the local edits and offer a retry. Pending saves are drained before preview, publication, duplication, and normal in-app navigation. Unsaved changes still trigger a browser warning on reload or leaving the page. Wait for All changes saved before switching devices.
- Incomplete or invalid definitions can be saved and reopened on another device. Preview and publication validate the definition and show errors to fix first.
- Preview draft is creator-only and available only while a survey is a draft. It uses the saved draft and the real branching/review UI. Submit preview validates answers but never inserts a response, changes survey status, creates a duplicate, or issues a respondent cookie. Test runs can be repeated freely.
- Publishing validates and freezes the question definition permanently.
- Supported answers: single choice, multiple choice, free text, numbers, and individual ratings. There are no file uploads.
- Single- and multiple-choice answers can have an optional plain-text description. Nonblank descriptions appear beneath their labels in surveys and draft previews; missing, empty, or whitespace-only descriptions are hidden. Descriptions autosave with the draft and are preserved by duplication and publication.
- Only single-choice options branch. An option's destination overrides the question's default destination. Otherwise, questions advance in order.
- Destinations point forward, so paths can split and rejoin without cycles.
End surveyfinishes a path explicitly. - Respondents can go back and review. Inactive branch answers stay only in page memory in case they switch back. The server independently follows the published path and discards inactive answers on final submission.
- There is no save/resume, submission editing, or respondent account.
- A random, HttpOnly, per-survey cookie limits submission to once per browser. The database uniqueness constraint also prevents concurrent double submits. Clearing cookies or using another browser bypasses this by design. Players sharing the same browser also share that response limit.
- Only the owning creator can read aggregate results and individual anonymous submissions. Multiple-choice percentages use people who answered that question as the denominator, so percentages can total more than 100%.
- Expand an individual response and choose Delete response to remove it after confirmation. Only the survey owner can do this, on open or closed surveys. Responses are soft-deleted and excluded from results, summaries, and all response counts; there is no restore UI or API. If the survey is still open, the same browser can submit a replacement response.
- Closed surveys cannot reopen. Duplication creates an editable draft without copying responses. Deletion atomically soft-deletes the survey and responses; there is no restore UI or API.
No respondent name, email, IP, or user agent is stored. Free text can still contain identifying information if a respondent enters it. Private URLs are bearer links: anyone receiving one can view and answer the survey. No answer or private-link request logging is enabled; review ingress logs separately.
Production
Required configuration:
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL DSN; use verified TLS as appropriate for your cluster |
BASE_URL |
Exact public HTTPS origin, without a path |
GOOGLE_CLIENT_ID |
Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Google OAuth client secret |
LISTEN_ADDR |
Optional; defaults to :8080 |
Mutation requests must have an Origin matching BASE_URL. Sessions are
shared in PostgreSQL; no sticky sessions or writable application volume are
needed. Health endpoints: /healthz for liveness and /readyz for database
readiness. Logs use log/slog JSON on stdout, including Goose migrations.
docker build -t YOUR_REGISTRY/lantern:YOUR_TAG .
kubectl kustomize k8s > rendered.yaml
The Kubernetes files are examples, not cluster-specific production values.
Patch the image (preferably to a digest), public origin, ingress host/class,
and TLS secret in a GitOps overlay. Provision lantern-secrets with the three
secret variables above; no plaintext production Secret is checked in. The
base uses a non-root container, read-only filesystem, dropped capabilities,
resource bounds, and startup/readiness/liveness probes. PostgreSQL is external.
Verification and current limits
Tests cover graph validation, branch pruning/rejoining, required answers,
choice and numeric validation, ownership isolation, immutable publication,
cookie replay protection, closing, duplication, and soft deletion. Individual
response deletion tests cover ownership and survey isolation, result/count
filtering, replacement submissions, confirmation, failures, and summary updates.
Preview tests
also cover ownership, draft-only access, validation, repeated submissions with no
stored responses/cookies, and no survey copies. Autosave tests cover coalescing
edits, request ordering, mid-flight changes, and failed-save recovery. Integration
tests use generated queries and run against real PostgreSQL when
TEST_DATABASE_URL is set; use a disposable development/test database.
A headless Chromium smoke test also exercised draft editing/saving, branch switching with retained answers, review, submission, cookie replay prevention, and creator results without browser exceptions. A further browser run verified incomplete-draft persistence across reload, validation gates, repeated preview submissions with no cookies/copies/responses, offline recovery, slow-save ordering, navigation flushes, and a real response after preview. The built container was smoke-tested as non-root with a read-only filesystem against local PostgreSQL; the Kustomize base renders successfully. It has not been deployed to a cluster.
Google's live OAuth flow requires your credentials and has not been verified against an actual Google account here. This initial implementation does not include exports, a restore interface, ranking questions, survey templates, or a visual node-graph editor. Branches are configured through destination selectors in the visual question builder.