Looking for Homepad itself? This is its backend. To run Homepad, use the quickstart: one compose file with published images for amd64 and arm64, and no build. Website: gethomepad.dev · live demo: demo.gethomepad.dev.
Image:
ghcr.io/finish06/homepad-api:<version>, tagged with the same version asghcr.io/finish06/homepad.
Backend (Go) for homepad — the self-hosted homelab launcher with live uptime badges. Serves the shared service catalog, per-user favorites & ordering, admin catalog CRUD, and both auth paths; polls Gatus server-side so the browser never sees the Gatus URL.
Web frontend (React + Vite) lives at homepad
— see its README for the banner, screenshots, and rendered architecture / auth
diagrams.
Spec:
homepad/specs/v1-launcher.mdTest plan:homepad/specs/test-plan-v1.md
✅ Alpha-complete. Every acceptance criterion this repo owns (A1, A4, A5, A6,
A9, A10, A11-backend) is implemented and green against real Postgres — no 501
stubs remain. go test ./... runs 26 tests green; go vet ./... clean.
- Auth — local register / login / logout (bcrypt, persistent sessions) and
PocketID OIDC (Authorization Code + PKCE, ID-token verified on stdlib crypto,
account-link by verified email, admin role from the OIDC group). OIDC is fully additive
and only active when
OIDC_ENABLED=true. - Sessions — stored in Postgres (migration
0017), so a restart or deploy does not sign anyone out. Onlysha256(token)is stored, never the token. Thehomepad_sessioncookie is HttpOnly, SameSite=Lax,Max-Age30 days, and sliding: the first request a minute or more after the last write extends the expiry and re-issues the cookie (so polling writes at most once a minute per session). Logout deletes the row; expired rows are swept hourly. Kid sessions last 180 days, also sliding. - Kid accounts — a third role,
kid(migration0018), created by an admin with just a name: no email, no password, and no way to sign in except an admin's "sign in this device as …". A kid sees only the services on its allow-list; every catalog read is filtered server-side throughstorage.VisibleServiceIDs, and every catalog, library, layout, icon-write, admin and settings write is 403. A kid may change its favorites (allowed apps only) and its theme.internal/api/routes_test.goclassifies every route and probes each one with a kid session; a new route that is not classified there fails the build. - Catalog — shared service list; order-aware
GET /api/servicesmerges each user's saved layout. Admin-only create / edit / delete (non-admin → 403). - Per-user state — favorites and personal sort order, persisted in Postgres.
- Status poller — polls Gatus on an interval (≤30s, status
as_ofexposed) and serves status per service; Gatus unreachable → allUNKNOWN, never a 5xx. - A11 — the Gatus URL is never present in any API response.
- Status widgets — a few numbers on tiles (Jellyfin, Sonarr, Radarr), a
clock and the weather, fetched by this server and never by the browser
(migration
0020,internal/widgets). Every request goes through one guarded client: http/https only, no redirects, 5s per request, 1 MiB bodies, and no link-local or cloud metadata address (checked on the resolved IP). Keys are write-only and sealed with AES-256-GCM underHOMEPAD_SECRET_KEY(64 hex characters, e.g.openssl rand -hex 32); without it, the API refuses to store a key and keyless widgets still work. Kids get no widget data. The favicon fetcher uses the same client.
POST /api/register GET /api/me
POST /api/login POST /api/logout
GET /api/auth/config GET /api/auth/oidc/login (when OIDC on)
GET /api/auth/oidc/callback (when OIDC on)
GET /api/services POST /api/services (admin)
PUT /api/layout PATCH /api/services/{id} (admin)
DELETE /api/services/{id} (admin)
GET /api/status POST /api/status/refresh (re-poll Gatus now)
Admin Accounts API (all admin-only; {id} is always a kid — an adult's id is 404):
GET /api/admin/accounts [{id, name, role, lastSeenAt, …}]
POST /api/admin/kids {name, avatarColor?} → 201
PATCH /api/admin/kids/{id} {name?, avatarColor?}
DELETE /api/admin/kids/{id} → 204 (sessions, favorites, allow-list go too)
GET /api/admin/kids/{id}/services {serviceIds: [...]}
PUT /api/admin/kids/{id}/services {serviceIds: [...]} whole-set replace → 204
GET /api/admin/kids/{id}/sessions [{id, userAgent, createdAt, lastSeenAt}]
DELETE /api/admin/kids/{id}/sessions/{sid} → 204 (the device's next request is 401)
POST /api/admin/kids/{id}/sign-in-here ends the admin's session, sets the kid's → 204
Backup, restore and Homepage import (all admin-only; SPEC-backup-import):
GET /api/admin/export?people=true|false the backup file (JSON, schema v1)
POST /api/admin/restore/preview?mode=merge|replace body: the backup → Plan
POST /api/admin/restore?mode=…&plan={planHash} same body → Result, or 409 + new Plan
POST /api/admin/import/homepage/preview {services, bookmarks?, settings?} → Plan
POST /api/admin/import/homepage?plan={planHash} same body → Result, or 409 + new Plan
A preview changes nothing. An apply re-plans inside one transaction holding an
advisory lock, refuses with 409 when the plan changed since the preview, and
writes everything or nothing. Every write lands in the shared catalog owner's
rows, whichever admin runs it. A backup holds no ids, password hashes,
sessions, OIDC or env config, or the Gatus URL; its JSON Schema is
docs/schema/homepad-backup-v1.json.
Homepage widget, widgets and providers values are dropped while parsing
and never stored, logged or returned. url and icon written by these routes
are always http(s) or empty. Limits: a backup up to 16 MB, each Homepage file
up to 1 MB, at most 1,000 apps, 200 sections, 1,000 offers and 100 kids.
Status widgets (SPEC-status-widgets §7):
GET /api/widgets/info clock, weather, web search (any session, kids too)
GET /api/admin/widgets/kinds (admin) {secretKeySet, kinds: [...]}
GET /api/services/{id}/widget (admin) config; the key is only `hasSecret`
PUT /api/services/{id}/widget (admin) {kind, address, secret?, fields, intervalS, …}
DELETE /api/services/{id}/widget (admin) → 204
POST /api/services/{id}/widget/test (admin) unsaved values → {ok, version, fields} | {ok:false, error}
GET /api/admin/geocode?q= (admin) place search for the weather
GET /api/services carries widget: {kind, state, asOf, fields} on a service
with an enabled widget, for adults only. PATCH /api/admin/settings takes the
clock, weather and web search settings and echoes them in a widgets block;
GET/PATCH /api/me carry showWidgets.
Kid names are 1–40 characters and unique among kids (case-insensitive);
avatarColor is one of the frontend accent ids (blue, teal, green,
yellow, orange, red, pink, purple; default blue). A session id is
an opaque handle, never the token or its hash.
Invites and sign-up (SPEC-household-basics):
GET /api/auth/config {oidcEnabled, registration, setupNeeded} (public)
POST /api/admin/invites {name, role} → 201 {id, name, role, createdAt, expiresAt, token}
GET /api/admin/invites pending only, newest first (no token)
DELETE /api/admin/invites/{id} → 204; 404 when not pending
POST /api/invites/lookup {token} → {name, role, inviterName, expiresAt} (public)
POST /api/invites/accept {token, email, password, name?} → 201 /api/me body + session (public)
HOMEPAD_REGISTRATIONdefaults toinvite_only. The first adult account always registers and becomes admin, in every mode, and exactly one wins a race (one advisory lock serializes every account insert). After that, an admin adds adults with invites;openstill lets anyone register.- An invite is for
adminoruser, neverkid, works once and lasts 7 days. The token is 32 random bytes, base64url, returned once on create; only its SHA-256 is stored (migration0019). Every dead token (unknown, malformed, expired, used, revoked) gets the same 404 at lookup and accept. Accept takes its role from the invite; any other field is a 400. - Lookup and accept share one in-process rate limit: 30 a minute, burst 10,
then 429 with
Retry-After. HOMEPAD_MAX_ACCOUNTScaps all accounts plus pending invites (unset or0: no limit; anything else that isn't a whole number stops the API). At the cap, kid create, invite create,openregister and OIDC auto-create answer 409account limit reached (limit N). Accepting an invite and the first account are never refused.- Every catalog write acts on the shared catalog for every admin, not just
the first (#87). Those routes are their own class in
server.go, andsecond_admin_test.goprobes each one as a second admin. - OIDC links or creates an account by email only when the provider's
email_verifiedclaim is true (#89).
GET /api/services carries responseTimeMs per service — the latest Gatus
check's response time — only when there is one; the key is omitted (never
0) for unmonitored services or when Gatus reported no duration.
A service whose latest check succeeded but took longer than GATUS_DEGRADED_MS
(default 1000; 0 disables) reads DEGRADED — "Slow" on the tile. Gatus has
no degraded state of its own; homepad derives it from the reported duration.
Admins can change the threshold at runtime from the System panel: it is
statusDegradedMs on GET /api/system/config (the effective value) and
PATCH /api/admin/settings (migration 0014); a saved value wins over the env
var and applies to the next poll with no restart.
POST /api/status/refresh re-polls Gatus synchronously: 200 {as_of} when it
answered, 503 {error, as_of} when it could not be reached (the last good
snapshot and its as_of stand).
GET /api/me / PATCH /api/me carry densityPref (large|compact|list, default
compact) alongside themePref. PATCH takes either or both fields; only the
fields present are validated and written.
categories.gridWidth is a 12-column span — 3 (quarter), 4 (third), 6
(half) or 12 (full); default 6. PATCH /api/categories/{id} {gridWidth} rejects
anything else with 400. Migration 0013 remapped the old 1–8 tile counts
(1→3, 2→4, 3→6, 4→6, 5→12, 6–8→12) and kept the old value in grid_width_legacy.
cmd/homepad-api/ entry point (opens Store, migrates, starts Poller)
internal/api/ HTTP router + handlers (+ integration tests)
internal/oidc/ PocketID OIDC: discovery, PKCE, ID-token verify, JWKS
internal/gatus/ Gatus client + status poller
internal/session/ session store: Postgres (production) + in-memory (tests)
internal/storage/ Postgres access + migrations
internal/testsupport/ test harness (httptest server, Gatus + IdP stubs)
internal/widgets/ status widgets: guarded client, key sealer, manager, weather
internal/widgets/providers/ Jellyfin, Sonarr, Radarr
migrations/ SQL migrations
cp .env.example .env
make db-up # docker compose Postgres on :5432
make run # boots on :8080make test # full suite (Postgres tests skipped if DATABASE_URL unset)
make test-unit # fast subset
make test-integration # spins Postgres + runs everythingK8s manifests are owned by Joe (homie / SRE bot), not in this repo. This repo ships:
Dockerfile(multi-stage, distroless final image, nonroot).env.example(the full env contract, incl. theOIDC_*values)specs/v1-launcher.md§ Deployment contract (canonical: image, ports, env, secrets, probes)