Skip to content

About

Go API for Homepad, the self-hosted household dashboard (users, catalog, live status via Gatus, OIDC).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Go 1.25 Postgres Local + PocketID OIDC MIT License

homepad-api

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 as ghcr.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.md Test plan: homepad/specs/test-plan-v1.md

Status

✅ 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.

What it does

  • 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. Only sha256(token) is stored, never the token. The homepad_session cookie is HttpOnly, SameSite=Lax, Max-Age 30 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 (migration 0018), 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 through storage.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.go classifies 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/services merges 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_of exposed) and serves status per service; Gatus unreachable → all UNKNOWN, 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 under HOMEPAD_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.

Endpoints

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_REGISTRATION defaults to invite_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; open still lets anyone register.
  • An invite is for admin or user, never kid, works once and lasts 7 days. The token is 32 random bytes, base64url, returned once on create; only its SHA-256 is stored (migration 0019). 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_ACCOUNTS caps all accounts plus pending invites (unset or 0: no limit; anything else that isn't a whole number stops the API). At the cap, kid create, invite create, open register and OIDC auto-create answer 409 account 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, and second_admin_test.go probes each one as a second admin.
  • OIDC links or creates an account by email only when the provider's email_verified claim 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.

Layout

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

Run locally

cp .env.example .env
make db-up         # docker compose Postgres on :5432
make run           # boots on :8080

Test

make test          # full suite (Postgres tests skipped if DATABASE_URL unset)
make test-unit     # fast subset
make test-integration  # spins Postgres + runs everything

Deploy

K8s 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. the OIDC_* values)
  • specs/v1-launcher.md § Deployment contract (canonical: image, ports, env, secrets, probes)

About

Go API for Homepad, the self-hosted household dashboard (users, catalog, live status via Gatus, OIDC).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages