gotifacts

A single, self-hosted Go service that hosts static sites by host-based
routing and serves a dynamic portal to browse them. You publish sites over
an HTTP API; gotifacts stores them on a volume with a SQLite registry and
serves them at https://<slug>.<group>.<base>.
gotifacts runs behind any reverse proxy you provide (nginx, Caddy, …) for
TLS and SSO/forward-auth. It serves plain HTTP on one port, never TLS, and
enforces its own authorization.
- One static binary (CGO-free, built
FROM scratch).
- SQLite + a volume are the only state.
- No hardcoded domains/hosts/paths — everything is configurable.
Table of contents
How it works
reverse proxy (operator-provided: TLS, forward-auth/SSO)
├── apex "/" and "/api/*" → forward-auth ON → portal UI + management API
├── apex "/ingest/*" → forward-auth OFF → machine publish API (API-key)
└── *.base, *.*.base → static site content
│ HTTP :8080
▼
┌──────────────────────────────────────────┐
│ gotifacts (Go, static scratch binary) │
│ Host router: │
│ Host == base → portal + /api + /ingest │
│ else → serve site files │
│ Registry + API keys: SQLite (modernc) │
└──────────────┬─────────────────────────────┘
▼ volume (rw)
/data/gotifacts.db + /data/sites/<group…>/<slug>/{index.html, assets…}
The service routes purely by the request Host:
- Apex host (
== GOTIFACTS_BASE_DOMAIN): serves the portal SPA, the
management API (/api/*), and the ingest API (/ingest/*).
- Any other host: maps the host to a site directory and serves static files.
Two-plane auth model
gotifacts has two independent authorization planes:
| Plane |
Routes |
Authenticated by |
Used by |
| Management |
/, /api/* |
a forward-auth identity header injected by your proxy |
the browser (portal) |
| Ingest |
/ingest/* |
a scoped API key (Authorization: Bearer <key>) |
machines (CI, the Claude skill) |
- The identity header (
GOTIFACTS_FORWARD_AUTH_HEADER, default Remote-User)
is honored only when the request's direct peer IP is within
GOTIFACTS_TRUSTED_PROXIES. From any other source it is stripped and ignored.
- The principal is that user; they are admin iff they are listed in
GOTIFACTS_ADMIN_USERS.
- On the ingest plane the identity header is irrelevant — only the API key
counts. Leave
/ingest/* out of your proxy's forward-auth.
No API key ever lives in the browser. The portal authenticates you purely
via the proxy-injected header.
URL ⇄ path convention
Strip base_domain from a host. The remaining sub-labels, read left→right, run
[most-specific … least-specific]. The served directory is those labels
reversed:
| Host |
Served directory |
group |
slug |
app.claude.<base> |
sites/claude/app |
claude |
app |
a.sub.grp.<base> |
sites/grp/sub/a |
grp/sub |
a |
demo.<base> |
sites/demo |
(flat) |
demo |
Rules:
- Total depth (group segments + slug) ≤ 3. Deeper hosts are rejected.
- Each label must match
^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$.
- A site is identified on publish by
group (0–2 segments, may be empty) +
slug (the leaf).
Permissions & API keys
Access on the ingest plane is capability-based. A key is either an
admin superuser or a set of grants, each binding a set of capabilities
to a target:
| Capability |
Allows |
publish |
create/replace sites — POST /ingest/sites |
unpublish |
delete sites — DELETE /ingest/sites |
rollback |
restore a site's previous version — POST /ingest/sites/…/rollback |
patch |
edit site metadata — PATCH /ingest/sites |
| Role |
Granted by |
Can do |
| Admin |
forward-auth allowlist or an admin key |
everything: manage keys + all capabilities on every site |
| Scoped key |
a key with one or more grants |
only the granted capabilities, confined to each grant's target |
| Viewer |
any authenticated forward-auth user |
view the portal and GET /api/sites |
A grant's target is either a group or a single site:
- A group grant on
docs owns the docs subdomain and everything beneath
it: docs.<base> itself (the flat site group "", slug docs) and every
site under *.docs.<base> (e.g. app.docs.<base> = group docs, slug app).
- A site grant on
docs/app is confined to exactly that one site
(app.docs.<base>) — not its children, not its siblings.
- A group grant with an empty target means all sites (global). Targets
are free-text (each label must match
^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$) — you
don't pre-register them.
API keys:
-
Token format gtf_<base64url-32B>, shown in plaintext once at creation.
-
Only the SHA-256 hash is stored; lookups are constant-time.
-
Optionally expire: a key may carry an expiry instant (or never expire, the
default); once past it, the key is rejected like an unknown token.
-
Mint them in the portal (API Keys view, admin only) or via the CLI:
# A CI key that can deploy AND tear down PR previews — no admin rights:
gotifacts keys create --name ci --grant "previews:publish,unpublish"
# A key confined to a single site:
gotifacts keys create --name docs-bot --grant-site "docs/app:publish,patch"
# An expiring key (also: --expires-at 2026-12-31):
gotifacts keys create --name temp --grant "docs:publish" --expires-in 720h
# Multiple grants, a global grant, and an admin key:
gotifacts keys create --name release --grant "claude:publish" --grant ":unpublish"
gotifacts keys create --name root --admin
gotifacts keys list
gotifacts keys revoke --id 3
--grant "group:caps" targets a group subtree (empty group = all sites);
--grant-site "group/slug:caps" targets one exact site.
There is no bootstrap key: set GOTIFACTS_ADMIN_USERS, log in through your
proxy, and create keys in the UI (the CLI is the headless fallback).
Backward compatibility: existing tokens keep working unchanged. A
migration backfills grants from the old scope/group_restriction model —
old publish keys get an equivalent publish grant; old admin keys become
admin superusers.
API reference
Management plane — /api/* (forward-auth)
| Method & path |
Scope |
Description |
GET /api/me |
viewer+ |
{ user, is_admin, base_domain } |
GET /api/sites |
viewer+ |
Flat list and nested group tree. Query: q, tag, group, sort (date|title|slug), hidden=true (admin), limit, offset |
POST /api/sites |
admin |
Manual upload (same multipart body as ingest) |
PATCH /api/sites/{group…}/{slug} |
admin |
Metadata-only update |
DELETE /api/sites/{group…}/{slug} |
admin |
Delete site + files |
POST /api/sites/{group…}/{slug}/rollback |
admin |
Restore the latest archived version |
GET /api/keys |
admin |
List keys (no plaintext) |
POST /api/keys |
admin |
{name, admin?, grants:[{kind:"group"|"site", target, permissions:[…]}], expires_at?} (expires_at is RFC3339 or YYYY-MM-DD) → 201 {id, name, admin, grants, expires_at, key} (plaintext once). Legacy {name, scope, group?} is still accepted. |
DELETE /api/keys/{id} |
admin |
Revoke a key |
Ingest plane — /ingest/* (API key)
POST /ingest/sites — create or replace a site. multipart/form-data:
meta — JSON: {group, slug, title, description?, date?, tags?, repo?, preview?, hidden?}
- either
bundle — a .tar.gz containing a top-level index.html
- or
index — a single self-contained HTML document (becomes index.html)
Requires the publish capability on the target group (admin keys always pass).
Idempotent (same group/slug replaces). Response:
{ "url": "https://app.claude.example.com", "group": "claude", "slug": "app", "updated_at": "2026-06-04T..." }
DELETE /ingest/sites/{group…}/{slug} — requires the unpublish capability
on the group (automation cleanup, e.g. PR-preview teardown). PATCH requires
patch; POST …/rollback requires rollback.
Example publish of a single HTML file:
printf '{"group":"claude","slug":"app","title":"My App"}' > meta.json
curl -fsS \
-H "Authorization: Bearer $GOTIFACTS_API_KEY" \
-F 'meta=<meta.json;type=application/json' \
-F 'index=@index.html;type=text/html' \
https://example.com/ingest/sites
Configuration
All configuration is via environment variables (no config file). See
.env.example for the annotated reference.
| Variable |
Default |
Notes |
GOTIFACTS_LISTEN_ADDR |
:8080 |
HTTP bind address |
GOTIFACTS_DATA_DIR |
/data |
Volume root (DB + site files) |
GOTIFACTS_DB_PATH |
${DATA_DIR}/gotifacts.db |
SQLite path |
GOTIFACTS_BASE_DOMAIN |
— |
Required. Apex domain |
GOTIFACTS_FORWARD_AUTH_HEADER |
Remote-User |
Identity header from the proxy |
GOTIFACTS_ADMIN_USERS |
— |
Comma-separated admin users |
GOTIFACTS_TRUSTED_PROXIES |
— |
Comma-separated CIDRs/IPs allowed to assert the identity header (required for the management plane) |
GOTIFACTS_MAX_UPLOAD_BYTES |
67108864 (64 MiB) |
Ingest body cap |
GOTIFACTS_MAX_EXTRACT_BYTES |
268435456 (256 MiB) |
Decompressed archive cap |
GOTIFACTS_MAX_EXTRACT_ENTRIES |
10000 |
Archive entry cap |
GOTIFACTS_VERSIONING_ENABLED |
false |
Keep old versions on replace; enable rollback |
GOTIFACTS_VERSIONING_KEEP |
5 |
Versions retained per site |
GOTIFACTS_MCP_ENABLED |
false |
Expose the OAuth-protected MCP server at /mcp |
GOTIFACTS_MCP_ALLOWED_USERS |
— |
Forward-auth users allowed to grant connector consent (falls back to GOTIFACTS_ADMIN_USERS) |
GOTIFACTS_MCP_GROUP |
claude |
Publish group subtree MCP tokens are confined to |
GOTIFACTS_MCP_TOKEN_TTL |
1h |
MCP access-token lifetime |
GOTIFACTS_MCP_REFRESH_TTL |
720h |
MCP refresh-token lifetime |
gotifacts serve refuses to start if no admins are reachable (no
GOTIFACTS_ADMIN_USERS + GOTIFACTS_TRUSTED_PROXIES); use the CLI to mint an
admin key for purely headless setups.
Running with Docker
A proxy-agnostic docker-compose.yml is provided. The
image is published to ghcr.io/lmgarret/gotifacts.
cp .env.example .env
# edit .env: set GOTIFACTS_BASE_DOMAIN, GOTIFACTS_ADMIN_USERS, GOTIFACTS_TRUSTED_PROXIES
docker compose up -d
gotifacts is reachable only on the internal network (expose: 8080). Put your
reverse proxy in front of it — never expose port 8080 to the internet.
The image is a three-stage build (Node → Go → scratch): a static, non-root,
CA-cert-only runtime that declares /data as a volume.
Reverse proxy setup
gotifacts is proxy-agnostic. Reference snippets (illustrative, adapt to your
SSO provider):
Each shows: TLS; apex / + /api/* behind forward-auth with the identity
header injected (and any client-supplied copy stripped); apex /ingest/* with
forward-auth off; and *.base / *.*.base serving site content.
Framing sites in the portal
The portal renders live, sandboxed iframe thumbnails of sites. For a site
to be framable by the portal, it must be served with:
Content-Security-Policy: frame-ancestors https://<base>
The proxy examples add this header to site responses. If you set preview in a
site's metadata, the portal uses that image instead of an iframe.
Security
See SECURITY.md for the threat model and private reporting.
Highlights:
- Identity-header spoofing is the top risk. The header is honored only from
GOTIFACTS_TRUSTED_PROXIES; otherwise stripped. Never expose gotifacts
directly; your proxy must strip any client-supplied identity header before
injecting the real one.
- Uploads are guarded against zip-slip, symlink escapes, tar-bombs, and
oversized payloads. Sites are written to a temp dir on the same volume,
validated, then atomically swapped into place.
- API keys are hashed at rest, shown once, compared in constant time, and
never logged.
Publishing from CI or Claude
There are two ways to let Claude publish, suited to different surfaces.
A distributable Claude skill lives in
examples/skill/. It asks for consent, writes a
self-contained index.html, picks a URL-safe slug/group (default
claude), publishes via the single-index ingest form using GOTIFACTS_URL +
a GOTIFACTS_API_KEY holding the publish capability, and reports the URL. It
never touches server/proxy credentials.
This works wherever you control the environment (CI, Claude Code, the API
code-execution tool). It does not work in default claude.ai / Claude mobile
conversations, which provide no way to inject those environment variables.
MCP connector + OAuth (claude.ai mobile/web, Claude Code, the API)
For the consumer apps, set GOTIFACTS_MCP_ENABLED=true to expose an OAuth
2.1-protected Model Context Protocol server
at /mcp with a single publish_site tool. Claude's "custom connector" UI
authenticates remote MCP servers exclusively via OAuth (no bearer/header
field), so this is the only path that works on mobile/web.
How it fits the two-plane model:
- The browser-facing consent step (
/mcp/oauth/authorize) is authenticated
by your existing forward-auth SSO — it sits on the forward-auth plane and
is gated to GOTIFACTS_MCP_ALLOWED_USERS (or admins). This is the gate that
decides who may publish.
- Every machine-facing endpoint (
/mcp, the token/registration endpoints, and
the /.well-known/oauth-* discovery documents) is called server-to-server by
Claude and must sit on the no-forward-auth plane, like /ingest/*.
Authentication there is OAuth (PKCE, then a bearer access token).
Tokens carry the same grant model as API keys: at the consent screen the
approver picks a target — a group subtree or a single site — and the
capabilities to allow (publish, patch, unpublish, rollback), prefilled
from GOTIFACTS_MCP_GROUP (default claude) + publish. The connector can
never act outside what was granted. Dynamic Client Registration (RFC 7591) lets
users add the connector by pasting the URL; it only issues a client_id and
grants no access on its own. See the proxy examples for the exact routing split,
and .env.example for all MCP settings.
To connect: in Claude → Settings → Connectors → Add custom connector, enter
https://<your-base-domain>/mcp, complete the SSO consent (choosing the scope),
then ask Claude to publish a page. For the API MCP connector or Claude
Code, the same server works with a token obtained via the OAuth flow.
Managing connections. Each consent creates a connection you can review and
revoke. Admins see them in the portal's Connections view, via
GET /api/mcp/connections, or headless:
gotifacts mcp connections # list active connections (client, user, scope, last used)
gotifacts mcp revoke --id <id> # revoke one — the connector loses access immediately
Development
See CONTRIBUTING.md. In short:
go test -race ./... # backend tests
golangci-lint run ./... # backend lint
cd web && npm ci && npm run lint && npm run build # frontend
The Go module path is github.com/lmgarret/gotifacts; go.mod is the single
source of truth for the Go version.
License
MIT.