A household chore coordination web app. Designed as a PWA for iPhone homescreen, beautiful enough for a non-technical grandmother.
# Development (requires Podman)
make local # Start stack (app:8080, Mailpit:8025, Postgres:5432)
make local-fresh # Fresh rebuild with volume wipe (required after any JS/template change)
make run # Run without database (in-memory stores)
make seed # Seed test user (test@nabu.local / "correct horse battery")
# Testing
make test # Go + JS tests
make test-go # Go tests only
make test-js # JS tests only
make coverage # Go test coverage report
make e2e # End-to-end tests (~31 spec files)
make lint # golangci-lint
make fmt # Format Go code- Go 1.25+ (CI uses 1.25,
go.modspecifies 1.25.11) - JS tests require
pnpm installfirst forjsdom(dev dependency). Tests use Node's built-in test runner. - E2E tests require
pnpm exec playwright install chromiumto download the browser binary. - Podman Compose for local stack (
make local). Docker Compose may work but is untested.
Go standard-library HTTP server — no web framework. Dependencies: pgx/v5 (Postgres) and golang.org/x/crypto (bcrypt). Frontend is plain ES modules — no bundler, no framework, no build step.
| Package | Purpose |
|---|---|
app |
Route registration, middleware chain, dependency wiring, JS/SW cache-busting |
auth |
Registration, login, sessions, magic link, password reset, Google OIDC |
household |
Household creation, invite system, role management, multi-household switching |
chore |
Chore CRUD, reorder, restore defaults, 12 predefined system chores |
log |
Chore logging (idempotent per chore/day), today/week/month/history queries |
schedule |
Recurring schedules with time-slot matching engine, drag-and-drop rescheduling |
notification |
In-app notification store, per-type notification preferences, push dispatch |
push |
Web Push (RFC 8291) — VAPID JWT signing, aes128gcm encryption, subscription store |
stats |
Leaderboard, streaks, heatmap, category breakdown, busy hours, weekly recap |
userprefs |
Chore ordering, hidden home chores, timezone preferences |
handlers |
HTTP handlers grouped by feature — no business logic, call services directly |
middleware |
RequestLogger → SecurityHeaders → Session → CSRF → RateLimiter (strict /api/auth + optional global /api/* backstop) |
database |
pgx connection pool + embedded SQL migration runner (26 migrations) |
config |
Environment variable loader with defaults |
audit |
Audit logging interface + std logger implementation |
mail |
SMTP mailer for verification, magic link, password reset emails |
version |
Build-time version string (injected via -ldflags) |
| Module | Purpose |
|---|---|
app.js |
SPA entry point; event delegation for all user interactions |
state.js |
Single mutable app state object |
morph.js |
DOM morphing — updates DOM to match new HTML without losing focus/form state |
api.js |
apiFetch() wrapper; injects CSRF token on state-changing requests |
utils.js |
Shared helpers (escapeHTML, date formatting, etc.) |
head-init.js |
Inline <head> script — theme, timezone, and initial state bootstrap |
auth.js |
Login, register, magic link, password reset, OIDC views |
household.js |
Household create/join, member management, multi-household switching |
today.js |
Chore grid, tap-to-log/undo, logChore() API wrapper |
calendar.js |
Day and week calendar views; ad-hoc log placement by slotHour |
home.js |
Home-tab grid and quick-log sheet |
schedule.js |
Schedule CRUD, time-slot matching engine |
schedule-tab.js |
Schedule tab UI, pick-chore sheet, drag-and-drop rescheduling |
preferences.js |
Chore ordering preferences |
stats.js |
Leaderboard, streaks, category bars, weekly recap |
chores.js |
Chores management tab — CRUD, indicator defaults, volume settings |
profile.js |
Profile / settings tab |
notifications.js |
Notification bell, dropdown, and preferences |
POST /api/logs accepts an optional hour integer (0–23) in the request body, stored as slot_hour. This drives where a log appears in the calendar:
slot_hour IS NULL→ Anytime rowslot_hour = N→ N:00 hour row
All logs created from the home tab must pass a non-null slotHour. Logs must never land in Anytime unless they were explicitly unscheduled. This is enforced by E2E tests in tests/e2e/home-log-to-calendar.spec.js.
Web Push (RFC 8291) for chore reminders on iOS and Android PWAs. Uses VAPID (voluntary application server identification) with ES256-signed JWTs and aes128gcm content encryption. Push subscriptions are stored per-device; users configure which chore events trigger pushes via notification preferences.
Required env vars for push: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@example.com).
The service worker (web/static/service-worker.js) handles incoming pushes and displays notifications. On new deploys the SW version is bumped automatically via injected build version, triggering the browser's update flow and showing an "App updated" toast.
See PUSH_DEBUG.md for the diagnostic playbook.
Nabu is a Progressive Web App with a service worker caching strategy. Key PWA features:
- Offline-ready: SW caches static assets and serves stale-while-revalidate
- iOS homescreen: Manifest, apple-touch-icon, standalone display mode
- Update detection: Version-aware SW triggers update toast on new deploys
- Push notifications: Native browser/web push integration (see above)
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP listen port |
APP_ENV |
development |
Environment name |
APP_BASE_URL |
http://localhost:8080 |
Public base URL (for magic links, OIDC redirects) |
SERVER_SECURE |
false |
Set to true when behind TLS (enables Secure cookies, HSTS) |
DATABASE_URL |
(empty) | Postgres connection string; empty = in-memory stores |
SMTP_HOST |
(empty) | SMTP server hostname |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
(empty) | SMTP username |
SMTP_PASS |
(empty) | SMTP password |
SMTP_FROM |
(empty) | From address for emails |
GOOGLE_CLIENT_ID |
(empty) | Google OAuth2 client ID |
GOOGLE_CLIENT_SECRET |
(empty) | Google OAuth2 client secret |
APPLE_CLIENT_IDS |
(empty) | Comma-separated audiences accepted on Sign in with Apple identity tokens (the iOS bundle ID; empty disables the native endpoint) |
APPLE_WEB_CLIENT_ID |
(empty) | Services ID for web Sign in with Apple (must have APP_BASE_URL's domain + /api/auth/apple/web/callback registered in the Apple portal; empty hides the web button). Automatically accepted as a token audience |
APNS_AUTH_KEY_P8 |
(empty) | APNs .p8 auth key contents (PEM, PEM with \n escapes, or base64 of the PEM). All four APNS_* must be set to enable native push |
APNS_KEY_ID |
(empty) | APNs auth key ID |
APNS_TEAM_ID |
(empty) | Apple developer team ID (also enables the universal-links AASA with APNS_BUNDLE_ID) |
APNS_BUNDLE_ID |
(empty) | iOS app bundle ID (APNs topic) |
TRUSTED_PROXY_CIDRS |
(empty) | CIDR list for trusted reverse-proxy IPs. Enables real client-IP attribution (X-Forwarded-For) for rate limiting and audit logs, and gates the global rate-limit backstop. Logs a startup warning if empty when APP_ENV=production. |
RATE_LIMIT_AUTH_MAX |
5 |
Max /api/auth requests per minute per IP |
RATE_LIMIT_GLOBAL_MAX |
120 |
Permissive backstop on all /api/*, per IP per path. Only active when TRUSTED_PROXY_CIDRS is set. |
VAPID_PUBLIC_KEY |
(empty) | VAPID public key (base64-encoded uncompressed EC point) |
VAPID_PRIVATE_KEY |
(empty) | VAPID private key (base64-encoded) |
VAPID_SUBJECT |
(empty) | VAPID subject (e.g. mailto:admin@example.com) |
POST /api/auth/register POST /api/auth/login POST /api/auth/logout GET /api/me POST /api/auth/email/verification/resend GET /api/auth/email/verify POST /api/auth/magic-link/request GET /api/auth/magic-link/consume POST /api/auth/password/forgot POST /api/auth/password/reset POST /api/auth/password GET /api/auth/google/login GET /api/auth/google/callback POST /api/auth/apple/native GET /api/auth/apple/web/login POST /api/auth/apple/web/callback
GET /api/household POST /api/household PATCH /api/household POST /api/household/invites GET /api/household/invites DELETE /api/household/invites/{id} POST /api/household/join PATCH /api/household/members/{id} DELETE /api/household/members/{id} POST /api/household/leave POST /api/household/transfer
GET /api/households POST /api/households/{id}/activate
GET /api/chores POST /api/chores GET /api/chores/{id} PATCH /api/chores/{id} DELETE /api/chores/{id} POST /api/chores/reorder GET /api/chores/defaults POST /api/chores/seed-defaults POST /api/chores/{id}/restore-default
POST /api/logs PATCH /api/logs/{id} DELETE /api/logs/{id} GET /api/logs/today GET /api/logs/week GET /api/logs/month GET /api/logs/history GET /api/logs/latest-per-chore
GET /api/schedules POST /api/schedules PATCH /api/schedules/{id} DELETE /api/schedules/{id} GET /api/schedules/for-date
GET /api/notifications POST /api/notifications/read-all POST /api/notifications/{id}/read DELETE /api/notifications/{id}
GET /api/notification-preferences PATCH /api/notification-preferences
POST /api/push/subscribe POST /api/push/unsubscribe
GET /api/preferences PATCH /api/preferences
GET /api/stats/leaderboard GET /api/stats/streaks GET /api/stats/heatmap GET /api/stats/breakdown GET /api/stats/recap GET /api/stats/overview GET /api/stats/busy-hours GET /api/stats/chores GET /api/stats/chores/{id} GET /api/stats/chores/{id}/time-series
GET /health GET /ready
At startup, buildVersionedJSCache in internal/app/server.go walks every .js file in the embedded FS and rewrites all relative ES module import paths to include ?v=<version> (e.g. from './calendar.js' → from './calendar.js?v=0.1.187'). All JS files are then served from memory with Cache-Control: no-store.
Why: Cloudflare overrides Cache-Control: no-cache with max-age=14400 (4 hours). no-store bypasses this entirely (cf-cache-status: BYPASS). The versioned import paths bust browser module caches on every deploy.
Rule: never add ?v=anything manually to a JS import path. The rewriter skips paths that already contain ?, so a hard-coded version won't be updated on deploy.
The service worker file (/service-worker.js) is also version-injected at startup so the browser detects a new SW on every deploy.
- Production URL:
https://nabu-app.com - Container image:
quay.io/nabu/nabu(multi-arch: amd64 + arm64) - Deploy: push a
v*tag — CI builds, runs all tests, scans for vulnerabilities, signs the image, and deploys automatically - Test account:
verify@yearofbingo.com/test123456
Pushes to main and v* tags trigger:
- Secret scan (Gitleaks)
- Lint (golangci-lint)
- Go tests with race detector and coverage (reported to Codecov)
- JS tests (Node built-in test runner)
- E2E tests (Playwright, Chromium, 31 spec files)
- iOS unit tests (
xcodebuild testofNabuTestson macOS, whenios/**changes) - Client parity (PR only) — lints the parity matrix and requires it to be updated when client/API code changes
- Build — multi-arch container image (linux/amd64, linux/arm64) on Quay.io
- Scan & Sign — Trivy vulnerability scan + Cosign keyless signing
- GitHub Release — auto-generated from conventional commits
- Deploy — verifies tag is on
main, then deploys via SSH + Cloudflare Tunnel
# Imports must carry the new version tag
curl -s https://nabu-app.com/static/js/calendar.js | grep "^import"
# Expected: import { ... } from "./utils.js?v=0.1.X";
# Cache headers must be no-store / BYPASS (not max-age / HIT)
curl -sI https://nabu-app.com/static/js/app.js | grep -i cache
# Expected: cache-control: no-store
# cf-cache-status: BYPASS
# Version endpoint
curl -s https://nabu-app.com/ | grep 'app.js'
# Expected: src="/static/js/app.js?v=0.1.X"See compose.server.yaml for full production setup (Cloudflare Tunnel, /mnt/data volumes, R2 backups). Server provisioning via cloud-init.yaml.
MIT