gotifacts

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT

README

gotifacts

CI Go Report Card

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.

Skill + API key (CI, Claude Code, the API code-execution tool)

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.

Directories

Path Synopsis
cmd
gotifacts command
Command gotifacts is a self-hosted service that hosts static sites by host-based routing and serves a dynamic management portal.
Command gotifacts is a self-hosted service that hosts static sites by host-based routing and serves a dynamic management portal.
internal
api
Package api wires the management plane (/api/*, forward-auth) and the ingest plane (/ingest/*, API-key) onto the apex host, plus the embedded SPA.
Package api wires the management plane (/api/*, forward-auth) and the ingest plane (/ingest/*, API-key) onto the apex host, plus the embedded SPA.
archive
Package archive safely extracts site bundles, defending against zip-slip, tar-bombs, symlink escapes, and oversized payloads.
Package archive safely extracts site bundles, defending against zip-slip, tar-bombs, symlink escapes, and oversized payloads.
auth
Package auth implements the two-plane authorization model.
Package auth implements the two-plane authorization model.
config
Package config loads and validates the gotifacts runtime configuration.
Package config loads and validates the gotifacts runtime configuration.
ingest
Package ingest implements site publishing: staging an upload to a temp dir on the same volume, validating it, atomically swapping it into place (with optional versioning), and recording the registry row in a transaction.
Package ingest implements site publishing: staging an upload to a temp dir on the same volume, validating it, atomically swapping it into place (with optional versioning), and recording the registry row in a transaction.
keys
Package keys handles API-key token generation, hashing, and scope semantics.
Package keys handles API-key token generation, hashing, and scope semantics.
mcpserver
Package mcpserver embeds an OAuth 2.1-protected Model Context Protocol server inside gotifacts, exposing a single publish_site tool over Streamable HTTP.
Package mcpserver embeds an OAuth 2.1-protected Model Context Protocol server inside gotifacts, exposing a single publish_site tool over Streamable HTTP.
portal
Package portal serves static site content (host-routed) and the embedded management SPA.
Package portal serves static site content (host-routed) and the embedded management SPA.
router
Package router implements the URL ⇄ filesystem-path convention that is the core contract of gotifacts, plus host-based request classification.
Package router implements the URL ⇄ filesystem-path convention that is the core contract of gotifacts, plus host-based request classification.
store
Package store is the SQLite-backed registry: the sole source of truth for sites and API keys.
Package store is the SQLite-backed registry: the sole source of truth for sites and API keys.
Package web embeds the built management SPA (web/dist) for serving by the portal.
Package web embeds the built management SPA (web/dist) for serving by the portal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL