Architecture 01 Feature map 02 Who renders what 03 Traps 04 Safety property 05 Trust model 06 The little backend 07 Counting visitors 08 Create with AI 09 Runtime 10 Non-goals
00.
Architecture brief · blast-radius map

Every place
a feature lives.

People tell AI agents what to build; the agents build, deploy and manage static sites here. This page exists so that changing one of those features never silently breaks a surface nobody remembered.

Simple Host is one Go service, one Postgres database and one directory on disk, behind an nginx edge that fronts the content host sites.simple-host.app (sites by path), every connected custom domain, and the dashboard origin. No CDN, no per-site infrastructure, no third-party hosting. Uploads are static data that can never execute on the server; every version is owned, timestamped and revertible.

Why the map is first. The analytics response shape changed from a flat {views, visitors} to a person / bot / infra / unknown split. One consuming page was updated. A second — the owner Analytics tab, in a different file — was not, and quietly rendered zero for every site. Nothing failed; nothing logged; the owner found it by looking. The fix for that class of bug is not more care, it is a written-down list of every surface a feature touches. Sections 01–03 are that list. Sections 04–10 are the reasoning behind the design.

simple-host.app · public playground · companion: /llms.txt · /openapi.yaml · /docs.html
01.

Feature map

One card per feature. Open it and you have every file, route, screen, table and document that feature touches — and the line that says what else you must change with it.

The UI row is the one that matters most, and it is the one that gets skipped. It names the file and the tab inside it, because "I updated the dashboard" is exactly the sentence that shipped the analytics bug — there are two dashboards, and a third at /admin.

Each card's Product row links to the plain-language entries on the features page. That is the other half of the same job: this page says where a feature lives, that one says what it promises — and a change usually invalidates both.

Visitor analyticsanalyticsPer-site views and unique visitors from the edge log, split into people, bots and monitoring.
Gointernal/analytics/ — ingest.go (tail, attribute, upsert) · classify.go (person/bot/infra) · rebuild.go · internal/db/analytics.go · internal/handler/analytics.go · cmd/analytics-rebuild/main.go · started at cmd/server/main.go
RoutesGET /v1/sites/{sitename}/analytics · owner
GET /v1/sites/{sitename}/analytics/geo · owner — people by country
GET /v1/analytics/sites · owner-scoped; all=1 admin-only, 403 otherwise
UIindex.html → Sites tab, per-card slot #an-{name} — People/Bots/Infra cells + 24 h columns; also feeds the #site-sort ordering, and the same cards render inside the Admin tab
showcase.html → owner Analytics tab #owner-analytics-view — 7/14/30/90-day ranges, #an-totals + #an-body + sparklines. This is the surface that was missed.
Tableswrites site_view_hourly · site_visitor_hourly · analytics_ingest_state
reads those plus site_view_daily · site_visitor_daily (frozen history — never drop) and sites/users for attribution
Infranginx log_format shanalytics (per vhost) · /var/log/simple-host/analytics.log · /etc/logrotate.d/simple-host-analytics (create + delaycompress) · ANALYTICS_LOG env
Docsopenapi.yaml (TrafficCounts, TrafficSplit, both paths) · skills/website-deploy/references/operations.md · this page §07 · stale: docs/designs/analytics-design.md · absent from README.md, llms.txt, every SKILL.md
Also touchBoth consuming pages, every time — showcase.html reads only daily[].person.views and totals.person.visitors, so renaming a class breaks it silently while index.html keeps working. Then openapi.yaml and operations.md. If the ingest or classifier changed, re-run analytics-rebuild so history matches.
Per-site JSON statestateOne shared JSON document per site with atomic ops and ETags. The widest blast radius on the platform.
Gointernal/handler/stateops.go (set/inc/append/remove/removeWhere under a row lock) · internal/handler/site.go (getSiteState, putSiteState, authorizeStateOrigin, resolveSiteID) · internal/handler/visitorsession.go (write gate) · internal/db/queries.go
RoutesGET · PUT · PATCH · OPTIONS /v1/sites/{sitename}/state
GET · PUT · PATCH · OPTIONS /v1/u/{handle}/sites/{sitename}/state (the v3 twin — both must change together)
UIevery site any LLM has generated from llms.txt, which prescribes this exact contract, plus the hosted auth.js sign-in helper those sites load on a custom domain
No first-party dashboard renders state — which is precisely why a break here is invisible in-house.
Tablessites.state (jsonb) · sites.state_version · sites.updated_at · reads sites.allowed_origins, sites.allow_anonymous_writes
Docsopenapi.yaml (both path shapes) · llms.txt · skills/website-deploy/references/backend.md · website-deploy-builder/SKILL.md §2 · CLAUDE.md · docs/designs/state-storage.md (proposal, not shipped)
Also touchThe ETag header is load-bearing — generated pages poll with If-None-Match and treat 304 as "no change". A PATCH must return the new document. The error body {error, code:"visitor_auth_required"} is matched by string by auth.js and by generated sites. Changing any of it means auth.js, llms.txt, backend.md and openapi.yaml — and it still cannot reach sites already deployed.
Append-only collectionscollectionsGrowing lists — signups, RSVPs, form submissions — with paginated reads and CSV export.
Gointernal/handler/collections.go · internal/db/collections.go · shares the Origin gate and write gate with state
RoutesGET · POST · OPTIONS /v1/sites/{sitename}/collections/{coll} + the /v1/u/{handle}/… twin
GET /v1/sites/{sitename}/collections · owner
GET /v1/sites/{sitename}/collections/{coll}/export.csv · owner
UIindex.html → Sites tab#data-{name} collection list and #collview-{name}-{coll} row table, plus the CSV download button
plus the admin.html viewer that llms.txt requires every generated site to ship
Tablescollection_items + idx_collection_items (db/schema.sql)
Docsopenapi.yaml (both shapes + export.csv) · llms.txt · backend.md · website-deploy-builder/SKILL.md · operations.md
Also touchThe read shape {items:[{id, data, created_at}], next} is prescribed to external LLMs — changing it breaks generated sites you will never see. The CSV export reads Content-Disposition in the browser. And deleting a site cascades every row away with no soft delete.
Registration & API keysauthEmail magic-link or 6-digit code, exchanged for the per-user API key everything else depends on.
Gointernal/handler/user.go · internal/auth/middleware.go (X-API-Key, admin short-circuit) · internal/email (Resend) · internal/handler/handles.go (handle claimed at verify) · internal/db/queries.go
RoutesPOST /v1/auth · POST /v1/auth/verify · GET /v1/me · POST /v1/me/api-key/rotate
UIindex.html → auth view#email-form-wrap, #code-form-wrap, #verifying-screen; /v1/me gates all of #main-view and the Admin tab's visibility
showcase.html → #ai-auth — the same two endpoints inline, so a build can resume after signing in
install.html describes the flow in prose
Tablesusers (api_key, handle, is_admin) · auth_tokens (never pruned)
Docsopenapi.yaml (AuthResponse) · skills/website-deploy/references/register.md · website-deploy/SKILL.md · README.md · CLAUDE.md
Also touch/v1/me gates the entire signed-in experience on both pages, and they read different fields — index.html needs username and is_admin, showcase.html needs handle. Any change to the auth response also touches register.md, which agents follow literally.
Owner OAuth sign-inoauth-ownerGoogle sign-in (more providers later) that lands on the same users row as the magic link.
Gointernal/handler/oauth.go (purpose="owner" fork, return_to classification) · internal/oauth/ (provider.go, google.go — PKCE) · internal/config
RoutesGET /v1/auth/oauth/providers · /v1/auth/oauth/{provider} · /v1/auth/oauth/{provider}/callback — the same three routes also serve visitor sign-in; a purpose forks the callback
UIindex.html → auth view #oauth-providers
the hosted auth.js helper on every custom-domain site
prescribed by llms.txt to every generated site
Tablesoauth_states (+ purpose CHECK) · oauth_identities · users · auth_tokens (owner handoff)
Docsopenapi.yaml · SPEC.md · UNIFY.md (account matching, credential boundary) · README.md · docs/designs/oauth2-authentication.md is superseded prior art
Also touch/v1/auth/oauth/providers has more consumers than any other auth route — the dashboard, auth.js and every generated site. Adding or renaming a provider changes what all of them render. Owner and visitor flows share the routes: touching one is touching both.
Visitor sign-in & sessionsvisitor-sessionsThe site-scoped identity that lets a stranger write to state or leave a comment.
Gointernal/handler/visitorsession.go (cookie __Host-sh_vsess, X-SH-CSRF, visitorWriteOK) · internal/handler/oauth.go (mints the one-time establish token) · internal/db/visitors.go
RoutesGET /v1/visitor/establish · GET …/me · POST …/visitor/auth · POST …/visitor/auth/verify · POST /v1/visitor/logout
UIthe hosted auth.js helper — asks GET …/me, and SH.requireSignIn() offers Google or an emailed 6-digit code before the page saves. Custom domain only: on the shared host every site shares one origin, so no visitor session is issued there; writes there are open instead (anyone can change that data), and agents write with an API key anywhere. No first-party page exercises this path.
Tablesvisitor_sessions · visitor_establish_tokens · oauth_states · oauth_identities · users · reads sites for the write gate
Docsopenapi.yaml (VisitorAuthRequired) · SPEC.md (whole document) · UNIFY.md · llms.txt · backend.md · CLAUDE.md
Also touchThis feature's tables once shipped without a GRANT for the service role (fixed 2026-09-05); the symptom was a route that 500ed while everything around it looked healthy. Any migration that creates a table must grant it — verify grants after any migration, not just the SQL file.
Site create, list, delete & visibilitysitesThe core object. Almost every other feature hangs off a sites row.
Gointernal/handler/site.go · internal/handler/sitename.go (shape + reserved names) · internal/storage/disk.go · internal/db/queries.go
RoutesPOST · PUT · PATCH (rename) · DELETE /v1/sites/{sitename} · GET /v1/sites · PUT /v1/sites/{sitename}/visibility · GET /v1/admin/users
UIindex.html → Sites tab #sites-container and → Admin tab #admin-sites-container
showcase.html → owner Sites tab #inventory, plus it seeds the Analytics tab's site list
showcase.html → public gallery #gallery (server-injected, not fetched)
admin.html → #users
Tablessites · cascades on delete to versions, collection_items, both analytics generations, legacy_hostnames, oauth_states, visitor_sessions
Docsopenapi.yaml · website-deploy/SKILL.md · operations.md (listing, delete) · README.md · CLAUDE.md
Also touchGET /v1/sites feeds four tabs across two pages and each reads a different field set — index.html wants site_url, active_version, owner_username, custom_domain, domain_status; showcase.html wants visibility and user_id. Both unwrap the {data:[…]} notice envelope, so the wrapper is part of the contract too.
File uploaduploadTwo paths — inline JSON files, or a tar.gz/zip archive. Same guards, same versioning.
Gointernal/tarball/ — extract.go (traversal, caps, secret skipping) · validate.go (script denylist) · sanitize.go (JSON path) · internal/handler/site.go · internal/storage/disk.go
RoutesPOST · PUT /v1/sites/{sitename}/files (JSON) · POST · PUT /v1/sites/{sitename} (archive)
UIindex.html → Sites tab, the "Create a site" builder — publishGenerated() writes files and reads data.site_url
Everything else that uploads is an agent following the skills, not a screen.
Tablessites · versions (disk_path, archive_sha256, status)
Docsopenapi.yaml (FilesBody) · website-deploy/SKILL.md · packaging-and-validation.md · frameworks.md · CLAUDE.md
Also touchThe dashboard builder relies on status codes as control flow: 404 on PUT means "recreate", 409 on POST means "update instead". Changing either code silently breaks publishing. The X-Content-Digest integrity header and the size caps are documented in packaging-and-validation.md, which agents follow step by step.
Versions & rollbackversionsEvery deploy is a new version; rollback re-points which one is live.
Gointernal/handler/site.go (listVersions, setActiveVersion) · internal/storage/disk.go (UpdateCurrent — copy, remove, rename) · internal/db/queries.go
RoutesGET /v1/sites/{sitename}/versions · PUT /v1/sites/{sitename}/active-version
UIindex.html → Sites tab #versions-{name}expects a bare array
showcase.html → owner Sites tab [data-versions]accepts an array or a {data:[…]} envelope
These two disagree today. If this route ever starts being notice-wrapped, showcase.html survives and index.html silently renders nothing.
Tablesversions · sites.active_version
Docsopenapi.yaml · operations.md ("there is no .../activate — this is the one") · README.md · CLAUDE.md §Versioning
Also touchBoth pages, and the envelope asymmetry above is a latent bug worth fixing while you are in here. Note also that versions.archive_sha256 is written but never read back, so there is no persisted integrity check to rely on.
Custom domainsdomainsBind a domain to a site, relay one DNS record, verify by fetching it.
Gointernal/handler/domains.go · internal/db/domains.go · internal/storage/disk.go (BindDomain — a symlink in the domains farm) · publicsuffix (apex → A record, subdomain → CNAME)
RoutesPOST · GET · DELETE /v1/sites/{sitename}/domain · GET /internal/tls-ask (dead — Caddy-era)
UIindex.html → Sites tab only — connect/disconnect buttons; the POST response's dns.host and dns.value are read straight into the toast the user copies from
showcase.html shows a bound domain but cannot change it.
Tablessites.custom_domain · domain_status · domain_verified_at · domain_last_error
Docsskills/connect-domain/SKILL.md (the whole flow) · openapi.yaml · operations.md · website-deploy-builder/SKILL.md · docs/designs/per-user-subdomains-and-custom-domains.md
Also touchnginx: a new domain needs a vhost and a certificate, both by hand — and that vhost must carry the shanalytics access-log line or the site records no analytics at all. The skill's promise ("verify by fetching the domain, never poll status") depends on domain_status staying advisory.
Write gate & allowed originswrite-gateDecides whether a write to state or collections is allowed. Enforcement is on: on a custom domain a write needs a visitor session or an API key; on the shared host writes are open.
Gointernal/handler/visitorsession.go (visitorWriteOK — on | log | off) · internal/handler/site.go (authorizeStateOrigin, setAllowedOrigins, setAllowAnonymousWrites) · internal/config/config.go (WRITE_AUTH_MODE)
RoutesPUT /v1/sites/{sitename}/allowed-origins · owner
PUT /v1/sites/{sitename}/allow-anonymous-writes · the only admin-required route on the platform
UINone. No screen exposes either switch — which is exactly why the deployed mode has to be checked, not assumed.
Tablessites.allowed_origins · sites.allow_anonymous_writes
DocsSPEC.md (the write-auth spec) · UNIFY.md (credential boundary) · openapi.yaml · backend.md · CLAUDE.md · README.md
Also touchon is live on simple-host.app (source default log); log and off are operator opt-downs that silently change the meaning of the trust model in §05 and of every "writes are authenticated" sentence in the docs. Change the mode and the wording has to move with it.
Create with AIai-createA chat that builds a page server-side and hands back a job id to poll.
Gointernal/handler/generate.go (prompt, provider call, sentinel) · internal/handler/generate_jobs.go (in-process store, owner binding, ceilings) · internal/config (LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY, plus the optional VISION_* trio)
RoutesPOST /v1/generate · GET /v1/generate/status?id=… — note the query string; there is no /jobs/{id}. Not registered at all without a provider key.
UIshowcase.html → #ai-create — the chat, #ai-chat + #ai-preview iframe + #attach-strip, with inline sign-in at #ai-auth. This is the only page that calls /v1/generate.
index.html → Sites tab #ai-create is a different builder — it asks you to build in your own AI and paste the JSON back, then publishes via the files API. Same name, same look, no shared endpoint.
TablesNone directly. Publishing goes through the normal deploy path into sites and versions; the only trace is a row in api_request_daily.
Docsopenapi.yaml · README.md · CLAUDE.md · superseded: docs/design-grok-backend.md · no skill covers AI create; LLM_PROVIDER appears in no doc
Also touchTwo builders share one name — a change described as "the AI builder" needs to say which. The status response fields status, reply, html, progress and error are all read by the poller. CLAUDE.md is the reference for the provider setup — there is one provider and no fallback.
Speech transcriptiontranscribeDictate instead of typing. The model runs on this box.
Gointernal/handler/transcribe.go (HMAC ticket for the WebSocket, batch fallback) · internal/config (TRANSCRIBE_URL, TRANSCRIBE_TICKET_SECRET)
RoutesPOST /v1/transcribe · POST /v1/transcribe/ticket (both conditional on config). The live-caption WebSocket /v1/transcribe/stream is proxied by nginx to a separate local service — it is not a Go route, though CLAUDE.md lists it as one.
UIshowcase.html only#chat-mic and #chat-input. A 404 from POST /v1/transcribe is deliberately load-bearing: it is how the page decides to hide the mic button entirely.
TablesNone.
Docsopenapi.yaml · README.md · CLAUDE.md · no skill coverage
Also touchTwo off-repo services on loopback and their nginx location block. If the mic silently disappears, the probe request is the first thing to check — not the microphone permission.
Handles & public showcaseshowcaseEvery user gets a URL path and a public page listing their sites.
Gointernal/handler/showcase.go (server-renders showcase.html, injects the data blob) · internal/handler/handles.go (reserved names) · internal/handler/ui.go (the catch-all that routes /{handle})
RoutesGET /internal/showcase/{handle} (nginx-only) · GET / catch-all (serves the owner app for a handle-shaped path)
UIshowcase.html → public gallery #public-view / #gallery — rendered from a server-injected blob, not a fetch
showcase.html → owner console #owner-view with its Sites and Analytics tabs
index.html links to it via #showcase-banner
Tablesusers.handle · sites (visibility=unlisted is filtered out of the gallery)
Docsdocs/superpowers/specs/2026-07-14-simple-host-showcase-and-404.md · register.md (the handle is the URL segment) · openapi.yaml (visibility)
Also touchThe injected blob is substituted by string replacement in Go — renaming a placeholder in the HTML breaks rendering with no compile error. And this one file is both a public page and the owner console, so a change aimed at one audience lands on the other.
Admin API analyticsapi-metricsPer-endpoint request counts and caller geography, for the admin only.
Gointernal/handler/apimetrics.go — the Wrap middleware counts every /v1/* request, flushes every 20 s, resolves geo lazily, prunes at 30 days
RoutesGET /v1/admin/api-analytics · GET /v1/admin/users (both answer 404, not 403, to non-admins)
UIadmin.html only#stats, #users, and #apitraffic with its route and IP tables. Note this is a third dashboard, separate from index.html's Admin tab.
Tablesapi_request_daily · api_ip_daily (raw IPs, deliberately, 30-day retention) · ip_geo (never pruned)
Docsopenapi.yaml — and nothing else. This feature is documented in exactly one file.
Also touchThe Wrap middleware is on the hot path of every API request, so a change here has platform-wide latency reach even though only one page renders it. Route labels are normalised to bound cardinality — adding a path parameter without teaching the normaliser will blow up the table.
Skills hub & installersskillsThe agent-facing product: three skills, an MCP server, and the installers that fetch them.
Gointernal/handler/skillshub.go · internal/handler/ui.go (zips, install scripts, PluginVersion) · the embedded simple-host-website/ tree
RoutesGET /v1/skills · /v1/skills/{name} · /v1/skills/{name}/references/{file} · /.well-known/skills/… · /skills.zip · /skills/version · /skills/<name>.zip · /skills/<name>/SKILL.md · /plugin.zip · /install.sh · /install.ps1
UIinstall.html — every install path, and the #copy-llms button that fetches /llms.txt
showcase.html → #skill-install for owners
index.html fetches /skills/version to set its skill-version header
TablesNone.
Docsthe three SKILL.md files and five references/ files are themselves the artefact · README.md · simple-host-website/README.md · docs/designs/simplify-and-share.md
Also touchThe plugin version lives in plugin.json and is echoed by marketplace.json and the SKILL.md header — the three have already drifted apart. Bumping one without the others changes who gets a staleness notice.
Stale-skill notice injectionnoticeCross-cutting: rewrites JSON bodies to warn an agent its skill is out of date.
Gointernal/handler/notice_middleware.go — buffers the response and injects _notice when X-Skill-Version is missing or stale; wraps roughly two dozen routes
RoutesMost of /v1/sites/* and /v1/auth/*. Deliberately not state, collections, static serving or skill downloads.
UIInvisible but decisive: index.html and showcase.html both unwrap {data:[…]} on /v1/sites because of this middleware. index.html additionally fetches /skills/version so its own calls are never wrapped.
TablesNone.
DocsCLAUDE.md §Skill staleness notice · website-deploy/SKILL.md · README.md
Also touchAdding a route to the wrapped set changes its response shape for every browser caller that does not send the header. That is the same failure mode as the analytics bug, arriving from the middleware instead of the handler.
Ephemeral preview sitespreviewSites from listed accounts self-destruct after a TTL.
Gointernal/handler/site.go (previewExpiry, sweepExpiredSites — a background loop, no route) · internal/config (PREVIEW_ACCOUNTS, PREVIEW_TTL_HOURS, default 48 h)
RoutesNone — it rides on site creation and a sweep.
UINone. Nothing tells a user their site is temporary, which is worth remembering before enabling it for an account.
Tablessites.expires_at
DocsNone. This feature is undocumented outside the config comments.
Also touchThe sweep deletes sites outright, cascading versions, collections and analytics. Never repurpose expires_at for anything else.
Branded 404 & legacy hostsnotfoundA miss on the content host comes back as a branded page pointing at the owner's showcase.
Gointernal/handler/showcase.go (notFound, reads X-Original-URI) · internal/handler/legacyhost.go (global middleware: old per-site hostname → path URL)
RoutesGET /internal/notfound (nginx error_page) · plus a 301 applied to every request
UInotfound.html — server-rendered by string substitution of the message and back-link placeholders
Tablesreads sites and users to decide the back-link. Not legacy_hostnames — that table has no live reader.
Docsdocs/superpowers/specs/2026-07-14-simple-host-showcase-and-404.md
Also touchThe nginx error_page and @notfound blocks are half of this feature; the Go handler alone cannot deliver it. The redirect middleware runs before everything, so a bug here affects every route on the platform.
Health & readinesshealthTwo probes. One is static, one touches the database.
Gointernal/handler/health.go
RoutesGET /healthz (static ok) · GET /readyz (2 s DB ping, 503 on failure)
UINone, and nothing in nginx consults them — on one box they are observability, not a gate.
Tablesping only
Docsopenapi.yaml · README.md
ProductInternal — no user-facing feature entry.
Also touchIf a load balancer or a monitor is ever pointed at these, they become traffic-affecting and the classifier should be checked — a hosted uptime checker lands in the infra class, which is where you want it.
Dashboard & static pagesdashboardEverything served out of the embedded static directory.
Gointernal/handler/ui.go (catch-all file server + the dashboard CSP) · internal/handler/showcase.go (ownerAppOrStatic)
RoutesGET /admin · GET / catch-all (also serves llms.txt, openapi.yaml/json, docs.html, privacy.html, install.html, auth.js — none of which are registered routes)
UIindex.html (auth view · Sites tab · Admin tab) · showcase.html (public gallery · owner Sites tab · owner Analytics tab · AI chat) · admin.html · install.html · docs.html · privacy.html · notfound.html · architecture.html (this page)
Tablesthe catch-all looks up users for handle-shaped paths
DocsCLAUDE.md · docs/designs/install-page-redesign.md · docs/designs/simplify-and-share.md
Also touchThese files are embedded into the binary — editing one changes nothing until a rebuild and restart. Every "I fixed the page" claim should be verified against the running server, not the file on disk.
02.

Who renders what

The inverse of the map: pick an endpoint, see every screen that would go blank if you changed its shape. Rows marked multi have more than one consumer — those are the ones that bite.

E1GET /v1/sites/{name}/analytics multiindex.html Sites tab cards + Admin tab · showcase.html owner Analytics tab. The two read different subsets — showcase touches only daily[].person.views and totals.person.visitors. This is the pair that already broke.
E2GET/PATCH …/state multiauth.js · every LLM-generated site following llms.txt. The ETag header and the visitor_auth_required error code are part of the contract. Widest reach on the platform, and mostly outside our control.
E3GET /v1/sites multiindex.html Sites tab and Admin tab · showcase.html owner Sites tab, which also seeds the Analytics tab. Four tabs, two pages, different required fields, both unwrapping the notice envelope.
E4GET /v1/auth/oauth/providers multiindex.html auth view · auth.js · prescribed by llms.txt. Adding or renaming a provider changes all of them.
E5GET /v1/me multiindex.html gates the whole signed-in app and the Admin tab · showcase.html gates the entire owner console. A field rename locks people out of both.
E6GET …/versions multiindex.html expects a bare array; showcase.html accepts either shape. Already inconsistent — wrapping this route would break exactly one of them, silently.
E7POST /v1/auth · /v1/auth/verify multiindex.html auth view and showcase.html inline builder sign-in.
E8PUT …/active-version · DELETE /v1/sites/{name} multiBoth pages' site lists call these.
E9POST …/collections/{coll} multiEvery generated site; all of them branch on visitor_auth_required.
E10GET /v1/analytics/sites oneindex.html Sites tab ordering only. A failure degrades the sort silently rather than showing an error — by design, but worth knowing when it looks "fine".
E11POST /v1/generate · /v1/generate/status oneshowcase.html only. index.html's similarly-named builder does not call it.
E12/v1/transcribe · /ticket · /stream oneshowcase.html only. A 404 on the probe is how the mic button hides itself.
E13PUT …/visibility oneshowcase.html owner Sites tab only.
E14…/domain · …/collections · export.csv oneindex.html Sites tab only.
E15/v1/admin/users · /v1/admin/api-analytics oneadmin.html only — a third dashboard, separate from index.html's Admin tab.
E16GET /openapi.yaml multidocs.html renders the whole spec through Swagger UI. Every endpoint change surfaces here whether or not anyone updated it — which is why the spec is the source of truth and not a summary.
03.

Traps that have already bitten

Not hypotheticals. Each of these has cost someone real time here.

X1

A response-shape change ripples further than the handler. There is no compile-time link between a Go struct and the JavaScript that reads it, so a renamed field fails by rendering zero, not by throwing. Before changing a shape, read §02 and open every consumer listed. The analytics split is the worked example: two pages, one updated, one silently blank.

X2

The two *_daily analytics tables must never be dropped. Nothing writes to them any more, which makes them look like debris. They are the only surviving record of 2026-07-11 → 2026-08-08 — the raw logs behind that month have rotated away and no rebuild can recover it. The API reads them for every day before classified_from and reports them as unknown. Dropping them silently deletes twenty-nine sites' first month, seventeen of which have no other history at all.

X3

The ingester's inode-and-offset tracking constrains the logrotate config. copytruncate keeps the inode and loses whatever was written between copy and truncate; the ingester needs the inode change to know it should drain the old file. It also cannot read gzip, so delaycompress is what gives it a full cycle to finish .log.1. Those two directives are code dependencies wearing a config's clothes.

X4

Some features are half in nginx. The branded 404, the analytics log and the live-captions WebSocket are each part Go and part edge config. Shipping the Go half and calling it done produces a feature that works on your laptop and not in production. A new custom-domain vhost that omits the shanalytics access-log line yields a site whose analytics are permanently empty, with nothing anywhere reporting a problem.

X5

A migration is not done until the grants are. Migrations once shipped without GRANTs for the service role, so the tables existed and the feature 500ed while everything around it looked healthy. A migration that creates a table must also grant it to the application role, and grants must be verified against the live database after any migration — not just the SQL file.

X6

Static pages are embedded in the binary. Editing anything under internal/handler/static/ changes nothing until the binary is rebuilt and the service restarted. "Deployed" and "visible" are different claims — verify from the client, never from the file you just saved.

X7

Two different builders are both called "Create a site". One calls the model server-side; the other asks you to paste JSON from your own AI. They live on different pages and share no endpoint. Any instruction about "the AI builder" that does not name the page is ambiguous.

X8

The docs-sync script catches less than it appears to. scripts/check-docs-sync.sh hard-fails when a registered /v1 route is missing from openapi.yaml or vice versa, and when an owner route is not auth-wrapped. Both are valuable. But it compares paths, not methods, so adding a verb to a documented path is invisible; it never checks whether prose descriptions still match behaviour; and it has no capability rule for analytics, custom domains, AI create or transcription. It would not have caught anything on this page.

The rule this page is built around: a feature is not changed until every cell in its row is changed. If that is too much work for the change you are making, the change is bigger than you thought — which is exactly the thing worth learning before shipping it, not after.

04.

The core safety property

The defining constraint: user uploads are data, never code — they can never execute on the server. That is what makes agent-generated ("vibe-coded") sites safe to host.

4.1

No execution path exists. The server has no interpreter, no SSR runtime, no plugin loader, no per-site processes. Uploaded files are written to disk and later returned as bytes with a content type — a hostile upload has no more power than an email attachment sitting in a folder.

4.2

Archives are validated in memory, before anything touches disk. Extraction produces a map of paths to bytes; every guard runs on that map, and only then is a version written. Path traversal is blocked twice over — at extract and again at write — along with absolute paths, control characters, depth over 32 and paths over 1024 bytes. Symlink, hardlink and device entries are dropped rather than followed. Caps: 100 MB per file, 500 MB uncompressed total, 50,000 entries, and a 100 MB request body (MAX_ARCHIVE_MB). Zip bombs are handled by counting bytes actually read, never the declared size in the header.

4.3

The extension check is a denylist, and it is a courtesy — not the safety control. Being honest about this matters: only source-script types are hard-rejected (.sh .bash .zsh .fish .bat .cmd .ps1 .py .pyc .rb .pl .go .php). Everything else is accepted, binaries included. Secret-shaped files (.env, .htpasswd, .npmrc, .netrc, id_rsa, anything under .git/ or .ssh/) are silently skipped, not rejected — the deploy still succeeds, minus the secret. The real guarantee is 4.1: nothing on disk can run.

4.4

Serving is rooted per site, by nginx, off disk. Static bytes never pass through the Go process. nginx maps /<handle>/<site>/… straight to that site's current/ directory, with the handle and site captured by regexes limited to [a-z0-9-] — traversal is not expressible in them. Directory listings are off (nginx defaults to autoindex off, and nothing turns it on); a directory with no index serves nothing. The Go path checks are real but they are write-time controls: they decide where bytes land, not what gets served.

4.5

Versions are per-upload, owned, timestamped and revertible — but "atomic symlink" would be a lie. Each upload writes a new vN/ and a versions row carrying the owner, a timestamp and a SHA-256 of the content. Going live copies that tree to current/ via remove-then-rename: current is a real directory, not a symlink, and there is a brief window during the swap where it is absent. Rollback re-points it the same way. Small, real, and worth knowing before you promise zero-downtime deploys.

Fig 01 · Upload, validation & serve pipeline Platform Accepted Rejected
People & agents
Coding agent
holds the user's API key
uploads .tar.gz / .zip · or JSON files
Viewer's browser
GET https://sites.simple-host.app/<handle>/<site>/
public on the internet
1 · upload  ·  GET site path
Platform · Go writes · nginx serves
Upload API · Go
X-API-Key → authenticated owner
2 · validate in memory ↓
nginx path match
/<handle>/<site>/… → that site's current/
[a-z0-9-] captures · no traversal ↓
Validation gate
100 MB/file · 500 MB total · 50k entries
traversal blocked twice · symlinks dropped
Static file serve
bytes + content type only
no SSR · no exec · no autoindex
.sh .php .py … refused outright · .env and other secrets silently skipped · nothing written to disk
3 · write vN · copy into current/  ·  read-only bytes from current/
Storage · versioned disk
Versioned site tree
v1 … vN · one per upload, owned + timestamped
current/ is a copied directory · remove + rename
Postgres
users · sites · versions
per-site state & collections

Top to bottom: an agent authenticates with the user's key and uploads; extraction happens in memory and the validation gate enforces size caps, traversal protection and the script-type refusal there — before anything touches disk. Accepted content is written as a new vN/ and copied into current/. Serving is a wholly separate read path that never enters the Go process: nginx matches a constrained path regex and returns bytes. No user code ever runs server-side — that is the guarantee, not the extension check.

05.

Trust model

Every surface is gated; the gates differ in strength, and a few are weaker than they sound. Sites are public on the internet — the protection is that they're static, owned, and revertible.

T1Deploy & manage sitesPer-user API key in X-API-Key (never Authorization: Bearer). Two registration paths mint one: email magic-link / 6-digit code, and owner Google sign-in. The key authenticates the owner of every upload, rollback and delete; each handler re-resolves the site by user_id, so a valid key for another account is a 404, not a leak. The key is stored in the users row as issued and matched by equality — there is no hash step.
T2AdminTwo independent paths: the ADMIN_API_KEY from the service environment file (constant-time compare, synthesises an in-memory admin that has no database row), and an is_admin flag on a real user. Never in code, config, or the repo.
T3Viewing sitesPublic, with no exceptions. There is no password lock and no private mode: anything deployed is readable by anyone who has the address. Its state and collections are readable too, subject only to the Origin gate.
T4Per-site state & collectionsReads are Origin-checked — attribution-grade, not authentication-grade, and documented as such. Writes run through WRITE_AUTH_MODE: onthe mode live on simple-host.app since 2026-09-05 (the source default is log) — requires a visitor session plus X-SH-CSRF: 1 or an API key; log and off are operator opt-downs (log records the same decision and allows the write anyway; off skips the check). An admin-only per-site allow-anonymous-writes flag overrides even on. Visitor sessions exist only on custom domains; on the shared host writes are open, so a page there saves without sign-in and anyone can change that data.
T5Create with AISign-in-gated and rate-limited, per user (burst 30, +1 per 10 s) and per IP (burst 20, +1 per 12 s), with a separate generous budget for status polling. The endpoint is not registered at all unless a provider key is configured. No model credential is ever emitted into a served page.
T6DB & secretsPostgres credentials and every key live in one environment file outside the repo, read at boot by the non-root service user and never reachable from a served page. Postgres itself listens on loopback only.
T7Traffic analyticsOwner scoping is a WHERE clause in the SQL, not a filter applied to results, so one owner's traffic cannot appear in another's list. The instance-wide all=1 is admin-only and answers 403 to anyone else rather than silently narrowing the scope — a quietly narrowed answer is a lie about what was returned.

Two limitations stated plainly, because both have surprised someone. The origin check on state reads is forgeable by anything that is not a browser — never put secrets in state. And every path-model site shares one origin, sites.simple-host.app, so one site's JavaScript can reach a sibling's state and storage: this platform does not offer site-to-site isolation in the browser, and no product copy should imply it.

06.

The little backend

Static hosting is commoditized. The difference here is that every site gets just enough backend — with no database to set up.

6.1

Per-site JSON state. One shared document per site (GET/PUT/PATCH /v1/sites/{site}/state, mirrored at /v1/u/{handle}/sites/{site}/state), with atomic ops — set, inc, append, remove, remove-where — applied under a row lock, and ETag / If-Match for cheap polling and optimistic concurrency. Counters, votes, saved app state, guestbooks.

6.2

Append-only collections. Growing lists (/collections/{name}) for signups, RSVPs and form submissions — cheap appends, paginated reads, and an owner-only CSV export.

6.3

Visitor sign-in on your own domain. A hosted auth.js helper gives a page Google or emailed-code sign-in, so visitors can write to 6.1 and 6.2. It works only on a site's own custom domain: on the shared host every site shares one origin, so no visitor sign-in is issued there — pages there save without one (anyone can change that data), and agents write with an API key anywhere.

6.4

Server-side visitor analytics. Per-site views and unique visitors, split into people, bots and monitoring, with an hourly and a daily trend — computed from the edge access log, so no tracking script and nothing to embed. IPs are salted-hashed and never stored raw. Section 07 is the whole pipeline.

All of it is backed by the same single Postgres — no per-site database, no separate service. The store is public to the site's own audience by design — sites and their data are public to anyone with the link. Note that llms.txt prescribes these exact request and response shapes to external models, so every site any LLM has generated from it is a live consumer of this contract.

07.

Counting who actually visited

The headline number is people, not requests. Getting those two apart was the entire point of this subsystem.

This box runs a directory service that probes every site's / every 30 seconds from 127.0.0.12,880 requests per site per day. Before traffic was classified that was roughly 97.8% of every recorded "view", which made the numbers worse than useless: a site with three real readers and a site with none looked identical. So nothing is counted now until it has been labelled.

7.1

nginx writes the ledger. Static pages are served straight off disk and the Go process never sees a page view, so the edge log is the only ground truth. A dedicated log_format shanalytics emits tab-separated ts · host · status · method · uri · remote_addr · user_agent into /var/log/simple-host/analytics.log. The directive is per vhost: a custom-domain server block added without that one line produces a site that silently records nothing.

7.2

A Go ingester tails it every 5 minutes. It resumes from a persisted (inode, byte offset), and that offset advances in the same transaction as the counter upserts — so a crash re-reads a batch instead of double-counting one. Each line is attributed to a site by host: content-host path, legacy label, or bound custom domain. Unattributable lines are skipped, never guessed. A run is capped at 200,000 lines and recovers from its own panics, so it can never take the server down.

7.3

Rotation is part of the design, not an afterthought. logrotate uses create plus delaycompress, and both are load-bearing. copytruncate would keep the inode and break offset tracking; and because the ingester drains the previous file as plain .log.1 and cannot read gzip, compression must be held back a full cycle. Change the logrotate stanza and you are changing the ingester's contract.

7.4

Three classes, and loopback always wins. Every request is labelled person (no automation signature), bot (crawlers, AI scrapers, SEO suites, security scanners, HTTP libraries — plus exploit paths like /wp-login.php regardless of what the User-Agent claims), or infra (loopback probes and hosted uptime checkers). A loopback address is infra whatever it calls itself. person is the default, deliberately: an unrecognised crawler inflates the human number rather than hiding real people, which is the safer direction to be wrong in.

7.5

Hourly buckets, split by class. site_view_hourly(site_id, hour, class, views) and site_visitor_hourly(site_id, hour, class, ip_hash) replaced the old date-grained tables. Hourly granularity is what makes "the last 24 hours" answerable at all, and the volume is trivial — a dozen sites × 24 hours × 3 classes is under 900 rows a day, so nothing is rolled up. Aggregates prune at 400 days.

7.6

Visitors are a salted hash, and the salt does not rotate. An IP is stored only as sha256(server-secret salt + ip) truncated to 16 bytes; the raw address is never written and the secret never leaves the box. The salt is stable on purpose. A per-day salt makes a returning visitor unlinkable across days, which makes "unique visitors over 30 days" impossible to compute — you are left summing daily uniques, which always overstates the audience. Stable salt is what makes a range total a real number instead of an inflated one.

7.7

The first month is kept, and honestly labelled. The pre-classifier *_daily tables are retained rather than dropped: they are the only surviving record of 2026-07-11 → 2026-08-08, whose raw logs have rotated away and cannot be rebuilt. The API reads them for any day before classified_from and reports them as a fourth class, unknown — plainly visible on the chart, never quietly folded into the human number.

GET /v1/sites/{name}/analytics returns totals, last_24h, daily and hourly, each as a class split, plus classified_from. The bulk GET /v1/analytics/sites exists because the dashboard must sort sites by traffic before it draws the first card, which one-request-per-site cannot do. A analytics-rebuild command replays the live log and rewrites every aggregate with the current classifier — so improving the classifier improves history, instead of leaving a permanent step in every chart.

Fig 02 · Analytics pipeline Platform Counted Stripped as infra
Edge · nginx
Real site requests
content host · legacy host · custom domain
served off disk — Go never sees a view
Loopback probe
nginx-directory · 127.0.0.1 · every 30s
2,880 hits / site / day
1 · log_format shanalytics · TSV → /var/log/simple-host/analytics.log · logrotate create + delaycompress
Ingester · in-process, every 5 min
Tail & attribute
resume at (inode, offset)
host → site · 200/304 documents only
Classifier
loopback → infra, first and always
UA + exploit paths → bot · else person
2 · one transaction: counter upserts and the offset advance commit together
Postgres · hourly aggregates
site_view_hourly
(site, hour, class) → views
pruned at 400 days
site_visitor_hourly
(site, hour, class) → ip_hash
sha256(stable secret + ip)[:16]
site_view_daily · site_visitor_daily — frozen, never dropped, read back as class “unknown”
3 · totals · last_24h · daily · hourly · classified_from
API & the two surfaces that render it
GET /v1/sites/{name}/analytics
index.html → Sites tab card + 24h columns
showcase.html → owner Analytics tab
GET /v1/analytics/sites
owner-scoped in SQL · all=1 admin-only
index.html → Sites tab ordering

What this cannot tell you, stated plainly. Identity is an IP hash, so an office behind one NAT reads as a single person and a phone moving from wifi to cellular reads as two. Only 200 and 304 responses to document requests count as a view — which means a scanner spraying 404s at /wp-login.php never reaches the bot column at all, because it never loaded anything; the bot number is "bots that actually read a page", which is the comparable figure to put next to people. analytics-rebuild replays only the live log, so anything already rotated out is beyond its reach. And there are no referrers, no per-path breakdown and no sessions — by choice, not by accident. Country (ordered by people, with bot and infrastructure counts alongside, from a separate /analytics/geo read) is the one geographic cut.

08.

Create with AI

A chat that builds a site for you. The model is called server-side, so the provider key stays on the server and never reaches a served page.

There are two builders and they are not the same thing. The chat builder lives on the showcase / owner page: it calls POST /v1/generate, which answers with a job id, and the page polls GET /v1/generate/status?id=…. A full build takes a minute or more — far longer than a browser will hold an idle connection open — so the turn runs as a background job and nothing depends on one long-lived request. The dashboard builder on the home page is deliberately different: it asks you to build in your own AI and paste the files back, then publishes them. Both publish through the same authenticated files API.

The sign-in gate and the rate limits are enforced in the Go handler, not at the edge — nginx does no auth for this route. The model is given no tools: the request body carries messages and nothing else, no function or tool definitions exist, and the contract is a text sentinel the model emits before a complete self-contained HTML document. It has no file access and no shell. You preview in a sandboxed, opaque-origin iframe — srcdoc with allow-same-origin deliberately absent — and only then publish.

You can speak instead of typing — captions appear as you talk and the text stays editable before you send. Speech is transcribed on this box by a local service on loopback, so recordings are never handed to a third party; the browser's WebSocket authenticates with a short-lived HMAC ticket rather than your API key. You can also attach a screenshot or notes and have the page built from them.

Each job is bound to the user who started it: a poll for someone else's id reports "not found" rather than "forbidden", so ids cannot be enumerated. The store is in-process — 8-minute run ceiling, 3 builds in flight per user, 64 in total, and results dropped 10 minutes after they finish. One builder provider is configured, Grok, and there is no fallback: if it is down the build fails honestly instead of quietly switching to a metered third-party key. Worth being precise about where it runs — the request goes to an OpenAI-compatible proxy on this same box over loopback, which then relays to the vendor. The inference is off-box; the proxy and its credentials are not.

09.

Runtime & deployment

Where this actually runs: one box, one process, no orchestration.

R1

One process behind nginx. A single Go binary under systemd, listening on loopback only — deliberately not a cluster. nginx terminates TLS, serves the content host by path straight off disk, proxies /v1/ and /internal/ to the app, and fronts every connected custom domain. There is no Caddy in this deployment; the on-demand-TLS endpoint in the code is a leftover from a design that was not adopted.

R2

Certificates are certbot, and issuing one is a human step. Let's Encrypt certs per domain, renewed by a timer twice daily. Connecting a custom domain through the API only records the binding and creates the symlink, then hands back the DNS record to add — an operator still writes the vhost and issues the cert before that domain serves anything. Any copy promising instant custom-domain HTTPS is describing a system this is not.

R3

Hardened & non-root. Runs as a dedicated simplehost user with NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, MemoryDenyWriteExecute, an empty capability bounding set, a syscall filter, and exactly one writable path — the sites directory. /healthz and /readyz (the latter pings the database) are real endpoints, but nothing is in front of them: on a single box they are observability, not a traffic gate.

R4

The service is small; the box is not exclusively its own. The process is one static binary doing static serving and pass-through proxying, with no heavy compute in-process. It shares the host with Postgres, the local speech-to-text service, the model proxy, the directory prober and several unrelated services — which is exactly why the analytics classifier had to learn to tell that neighbourhood apart from real readers.

R5

Releases are hand-swapped, and honestly so. Build, copy the binary in, restart; site data and versioned files are untouched. Timestamped simple-host.bak-* copies accumulate next to the live binary for rollback. There is no deploy script in the repo — the procedure lives in habit and prose, which is worth knowing before anyone describes it as automated.

R6

Local state, and the background loops. Postgres on the same box, loopback-bound (users, sites, versions, per-site state, collections, analytics); the versioned site tree on local disk; no external object store. Inside the process: the analytics ingester every 5 minutes; the API-metrics flush every 20 seconds and its prune every 6 hours; the geo worker; domain re-verification every 2 minutes (releasing unproven bindings after 24 hours); the visitor-auth sweep hourly; and the preview-expiry sweep hourly when enabled. Each recovers from its own panics — none can take the server down.

Fig 03 · Runtime — one box, one process Platform Traffic
Edge · nginx · certbot
nginx
TLS · content host by path, served off disk
proxies /v1/ + /internal/ to 127.0.0.1
Let's Encrypt
one cert per domain, issued by hand
renewed by certbot.timer
HTTPS · and the shanalytics access log
One box · systemd · non-root
simple-host · Go
REST API · dashboard · showcase · embedded skills
/healthz · /readyz · loopback only
Sandbox & loops
ProtectSystem=strict · one writable path
analytics 5 min · api-metrics 20 s
reads / writes
Local state
Postgres
users · sites · versions · state
collections · analytics
Versioned disk
/srv/simple-host/sites
v1 … vN · current/ · handle + domain farms
10.

What it deliberately is not

N1Not general computeNo user-code execution, by construction. No SSR, no backends, no PHP, no processes per site.
N2Static-only servingHTML/CSS/JS/images/fonts served as bytes. Framework output is welcome; framework servers are not.
N3Not a secret storePer-site state is readable by the site's audience and documented as such: sites and their data are public to anyone with the link.
N4Not browser-level isolationEvery path-model site shares one origin, so sites are not sandboxed from each other in the browser. This is a known, accepted trade of the path model — never imply otherwise in product copy.
N5Not analytics-as-a-productNo referrers, no per-path breakdown, no sessions, no cross-site identity — country is the one cut. Counting people honestly is the whole ambition.
N6Not bypassable by scalePer-site size and state caps are enforced server-side; AI create and state writes are rate-limited per user and per IP.