From a09d81ca2790192faec46b1fec93281893c3abb9 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Sat, 20 Jun 2026 21:04:07 +0530 Subject: [PATCH 01/10] fix(agent): drop nonexistent worker/ copy from Dockerfile --- agent/Dockerfile | 2 +- api/internal/db/db.go | 3 ++- api/internal/handlers/ingest.go | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/agent/Dockerfile b/agent/Dockerfile index 5faffc2..e4bed2d 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -17,7 +17,7 @@ RUN go work sync && go mod download -modfile agent/go.mod # Copy full source COPY agent/ ./agent/ COPY api/ ./api/ -COPY worker/ ./worker/ + # Build the agent binary (CGO disabled β†’ fully static) RUN CGO_ENABLED=0 go build -o /pulse-agent ./agent/cmd/agent diff --git a/api/internal/db/db.go b/api/internal/db/db.go index 90dfdb6..66c73cd 100644 --- a/api/internal/db/db.go +++ b/api/internal/db/db.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "embed" + "errors" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite" @@ -39,7 +40,7 @@ func runMigrations(conn *sql.DB) error { if err != nil { return err } - if err := m.Up(); err != nil && err != migrate.ErrNoChange { + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return err } return nil diff --git a/api/internal/handlers/ingest.go b/api/internal/handlers/ingest.go index 2a23df6..abda86e 100644 --- a/api/internal/handlers/ingest.go +++ b/api/internal/handlers/ingest.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "log" "net/http" "strings" "time" @@ -55,7 +56,7 @@ func (h *Ingest) PostMetrics(w http.ResponseWriter, r *http.Request) { if err := h.q.UpdateAgentLastSeen(r.Context(), agent.ID); err != nil { // Non-fatal: log but continue - _ = err + log.Printf("failed to update agent last seen: %v", err) } w.WriteHeader(http.StatusNoContent) From 943889796a344ff73f806a3b0be93a9b850dcd16 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Sat, 20 Jun 2026 21:14:21 +0530 Subject: [PATCH 02/10] refactor(api): changed int to bool for all isactive flag --- api/internal/db/sqlc.yaml | 5 +++++ api/internal/generated/models.go | 4 ++-- api/internal/generated/monitors.sql.go | 4 ++-- api/internal/handlers/monitors_test.go | 2 +- api/internal/handlers/overview.go | 6 +++--- api/internal/web/feed.go | 2 +- api/internal/web/public.go | 2 +- api/internal/worker/incident/detector_test.go | 4 ++-- api/internal/worker/pruner/pruner_test.go | 2 +- api/internal/worker/scheduler/scheduler_test.go | 4 ++-- 10 files changed, 20 insertions(+), 15 deletions(-) diff --git a/api/internal/db/sqlc.yaml b/api/internal/db/sqlc.yaml index ce48032..b876287 100644 --- a/api/internal/db/sqlc.yaml +++ b/api/internal/db/sqlc.yaml @@ -9,3 +9,8 @@ sql: out: "../generated" emit_json_tags: true emit_pointers_for_null_types: true + overrides: + - column: "monitors.is_active" + go_type: "bool" + - column: "infra_agents.is_active" + go_type: "bool" diff --git a/api/internal/generated/models.go b/api/internal/generated/models.go index 2c3250f..d0f72d1 100644 --- a/api/internal/generated/models.go +++ b/api/internal/generated/models.go @@ -63,7 +63,7 @@ type InfraAgent struct { HostLabel string `json:"host_label"` Token string `json:"token"` LastSeenAt *time.Time `json:"last_seen_at"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` } @@ -113,7 +113,7 @@ type Monitor struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` diff --git a/api/internal/generated/monitors.sql.go b/api/internal/generated/monitors.sql.go index 96c52d2..9320aba 100644 --- a/api/internal/generated/monitors.sql.go +++ b/api/internal/generated/monitors.sql.go @@ -27,7 +27,7 @@ type CreateMonitorParams struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` @@ -254,7 +254,7 @@ type UpdateMonitorParams struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID *int64 `json:"group_id"` ID int64 `json:"id"` } diff --git a/api/internal/handlers/monitors_test.go b/api/internal/handlers/monitors_test.go index aaa7494..b279128 100644 --- a/api/internal/handlers/monitors_test.go +++ b/api/internal/handlers/monitors_test.go @@ -27,7 +27,7 @@ func TestMonitorsCRUD(t *testing.T) { "timeout_seconds": 10, "degraded_threshold_ms": 500, "down_threshold_ms": 2000, - "is_active": 1, + "is_active": true, }) req := httptest.NewRequest(http.MethodPost, "/api/monitors", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") diff --git a/api/internal/handlers/overview.go b/api/internal/handlers/overview.go index f615d7b..496c444 100644 --- a/api/internal/handlers/overview.go +++ b/api/internal/handlers/overview.go @@ -31,7 +31,7 @@ func (h *Overview) Get(w http.ResponseWriter, r *http.Request) { var up, degraded, down int attention := []attentionItem{} for _, m := range monitors { - if m.IsActive == 0 { + if !m.IsActive { continue } s := status[m.ID] @@ -49,10 +49,10 @@ func (h *Overview) Get(w http.ResponseWriter, r *http.Request) { } } - // InfraAgent: Name string, IsActive int64, LastSeenAt *time.Time + // InfraAgent: Name string, IsActive bool, LastSeenAt *time.Time agents, _ := h.q.ListAgents(ctx) for _, a := range agents { - if a.IsActive == 0 || a.LastSeenAt == nil { + if !a.IsActive || a.LastSeenAt == nil { continue } if time.Since(*a.LastSeenAt) > 2*time.Minute { diff --git a/api/internal/web/feed.go b/api/internal/web/feed.go index aeef05d..4e2277d 100644 --- a/api/internal/web/feed.go +++ b/api/internal/web/feed.go @@ -42,7 +42,7 @@ func (p *Public) Feed(w http.ResponseWriter, r *http.Request) { } pageMonitors := map[int64]bool{} for _, m := range snap.Monitors { - if m.IsActive == 0 || m.GroupID == nil { + if !m.IsActive || m.GroupID == nil { continue } if allGroups || allow[*m.GroupID] { diff --git a/api/internal/web/public.go b/api/internal/web/public.go index 67e2a1f..f076c9a 100644 --- a/api/internal/web/public.go +++ b/api/internal/web/public.go @@ -257,7 +257,7 @@ func (p *Public) buildVM(ctx context.Context, rng string, rp ResolvedPage) statu pageMonitors := map[int64]bool{} byGroup := make(map[int64][]monitorVM) for _, m := range snap.Monitors { - if m.IsActive == 0 || m.GroupID == nil { + if !m.IsActive || m.GroupID == nil { continue } if !allowGroup(*m.GroupID) { diff --git a/api/internal/worker/incident/detector_test.go b/api/internal/worker/incident/detector_test.go index a71e82e..5941b1a 100644 --- a/api/internal/worker/incident/detector_test.go +++ b/api/internal/worker/incident/detector_test.go @@ -29,7 +29,7 @@ func TestDetector_IncidentOnSecondConsecutiveDown(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test API", Url: "http://example.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) @@ -77,7 +77,7 @@ func TestDetector_ResetsOnUp(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test API", Url: "http://example.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) diff --git a/api/internal/worker/pruner/pruner_test.go b/api/internal/worker/pruner/pruner_test.go index 7eaa9bb..0b5138a 100644 --- a/api/internal/worker/pruner/pruner_test.go +++ b/api/internal/worker/pruner/pruner_test.go @@ -19,7 +19,7 @@ func TestPruner_DeletesOldCheckResults(t *testing.T) { mon, err := q.CreateMonitor(context.Background(), store.CreateMonitorParams{ Name: "Test", Url: "http://x.com", Type: "http", IntervalSeconds: 60, TimeoutSeconds: 10, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", }) require.NoError(t, err) diff --git a/api/internal/worker/scheduler/scheduler_test.go b/api/internal/worker/scheduler/scheduler_test.go index 3ef093d..9cb60bc 100644 --- a/api/internal/worker/scheduler/scheduler_test.go +++ b/api/internal/worker/scheduler/scheduler_test.go @@ -27,7 +27,7 @@ func TestScheduler_CallsCheckerAtInterval(t *testing.T) { mon := store.Monitor{ ID: 1, Name: "test", Url: "tcp://x.com:80", Type: "tcp", IntervalSeconds: 1, TimeoutSeconds: 5, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", } @@ -51,7 +51,7 @@ func TestScheduler_ZeroIntervalDoesNotPanic(t *testing.T) { mon := store.Monitor{ ID: 1, Name: "test", Url: "tcp://x.com:80", Type: "tcp", IntervalSeconds: 0, TimeoutSeconds: 5, - DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: 1, + DegradedThresholdMs: 500, DownThresholdMs: 2000, IsActive: true, Source: "internal", } From 8c52a5328b4ce0b2d6e09417e207d73999b12935 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Sat, 20 Jun 2026 21:15:20 +0530 Subject: [PATCH 03/10] refactor(cli): changed isactive flag to bool from int64 --- cli/internal/pulseclient/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/internal/pulseclient/client.go b/cli/internal/pulseclient/client.go index 305c9d5..a510a9f 100644 --- a/cli/internal/pulseclient/client.go +++ b/cli/internal/pulseclient/client.go @@ -78,7 +78,7 @@ type createMonitorRequest struct { KeywordCheck string `json:"keyword_check"` DegradedThresholdMs int64 `json:"degraded_threshold_ms"` DownThresholdMs int64 `json:"down_threshold_ms"` - IsActive int64 `json:"is_active"` + IsActive bool `json:"is_active"` GroupID int64 `json:"group_id"` Source string `json:"source"` ExternalID string `json:"external_id"` @@ -96,7 +96,7 @@ func (c *Client) CreateMonitor(m uptimekuma.PulseMonitor, groupID int64) error { KeywordCheck: m.KeywordCheck, DegradedThresholdMs: 1000, DownThresholdMs: 3000, - IsActive: 1, + IsActive: true, GroupID: groupID, Source: "uptime-kuma", ExternalID: "", From d131239fc37c49bc77fe9904a3255c7cbf1b9efd Mon Sep 17 00:00:00 2001 From: memetics19 Date: Sat, 20 Jun 2026 21:16:12 +0530 Subject: [PATCH 04/10] refactor(ui): changed isactive flag to bool from int64 --- ui/src/app/admin/monitors/page.tsx | 2 +- ui/src/lib/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/app/admin/monitors/page.tsx b/ui/src/app/admin/monitors/page.tsx index 4641b4e..c2f9f57 100644 --- a/ui/src/app/admin/monitors/page.tsx +++ b/ui/src/app/admin/monitors/page.tsx @@ -9,7 +9,7 @@ import { const TYPES = ['http', 'tcp', 'ping', 'dns', 'ssl', 'infra'] const EMPTY: Partial = { name: '', url: '', type: 'http', interval_seconds: 60, timeout_seconds: 10, - degraded_threshold_ms: 500, down_threshold_ms: 2000, is_active: 1, + degraded_threshold_ms: 500, down_threshold_ms: 2000, is_active: true, keyword_check: '', source: 'internal', external_id: '', } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 9c70264..84ea58c 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -11,7 +11,7 @@ export interface Monitor { keyword_check: string degraded_threshold_ms: number down_threshold_ms: number - is_active: number + is_active: boolean group_id: number | null source: string external_id: string @@ -81,7 +81,7 @@ export interface InfraAgent { host_label: string token: string last_seen_at: string | null - is_active: number + is_active: boolean created_at: string } From 5693988e604566e8d46147c1800934d97169de91 Mon Sep 17 00:00:00 2001 From: memetics19 Date: Sat, 20 Jun 2026 21:18:15 +0530 Subject: [PATCH 05/10] ci: add release-please versioning and homelab deploy pipeline docs: add MIT license, deployment guide, contributing guide, and PR template --- .githooks/commit-msg | 11 + .githooks/pre-commit | 13 + .github/PULL_REQUEST_TEMPLATE.md | 50 + .github/workflows/release-please.yml | 19 + .github/workflows/release.yml | 32 +- .release-please-manifest.json | 3 + CONTRIBUTING.md | 75 ++ LICENSE.md | 21 + README.md | 4 +- deploy/Caddyfile | 27 + deploy/docker-compose.homelab.yml | 55 ++ deploy/docker-compose.prod.yml | 56 ++ docs/assets/README.md | 25 + docs/deployment.md | 92 ++ docs/getting-started.md | 9 +- docs/index.md | 3 + .../plans/2026-06-06-pulse-plan-2-auth.md | 909 ++++++++++++++++++ .../2026-06-07-pulse-plan-2b-api-keys.md | 300 ++++++ .../2026-06-07-pulse-plan-2c-setup-wizard.md | 559 +++++++++++ .../2026-06-07-pulse-plan-3-multipage.md | 137 +++ ...26-06-07-pulse-plan-3b-perpage-branding.md | 64 ++ .../2026-06-07-pulse-plan-4-admin-redesign.md | 231 +++++ .../2026-06-07-pulse-plan-5-maintenance.md | 132 +++ .../plans/2026-06-07-pulse-plan-totp.md | 126 +++ mkdocs.yml | 1 + release-please-config.json | 11 + 26 files changed, 2958 insertions(+), 7 deletions(-) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 deploy/Caddyfile create mode 100644 deploy/docker-compose.homelab.yml create mode 100644 deploy/docker-compose.prod.yml create mode 100644 docs/assets/README.md create mode 100644 docs/deployment.md create mode 100644 docs/superpowers/plans/2026-06-06-pulse-plan-2-auth.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-4-admin-redesign.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-5-maintenance.md create mode 100644 docs/superpowers/plans/2026-06-07-pulse-plan-totp.md create mode 100644 release-please-config.json diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..7215f27 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,11 @@ +#!/bin/sh +# Reject AI co-authorship / attribution in commit messages. +# Applies to everyone (humans and agents). Enable once per clone: +# git config core.hooksPath .githooks +msg_file="$1" +if grep -qiE 'co-authored-by|generated with .*(claude|code)|πŸ€–|noreply@anthropic' "$msg_file"; then + echo "commit-msg: AI / co-author attribution is not allowed in this repo." >&2 + echo "Remove Co-Authored-By and 'Generated with ...' lines, then commit again." >&2 + exit 1 +fi +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..387a9bc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# Basic "no slop" gate: staged Go must be gofmt-clean (generated code excluded). +# Enable once per clone: git config core.hooksPath .githooks +files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' | grep -v '/internal/generated/') +[ -z "$files" ] && exit 0 +bad=$(gofmt -l $files 2>/dev/null) +if [ -n "$bad" ]; then + echo "pre-commit: these Go files are not gofmt-formatted:" >&2 + echo "$bad" >&2 + echo "Run: gofmt -w " >&2 + exit 1 +fi +exit 0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6a2131b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,50 @@ + + +## Summary + + + +## Type of change + +- [ ] `feat` β€” new feature +- [ ] `fix` β€” bug fix +- [ ] `refactor` β€” no behaviour change +- [ ] `docs` +- [ ] `ci` / `build` β€” pipeline or tooling +- [ ] `test` +- [ ] `chore` +- [ ] **Breaking change** (`!` / `BREAKING CHANGE:` footer) + +## Changes + + + +- + +## Breaking changes + + + +## Test plan + +- [ ] `cd api && go test ./... -count=1` +- [ ] `cd agent && go test ./... -count=1` +- [ ] `cd cli && go test ./... -count=1` +- [ ] `cd ui && npx tsc --noEmit` (if the UI changed) +- [ ] Manual check: + +## Checklist + +- [ ] Commits follow Conventional Commits +- [ ] No `Co-Authored-By` / AI attribution (enforced by the `commit-msg` hook) +- [ ] `gofmt`-clean and `go vet ./...` passes +- [ ] Docs updated (`docs/`, `README.md`) where relevant +- [ ] No secrets or `.env` files committed + +## Deployment notes + + diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..e756967 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.PULSE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c80739..641860c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,33 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - name: Create GitHub release - uses: softprops/action-gh-release@v2 + + # Deploy runs only after the image is built and pushed, so the homelab never + # pulls a tag that does not exist yet. The GitHub release + changelog are + # created by release-please (see release-please.yml), not here. + deploy: + name: Deploy to homelab + needs: release + runs-on: ubuntu-latest + steps: + - name: Join Netbird VPN + env: + NB_URL: ${{ secrets.NETBIRD_MANAGEMENT_URL }} + run: | + curl -fsSL https://pkgs.netbird.io/install.sh | sudo sh + sudo netbird up --setup-key "${{ secrets.NETBIRD_SETUP_KEY }}" \ + ${NB_URL:+--management-url "$NB_URL"} + sudo netbird status + - name: Pull and restart on host over SSH + uses: appleboy/ssh-action@v1 with: - generate_release_notes: true + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + fingerprint: ${{ secrets.SSH_HOST_FINGERPRINT }} + script: | + cd ~/pulse + git fetch --tags && git pull --ff-only + docker compose -f deploy/docker-compose.homelab.yml pull + docker compose -f deploy/docker-compose.homelab.yml up -d + docker image prune -f diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5e19c6f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Pulse + +Thanks for contributing. This guide covers the dev setup, the commit policy +(enforced by git hooks), and how to open a pull request. + +## Development setup + +Pulse is a Go workspace (`go.work`) with three modules β€” `api`, `agent`, `cli` β€” +plus a Next.js admin UI in `ui/`. + +```sh +# Build the binary (embeds the admin UI) and run it on :8080 +make run + +# Run the test suites +cd api && go test ./... -count=1 +cd agent && go test ./... -count=1 +cd cli && go test ./... -count=1 + +# Regenerate DB access code after editing api/internal/db/queries or migrations +make sqlc # requires sqlc v1.31.1 + +# UI +cd ui && npm ci && npm run build +``` + +## Branching + +Branch off `main` with a type prefix: `feat/...`, `fix/...`, `docs/...`, +`refactor/...`, `ci/...`. Do not commit directly to `main`. + +## Commit policy (enforced) + +These rules apply to everyone, including AI assistants: + +- **Conventional Commits.** `type(scope): summary` (`feat`, `fix`, `refactor`, + `docs`, `build`, `ci`, `test`, `chore`). Mark breaking changes with a `!` after + the type or a `BREAKING CHANGE:` footer. This drives automatic versioning. +- **No AI / co-author attribution.** No `Co-Authored-By`, no "Generated with …" + lines, no bot trailers. +- **No force-pushes to `main`.** +- **No slop.** Code is `gofmt`-clean, passes `go vet ./...`, and passes the test + suite before it is committed. + +### Enable the hooks (once per clone) + +The repo ships hooks under `.githooks/` that enforce the rules above: + +```sh +git config core.hooksPath .githooks +``` + +`commit-msg` rejects AI/co-author trailers; `pre-commit` rejects un-`gofmt`'d Go. +(`--no-verify` bypasses git hooks β€” don't.) + +## Pull requests + +1. Push your branch and open a PR against `main`. The + [PR template](.github/PULL_REQUEST_TEMPLATE.md) loads automatically β€” fill in + every section (summary, type, changes, breaking changes, test plan, checklist). +2. CI must be green (vet, gofmt, tests, UI build) before review. +3. Keep PRs focused; one logical change per commit even if the PR bundles several. +4. **Merge-commit** the PR (don't squash) so each Conventional Commit reaches + `main` β€” release-please relies on them for the changelog and the version bump. + If you must squash, the PR title itself must be a Conventional Commit and carry + any `BREAKING CHANGE:` footer. + +## Releases & deployment + +Versioning is automated with +[release-please](https://github.com/googleapis/release-please): merging +Conventional Commits to `main` maintains a release PR; merging that PR tags +`vX.Y.Z`, publishes a GitHub release + `CHANGELOG.md`, builds and pushes the +image to GHCR, and deploys to the homelab. See +[docs/deployment.md](docs/deployment.md) for the full flow and required secrets. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5bdc93e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 memetics19 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2d8e171..3e81079 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Pulse is an open-source, self-hosted status page and monitoring tool. It ships a A live status page runs at [status.shreeda.xyz](https://status.shreeda.xyz). The full documentation is at [docs.shreeda.xyz](https://docs.shreeda.xyz). + + ## Highlights - **Single binary and SQLite.** One Go binary plus one SQLite file. The monitoring worker runs in-process. @@ -48,4 +50,4 @@ Full documentation is at [docs.shreeda.xyz](https://docs.shreeda.xyz). The Markd ## License -Pulse is open source. See the repository for license details. +Pulse is released under the MIT License. See [LICENSE.md](LICENSE.md). diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..f596609 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,27 @@ +# Pulse reverse proxy. +# +# This VM has a private address, so public Let's Encrypt certificates cannot be +# issued here. Caddy therefore serves plain HTTP and routes by Host. Point +# status.shreeda.xyz and docs.shreeda.xyz at this host (internal DNS or an +# /etc/hosts entry). To serve public HTTPS, place a proxy with public DNS in +# front, or switch these blocks to https:// once the domains resolve publicly. + +{ + auto_https off +} + +# Public status page. +http://status.shreeda.xyz { + reverse_proxy pulse:8080 +} + +# Documentation site (static, built by the docs-build service). +http://docs.shreeda.xyz { + root * /srv/docs + file_server +} + +# Anything else (for example the raw VM IP) shows the status page. +http:// { + reverse_proxy pulse:8080 +} diff --git a/deploy/docker-compose.homelab.yml b/deploy/docker-compose.homelab.yml new file mode 100644 index 0000000..81b48bb --- /dev/null +++ b/deploy/docker-compose.homelab.yml @@ -0,0 +1,55 @@ +# Homelab deployment: pulls the released image from GHCR (no local build), +# so `docker compose pull` picks up each new release. Used by the deploy job in +# .github/workflows/release.yml and for manual runs from the repo root on the host: +# +# docker compose -f deploy/docker-compose.homelab.yml pull +# docker compose -f deploy/docker-compose.homelab.yml up -d +# +# The caddy and docs-build services read config/markdown from the host's git +# checkout, so the deploy job runs `git pull` before `compose pull`. + +services: + pulse: + image: ghcr.io/memetics19/pulse:latest + environment: + SQLITE_PATH: /data/pulse.db + RESEND_API_KEY: ${RESEND_API_KEY:-} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + # Allow unprivileged ICMP (ping monitors) without privileged mode. + sysctls: + - net.ipv4.ping_group_range=0 2147483647 + volumes: + - pulse_data:/data + restart: unless-stopped + + # One-shot: render the Markdown docs into a static site on the docs_site volume. + docs-build: + image: squidfunk/mkdocs-material:latest + working_dir: /workspace + volumes: + - ./:/workspace:ro + - docs_site:/site + command: build -d /site -f /workspace/mkdocs.yml + restart: "no" + + caddy: + image: caddy:2-alpine + depends_on: + pulse: + condition: service_started + docs-build: + condition: service_completed_successfully + ports: + - "80:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - docs_site:/srv/docs:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + +volumes: + pulse_data: + docs_site: + caddy_data: + caddy_config: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..6cafeaf --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,56 @@ +# Production deployment: the Pulse status page and the documentation site, +# behind a single Caddy reverse proxy that routes by Host. +# +# docker compose -f deploy/docker-compose.prod.yml up -d --build +# +# Run from the repository root so the build context and bind mounts resolve. + +services: + pulse: + build: + context: . + dockerfile: api/Dockerfile + image: pulse:latest + environment: + SQLITE_PATH: /data/pulse.db + RESEND_API_KEY: ${RESEND_API_KEY:-} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + # Allow unprivileged ICMP (ping monitors) from inside the container. + # This is a namespaced sysctl, so no privileged mode is required. + sysctls: + - net.ipv4.ping_group_range=0 2147483647 + volumes: + - pulse_data:/data + restart: unless-stopped + + # One-shot: render the Markdown docs into a static site on the docs_site volume. + docs-build: + image: squidfunk/mkdocs-material:latest + working_dir: /workspace + volumes: + - ./:/workspace:ro + - docs_site:/site + command: build -d /site -f /workspace/mkdocs.yml + restart: "no" + + caddy: + image: caddy:2-alpine + depends_on: + pulse: + condition: service_started + docs-build: + condition: service_completed_successfully + ports: + - "80:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - docs_site:/srv/docs:ro + - caddy_data:/data + - caddy_config:/config + restart: unless-stopped + +volumes: + pulse_data: + docs_site: + caddy_data: + caddy_config: diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 0000000..b056dd2 --- /dev/null +++ b/docs/assets/README.md @@ -0,0 +1,25 @@ +# Docs assets + +## `status-page.gif` + +The home page and the project README embed `status-page.gif` β€” a short screen +recording of the live status page. **Drop the recorded file here as +`status-page.gif`** (this directory). It is referenced by `docs/index.md` and +`README.md`. + +### Recording it + +Record `https://status.shreeda.xyz` (a ~5–8s loop: page load, an incident, the +range selector). Any of: + +- **macOS:** [Kap](https://getkap.co) β€” record a region, export as GIF. +- **Linux:** [Peek](https://github.com/phw/peek). +- **Cross-platform:** [LICEcap](https://www.cockos.com/licecap/). +- **Scripted (Playwright):** + ```sh + npx playwright screenshot --wait-for-timeout=2000 https://status.shreeda.xyz status.png + # or record a video via a short Playwright script, then convert with gifski: + # gifski --fps 15 -o status-page.gif frames/*.png + ``` + +Keep it under ~3 MB so the docs site and README stay light. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..952bf22 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,92 @@ +# Deployment + +Pulse ships as a single container image published to the GitHub Container +Registry (GHCR) at `ghcr.io/memetics19/pulse`. Releases and deployment are +automated: merge work to `main`, merge the release PR, and the new version is +built, published, and rolled out to the homelab. + +## Release & deploy flow + +``` +commit (Conventional Commits) ─▢ main + β”‚ + β”œβ”€ ci.yml run tests on every push / PR + β”‚ + β”œβ”€ release-please.yml maintains a release PR from the commit history + β”‚ β”‚ + β”‚ └─ merge release PR ─▢ tag vX.Y.Z + GitHub release + CHANGELOG + β”‚ + └─ release.yml (on tag vX.Y.Z) + β”œβ”€ test re-run the suite + β”œβ”€ release build + push ghcr.io/memetics19/pulse:{version,latest} + └─ deploy join Netbird β†’ SSH to the host β†’ pull + restart +``` + +Tests gate everything: `release.yml` re-runs them before building, and `deploy` +only runs after `release` succeeds, so a broken build never reaches the homelab. + +## Versioning + +Versions are computed by [release-please](https://github.com/googleapis/release-please) +from Conventional Commit messages: `fix:` β†’ patch, `feat:` β†’ minor, `!` / +`BREAKING CHANGE:` β†’ major (minor while pre-1.0). The seed version lives in +`.release-please-manifest.json`. + +## Homelab host + +The status page runs on a private host (`10.2.0.115`) reachable over a +[Netbird](https://netbird.io) VPN. The deploy job joins the same Netbird +network from the GitHub runner, then connects over SSH. + +### One-time host setup + +```sh +# Docker + compose plugin must be installed. +git clone https://github.com/memetics19/pulse.git ~/pulse +cd ~/pulse +# If the GHCR package is private, authenticate once (else make the package public): +# echo "$GHCR_READ_TOKEN" | docker login ghcr.io -u memetics19 --password-stdin +docker compose -f deploy/docker-compose.homelab.yml up -d +``` + +`deploy/docker-compose.homelab.yml` pulls the `pulse` image from GHCR (no local +build) and serves it, plus the docs site, behind Caddy (`deploy/Caddyfile`). + +### Required GitHub secrets + +| Secret | Purpose | +|---|---| +| `PULSE_TOKEN` | GHCR push (already configured) | +| `NETBIRD_SETUP_KEY` | Ephemeral key so the runner joins the VPN | +| `NETBIRD_MANAGEMENT_URL` | Only if you self-host Netbird | +| `SSH_PRIVATE_KEY` | Deploy key authorized on the host | +| `SSH_HOST_FINGERPRINT` | Host key, to pin the SSH connection | +| `DEPLOY_HOST` | `10.2.0.115` | +| `DEPLOY_USER` | `ubuntu` | + +### What the deploy job runs on the host + +```sh +cd ~/pulse && git fetch --tags && git pull --ff-only +docker compose -f deploy/docker-compose.homelab.yml pull +docker compose -f deploy/docker-compose.homelab.yml up -d +docker image prune -f +``` + +## Rollback + +Pin the previous version and bring the stack back up on the host: + +```sh +cd ~/pulse +docker compose -f deploy/docker-compose.homelab.yml pull pulse # or edit the image tag +docker run ... ghcr.io/memetics19/pulse: # or set the tag and `up -d` +``` + +The simplest rollback is to re-point the `pulse` image tag to the previous +`vX.Y.Z` and run `docker compose ... up -d`. + +## Manual / local production stack + +For a self-built stack (no GHCR), `deploy/docker-compose.prod.yml` builds the +image locally instead of pulling it. diff --git a/docs/getting-started.md b/docs/getting-started.md index a845f56..888e572 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,7 @@ To serve a public page over HTTPS, you also need a reverse proxy in front of Pul Clone the repository and start the container: ```bash -git clone https://github.com/your-org/pulse.git +git clone https://github.com/memetics19/pulse.git cd pulse docker compose up -d ``` @@ -76,10 +76,13 @@ To produce it in Uptime Kuma: ### Run the import ```bash -pulse-cli import uptime-kuma --file backup.json +pulse-cli import uptime-kuma \ + --file backup.json \ + --server https://status.example.com \ + --token pulse_live_xxxxxxxx ``` -The command reads the backup file and creates the corresponding monitors and groups in Pulse. Historical check data is not imported, because Uptime Kuma backups do not include it. +The command reads the backup file and creates the monitors in Pulse under a single "Imported" group, through the REST API. `--server` is your Pulse base URL and `--token` is an API key with the `monitors:write` scope (create one under Admin β†’ API keys). Historical check data is not imported, because Uptime Kuma backups do not include it. ## Next steps diff --git a/docs/index.md b/docs/index.md index 03b884e..509ee3b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ Pulse is an open-source, self-hosted status page and monitoring tool. It runs as Pulse checks your services on a schedule, records uptime and latency, opens incidents when checks fail, and serves public status pages on your own domains. A live example runs at [status.shreeda.xyz](https://status.shreeda.xyz). These docs are hosted at [docs.shreeda.xyz](https://docs.shreeda.xyz). +![Pulse status page](assets/status-page.gif) + ## Highlights - **Single binary and SQLite.** One Go binary plus one SQLite file. The monitoring worker runs in-process. @@ -30,4 +32,5 @@ Pulse checks your services on a schedule, records uptime and latency, opens inci | [API](api.md) | The REST API base path, API keys and scopes, Bearer usage, curl examples, and the Atom feed. | | [Security](security.md) | Password hashing, sessions, TOTP, password reset, and API key handling. | | [Architecture](architecture.md) | The single binary, the in-process worker, the embedded admin, SQLite, and host-based page resolution. | +| [Deployment](deployment.md) | The GHCR image, release-please versioning, and the CI/CD release-and-deploy flow to the homelab. | | [Roadmap](roadmap.md) | Planned features that are not yet built. | diff --git a/docs/superpowers/plans/2026-06-06-pulse-plan-2-auth.md b/docs/superpowers/plans/2026-06-06-pulse-plan-2-auth.md new file mode 100644 index 0000000..6846963 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-pulse-plan-2-auth.md @@ -0,0 +1,909 @@ +# Pulse Plan 2 β€” Authentication (login) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. +> +> **PROJECT GIT RULE (overrides skill defaults):** Do **NOT** run `git add`, `git commit`, `git push`, or `git mv`. Wherever a step says **YOU COMMIT**, stop and let the human commit. Keep test-first discipline; never invoke git. + +**Goal:** Replace the static-token admin gate with real authentication β€” a first-run setup screen, username/password login with Argon2id hashing, and HttpOnly cookie sessions (Uptime-Kuma-style, single admin). + +**Architecture:** New `users` + `sessions` tables (golang-migrate migration + sqlc queries). A small `auth` package wraps Argon2id hashing and secure session-token generation. Auth HTTP handlers (`/api/auth/{status,setup,login,logout}`) issue/clear an HttpOnly cookie. A `RequireSession` middleware replaces `RequireToken` on the admin route group. The admin SPA gets a setup/login/logout flow and switches every admin API call from a `Bearer` header to `credentials: 'include'` (cookie). A `pulse-cli reset-password` command covers lockout recovery. + +**Tech Stack:** Go (`net/http`, chi, `crypto/rand`), `github.com/alexedwards/argon2id`, sqlc, golang-migrate, modernc SQLite, Next.js (admin SPA), cobra (CLI). + +**Scope / deferred:** This plan delivers setup + login + sessions + logout + CLI reset. **TOTP 2FA and API keys are deferred to a follow-up plan (2b)** β€” the `users` table includes nullable `totp_secret`/`totp_enabled` columns now so TOTP later needs no migration. + +--- + +## File Structure + +**Created:** +- `api/internal/db/migrations/2_auth.up.sql` / `2_auth.down.sql` β€” `users`, `sessions` tables +- `api/internal/db/queries/auth.sql` β€” sqlc queries (regenerates into `api/internal/generated/auth.sql.go`) +- `api/internal/auth/password.go` β€” Argon2id hash/verify +- `api/internal/auth/session.go` β€” session token generation + cookie constants/helpers +- `api/internal/auth/password_test.go`, `api/internal/auth/session_test.go` +- `api/internal/handlers/auth.go` β€” status/setup/login/logout handlers +- `api/internal/handlers/auth_test.go` +- `api/internal/middleware/session.go` β€” `RequireSession` +- `api/internal/middleware/session_test.go` +- `api/internal/account/account.go` + test β€” password-reset logic +- (subcommand wiring in `api/cmd/pulse/main.go` β€” `pulse reset-password`) + +**Modified:** +- `api/internal/server/server.go` β€” drop `adminToken`; add `/api/auth/*` (public) + `RequireSession(q)` on the admin group +- `api/cmd/pulse/main.go` β€” `server.New(conn)` (signature change) +- `api/cmd/pulse/main_test.go` β€” update `server.New` call +- `ui/src/app/admin/layout.tsx` β€” setup/login/logout flow (cookie-based) +- `ui/src/lib/api.ts` β€” drop `token` params, add `credentials: 'include'` +- `ui/src/app/admin/*/page.tsx` β€” remove `getToken()` and token args from `admin*` calls +- `api/go.mod` β€” add `github.com/alexedwards/argon2id` + +--- + +## Task 1: Auth schema migration + sqlc queries + +**Files:** +- Create: `api/internal/db/migrations/2_auth.up.sql`, `api/internal/db/migrations/2_auth.down.sql` +- Create: `api/internal/db/queries/auth.sql` +- Regenerate: `api/internal/generated/*` via `make sqlc` + +- [ ] **Step 1: Write the up migration** `api/internal/db/migrations/2_auth.up.sql`: +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); +``` + +- [ ] **Step 2: Write the down migration** `api/internal/db/migrations/2_auth.down.sql`: +```sql +DROP TABLE sessions; +DROP TABLE users; +``` + +- [ ] **Step 3: Write the sqlc queries** `api/internal/db/queries/auth.sql`: +```sql +-- name: CountUsers :one +SELECT COUNT(*) FROM users; + +-- name: CreateUser :one +INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING *; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE username = ?; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = ?; + +-- name: UpdateUserPassword :exec +UPDATE users SET password_hash = ? WHERE username = ?; + +-- name: CreateSession :exec +INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?); + +-- name: GetSession :one +SELECT s.token, s.user_id, s.expires_at, u.username +FROM sessions s JOIN users u ON u.id = s.user_id +WHERE s.token = ?; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE token = ?; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP; +``` +> Note: `GetSession` does NOT filter by expiry in SQL (CURRENT_TIMESTAMP/stored-format mismatch is error-prone). Expiry is checked in Go (Task 4/5). + +- [ ] **Step 4: Regenerate sqlc + migrate-build check** + +Run: `cd /Users/shreedabhat/Documents/statuspage/pulse/api && make -C .. sqlc 2>/dev/null || (cd internal/db && sqlc generate)` then `go build ./...` +Expected: `api/internal/generated/auth.sql.go` created with `CountUsers`, `CreateUser`, `GetUserByUsername`, `GetUserByID`, `UpdateUserPassword`, `CreateSession`, `GetSession`, `DeleteSession`, `DeleteExpiredSessions`; build succeeds. +> Verify generated method/struct names. `CreateUser`/`GetUserByUsername` return a `generated.User` with fields `ID, Username, PasswordHash, TotpSecret (*string), TotpEnabled int64, CreatedAt time.Time`. `GetSession` returns a row struct (e.g. `generated.GetSessionRow`) with `Token, UserID, ExpiresAt, Username`. Use the actual generated names in later tasks. + +- [ ] **Step 5: Migration smoke test** β€” confirm a fresh DB migrates with the new tables. + +`api/internal/db/db_test.go` already tests `Open`; add a check there (or a new test) that the `users` table exists: +```go +func TestMigrationsCreateUsersTable(t *testing.T) { + conn, err := Open(t.TempDir() + "/m.db") + if err != nil { t.Fatal(err) } + defer conn.Close() + var n int + if err := conn.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("users table missing: %v", err) + } +} +``` +Run: `cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/db/ -run TestMigrationsCreateUsersTable -v` +Expected: PASS. + +- [ ] **Step 6: YOU COMMIT** β€” "feat(db): users + sessions schema and queries" + +--- + +## Task 2: Argon2id password hashing + +**Files:** +- Create: `api/internal/auth/password.go`, `api/internal/auth/password_test.go` +- Modify: `api/go.mod` (add `github.com/alexedwards/argon2id`) + +- [ ] **Step 1: Add the dependency** + +Run: `cd /Users/shreedabhat/Documents/statuspage/pulse/api && go get github.com/alexedwards/argon2id@latest && go mod tidy` + +- [ ] **Step 2: Write the failing test** `api/internal/auth/password_test.go`: +```go +package auth + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash: %v", err) + } + if hash == "" || hash == "correct horse battery staple" { + t.Fatal("hash must be non-empty and not the plaintext") + } + + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil || !ok { + t.Fatalf("verify correct password: ok=%v err=%v", ok, err) + } + + bad, err := VerifyPassword("wrong", hash) + if err != nil { + t.Fatalf("verify wrong password errored: %v", err) + } + if bad { + t.Fatal("wrong password must not verify") + } +} +``` + +- [ ] **Step 3: Run test, confirm FAILS** (undefined: HashPassword): +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/auth/ -run TestHashAndVerifyPassword -v` + +- [ ] **Step 4: Implement** `api/internal/auth/password.go`: +```go +package auth + +import "github.com/alexedwards/argon2id" + +// params: OWASP-recommended, lightweight enough for a single rare admin login. +var argonParams = &argon2id.Params{ + Memory: 19 * 1024, // 19 MiB + Iterations: 2, + Parallelism: 1, + SaltLength: 16, + KeyLength: 32, +} + +// HashPassword returns an encoded Argon2id hash (includes salt + params). +func HashPassword(plain string) (string, error) { + return argon2id.CreateHash(plain, argonParams) +} + +// VerifyPassword reports whether plain matches the encoded hash. +func VerifyPassword(plain, encodedHash string) (bool, error) { + return argon2id.ComparePasswordAndHash(plain, encodedHash) +} +``` + +- [ ] **Step 5: Run test, confirm PASS:** +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/auth/ -run TestHashAndVerifyPassword -v` + +- [ ] **Step 6: YOU COMMIT** β€” "feat(auth): Argon2id password hashing" + +--- + +## Task 3: Session tokens + cookie helper + +**Files:** +- Create: `api/internal/auth/session.go`, `api/internal/auth/session_test.go` + +- [ ] **Step 1: Write the failing test** `api/internal/auth/session_test.go`: +```go +package auth + +import ( + "net/http/httptest" + "testing" +) + +func TestNewSessionTokenIsRandom(t *testing.T) { + a, err := NewSessionToken() + if err != nil { t.Fatal(err) } + b, err := NewSessionToken() + if err != nil { t.Fatal(err) } + if a == "" || a == b { + t.Fatalf("tokens must be non-empty and unique: %q %q", a, b) + } +} + +func TestSetAndClearSessionCookie(t *testing.T) { + rec := httptest.NewRecorder() + SetSessionCookie(rec, "tok123", false) + c := rec.Result().Cookies() + if len(c) != 1 || c[0].Name != SessionCookieName || c[0].Value != "tok123" || !c[0].HttpOnly { + t.Fatalf("unexpected cookie: %+v", c) + } + rec2 := httptest.NewRecorder() + ClearSessionCookie(rec2) + c2 := rec2.Result().Cookies() + if len(c2) != 1 || c2[0].MaxAge >= 0 { + t.Fatalf("expected an expiring cookie, got: %+v", c2) + } +} +``` + +- [ ] **Step 2: Run test, confirm FAILS:** +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/auth/ -run 'Session' -v` + +- [ ] **Step 3: Implement** `api/internal/auth/session.go`: +```go +package auth + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "time" +) + +const ( + SessionCookieName = "pulse_session" + SessionDuration = 30 * 24 * time.Hour +) + +// NewSessionToken returns a 256-bit URL-safe random token. +func NewSessionToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// SetSessionCookie writes the session cookie. secure=true sets the Secure flag +// (use when serving over HTTPS). +func SetSessionCookie(w http.ResponseWriter, token string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(SessionDuration.Seconds()), + }) +} + +// ClearSessionCookie expires the session cookie. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) +} +``` + +- [ ] **Step 4: Run test, confirm PASS:** +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/auth/ -run 'Session' -v` + +- [ ] **Step 5: YOU COMMIT** β€” "feat(auth): session tokens and cookie helpers" + +--- + +## Task 4: Auth HTTP handlers (status / setup / login / logout) + +**Files:** +- Create: `api/internal/handlers/auth.go`, `api/internal/handlers/auth_test.go` + +- [ ] **Step 1: Write the failing test** `api/internal/handlers/auth_test.go`: +```go +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +func TestAuthSetupThenLoginThenStatus(t *testing.T) { + db := testutil.NewTestDB(t) + q := generated.New(db) + h := NewAuth(q, false) + + // Fresh DB β†’ needs setup + rec := httptest.NewRecorder() + h.Status(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + var st struct{ NeedsSetup, Authenticated bool } + json.NewDecoder(rec.Body).Decode(&st) + if !st.NeedsSetup || st.Authenticated { + t.Fatalf("fresh DB: want needs_setup=true authenticated=false, got %+v", st) + } + + // Setup creates the admin and logs in (sets cookie) + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret-pass"}) + rec = httptest.NewRecorder() + h.Setup(rec, httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewReader(body))) + if rec.Code != http.StatusCreated { + t.Fatalf("setup = %d, want 201", rec.Code) + } + if len(rec.Result().Cookies()) == 0 { + t.Fatal("setup should set a session cookie") + } + + // Second setup is forbidden + rec = httptest.NewRecorder() + h.Setup(rec, httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewReader(body))) + if rec.Code != http.StatusForbidden { + t.Fatalf("second setup = %d, want 403", rec.Code) + } + + // Login with correct creds β†’ 200 + cookie + rec = httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))) + if rec.Code != http.StatusOK || len(rec.Result().Cookies()) == 0 { + t.Fatalf("login = %d, cookies=%d", rec.Code, len(rec.Result().Cookies())) + } + + // Login with wrong password β†’ 401 + bad, _ := json.Marshal(map[string]string{"username": "admin", "password": "nope"}) + rec = httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(bad))) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad login = %d, want 401", rec.Code) + } +} +``` + +- [ ] **Step 2: Run test, confirm FAILS** (undefined: NewAuth): +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/handlers/ -run TestAuthSetupThenLoginThenStatus -v` + +- [ ] **Step 3: Implement** `api/internal/handlers/auth.go`: +```go +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" +) + +type Auth struct { + q *generated.Queries + secure bool // set Secure flag on cookies (HTTPS) +} + +func NewAuth(q *generated.Queries, secure bool) *Auth { return &Auth{q: q, secure: secure} } + +type credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (a *Auth) Status(w http.ResponseWriter, r *http.Request) { + n, _ := a.q.CountUsers(r.Context()) + authenticated := false + username := "" + if c, err := r.Cookie(auth.SessionCookieName); err == nil { + if sess, err := a.q.GetSession(r.Context(), c.Value); err == nil && sess.ExpiresAt.After(time.Now()) { + authenticated = true + username = sess.Username + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "needs_setup": n == 0, + "authenticated": authenticated, + "username": username, + }) +} + +func (a *Auth) Setup(w http.ResponseWriter, r *http.Request) { + n, _ := a.q.CountUsers(r.Context()) + if n > 0 { + http.Error(w, "already set up", http.StatusForbidden) + return + } + var c credentials + if err := json.NewDecoder(r.Body).Decode(&c); err != nil || c.Username == "" || len(c.Password) < 8 { + http.Error(w, "username and password (min 8 chars) required", http.StatusBadRequest) + return + } + hash, err := auth.HashPassword(c.Password) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + u, err := a.q.CreateUser(r.Context(), generated.CreateUserParams{Username: c.Username, PasswordHash: hash}) + if err != nil { + http.Error(w, "could not create user", http.StatusInternalServerError) + return + } + a.startSession(w, r, u.ID) + w.WriteHeader(http.StatusCreated) +} + +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { + var c credentials + if err := json.NewDecoder(r.Body).Decode(&c); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + u, err := a.q.GetUserByUsername(r.Context(), c.Username) + if err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + ok, err := auth.VerifyPassword(c.Password, u.PasswordHash) + if err != nil || !ok { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + a.startSession(w, r, u.ID) + w.WriteHeader(http.StatusOK) +} + +func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(auth.SessionCookieName); err == nil { + _ = a.q.DeleteSession(r.Context(), c.Value) + } + auth.ClearSessionCookie(w) + w.WriteHeader(http.StatusOK) +} + +func (a *Auth) startSession(w http.ResponseWriter, r *http.Request, userID int64) { + token, err := auth.NewSessionToken() + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + _ = a.q.CreateSession(r.Context(), generated.CreateSessionParams{ + Token: token, + UserID: userID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + auth.SetSessionCookie(w, token, a.secure) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} +``` +> Before coding: confirm there isn't already a `writeJSON` helper in `handlers` (grep). If one exists, reuse it and delete this duplicate. Confirm generated param struct names (`CreateUserParams`, `CreateSessionParams`) and the `GetSession` row field names (`ExpiresAt`, `Username`) match Task 1's generated output; adjust if different. + +- [ ] **Step 4: Run test, confirm PASS:** +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/handlers/ -run TestAuthSetupThenLoginThenStatus -v` + +- [ ] **Step 5: YOU COMMIT** β€” "feat(auth): setup/login/logout/status handlers" + +--- + +## Task 5: RequireSession middleware + wire into the server + +**Files:** +- Create: `api/internal/middleware/session.go`, `api/internal/middleware/session_test.go` +- Modify: `api/internal/server/server.go`, `api/cmd/pulse/main.go`, `api/cmd/pulse/main_test.go` + +- [ ] **Step 1: Write the failing middleware test** `api/internal/middleware/session_test.go`: +```go +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +func TestRequireSession(t *testing.T) { + db := testutil.NewTestDB(t) + q := generated.New(db) + + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := RequireSession(q)(ok) + + // No cookie β†’ 401 + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/monitors", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("no cookie = %d, want 401", rec.Code) + } + + // Valid session β†’ 200 + hash, _ := auth.HashPassword("pw-at-least-8") + u, _ := q.CreateUser(t.Context(), generated.CreateUserParams{Username: "a", PasswordHash: hash}) + tok, _ := auth.NewSessionToken() + _ = q.CreateSession(t.Context(), generated.CreateSessionParams{Token: tok, UserID: u.ID, ExpiresAt: time.Now().Add(time.Hour)}) + + req := httptest.NewRequest(http.MethodGet, "/api/monitors", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: tok}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("valid session = %d, want 200", rec.Code) + } +} +``` +> If `t.Context()` is unavailable in this Go version, use `context.Background()`. + +- [ ] **Step 2: Run test, confirm FAILS** (undefined: RequireSession): +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/middleware/ -run TestRequireSession -v` + +- [ ] **Step 3: Implement** `api/internal/middleware/session.go`: +```go +package middleware + +import ( + "net/http" + "time" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" +) + +// RequireSession allows the request only if it carries a valid, unexpired +// session cookie. +func RequireSession(q *generated.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(auth.SessionCookieName) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + sess, err := q.GetSession(r.Context(), c.Value) + if err != nil || !sess.ExpiresAt.After(time.Now()) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } +} +``` + +- [ ] **Step 4: Run test, confirm PASS.** + +- [ ] **Step 5: Wire the server.** In `api/internal/server/server.go`: + - Change signature to `func New(db *sql.DB) http.Handler` (drop `adminToken`). + - Add public auth routes near the other public routes: + ```go + authH := handlers.NewAuth(q, false) // secure=false for HTTP; Plan 3 sets true under TLS + r.Post("/api/auth/setup", authH.Setup) + r.Post("/api/auth/login", authH.Login) + r.Post("/api/auth/logout", authH.Logout) + r.Get("/api/auth/status", authH.Status) + ``` + - Replace `r.Use(middleware.RequireToken(adminToken))` in the admin group with `r.Use(middleware.RequireSession(q))`. + - Delete the now-unused `RequireToken` from `api/internal/middleware/auth.go` (and its test `auth_test.go`) β€” grep to confirm nothing else uses it. + +- [ ] **Step 6: Update the entrypoint + its test.** + - `api/cmd/pulse/main.go`: `srv := server.New(conn)` (drop `cfg.AdminToken`). `cfg.AdminToken` may now be unused β€” leave the config field (harmless) or remove if nothing references it. + - `api/cmd/pulse/main_test.go`: change `server.New(db, "test-token")` β†’ `server.New(db)`. + +- [ ] **Step 7: Verify full build + tests:** +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go build ./... && go test ./... -count=1` +Expected: build + all tests PASS. + +- [ ] **Step 8: YOU COMMIT** β€” "feat(auth): session middleware; replace static token gate" + +--- + +## Task 6: Admin SPA β€” setup / login / logout (cookie-based) + +**Files:** +- Modify: `ui/src/lib/api.ts` (drop `token` params, add `credentials: 'include'`) +- Modify: `ui/src/app/admin/layout.tsx` (auth flow) +- Modify: `ui/src/app/admin/{monitors,incidents,agents,notifications,theme}/page.tsx` (remove token args) + +- [ ] **Step 1: Switch the API client to cookies.** In `ui/src/lib/api.ts`: + - Replace `adminHeaders(token)` with a constant `const ADMIN_HEADERS = { 'Content-Type': 'application/json' }`. + - Remove the `token: string` first parameter from EVERY `admin*` function. + - Add `credentials: 'include'` to EVERY `admin*` fetch call (so the session cookie is sent). + - Add auth client functions: + ```ts + export type AuthStatus = { needs_setup: boolean; authenticated: boolean; username: string } + export async function authStatus(): Promise { + const res = await fetch(`${BASE}/api/auth/status`, { credentials: 'include', cache: 'no-store' }) + return res.json() + } + export async function authSetup(username: string, password: string): Promise { + const res = await fetch(`${BASE}/api/auth/setup`, { method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) + if (!res.ok) throw new Error(await res.text()) + } + export async function authLogin(username: string, password: string): Promise { + const res = await fetch(`${BASE}/api/auth/login`, { method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) + if (!res.ok) throw new Error('Invalid username or password') + } + export async function authLogout(): Promise { + await fetch(`${BASE}/api/auth/logout`, { method: 'POST', credentials: 'include' }) + } + ``` + +- [ ] **Step 2: Update every admin page call site.** In each of `ui/src/app/admin/{monitors,incidents,agents,notifications,theme}/page.tsx`: + - Remove the local `getToken()` helper and any `localStorage.getItem('pulse-admin-token')`. + - Remove the `token` argument from every `admin*(...)` call (e.g. `adminListMonitors(getToken())` β†’ `adminListMonitors()`). + - These are mechanical edits; the TypeScript compiler (`npm run build`) will flag any you miss. + +- [ ] **Step 3: Rewrite the admin layout auth gate.** Replace `ui/src/app/admin/layout.tsx` with: +```tsx +'use client' +import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { authStatus, authSetup, authLogin, authLogout, type AuthStatus } from '@/lib/api' + +const NAV = [ + { href: '/admin/monitors', label: 'Monitors' }, + { href: '/admin/incidents', label: 'Incidents' }, + { href: '/admin/theme', label: 'Theme' }, + { href: '/admin/notifications', label: 'Notifications' }, + { href: '/admin/agents', label: 'Agents' }, +] + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const [status, setStatus] = useState(null) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + + const refresh = useCallback(() => { + authStatus().then(setStatus).catch(() => setStatus({ needs_setup: false, authenticated: false, username: '' })) + }, []) + useEffect(() => { refresh() }, [refresh]) + + async function submitSetup(e: React.FormEvent) { + e.preventDefault(); setError('') + try { await authSetup(username, password); setPassword(''); refresh() } + catch (err) { setError(err instanceof Error ? err.message : 'Setup failed') } + } + async function submitLogin(e: React.FormEvent) { + e.preventDefault(); setError('') + try { await authLogin(username, password); setPassword(''); refresh() } + catch (err) { setError(err instanceof Error ? err.message : 'Login failed') } + } + async function signOut() { await authLogout(); refresh() } + + if (status === null) return null + + if (status.needs_setup) { + return ( +
+

Welcome to Pulse

+

Create your admin account to get started.

+
+
setUsername(e.target.value)} autoFocus />
+
setPassword(e.target.value)} />
+ {error &&
{error}
} + +
+
+ ) + } + + if (!status.authenticated) { + return ( +
+

Pulse Admin

+

Sign in to continue.

+
+
setUsername(e.target.value)} autoFocus />
+
setPassword(e.target.value)} />
+ {error &&
{error}
} + +
+
+ ) + } + + return ( +
+ +
{children}
+
+ ) +} +``` + +- [ ] **Step 4: Verify the export builds.** +`cd /Users/shreedabhat/Documents/statuspage/pulse/ui && NEXT_PUBLIC_API_URL="" npm run build` +Expected: build succeeds (TS errors here mean a missed token-argument removal in Step 2 β€” fix them). Confirm `ui/out/admin/` present. + +- [ ] **Step 5: YOU COMMIT** β€” "feat(admin): username/password setup + login + logout (cookie sessions)" + +--- + +## Task 7: `pulse reset-password` subcommand + +**Why a subcommand of `pulse` (not `pulse-cli`):** the `cli` module is a separate Go module and **cannot import `api/internal/...`** (Go's internal-package rule). The `pulse` binary lives in the `api` module, so it can use `auth` + `generated` directly. This also keeps recovery in the one shipped binary β€” better for the single-binary/lightweight goal. Invocation: `pulse reset-password --username admin --password `. + +**Files:** +- Create: `api/internal/account/account.go`, `api/internal/account/account_test.go` +- Modify: `api/cmd/pulse/main.go` (subcommand dispatch) + +- [ ] **Step 1: Write the failing test** `api/internal/account/account_test.go`: +```go +package account + +import ( + "context" + "testing" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +func TestResetPasswordUpdatesHash(t *testing.T) { + db := testutil.NewTestDB(t) + q := generated.New(db) + h, _ := auth.HashPassword("old-password") + _, _ = q.CreateUser(context.Background(), generated.CreateUserParams{Username: "admin", PasswordHash: h}) + + if err := ResetPassword(context.Background(), q, "admin", "brand-new-pass"); err != nil { + t.Fatalf("reset: %v", err) + } + u, _ := q.GetUserByUsername(context.Background(), "admin") + ok, _ := auth.VerifyPassword("brand-new-pass", u.PasswordHash) + if !ok { + t.Fatal("password was not updated to the new value") + } +} +``` + +- [ ] **Step 2: Run test, confirm FAILS** (undefined: ResetPassword): +`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/account/ -run TestResetPasswordUpdatesHash -v` + +- [ ] **Step 3: Implement** `api/internal/account/account.go`: +```go +package account + +import ( + "context" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" +) + +// ResetPassword sets a new Argon2id password hash for the given username. +func ResetPassword(ctx context.Context, q *generated.Queries, username, newPassword string) error { + hash, err := auth.HashPassword(newPassword) + if err != nil { + return err + } + return q.UpdateUserPassword(ctx, generated.UpdateUserPasswordParams{ + PasswordHash: hash, + Username: username, + }) +} +``` +> Confirm the generated `UpdateUserPasswordParams` field order/names from Task 1. + +- [ ] **Step 4: Run test, confirm PASS.** + +- [ ] **Step 5: Add the subcommand to `api/cmd/pulse/main.go`.** Before the normal server startup in `main()`, dispatch on `os.Args[1]`: +```go + if len(os.Args) > 1 && os.Args[1] == "reset-password" { + runResetPassword(os.Args[2:]) + return + } +``` +and add (same file or a `reset_password.go` in package `main`): +```go +func runResetPassword(args []string) { + fs := flag.NewFlagSet("reset-password", flag.ExitOnError) + username := fs.String("username", "", "admin username") + password := fs.String("password", "", "new password (min 8 chars)") + _ = fs.Parse(args) + if *username == "" || len(*password) < 8 { + log.Fatal("usage: pulse reset-password --username --password ") + } + cfg := config.Load() + path := cfg.SQLitePath + if path == "" { + path = "/data/pulse.db" + } + conn, err := db.Open(path) + if err != nil { + log.Fatalf("db: %v", err) + } + defer conn.Close() + if err := account.ResetPassword(context.Background(), generated.New(conn), *username, *password); err != nil { + log.Fatalf("reset-password: %v", err) + } + log.Printf("password updated for %q", *username) +} +``` +Add imports: `flag`, `context`, `github.com/memetics19/pulse/api/internal/account`, `github.com/memetics19/pulse/api/internal/generated`. + +- [ ] **Step 6: Verify build + a real reset:** +```bash +cd /Users/shreedabhat/Documents/statuspage/pulse/api && go build -o /tmp/pulse ./cmd/pulse +SQLITE_PATH=/tmp/rp.db /tmp/pulse reset-password --username nobody --password testpass123 ; echo "exit=$?" # no such user -> non-zero, but DB migrates +rm -f /tmp/rp.db +``` +Expected: builds; running against a user that doesn't exist updates 0 rows (UpdateUserPassword is `:exec`, so it succeeds with no error even if no row matches β€” acceptable; the happy path is covered by the unit test). The command prints the success line. + +- [ ] **Step 7: YOU COMMIT** β€” "feat: pulse reset-password subcommand" + +--- + +## Task 8: End-to-end verification (run both FE + BE) + +- [ ] **Step 1: Build the binary with the real admin embedded.** +```bash +cd /Users/shreedabhat/Documents/statuspage/pulse && make build +``` + +- [ ] **Step 2: Run on a spare port with a fresh DB:** +```bash +API_PORT=8090 SQLITE_PATH=/tmp/pulse-auth.db /Users/shreedabhat/Documents/statuspage/pulse/bin/pulse & +SRV=$!; sleep 2 +``` + +- [ ] **Step 3: Exercise the full auth flow via curl (cookie jar):** +```bash +echo "status (fresh -> needs_setup true):" && curl -s localhost:8090/api/auth/status +echo "setup:" && curl -s -i -c /tmp/jar -X POST localhost:8090/api/auth/setup -H 'Content-Type: application/json' -d '{"username":"admin","password":"supersecret"}' | grep -i 'HTTP/\|set-cookie' +echo "monitors WITHOUT cookie (expect 401):" && curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/api/monitors +echo "monitors WITH cookie (expect 200):" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar localhost:8090/api/monitors +echo "logout:" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar -c /tmp/jar -X POST localhost:8090/api/auth/logout +echo "monitors after logout (expect 401):" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar localhost:8090/api/monitors +kill $SRV 2>/dev/null; rm -f /tmp/pulse-auth.db /tmp/jar +``` +Expected: status shows `needs_setup:true`; setup returns 201 + `Set-Cookie: pulse_session=...`; monitors 401 without cookie, 200 with; logout 200; monitors 401 after logout. + +- [ ] **Step 4: Manual browser check.** Open `http://localhost:8090/admin/` β†’ setup screen (create admin) β†’ lands in admin β†’ reload stays logged in β†’ Sign out β†’ login screen. Confirm no token field remains. + +- [ ] **Step 5: YOU COMMIT** β€” "test: verify end-to-end auth flow" (if any verification helpers were added; otherwise nothing to commit). + +--- + +## Self-Review notes (for the executor) + +- **Spec coverage (Β§5):** setup βœ…, login βœ…, Argon2id βœ…, sessions βœ…, logout βœ…, CLI reset βœ…. **Deferred to Plan 2b: TOTP 2FA and API keys** (the `users` table already has `totp_secret`/`totp_enabled` so TOTP needs no further migration). +- **Signature change:** `server.New` drops `adminToken` (Task 5); the `cmd/pulse` test is updated in the same task. The old `RequireToken` + its test are removed. +- **Security note:** cookies are issued with `secure=false` for plain HTTP local/dev. When Plan 3 adds autocert/TLS, pass `secure=true` to `handlers.NewAuth` (and thread a config flag). +- **DRY:** reuse any existing `writeJSON` in `handlers` rather than the one shown. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md b/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md new file mode 100644 index 0000000..5e623b8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md @@ -0,0 +1,300 @@ +# Pulse Plan 2b β€” API Keys Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. +> **PROJECT GIT RULE:** no git add/commit/push/mv from the agent. Stop at **YOU COMMIT**. + +**Goal:** Named, revocable API keys for programmatic REST access, with granular scopes, shown once on creation, accepted via `Authorization: Bearer pulse_…` as an alternative to the admin session cookie. + +**Architecture:** New `api_keys` table (sha256 hash of the key + visible prefix + JSON scopes). A `keyauth` helper generates `pulse_live_` keys and hashes them. CRUD handlers under `/api/keys` (session-only). A combined middleware `RequireSessionOrAPIKey(q)` replaces `RequireSession` on the API admin group: a valid session cookie grants full access (unchanged); otherwise a valid, unrevoked API key is checked against the **scope required for that method+path** and its `last_used_at` is touched. An admin **API Keys** page lists keys (name, prefix, scopes, last used) and creates/revokes them β€” the full key is displayed once. + +**Tech Stack:** Go (chi, crypto/rand, crypto/sha256), sqlc/SQLite, Next.js admin SPA. + +**Builds on:** Plan 2 (`auth`, sessions, `RequireSession`), Plan 2c (`app.App`, server wiring via `app.LiveDBTX`). + +**Scopes (offered at creation):** `monitors:read`, `monitors:write`, `incidents:read`, `incidents:write`, `notifications:read`, `notifications:write`, `agents:read`, `agents:write`, `theme:read`, `theme:write`, `status:read`. Key management (`/api/keys`), auth, and setup are **session-only** (never reachable with an API key). + +**Path β†’ required scope (for API-key requests):** +| Path prefix | GET | mutate (POST/PUT/DELETE) | +|---|---|---| +| `/api/monitors`, `/api/groups` | `monitors:read` | `monitors:write` | +| `/api/incidents` | `incidents:read` | `incidents:write` | +| `/api/notifications` | `notifications:read` | `notifications:write` | +| `/api/agents` | `agents:read` | `agents:write` | +| `/api/theme` | `theme:read` | `theme:write` | +| `/api/overview` | `status:read` | β€” | + +--- + +## Task 1: api_keys schema + queries + +**Files:** `api/internal/db/migrations/4_api_keys.{up,down}.sql`, `api/internal/db/queries/api_keys.sql`, regenerate. + +- [ ] **Step 1** `4_api_keys.up.sql`: +```sql +CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '[]', + last_used_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP +); +``` +- [ ] **Step 2** `4_api_keys.down.sql`: `DROP TABLE api_keys;` +- [ ] **Step 3** `api_keys.sql`: +```sql +-- name: CreateAPIKey :one +INSERT INTO api_keys (name, key_hash, prefix, scopes) VALUES (?, ?, ?, ?) RETURNING *; + +-- name: ListAPIKeys :many +SELECT * FROM api_keys ORDER BY created_at DESC; + +-- name: GetAPIKeyByHash :one +SELECT * FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL; + +-- name: RevokeAPIKey :exec +UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?; + +-- name: TouchAPIKey :exec +UPDATE api_keys SET last_used_at = ? WHERE id = ?; +``` +- [ ] **Step 4** regenerate (`cd api/internal/db && sqlc generate`), `go build ./...`. Note generated `ApiKey` fields + `CreateAPIKeyParams` (Name, KeyHash, Prefix, Scopes), `TouchAPIKeyParams` (LastUsedAt *time.Time, ID). +- [ ] **Step 5** migration smoke test in `db_test.go` (insert + select an api_keys row) β†’ PASS. +- [ ] **Step 6: YOU COMMIT** β€” "feat(db): api_keys schema + queries" + +--- + +## Task 2: key generation + hashing + +**Files:** `api/internal/keyauth/keyauth.go` + test. + +- [ ] **Step 1: failing test** `keyauth_test.go`: +```go +package keyauth + +import "testing" + +func TestGenerateAndHash(t *testing.T) { + full, prefix, hash, err := Generate() + if err != nil { t.Fatal(err) } + if len(full) < 20 || prefix == "" || hash == "" { + t.Fatalf("bad outputs: full=%q prefix=%q", full, prefix) + } + if Hash(full) != hash { + t.Fatal("Hash(full) must equal the returned hash") + } + if !hasPrefix(full) { + t.Fatalf("key should start with the pulse prefix: %q", full) + } +} +func hasPrefix(s string) bool { return len(s) > 10 && s[:10] == "pulse_live" } +``` +- [ ] **Step 2** run β†’ FAIL. +- [ ] **Step 3: implement** `keyauth.go`: +```go +package keyauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" +) + +const keyPrefix = "pulse_live_" + +// Generate returns (fullKey, displayPrefix, sha256hash). The full key is shown +// to the user once; only the hash is stored. +func Generate() (full, prefix, hash string, err error) { + b := make([]byte, 24) + if _, err = rand.Read(b); err != nil { + return "", "", "", err + } + secret := base64.RawURLEncoding.EncodeToString(b) + full = keyPrefix + secret + prefix = full[:len(keyPrefix)+6] // e.g. pulse_live_ab12cd + hash = Hash(full) + return full, prefix, hash, nil +} + +// Hash returns the hex sha256 of a key (API keys are high-entropy, so a fast +// hash is appropriate β€” unlike user passwords). +func Hash(full string) string { + sum := sha256.Sum256([]byte(full)) + return hex.EncodeToString(sum[:]) +} +``` +- [ ] **Step 4** run β†’ PASS. **Step 5: YOU COMMIT** β€” "feat(keyauth): API key generation + hashing" + +--- + +## Task 3: API-key CRUD handlers + +**Files:** `api/internal/handlers/apikeys.go` + test. + +- [ ] **Step 1: failing test** `apikeys_test.go`: create a key (POST body `{name, scopes:[...]}`) β†’ 201 with a `key` field (the full key, starts with `pulse_live_`) and it is NOT returned again by list; list returns the key with `prefix` but no full key; revoke β†’ 204/200. +```go +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/testutil" +) + +func TestAPIKeyCreateListRevoke(t *testing.T) { + q := generated.New(testutil.NewTestDB(t)) + h := NewAPIKeys(q) + + body, _ := json.Marshal(map[string]any{"name": "ci", "scopes": []string{"monitors:read"}}) + rec := httptest.NewRecorder() + h.Create(rec, httptest.NewRequest(http.MethodPost, "/api/keys", bytes.NewReader(body))) + if rec.Code != http.StatusCreated { t.Fatalf("create=%d", rec.Code) } + var created struct{ Key string `json:"key"` } + json.NewDecoder(rec.Body).Decode(&created) + if !strings.HasPrefix(created.Key, "pulse_live_") { t.Fatalf("missing full key: %q", created.Key) } + + rec = httptest.NewRecorder() + h.List(rec, httptest.NewRequest(http.MethodGet, "/api/keys", nil)) + if strings.Contains(rec.Body.String(), created.Key) { t.Fatal("list must NOT contain the full key") } +} +``` +- [ ] **Step 2** run β†’ FAIL. +- [ ] **Step 3: implement** `apikeys.go`: `NewAPIKeys(q)`; `Create` (decode {name, scopes []string}; `keyauth.Generate()`; store via `CreateAPIKey` with `scopes` JSON-marshaled; respond 201 `{id, name, prefix, scopes, key: }` β€” full key ONLY here); `List` (return rows mapped to `{id,name,prefix,scopes,last_used_at,created_at}` β€” never the hash/full key); `Revoke` (path id β†’ `RevokeAPIKey`). Reuse `writeJSON`. Marshal/unmarshal `scopes` to/from the JSON text column. +- [ ] **Step 4** run β†’ PASS, `go build ./...`. **Step 5: YOU COMMIT** β€” "feat(api): API key CRUD handlers" + +--- + +## Task 4: combined session-or-API-key middleware + +**Files:** `api/internal/middleware/apikey.go` + test. Modify `api/internal/middleware/session.go` only if needed. + +- [ ] **Step 1: failing test** `apikey_test.go`: build `RequireSessionOrAPIKey(q)`; (a) no cred β†’ 401; (b) valid session cookie β†’ 200 (full); (c) valid API key with `monitors:read` on a GET `/api/monitors` β†’ 200; (d) same key on POST `/api/monitors` (needs `monitors:write`) β†’ 403; (e) revoked key β†’ 401. (Seed keys via `keyauth.Generate` + `CreateAPIKey`.) +- [ ] **Step 2** run β†’ FAIL. +- [ ] **Step 3: implement** `apikey.go`: +```go +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/generated" + "github.com/memetics19/pulse/api/internal/keyauth" +) + +// requiredScope maps an API request to the scope an API key must hold. +// Returns "" if API keys may not access this path (session-only). +func requiredScope(method, path string) string { + resource := "" + switch { + case strings.HasPrefix(path, "/api/monitors"), strings.HasPrefix(path, "/api/groups"): + resource = "monitors" + case strings.HasPrefix(path, "/api/incidents"): + resource = "incidents" + case strings.HasPrefix(path, "/api/notifications"): + resource = "notifications" + case strings.HasPrefix(path, "/api/agents"): + resource = "agents" + case strings.HasPrefix(path, "/api/theme"): + resource = "theme" + case strings.HasPrefix(path, "/api/overview"): + return "status:read" // read-only resource + default: + return "" // /api/keys, /api/auth, /api/setup β†’ session only + } + if method == http.MethodGet { + return resource + ":read" + } + return resource + ":write" +} + +func RequireSessionOrAPIKey(q *generated.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. Session cookie (full access, unchanged behaviour). + if c, err := r.Cookie(auth.SessionCookieName); err == nil { + if sess, err := q.GetSession(r.Context(), c.Value); err == nil && sess.ExpiresAt.After(time.Now()) { + next.ServeHTTP(w, r) + return + } + } + // 2. API key. + authz := r.Header.Get("Authorization") + if strings.HasPrefix(authz, "Bearer pulse_") { + token := strings.TrimPrefix(authz, "Bearer ") + key, err := q.GetAPIKeyByHash(r.Context(), keyauth.Hash(token)) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + need := requiredScope(r.Method, r.URL.Path) + if need == "" || !scopesContain(key.Scopes, need) { + http.Error(w, "insufficient scope", http.StatusForbidden) + return + } + now := time.Now() + _ = q.TouchAPIKey(r.Context(), generated.TouchAPIKeyParams{LastUsedAt: &now, ID: key.ID}) + next.ServeHTTP(w, r) + return + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + } +} + +func scopesContain(scopesJSON, need string) bool { + // scopes stored as JSON array text; simple substring match on the quoted scope is sufficient + return strings.Contains(scopesJSON, `"`+need+`"`) +} +``` +> Confirm `key.Scopes` is the column (string) and `key.ID` exist on the generated `ApiKey`. Adjust `TouchAPIKeyParams` field names to the generated ones. +- [ ] **Step 4** run β†’ PASS. **Step 5: YOU COMMIT** β€” "feat(auth): session-or-API-key middleware with scopes" + +--- + +## Task 5: wire routes + middleware + +**Files:** `api/internal/server/server.go`. + +- [ ] **Step 1** In the admin API group, change `r.Use(middleware.RequireSession(q))` to `r.Use(middleware.RequireSessionOrAPIKey(q))`. +- [ ] **Step 2** Add a **session-only** sub-group (still `RequireSession`) for key management, OR register `/api/keys` routes that explicitly use `RequireSession(q)` (NOT the combined one β€” an API key must not manage keys): +```go +r.Group(func(r chi.Router) { + r.Use(middleware.RequireSession(q)) + kh := handlers.NewAPIKeys(q) + r.Get("/api/keys", kh.List) + r.Post("/api/keys", kh.Create) + r.Delete("/api/keys/{id}", kh.Revoke) +}) +``` +- [ ] **Step 3** `go build ./... && go test ./... -count=1` β†’ green. **Step 4: YOU COMMIT** β€” "feat: wire API keys + scoped auth" + +--- + +## Task 6: admin API Keys page + +**Files:** `ui/src/app/admin/api-keys/page.tsx` (new), `ui/src/app/admin/layout.tsx` (nav), `ui/src/lib/api.ts` (client fns). + +- [ ] **Step 1** `api.ts`: `listApiKeys()`, `createApiKey(name, scopes: string[])` (returns `{key, ...}`), `revokeApiKey(id)` β€” all `credentials:'include'`. +- [ ] **Step 2** Add `{ href: '/admin/api-keys', label: 'API Keys' }` to `NAV` (after Notifications). +- [ ] **Step 3** Create `ui/src/app/admin/api-keys/page.tsx` (`'use client'`, v3 look): a `.card` table of keys (name, prefix `pulse_live_…`, scopes, last used, created, Revoke with inline confirm). A "New API key" modal: name + a checkbox list of the scopes (`monitors:read`, `monitors:write`, `incidents:read`, `incidents:write`, `notifications:read`, `notifications:write`, `agents:read`, `agents:write`, `theme:read`, `theme:write`, `status:read`). On create, show the **full key once** in a highlighted box with a "Copy" button and a "you won't see this again" note; list refreshes after. +- [ ] **Step 4** `cd ui && NEXT_PUBLIC_API_URL="" npm run build` β†’ succeeds; `ui/out/admin/api-keys/` present. +- [ ] **Step 5: YOU COMMIT** β€” "feat(admin): API Keys page" + +--- + +## Self-Review +- Spec coverage: named/revocable keys βœ…, hash-stored + shown-once βœ…, granular scopes βœ…, `Bearer` + scope-checked middleware βœ…, key mgmt session-only βœ…, admin UI βœ…. (Maintenance scopes omitted β€” maintenance isn't built yet; add `maintenance:*` when it lands.) +- Consistency: scope strings identical across the create UI (Task 6), the `requiredScope` map (Task 4), and the docs table. `keyauth.Hash` used in both create and verify. +- Security: only sha256 hash stored; full key shown once; revoked keys rejected (`GetAPIKeyByHash` filters `revoked_at IS NULL`); API keys cannot reach `/api/keys`, auth, or setup. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md b/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md new file mode 100644 index 0000000..3bcb334 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md @@ -0,0 +1,559 @@ +# Pulse Plan 2c β€” First-Run Setup Wizard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes. +> +> **PROJECT GIT RULE:** Do NOT run git add/commit/push/mv. At each **YOU COMMIT** marker, stop for the human. Keep test-first discipline. + +**Goal:** Replace the bare username/password setup with a gated, multi-step first-run wizard (Account β†’ Branding β†’ Database β†’ Finish) that the whole app redirects to until configured. + +**Architecture:** A `bootstrap` config file (`/pulse.json`, `{configured, sqlite_path}`) records setup state and the chosen SQLite path. The binary boots in **setup mode** when unconfigured: a swappable DB holder starts empty, the router serves only the wizard + `/api/setup*` + assets, and a gate redirects everything else to `/setup`. The wizard's Finish endpoint writes the bootstrap file, opens+migrates the chosen SQLite DB, creates the admin (name/email/username/password) and branding (logo/site name), then the app flips to configured. SQLite only for now β€” the DB step shows Postgres as "coming soon." + +**Tech Stack:** Go (`net/http`, chi, `encoding/json`, `os`), sqlc/SQLite, golang-migrate, Argon2id (existing `auth` pkg), Next.js static export (wizard page), `go:embed`. + +**Builds on:** Plan 2 (auth: `auth` pkg, sessions, `users`/`sessions` tables, `handlers.NewAuth`). + +--- + +## File Structure + +**Created:** +- `api/internal/bootstrap/bootstrap.go` (+ test) β€” read/write the bootstrap config file +- `api/internal/app/app.go` (+ test) β€” `App` holder with a swappable `*sql.DB`/`*generated.Queries` and `Configured()` state +- `api/internal/handlers/setup.go` (+ test) β€” `GET /api/setup/state`, `POST /api/setup` +- `api/internal/middleware/setupgate.go` (+ test) β€” redirect to `/setup` when unconfigured +- `api/internal/db/migrations/3_user_profile.{up,down}.sql` β€” add `name`, `email` to `users` +- `ui/src/app/setup/page.tsx` β€” multi-step wizard SPA page + +**Modified:** +- `api/internal/db/queries/auth.sql` β€” `CreateUser` includes name/email +- `api/internal/server/server.go` β€” accept the `App` holder; mount `/api/setup*` + setup gate; routes resolve queries from the holder +- `api/cmd/pulse/main.go` β€” boot via bootstrap config; setup vs configured mode +- `ui/src/lib/api.ts` β€” `setupState()` / `runSetup()` client fns + +--- + +## Task 1: `users` gains name + email + +**Files:** `api/internal/db/migrations/3_user_profile.up.sql` / `.down.sql`; `api/internal/db/queries/auth.sql`; regenerate. + +- [ ] **Step 1:** `3_user_profile.up.sql`: +```sql +ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''; +``` +- [ ] **Step 2:** `3_user_profile.down.sql`: +```sql +ALTER TABLE users DROP COLUMN email; +ALTER TABLE users DROP COLUMN name; +``` +- [ ] **Step 3:** In `api/internal/db/queries/auth.sql`, replace `CreateUser` with: +```sql +-- name: CreateUser :one +INSERT INTO users (username, password_hash, name, email) VALUES (?, ?, ?, ?) RETURNING *; +``` +- [ ] **Step 4:** Regenerate: `cd api/internal/db && sqlc generate` then `cd .. && go build ./...`. Confirm `CreateUserParams` now has `Username, PasswordHash, Name, Email`; `User` has `Name, Email`. +- [ ] **Step 5:** Migration test β€” extend `api/internal/db/db_test.go`: +```go +func TestUsersHaveProfileColumns(t *testing.T) { + conn, err := Open(t.TempDir() + "/p.db") + if err != nil { t.Fatal(err) } + defer conn.Close() + if _, err := conn.Exec(`INSERT INTO users (username, password_hash, name, email) VALUES ('a','h','A','a@b.c')`); err != nil { + t.Fatalf("insert with name/email failed: %v", err) + } +} +``` +Run it β†’ PASS. +- [ ] **Step 6: YOU COMMIT** β€” "feat(db): users.name + users.email" + +> NOTE: The existing `handlers/auth.go` `Setup`/`Login` call `CreateUser` with the old 2-field params and will no longer compile. They are replaced by the new setup flow in Task 4; if you need an intermediate compile, pass `Name: "", Email: ""` until Task 4 rewrites them. + +--- + +## Task 2: bootstrap config file + +**Files:** `api/internal/bootstrap/bootstrap.go` + `bootstrap_test.go` + +- [ ] **Step 1: failing test** `bootstrap_test.go`: +```go +package bootstrap + +import ( + "path/filepath" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + if c, _ := Load(dir); c.Configured { + t.Fatal("fresh dir must be unconfigured") + } + want := Config{Configured: true, SQLitePath: filepath.Join(dir, "pulse.db")} + if err := Save(dir, want); err != nil { + t.Fatal(err) + } + got, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if !got.Configured || got.SQLitePath != want.SQLitePath { + t.Fatalf("round trip mismatch: %+v", got) + } +} +``` +- [ ] **Step 2:** run β†’ FAIL. +- [ ] **Step 3: implement** `bootstrap.go`: +```go +package bootstrap + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// Config is the pre-database bootstrap state, stored as JSON on disk so the +// binary knows whether setup has run and which SQLite file to open. +type Config struct { + Configured bool `json:"configured"` + SQLitePath string `json:"sqlite_path"` +} + +func file(dataDir string) string { return filepath.Join(dataDir, "pulse.json") } + +// Load reads the bootstrap config; a missing file yields a zero Config (unconfigured). +func Load(dataDir string) (Config, error) { + b, err := os.ReadFile(file(dataDir)) + if errors.Is(err, os.ErrNotExist) { + return Config{}, nil + } + if err != nil { + return Config{}, err + } + var c Config + if err := json.Unmarshal(b, &c); err != nil { + return Config{}, err + } + return c, nil +} + +// Save writes the bootstrap config, creating the data dir if needed. +func Save(dataDir string, c Config) error { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(file(dataDir), b, 0o600) +} +``` +- [ ] **Step 4:** run β†’ PASS. +- [ ] **Step 5: YOU COMMIT** β€” "feat(bootstrap): setup-state config file" + +--- + +## Task 3: App holder (swappable DB + configured state) + +**Files:** `api/internal/app/app.go` + `app_test.go` + +- [ ] **Step 1: failing test** `app_test.go`: +```go +package app + +import ( + "testing" + + "github.com/memetics19/pulse/api/testutil" +) + +func TestAppConfiguredFlips(t *testing.T) { + a := New() + if a.Configured() { + t.Fatal("new app must be unconfigured") + } + if a.Queries() != nil { + t.Fatal("no queries before configuration") + } + db := testutil.NewTestDB(t) + a.SetDB(db) + if !a.Configured() || a.Queries() == nil { + t.Fatal("SetDB must make the app configured with queries") + } +} +``` +- [ ] **Step 2:** run β†’ FAIL. +- [ ] **Step 3: implement** `app.go`: +```go +package app + +import ( + "database/sql" + "sync" + + "github.com/memetics19/pulse/api/internal/generated" +) + +// App holds the (initially absent) database so the server can run in setup +// mode and flip to configured once setup completes. +type App struct { + mu sync.RWMutex + db *sql.DB + q *generated.Queries +} + +func New() *App { return &App{} } + +// SetDB installs an open, migrated database and marks the app configured. +func (a *App) SetDB(db *sql.DB) { + a.mu.Lock() + defer a.mu.Unlock() + a.db = db + a.q = generated.New(db) +} + +func (a *App) Configured() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.db != nil +} + +// Queries returns the query handle, or nil when unconfigured. +func (a *App) Queries() *generated.Queries { + a.mu.RLock() + defer a.mu.RUnlock() + return a.q +} +``` +- [ ] **Step 4:** run β†’ PASS. +- [ ] **Step 5: YOU COMMIT** β€” "feat(app): swappable DB holder" + +--- + +## Task 4: Setup API (state + complete) + +**Files:** `api/internal/handlers/setup.go` + `setup_test.go`. Modifies `handlers/auth.go` (CreateUser call signature). + +- [ ] **Step 1: failing test** `setup_test.go`: +```go +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/app" +) + +func TestSetupStateAndComplete(t *testing.T) { + dataDir := t.TempDir() + a := app.New() + h := NewSetup(a, dataDir, false) + + rec := httptest.NewRecorder() + h.State(rec, httptest.NewRequest(http.MethodGet, "/api/setup/state", nil)) + var st struct{ Configured bool `json:"configured"` } + json.NewDecoder(rec.Body).Decode(&st) + if st.Configured { + t.Fatal("fresh app: configured should be false") + } + + body, _ := json.Marshal(map[string]any{ + "name": "Shreeda", "email": "s@b.c", "username": "admin", "password": "supersecret", + "site_name": "Acme", "logo_url": "", "sqlite_path": dataDir + "/pulse.db", + }) + rec = httptest.NewRecorder() + h.Complete(rec, httptest.NewRequest(http.MethodPost, "/api/setup", bytes.NewReader(body))) + if rec.Code != http.StatusCreated { + t.Fatalf("setup complete = %d, want 201", rec.Code) + } + if !a.Configured() { + t.Fatal("app should be configured after setup") + } + if len(rec.Result().Cookies()) == 0 { + t.Fatal("setup should log the admin in (session cookie)") + } + + // Second attempt is rejected + rec = httptest.NewRecorder() + h.Complete(rec, httptest.NewRequest(http.MethodPost, "/api/setup", bytes.NewReader(body))) + if rec.Code != http.StatusForbidden { + t.Fatalf("second setup = %d, want 403", rec.Code) + } +} +``` +- [ ] **Step 2:** run β†’ FAIL. +- [ ] **Step 3: implement** `setup.go`: +```go +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/memetics19/pulse/api/internal/app" + "github.com/memetics19/pulse/api/internal/auth" + "github.com/memetics19/pulse/api/internal/bootstrap" + "github.com/memetics19/pulse/api/internal/db" + "github.com/memetics19/pulse/api/internal/generated" +) + +type Setup struct { + app *app.App + dataDir string + secure bool +} + +func NewSetup(a *app.App, dataDir string, secure bool) *Setup { + return &Setup{app: a, dataDir: dataDir, secure: secure} +} + +func (s *Setup) State(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "configured": s.app.Configured(), + "default_sqlite_path": s.dataDir + "/pulse.db", + }) +} + +type setupReq struct { + Name, Email, Username, Password string + SiteName string `json:"site_name"` + LogoURL string `json:"logo_url"` + SQLitePath string `json:"sqlite_path"` +} + +func (s *Setup) Complete(w http.ResponseWriter, r *http.Request) { + if s.app.Configured() { + http.Error(w, "already configured", http.StatusForbidden) + return + } + var req setupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || + req.Username == "" || len(req.Password) < 8 { + http.Error(w, "username and password (min 8) required", http.StatusBadRequest) + return + } + path := req.SQLitePath + if path == "" { + path = s.dataDir + "/pulse.db" + } + + conn, err := db.Open(path) // opens + migrates + if err != nil { + http.Error(w, "could not open database: "+err.Error(), http.StatusBadRequest) + return + } + q := generated.New(conn) + + hash, err := auth.HashPassword(req.Password) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + u, err := q.CreateUser(r.Context(), generated.CreateUserParams{ + Username: req.Username, PasswordHash: hash, Name: req.Name, Email: req.Email, + }) + if err != nil { + http.Error(w, "could not create admin", http.StatusInternalServerError) + return + } + + // Branding -> theme config (best-effort). + cfg, _ := json.Marshal(map[string]string{"site_name": req.SiteName, "logo_url": req.LogoURL}) + _ = q.UpdateTheme(r.Context(), generated.UpdateThemeParams{ + Preset: "default-light", CustomCss: "", ConfigJson: string(cfg), + }) + + if err := bootstrap.Save(s.dataDir, bootstrap.Config{Configured: true, SQLitePath: path}); err != nil { + http.Error(w, "could not persist config", http.StatusInternalServerError) + return + } + s.app.SetDB(conn) + + // Log the new admin in. + token, err := auth.NewSessionToken() + if err == nil { + _ = q.CreateSession(r.Context(), generated.CreateSessionParams{ + Token: token, UserID: u.ID, ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + auth.SetSessionCookie(w, token, s.secure) + } + w.WriteHeader(http.StatusCreated) +} +``` +> Before coding: confirm the theme update query/params name (`UpdateTheme` / `UpdateThemeParams`) by reading `api/internal/generated/theme.sql.go`; adjust the call to match. Confirm `db.Open` runs migrations (it does). Update `handlers/auth.go`'s existing `CreateUser` call to pass `Name`/`Email` (empty is fine there) so the package compiles β€” though the legacy `/api/auth/setup` is superseded by this wizard (server wiring in Task 6 drops it). + +- [ ] **Step 4:** run β†’ PASS. +- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): wizard state + complete endpoints" + +--- + +## Task 5: setup-gate middleware + +**Files:** `api/internal/middleware/setupgate.go` + test + +- [ ] **Step 1: failing test** `setupgate_test.go`: +```go +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/memetics19/pulse/api/internal/app" +) + +func TestSetupGateRedirectsWhenUnconfigured(t *testing.T) { + a := app.New() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + h := SetupGate(a)(next) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/setup/" { + t.Fatalf("unconfigured / should redirect to /setup/, got %d %q", rec.Code, rec.Header().Get("Location")) + } +} +``` +- [ ] **Step 2:** run β†’ FAIL. +- [ ] **Step 3: implement** `setupgate.go`: +```go +package middleware + +import ( + "net/http" + "strings" + + "github.com/memetics19/pulse/api/internal/app" +) + +// SetupGate redirects all traffic to /setup/ until the app is configured. +// Paths needed BY the wizard (the wizard page, its assets, and setup APIs) +// pass through so the wizard can render and submit. +func SetupGate(a *app.App) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.Configured() || allowedDuringSetup(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, "/setup/", http.StatusFound) + }) + } +} + +func allowedDuringSetup(p string) bool { + return p == "/setup" || strings.HasPrefix(p, "/setup/") || + strings.HasPrefix(p, "/_next/") || strings.HasPrefix(p, "/static/") || + strings.HasPrefix(p, "/api/setup") || p == "/favicon.ico" || p == "/healthz" +} +``` +- [ ] **Step 4:** run β†’ PASS. +- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): gate middleware" + +--- + +## Task 6: Wire the server + entrypoint to the App holder + setup mode + +**Files:** `api/internal/server/server.go`, `api/cmd/pulse/main.go`, `api/cmd/pulse/main_test.go` + +- [ ] **Step 1:** Change `server.New` to take the holder + data dir: `func New(a *app.App, dataDir string) http.Handler`. Inside: + - Apply `middleware.SetupGate(a)` as global middleware (after Logger/Recoverer/CORS). + - Mount setup routes (always): `setupH := handlers.NewSetup(a, dataDir, false)`; `r.Get("/api/setup/state", setupH.State)`; `r.Post("/api/setup", setupH.Complete)`. + - For all routes that need the DB (status, monitors, auth login/logout/status, admin group, public page), resolve the queries from `a.Queries()` **per request** (the handlers currently capture `q` at wiring time). Simplest: wrap DB-dependent handlers so they fetch `a.Queries()` at call time and 503 if nil. Provide a small helper: + ```go + func needDB(a *app.App, fn func(*generated.Queries) http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := a.Queries() + if q == nil { http.Error(w, "not configured", http.StatusServiceUnavailable); return } + fn(q)(w, r) + } + } + ``` + and register e.g. `r.Get("/api/status", needDB(a, func(q *generated.Queries) http.HandlerFunc { return handlers.NewStatus(q).Get }))`. Apply the same wrapper to every DB-backed route (monitors, incidents, groups, agents, theme, notifications, checks, incident-updates, auth login/logout/status, public `/`). The setup gate already redirects browsers pre-config, so this is a belt-and-suspenders 503 for API clients. + - Keep `/admin/*`, `/_next/*`, `/static/*`, `/favicon.ico` served as today. +- [ ] **Step 2:** `cmd/pulse/main.go`: + - Determine `dataDir` (env `PULSE_DATA_DIR`, else dir of `SQLITE_PATH`, else `/data`). + - `boot, _ := bootstrap.Load(dataDir)`. + - `a := app.New()`. If `boot.Configured`, open `db.Open(boot.SQLitePath)` and `a.SetDB(conn)`; the worker starts only once configured. If not configured, start in setup mode (no DB, no worker). + - `srv := server.New(a, dataDir)`. Serve as before. + - The worker (`worker.Run`) must only run with a DB β€” start it after `a.SetDB`, or have it poll until configured. Simplest for v1: if configured at boot, start the worker; otherwise start it after setup completes. To keep main simple, start a goroutine that waits for `a.Configured()` to become true (poll every 2s) then runs `worker.Run(ctx, a.DB(), cfg)`. (Add an `App.DB()` accessor.) + - The legacy `/api/auth/setup` route is removed (the wizard replaces it); `/api/auth/login|logout|status` stay (resolved via `needDB`). +- [ ] **Step 3:** Update `main_test.go` (`server.New(db, ...)` β†’ build an `app.App` with the test DB and call `server.New(a, t.TempDir())`); the healthz test still expects 200 (healthz is allowed during setup). +- [ ] **Step 4:** `go build ./... && go test ./... -count=1` β†’ all green. Fix any handler that still captured `q` at wiring time. +- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): server boots in setup mode, gated, App-backed" + +--- + +## Task 7: Wizard UI (multi-step) + client + +**Files:** `ui/src/app/setup/page.tsx`, `ui/src/lib/api.ts` + +- [ ] **Step 1:** Add to `ui/src/lib/api.ts`: +```ts +export type SetupState = { configured: boolean; default_sqlite_path: string } +export async function setupState(): Promise { + const res = await fetch(`${BASE}/api/setup/state`, { cache: 'no-store' }) + return res.json() +} +export async function runSetup(payload: { + name: string; email: string; username: string; password: string + site_name: string; logo_url: string; sqlite_path: string +}): Promise { + const res = await fetch(`${BASE}/api/setup`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + }) + if (!res.ok) throw new Error(await res.text()) +} +``` +- [ ] **Step 2:** Create `ui/src/app/setup/page.tsx` β€” a `'use client'` 3-step wizard reusing the framed-card style from the admin auth screen: + - **Step 1 Account:** name, email, username, password, confirm password (min 8, must match). + - **Step 2 Branding:** site name; logo upload (FileReader β†’ data URL, same as the Theme page) with a preview. + - **Step 3 Database:** radio "SQLite (recommended)" selected; an editable SQLite path defaulting to `state.default_sqlite_path`; a disabled "PostgreSQL β€” coming soon" radio. A "Finish setup" button calls `runSetup(...)`; on success `window.location.href = '/admin/'`. + - A stepper header (1 Account Β· 2 Branding Β· 3 Database) and Back/Next buttons. On mount, call `setupState()`; if already `configured`, redirect to `/admin/`. + - Use the `Logo` mark (the Pul⚑se bolt) at the top. Match the modern/formal card aesthetic (white card, hairline border, soft shadow, indigo primary button) β€” no gradients/glow. + > Full component code: model it on the existing `AuthScreen` card in `ui/src/app/admin/layout.tsx` (reuse the same card container styles and `.form-input`/`.btn primary` classes); render one step's fields at a time based on a `step` state (1|2|3). +- [ ] **Step 3:** `cd ui && NEXT_PUBLIC_API_URL="" npm run build` β†’ confirm `ui/out/setup/` is exported. Fix any TS errors. +- [ ] **Step 4: YOU COMMIT** β€” "feat(admin): multi-step setup wizard UI" + +--- + +## Task 8: End-to-end verification + +- [ ] **Step 1:** `make build`; run on a SPARE port with a FRESH data dir: +```bash +PULSE_DATA_DIR=/tmp/pulse-setup SQLITE_PATH=/tmp/pulse-setup/pulse.db API_PORT=8090 ./bin/pulse & +``` +(ensure `/tmp/pulse-setup` is empty first: `rm -rf /tmp/pulse-setup`) +- [ ] **Step 2:** Verify gating + flow with curl: +```bash +echo "setup state:" && curl -s localhost:8090/api/setup/state +echo "/ redirects to /setup (expect 302 -> /setup/):" && curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:8090/ +echo "/admin redirects too:" && curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:8090/admin/ +echo "complete setup:" && curl -s -i -c /tmp/sj -X POST localhost:8090/api/setup -H 'Content-Type: application/json' \ + -d '{"name":"Shreeda","email":"s@b.c","username":"admin","password":"supersecret","site_name":"Acme","logo_url":"","sqlite_path":"/tmp/pulse-setup/pulse.db"}' | grep -i 'HTTP/\|set-cookie' +echo "state after (configured true):" && curl -s localhost:8090/api/setup/state +echo "/ now serves public page (200):" && curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/ +echo "bootstrap file:" && cat /tmp/pulse-setup/pulse.json +``` +Expected: state `configured:false` β†’ `/` and `/admin/` 302 β†’ `/setup/`; complete β†’ 201 + Set-Cookie; state `configured:true`; `/` β†’ 200; `pulse.json` shows `configured:true`. +- [ ] **Step 3:** Restart the binary against the same data dir β†’ it boots **configured** (reads `pulse.json`), `/` serves the public page directly, `/setup/` redirects to `/admin/`. +- [ ] **Step 4:** Headless screenshot of `/setup/` on a fresh data dir to confirm the wizard renders. +- [ ] **Step 5: YOU COMMIT** β€” "test: end-to-end setup wizard flow" + +--- + +## Self-Review notes + +- **Spec coverage:** gate-until-setup βœ…, name/email/password βœ…, logo/site-name βœ…, multi-step UI βœ…, SQLite DB step βœ… (Postgres explicitly "coming soon" β€” its own future plan). +- **Supersedes:** the Plan 2 `/api/auth/setup` endpoint + the admin layout's setup branch (the wizard at `/setup` now owns first-run; the admin layout keeps only the login branch). Update `layout.tsx` to drop the `needs_setup` branch (or leave it β€” it'll never trigger once the gate redirects, but removing it is cleaner). +- **Worker:** starts only once configured (poll-for-configured goroutine). +- **Risk:** every DB-backed route must resolve queries from `App` at request time, not capture a nil `q` at wiring. The `needDB` wrapper enforces this β€” audit that no handler is wired with a captured `q`. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md b/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md new file mode 100644 index 0000000..60a8eca --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md @@ -0,0 +1,137 @@ +# Pulse Plan 3 (v1) β€” Multiple Status Pages (core routing + per-page content) + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. +> **PROJECT GIT RULE:** no git add/commit/push/mv from the agent. Stop at **YOU COMMIT**. + +**Goal:** Host more than one public status page. Each page has a title, a custom domain, a published flag, and a chosen set of monitor groups. The public page is resolved by the request `Host` header; an unknown host falls back to the **default** page (which shows all groups, preserving today's behaviour). An admin **Pages** section manages them. + +**Architecture:** New `status_pages` (+ `status_page_groups`) tables; a migration seeds one `is_default` page. A resolver maps `Host` β†’ page (or the default). The Go public renderer resolves the page, uses its title, and shows only its groups' monitors β€” with incidents/maintenance scoped to those monitors. Non-default unpublished pages 404. Admin Pages CRUD + UI. + +**Scope / deferred (later passes):** autocert per-domain TLS (host routing works now behind any proxy / via the existing setup); **per-page branding/theme/footer** (branding stays the global Theme config for all pages in v1); per-page **RSS**. These are explicitly out of this pass. + +**Builds on:** Plan 2c (app/server), the Go public renderer (`web/public.go`), groups/monitors, API-key scopes. + +--- + +## Task 1: schema + queries + default-page seed +**Files:** `api/internal/db/migrations/7_status_pages.{up,down}.sql`, `api/internal/db/queries/status_pages.sql`, regenerate. + +- [ ] **Step 1** `7_status_pages.up.sql`: +```sql +CREATE TABLE status_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL DEFAULT '' UNIQUE, + title TEXT NOT NULL DEFAULT 'Status', + is_default INTEGER NOT NULL DEFAULT 0, + published INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE status_page_groups ( + status_page_id INTEGER NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + group_id INTEGER NOT NULL, + PRIMARY KEY (status_page_id, group_id) +); +INSERT INTO status_pages (domain, title, is_default, published) VALUES ('', 'Status', 1, 1); +``` +`7_status_pages.down.sql`: `DROP TABLE status_page_groups; DROP TABLE status_pages;` +- [ ] **Step 2** `status_pages.sql`: +```sql +-- name: GetPageByDomain :one +SELECT * FROM status_pages WHERE domain = ?; + +-- name: GetDefaultPage :one +SELECT * FROM status_pages WHERE is_default = 1 LIMIT 1; + +-- name: ListStatusPages :many +SELECT * FROM status_pages ORDER BY is_default DESC, created_at ASC; + +-- name: GetStatusPage :one +SELECT * FROM status_pages WHERE id = ?; + +-- name: CreateStatusPage :one +INSERT INTO status_pages (domain, title, published) VALUES (?, ?, ?) RETURNING *; + +-- name: UpdateStatusPage :exec +UPDATE status_pages SET domain = ?, title = ?, published = ? WHERE id = ?; + +-- name: DeleteStatusPage :exec +DELETE FROM status_pages WHERE id = ? AND is_default = 0; + +-- name: ListPageGroupIDs :many +SELECT group_id FROM status_page_groups WHERE status_page_id = ?; + +-- name: ClearPageGroups :exec +DELETE FROM status_page_groups WHERE status_page_id = ?; + +-- name: AddPageGroup :exec +INSERT INTO status_page_groups (status_page_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING; +``` +- [ ] **Step 3** regenerate + build. Report generated `StatusPage` fields + `CreateStatusPageParams`, `UpdateStatusPageParams`, `AddPageGroupParams`. +- [ ] **Step 4** smoke test: fresh DB has exactly one `is_default=1` page with `domain=''`. +- [ ] **Step 5: YOU COMMIT** β€” "feat(db): status_pages schema + default seed" + +--- + +## Task 2: page resolver +**Files:** `api/internal/web/pages.go` (+ test). + +- [ ] **Step 1: failing test** `pages_test.go`: with the seeded default page, `ResolvePage(ctx, q, "anything.com")` returns the default page (`IsDefault==1`) and `groupIDs == nil` (meaning "all groups"). After creating a published page `domain="acme.com"` with group 5, `ResolvePage(ctx, q, "acme.com")` returns that page with `groupIDs == [5]`. An unpublished non-default domain resolves to... return `(page, _, published=false)` β€” caller 404s. Design the signature so the caller knows published state, e.g.: +```go +type ResolvedPage struct { + Page generated.StatusPage + GroupIDs []int64 // nil => all groups (default page) +} +func ResolvePage(ctx context.Context, q *generated.Queries, host string) (ResolvedPage, bool) // bool = serve (published) +``` +- [ ] **Step 2** run β†’ FAIL. +- [ ] **Step 3** implement `ResolvePage`: strip port from host (`host, _, _ := net.SplitHostPort(host)` fallback to raw). `GetPageByDomain(host)`; on error β†’ `GetDefaultPage()`. If the resolved page `IsDefault==1` β†’ `GroupIDs=nil` (all), serve=true. Else `GroupIDs = ListPageGroupIDs(page.ID)`, serve = `page.Published==1`. +- [ ] **Step 4** run β†’ PASS; build. **Step 5: YOU COMMIT** β€” "feat(web): hostβ†’page resolver" + +--- + +## Task 3: public renderer uses the resolved page +**Files:** `api/internal/web/public.go`. + +- [ ] **Step 1** Change `Get` to pass `r.Host`; `buildVM(ctx, rng, host)`. At the top of `buildVM`, `rp, serve := ResolvePage(ctx, p.q, host)`; if `!serve`, the `Get` handler should `http.NotFound` (return a sentinel from buildVM or check in Get before building β€” simplest: do `ResolvePage` in `Get`, 404 if `!serve`, else pass `rp` into `buildVM`). +- [ ] **Step 2** In `buildVM`, when `rp.GroupIDs != nil` build a `set := map[int64]bool` of allowed group IDs and **filter** `snap.Groups` to those in the set (default page: no filter). Use `rp.Page.Title` for `SiteName` (falls back to the theme `site_name`/"Status" if title empty). Collect the page's monitor IDs (monitors whose `GroupID` is in the allowed set; for default, all active monitors). +- [ ] **Step 3** **Scope incidents & maintenance**: only include an incident/maintenance window if its `affected_monitor_ids` intersects the page's monitor-ID set (for the default page, include all). Add a small `intersects(affectedJSON string, ids map[int64]bool) bool` helper (parse JSON array; true if any id in set; for default page where set is "all", pass a nil set meaning all-match). +- [ ] **Step 4** `go build ./... && go test ./internal/web/ -count=1` β†’ green. Existing tests pass (default page β†’ all groups, behaviour unchanged on the default host). +- [ ] **Step 5: YOU COMMIT** β€” "feat(public): render the host-resolved page (scoped groups + incidents)" + +--- + +## Task 4: admin Pages CRUD + routes +**Files:** `api/internal/handlers/pages.go` (+ test), `server.go`, `middleware/apikey.go` (add `pages` scope). + +- [ ] **Step 1: failing test**: `NewPages(q)`; Create (`{domain, title, published, group_ids:[]}`) β†’ 201; List β†’ includes default + the new one; Update sets groups; Delete (non-default) β†’ 204; deleting the default page is refused (DeleteStatusPage has `AND is_default=0`, so it no-ops β€” return 400 if the target is default). +- [ ] **Step 2** run β†’ FAIL. +- [ ] **Step 3** implement `pages.go`: `List` (each page with its `group_ids` via `ListPageGroupIDs`), `Create` (CreateStatusPage then AddPageGroup per id), `Update` (UpdateStatusPage + ClearPageGroups + AddPageGroup loop), `Delete` (reject if `GetStatusPage().IsDefault==1` β†’ 400, else DeleteStatusPage). View shape `{id, domain, title, is_default, published, group_ids:[]int64}`. Reuse `writeJSON`. +- [ ] **Step 4** run β†’ PASS; build. +- [ ] **Step 5** `server.go`: in the `RequireSessionOrAPIKey` group add `/api/pages` routes (Get/Post/Put{id}/Delete{id}). Add to `requiredScope`: `case strings.HasPrefix(path, "/api/pages"): resource = "pages"`. (Add `pages:read|write` to the API-key UI scope list too β€” small edit to the api-keys page checklist.) +- [ ] **Step 6** `go build ./... && go test ./... -count=1` green. **Step 7: YOU COMMIT** β€” "feat(api): status-page CRUD" + +--- + +## Task 5: admin Pages UI + nav +**Files:** `ui/src/app/admin/pages/page.tsx` (new), `ui/src/app/admin/layout.tsx` (nav), `ui/src/lib/api.ts`. + +- [ ] **Step 1** `api.ts`: `listPages()`, `createPage(p)`, `updatePage(id, p)`, `deletePage(id)` (cookie). Type `StatusPage { id; domain; title; is_default; published; group_ids: number[] }`. +- [ ] **Step 2** `layout.tsx` NAV: add `{ href: '/admin/pages', label: 'Pages' }` (after Overview). +- [ ] **Step 3** `pages/page.tsx` (v3 look): list pages β€” title, domain (or "default" chip for the default page), published toggle, group count. "New page" modal: title, domain, published checkbox, and a checkbox list of monitor **groups** (from a `adminListGroups()` call β€” confirm it exists in api.ts). Default page row: domain shown as "β€” (default)", not deletable, but its groups/title editable. Non-default: editable + deletable (inline confirm). On save β†’ create/update; refresh. +- [ ] **Step 4** build β†’ succeeds; `ui/out/admin/pages/` present. **Step 5: YOU COMMIT** β€” "feat(admin): Pages management" + +--- + +## Task 6: end-to-end verification +- [ ] **Step 1** `make build`/binary; fresh data dir; setup. Create groups + monitors (or seed). +- [ ] **Step 2** Create a page `domain=acme.test`, published, with group A only. +- [ ] **Step 3** `curl -H 'Host: acme.test' localhost:PORT/` β†’ shows only group A's monitors + page title; `curl localhost:PORT/` (default host) β†’ shows all groups. `curl -H 'Host: unpublished.test'` for an unpublished page β†’ 404. +- [ ] **Step 4** Confirm `/admin/pages` lists default + created page. +- [ ] **Step 5: YOU COMMIT** β€” "test: multi-page host routing verified" + +--- + +## Self-Review +- Spec coverage (v1): status_pages + default βœ…, host routing βœ…, per-page group selection βœ…, published βœ…, admin Pages βœ…, incident/maintenance scoping βœ…. Deferred (noted): autocert TLS, per-page branding/theme/footer, per-page RSS. +- Consistency: default page = `domain=''`, `is_default=1`, all groups; non-default = own domain + selected groups, published-gated. `pages` scope added to apikey middleware + UI. `affected_monitor_ids` parsing reuses the JSON-array convention. +- Safety: default page cannot be deleted (query guard + handler 400). Unknown host β†’ default (never 404 for the default). diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md b/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md new file mode 100644 index 0000000..54c475d --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md @@ -0,0 +1,64 @@ +# Pulse Plan 3b β€” Per-Page Branding (logo + theme) + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. +> **PROJECT GIT RULE:** no git add/commit/push/mv. Stop at **YOU COMMIT**. + +**Goal:** Each status page can have its own **logo** and **theme preset** (default-light/dark/terminal-dark), so different domains look like distinct branded sites. Empty values fall back to the global Theme config (today's behaviour). Title + group selection already exist (Plan 3). + +**Scope:** per-page `logo_url` + `theme_preset` only. Per-page favicon / custom-CSS / footer stay global (the existing Theme page) for now. + +**Builds on:** Plan 3 (status_pages, ResolvePage, public renderer, Pages admin). + +--- + +## Task 1: columns + queries + handler view +**Files:** `api/internal/db/migrations/8_page_branding.{up,down}.sql`, `api/internal/db/queries/status_pages.sql` (update Create/Update), regenerate; `api/internal/handlers/pages.go` (view + create/update accept fields). + +- [ ] **Step 1** `8_page_branding.up.sql`: +```sql +ALTER TABLE status_pages ADD COLUMN logo_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE status_pages ADD COLUMN theme_preset TEXT NOT NULL DEFAULT ''; +``` +`8_page_branding.down.sql`: +```sql +ALTER TABLE status_pages DROP COLUMN theme_preset; +ALTER TABLE status_pages DROP COLUMN logo_url; +``` +- [ ] **Step 2** in `status_pages.sql`, update: +```sql +-- name: CreateStatusPage :one +INSERT INTO status_pages (domain, title, published, logo_url, theme_preset) VALUES (?, ?, ?, ?, ?) RETURNING *; + +-- name: UpdateStatusPage :exec +UPDATE status_pages SET domain = ?, title = ?, published = ?, logo_url = ?, theme_preset = ? WHERE id = ?; +``` +- [ ] **Step 3** regenerate; `go build ./...` will break `pages.go` (Create/Update call sites) β€” that's expected; fix in Step 4. Report new `CreateStatusPageParams`/`UpdateStatusPageParams` fields. +- [ ] **Step 4** `handlers/pages.go`: add `logo_url string` + `theme_preset string` to the request decode and the `pageView`; pass them to `CreateStatusPage`/`UpdateStatusPage`. (The default page may set these too.) +- [ ] **Step 5** `go build ./... && go test ./... -count=1` green (the existing pages_test still passes β€” new fields default to ""). **Step 6: YOU COMMIT** β€” "feat(db): per-page logo + theme_preset" + +--- + +## Task 2: public renderer uses per-page branding +**Files:** `api/internal/web/public.go`, `api/internal/web/pages.go` (if needed). + +- [ ] **Step 1** In `buildVM`, after resolving `rp` and parsing the global theme `cfg`: if `rp.Page.LogoURL != ""` β†’ use it for `vm.LogoURL` (`template.URL(rp.Page.LogoURL)`), overriding the global logo. If `rp.Page.ThemePreset != ""` β†’ set `vm.ThemePreset = rp.Page.ThemePreset` (overriding the global theme preset). (Title precedence already handled.) +- [ ] **Step 2** `go build ./... && go test ./internal/web/ -count=1` green. +- [ ] **Step 3: YOU COMMIT** β€” "feat(public): per-page logo + theme" + +--- + +## Task 3: Pages admin UI β€” logo + theme +**Files:** `ui/src/lib/api.ts` (extend StatusPage + create/update payloads), `ui/src/app/admin/pages/page.tsx`. + +- [ ] **Step 1** `api.ts`: add `logo_url: string; theme_preset: string` to `StatusPage`; add the same two fields to the `createPage`/`updatePage` payload params. +- [ ] **Step 2** `pages/page.tsx` modal: add a **Logo** uploader (file β†’ data URL via `FileReader`, same pattern as the Theme page; with a preview + "Remove") bound to `logo_url`; and a **Theme** ` setUsername(e.target.value)} autoFocus /> -
setPassword(e.target.value)} />
- {error &&
{error}
} - - - - ) - } - - if (!status.authenticated) { - return ( -
-

Pulse Admin

-

Sign in to continue.

-
-
setUsername(e.target.value)} autoFocus />
-
setPassword(e.target.value)} />
- {error &&
{error}
} - -
-
- ) - } - - return ( -
- -
{children}
-
- ) -} -``` - -- [ ] **Step 4: Verify the export builds.** -`cd /Users/shreedabhat/Documents/statuspage/pulse/ui && NEXT_PUBLIC_API_URL="" npm run build` -Expected: build succeeds (TS errors here mean a missed token-argument removal in Step 2 β€” fix them). Confirm `ui/out/admin/` present. - -- [ ] **Step 5: YOU COMMIT** β€” "feat(admin): username/password setup + login + logout (cookie sessions)" - ---- - -## Task 7: `pulse reset-password` subcommand - -**Why a subcommand of `pulse` (not `pulse-cli`):** the `cli` module is a separate Go module and **cannot import `api/internal/...`** (Go's internal-package rule). The `pulse` binary lives in the `api` module, so it can use `auth` + `generated` directly. This also keeps recovery in the one shipped binary β€” better for the single-binary/lightweight goal. Invocation: `pulse reset-password --username admin --password `. - -**Files:** -- Create: `api/internal/account/account.go`, `api/internal/account/account_test.go` -- Modify: `api/cmd/pulse/main.go` (subcommand dispatch) - -- [ ] **Step 1: Write the failing test** `api/internal/account/account_test.go`: -```go -package account - -import ( - "context" - "testing" - - "github.com/memetics19/pulse/api/internal/auth" - "github.com/memetics19/pulse/api/internal/generated" - "github.com/memetics19/pulse/api/testutil" -) - -func TestResetPasswordUpdatesHash(t *testing.T) { - db := testutil.NewTestDB(t) - q := generated.New(db) - h, _ := auth.HashPassword("old-password") - _, _ = q.CreateUser(context.Background(), generated.CreateUserParams{Username: "admin", PasswordHash: h}) - - if err := ResetPassword(context.Background(), q, "admin", "brand-new-pass"); err != nil { - t.Fatalf("reset: %v", err) - } - u, _ := q.GetUserByUsername(context.Background(), "admin") - ok, _ := auth.VerifyPassword("brand-new-pass", u.PasswordHash) - if !ok { - t.Fatal("password was not updated to the new value") - } -} -``` - -- [ ] **Step 2: Run test, confirm FAILS** (undefined: ResetPassword): -`cd /Users/shreedabhat/Documents/statuspage/pulse/api && go test ./internal/account/ -run TestResetPasswordUpdatesHash -v` - -- [ ] **Step 3: Implement** `api/internal/account/account.go`: -```go -package account - -import ( - "context" - - "github.com/memetics19/pulse/api/internal/auth" - "github.com/memetics19/pulse/api/internal/generated" -) - -// ResetPassword sets a new Argon2id password hash for the given username. -func ResetPassword(ctx context.Context, q *generated.Queries, username, newPassword string) error { - hash, err := auth.HashPassword(newPassword) - if err != nil { - return err - } - return q.UpdateUserPassword(ctx, generated.UpdateUserPasswordParams{ - PasswordHash: hash, - Username: username, - }) -} -``` -> Confirm the generated `UpdateUserPasswordParams` field order/names from Task 1. - -- [ ] **Step 4: Run test, confirm PASS.** - -- [ ] **Step 5: Add the subcommand to `api/cmd/pulse/main.go`.** Before the normal server startup in `main()`, dispatch on `os.Args[1]`: -```go - if len(os.Args) > 1 && os.Args[1] == "reset-password" { - runResetPassword(os.Args[2:]) - return - } -``` -and add (same file or a `reset_password.go` in package `main`): -```go -func runResetPassword(args []string) { - fs := flag.NewFlagSet("reset-password", flag.ExitOnError) - username := fs.String("username", "", "admin username") - password := fs.String("password", "", "new password (min 8 chars)") - _ = fs.Parse(args) - if *username == "" || len(*password) < 8 { - log.Fatal("usage: pulse reset-password --username --password ") - } - cfg := config.Load() - path := cfg.SQLitePath - if path == "" { - path = "/data/pulse.db" - } - conn, err := db.Open(path) - if err != nil { - log.Fatalf("db: %v", err) - } - defer conn.Close() - if err := account.ResetPassword(context.Background(), generated.New(conn), *username, *password); err != nil { - log.Fatalf("reset-password: %v", err) - } - log.Printf("password updated for %q", *username) -} -``` -Add imports: `flag`, `context`, `github.com/memetics19/pulse/api/internal/account`, `github.com/memetics19/pulse/api/internal/generated`. - -- [ ] **Step 6: Verify build + a real reset:** -```bash -cd /Users/shreedabhat/Documents/statuspage/pulse/api && go build -o /tmp/pulse ./cmd/pulse -SQLITE_PATH=/tmp/rp.db /tmp/pulse reset-password --username nobody --password testpass123 ; echo "exit=$?" # no such user -> non-zero, but DB migrates -rm -f /tmp/rp.db -``` -Expected: builds; running against a user that doesn't exist updates 0 rows (UpdateUserPassword is `:exec`, so it succeeds with no error even if no row matches β€” acceptable; the happy path is covered by the unit test). The command prints the success line. - -- [ ] **Step 7: YOU COMMIT** β€” "feat: pulse reset-password subcommand" - ---- - -## Task 8: End-to-end verification (run both FE + BE) - -- [ ] **Step 1: Build the binary with the real admin embedded.** -```bash -cd /Users/shreedabhat/Documents/statuspage/pulse && make build -``` - -- [ ] **Step 2: Run on a spare port with a fresh DB:** -```bash -API_PORT=8090 SQLITE_PATH=/tmp/pulse-auth.db /Users/shreedabhat/Documents/statuspage/pulse/bin/pulse & -SRV=$!; sleep 2 -``` - -- [ ] **Step 3: Exercise the full auth flow via curl (cookie jar):** -```bash -echo "status (fresh -> needs_setup true):" && curl -s localhost:8090/api/auth/status -echo "setup:" && curl -s -i -c /tmp/jar -X POST localhost:8090/api/auth/setup -H 'Content-Type: application/json' -d '{"username":"admin","password":"supersecret"}' | grep -i 'HTTP/\|set-cookie' -echo "monitors WITHOUT cookie (expect 401):" && curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/api/monitors -echo "monitors WITH cookie (expect 200):" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar localhost:8090/api/monitors -echo "logout:" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar -c /tmp/jar -X POST localhost:8090/api/auth/logout -echo "monitors after logout (expect 401):" && curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar localhost:8090/api/monitors -kill $SRV 2>/dev/null; rm -f /tmp/pulse-auth.db /tmp/jar -``` -Expected: status shows `needs_setup:true`; setup returns 201 + `Set-Cookie: pulse_session=...`; monitors 401 without cookie, 200 with; logout 200; monitors 401 after logout. - -- [ ] **Step 4: Manual browser check.** Open `http://localhost:8090/admin/` β†’ setup screen (create admin) β†’ lands in admin β†’ reload stays logged in β†’ Sign out β†’ login screen. Confirm no token field remains. - -- [ ] **Step 5: YOU COMMIT** β€” "test: verify end-to-end auth flow" (if any verification helpers were added; otherwise nothing to commit). - ---- - -## Self-Review notes (for the executor) - -- **Spec coverage (Β§5):** setup βœ…, login βœ…, Argon2id βœ…, sessions βœ…, logout βœ…, CLI reset βœ…. **Deferred to Plan 2b: TOTP 2FA and API keys** (the `users` table already has `totp_secret`/`totp_enabled` so TOTP needs no further migration). -- **Signature change:** `server.New` drops `adminToken` (Task 5); the `cmd/pulse` test is updated in the same task. The old `RequireToken` + its test are removed. -- **Security note:** cookies are issued with `secure=false` for plain HTTP local/dev. When Plan 3 adds autocert/TLS, pass `secure=true` to `handlers.NewAuth` (and thread a config flag). -- **DRY:** reuse any existing `writeJSON` in `handlers` rather than the one shown. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md b/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md deleted file mode 100644 index 5e623b8..0000000 --- a/docs/superpowers/plans/2026-06-07-pulse-plan-2b-api-keys.md +++ /dev/null @@ -1,300 +0,0 @@ -# Pulse Plan 2b β€” API Keys Implementation Plan - -> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. -> **PROJECT GIT RULE:** no git add/commit/push/mv from the agent. Stop at **YOU COMMIT**. - -**Goal:** Named, revocable API keys for programmatic REST access, with granular scopes, shown once on creation, accepted via `Authorization: Bearer pulse_…` as an alternative to the admin session cookie. - -**Architecture:** New `api_keys` table (sha256 hash of the key + visible prefix + JSON scopes). A `keyauth` helper generates `pulse_live_` keys and hashes them. CRUD handlers under `/api/keys` (session-only). A combined middleware `RequireSessionOrAPIKey(q)` replaces `RequireSession` on the API admin group: a valid session cookie grants full access (unchanged); otherwise a valid, unrevoked API key is checked against the **scope required for that method+path** and its `last_used_at` is touched. An admin **API Keys** page lists keys (name, prefix, scopes, last used) and creates/revokes them β€” the full key is displayed once. - -**Tech Stack:** Go (chi, crypto/rand, crypto/sha256), sqlc/SQLite, Next.js admin SPA. - -**Builds on:** Plan 2 (`auth`, sessions, `RequireSession`), Plan 2c (`app.App`, server wiring via `app.LiveDBTX`). - -**Scopes (offered at creation):** `monitors:read`, `monitors:write`, `incidents:read`, `incidents:write`, `notifications:read`, `notifications:write`, `agents:read`, `agents:write`, `theme:read`, `theme:write`, `status:read`. Key management (`/api/keys`), auth, and setup are **session-only** (never reachable with an API key). - -**Path β†’ required scope (for API-key requests):** -| Path prefix | GET | mutate (POST/PUT/DELETE) | -|---|---|---| -| `/api/monitors`, `/api/groups` | `monitors:read` | `monitors:write` | -| `/api/incidents` | `incidents:read` | `incidents:write` | -| `/api/notifications` | `notifications:read` | `notifications:write` | -| `/api/agents` | `agents:read` | `agents:write` | -| `/api/theme` | `theme:read` | `theme:write` | -| `/api/overview` | `status:read` | β€” | - ---- - -## Task 1: api_keys schema + queries - -**Files:** `api/internal/db/migrations/4_api_keys.{up,down}.sql`, `api/internal/db/queries/api_keys.sql`, regenerate. - -- [ ] **Step 1** `4_api_keys.up.sql`: -```sql -CREATE TABLE api_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - prefix TEXT NOT NULL, - scopes TEXT NOT NULL DEFAULT '[]', - last_used_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - revoked_at TIMESTAMP -); -``` -- [ ] **Step 2** `4_api_keys.down.sql`: `DROP TABLE api_keys;` -- [ ] **Step 3** `api_keys.sql`: -```sql --- name: CreateAPIKey :one -INSERT INTO api_keys (name, key_hash, prefix, scopes) VALUES (?, ?, ?, ?) RETURNING *; - --- name: ListAPIKeys :many -SELECT * FROM api_keys ORDER BY created_at DESC; - --- name: GetAPIKeyByHash :one -SELECT * FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL; - --- name: RevokeAPIKey :exec -UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?; - --- name: TouchAPIKey :exec -UPDATE api_keys SET last_used_at = ? WHERE id = ?; -``` -- [ ] **Step 4** regenerate (`cd api/internal/db && sqlc generate`), `go build ./...`. Note generated `ApiKey` fields + `CreateAPIKeyParams` (Name, KeyHash, Prefix, Scopes), `TouchAPIKeyParams` (LastUsedAt *time.Time, ID). -- [ ] **Step 5** migration smoke test in `db_test.go` (insert + select an api_keys row) β†’ PASS. -- [ ] **Step 6: YOU COMMIT** β€” "feat(db): api_keys schema + queries" - ---- - -## Task 2: key generation + hashing - -**Files:** `api/internal/keyauth/keyauth.go` + test. - -- [ ] **Step 1: failing test** `keyauth_test.go`: -```go -package keyauth - -import "testing" - -func TestGenerateAndHash(t *testing.T) { - full, prefix, hash, err := Generate() - if err != nil { t.Fatal(err) } - if len(full) < 20 || prefix == "" || hash == "" { - t.Fatalf("bad outputs: full=%q prefix=%q", full, prefix) - } - if Hash(full) != hash { - t.Fatal("Hash(full) must equal the returned hash") - } - if !hasPrefix(full) { - t.Fatalf("key should start with the pulse prefix: %q", full) - } -} -func hasPrefix(s string) bool { return len(s) > 10 && s[:10] == "pulse_live" } -``` -- [ ] **Step 2** run β†’ FAIL. -- [ ] **Step 3: implement** `keyauth.go`: -```go -package keyauth - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" -) - -const keyPrefix = "pulse_live_" - -// Generate returns (fullKey, displayPrefix, sha256hash). The full key is shown -// to the user once; only the hash is stored. -func Generate() (full, prefix, hash string, err error) { - b := make([]byte, 24) - if _, err = rand.Read(b); err != nil { - return "", "", "", err - } - secret := base64.RawURLEncoding.EncodeToString(b) - full = keyPrefix + secret - prefix = full[:len(keyPrefix)+6] // e.g. pulse_live_ab12cd - hash = Hash(full) - return full, prefix, hash, nil -} - -// Hash returns the hex sha256 of a key (API keys are high-entropy, so a fast -// hash is appropriate β€” unlike user passwords). -func Hash(full string) string { - sum := sha256.Sum256([]byte(full)) - return hex.EncodeToString(sum[:]) -} -``` -- [ ] **Step 4** run β†’ PASS. **Step 5: YOU COMMIT** β€” "feat(keyauth): API key generation + hashing" - ---- - -## Task 3: API-key CRUD handlers - -**Files:** `api/internal/handlers/apikeys.go` + test. - -- [ ] **Step 1: failing test** `apikeys_test.go`: create a key (POST body `{name, scopes:[...]}`) β†’ 201 with a `key` field (the full key, starts with `pulse_live_`) and it is NOT returned again by list; list returns the key with `prefix` but no full key; revoke β†’ 204/200. -```go -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/memetics19/pulse/api/internal/generated" - "github.com/memetics19/pulse/api/testutil" -) - -func TestAPIKeyCreateListRevoke(t *testing.T) { - q := generated.New(testutil.NewTestDB(t)) - h := NewAPIKeys(q) - - body, _ := json.Marshal(map[string]any{"name": "ci", "scopes": []string{"monitors:read"}}) - rec := httptest.NewRecorder() - h.Create(rec, httptest.NewRequest(http.MethodPost, "/api/keys", bytes.NewReader(body))) - if rec.Code != http.StatusCreated { t.Fatalf("create=%d", rec.Code) } - var created struct{ Key string `json:"key"` } - json.NewDecoder(rec.Body).Decode(&created) - if !strings.HasPrefix(created.Key, "pulse_live_") { t.Fatalf("missing full key: %q", created.Key) } - - rec = httptest.NewRecorder() - h.List(rec, httptest.NewRequest(http.MethodGet, "/api/keys", nil)) - if strings.Contains(rec.Body.String(), created.Key) { t.Fatal("list must NOT contain the full key") } -} -``` -- [ ] **Step 2** run β†’ FAIL. -- [ ] **Step 3: implement** `apikeys.go`: `NewAPIKeys(q)`; `Create` (decode {name, scopes []string}; `keyauth.Generate()`; store via `CreateAPIKey` with `scopes` JSON-marshaled; respond 201 `{id, name, prefix, scopes, key: }` β€” full key ONLY here); `List` (return rows mapped to `{id,name,prefix,scopes,last_used_at,created_at}` β€” never the hash/full key); `Revoke` (path id β†’ `RevokeAPIKey`). Reuse `writeJSON`. Marshal/unmarshal `scopes` to/from the JSON text column. -- [ ] **Step 4** run β†’ PASS, `go build ./...`. **Step 5: YOU COMMIT** β€” "feat(api): API key CRUD handlers" - ---- - -## Task 4: combined session-or-API-key middleware - -**Files:** `api/internal/middleware/apikey.go` + test. Modify `api/internal/middleware/session.go` only if needed. - -- [ ] **Step 1: failing test** `apikey_test.go`: build `RequireSessionOrAPIKey(q)`; (a) no cred β†’ 401; (b) valid session cookie β†’ 200 (full); (c) valid API key with `monitors:read` on a GET `/api/monitors` β†’ 200; (d) same key on POST `/api/monitors` (needs `monitors:write`) β†’ 403; (e) revoked key β†’ 401. (Seed keys via `keyauth.Generate` + `CreateAPIKey`.) -- [ ] **Step 2** run β†’ FAIL. -- [ ] **Step 3: implement** `apikey.go`: -```go -package middleware - -import ( - "net/http" - "strings" - "time" - - "github.com/memetics19/pulse/api/internal/auth" - "github.com/memetics19/pulse/api/internal/generated" - "github.com/memetics19/pulse/api/internal/keyauth" -) - -// requiredScope maps an API request to the scope an API key must hold. -// Returns "" if API keys may not access this path (session-only). -func requiredScope(method, path string) string { - resource := "" - switch { - case strings.HasPrefix(path, "/api/monitors"), strings.HasPrefix(path, "/api/groups"): - resource = "monitors" - case strings.HasPrefix(path, "/api/incidents"): - resource = "incidents" - case strings.HasPrefix(path, "/api/notifications"): - resource = "notifications" - case strings.HasPrefix(path, "/api/agents"): - resource = "agents" - case strings.HasPrefix(path, "/api/theme"): - resource = "theme" - case strings.HasPrefix(path, "/api/overview"): - return "status:read" // read-only resource - default: - return "" // /api/keys, /api/auth, /api/setup β†’ session only - } - if method == http.MethodGet { - return resource + ":read" - } - return resource + ":write" -} - -func RequireSessionOrAPIKey(q *generated.Queries) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 1. Session cookie (full access, unchanged behaviour). - if c, err := r.Cookie(auth.SessionCookieName); err == nil { - if sess, err := q.GetSession(r.Context(), c.Value); err == nil && sess.ExpiresAt.After(time.Now()) { - next.ServeHTTP(w, r) - return - } - } - // 2. API key. - authz := r.Header.Get("Authorization") - if strings.HasPrefix(authz, "Bearer pulse_") { - token := strings.TrimPrefix(authz, "Bearer ") - key, err := q.GetAPIKeyByHash(r.Context(), keyauth.Hash(token)) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - need := requiredScope(r.Method, r.URL.Path) - if need == "" || !scopesContain(key.Scopes, need) { - http.Error(w, "insufficient scope", http.StatusForbidden) - return - } - now := time.Now() - _ = q.TouchAPIKey(r.Context(), generated.TouchAPIKeyParams{LastUsedAt: &now, ID: key.ID}) - next.ServeHTTP(w, r) - return - } - http.Error(w, "unauthorized", http.StatusUnauthorized) - }) - } -} - -func scopesContain(scopesJSON, need string) bool { - // scopes stored as JSON array text; simple substring match on the quoted scope is sufficient - return strings.Contains(scopesJSON, `"`+need+`"`) -} -``` -> Confirm `key.Scopes` is the column (string) and `key.ID` exist on the generated `ApiKey`. Adjust `TouchAPIKeyParams` field names to the generated ones. -- [ ] **Step 4** run β†’ PASS. **Step 5: YOU COMMIT** β€” "feat(auth): session-or-API-key middleware with scopes" - ---- - -## Task 5: wire routes + middleware - -**Files:** `api/internal/server/server.go`. - -- [ ] **Step 1** In the admin API group, change `r.Use(middleware.RequireSession(q))` to `r.Use(middleware.RequireSessionOrAPIKey(q))`. -- [ ] **Step 2** Add a **session-only** sub-group (still `RequireSession`) for key management, OR register `/api/keys` routes that explicitly use `RequireSession(q)` (NOT the combined one β€” an API key must not manage keys): -```go -r.Group(func(r chi.Router) { - r.Use(middleware.RequireSession(q)) - kh := handlers.NewAPIKeys(q) - r.Get("/api/keys", kh.List) - r.Post("/api/keys", kh.Create) - r.Delete("/api/keys/{id}", kh.Revoke) -}) -``` -- [ ] **Step 3** `go build ./... && go test ./... -count=1` β†’ green. **Step 4: YOU COMMIT** β€” "feat: wire API keys + scoped auth" - ---- - -## Task 6: admin API Keys page - -**Files:** `ui/src/app/admin/api-keys/page.tsx` (new), `ui/src/app/admin/layout.tsx` (nav), `ui/src/lib/api.ts` (client fns). - -- [ ] **Step 1** `api.ts`: `listApiKeys()`, `createApiKey(name, scopes: string[])` (returns `{key, ...}`), `revokeApiKey(id)` β€” all `credentials:'include'`. -- [ ] **Step 2** Add `{ href: '/admin/api-keys', label: 'API Keys' }` to `NAV` (after Notifications). -- [ ] **Step 3** Create `ui/src/app/admin/api-keys/page.tsx` (`'use client'`, v3 look): a `.card` table of keys (name, prefix `pulse_live_…`, scopes, last used, created, Revoke with inline confirm). A "New API key" modal: name + a checkbox list of the scopes (`monitors:read`, `monitors:write`, `incidents:read`, `incidents:write`, `notifications:read`, `notifications:write`, `agents:read`, `agents:write`, `theme:read`, `theme:write`, `status:read`). On create, show the **full key once** in a highlighted box with a "Copy" button and a "you won't see this again" note; list refreshes after. -- [ ] **Step 4** `cd ui && NEXT_PUBLIC_API_URL="" npm run build` β†’ succeeds; `ui/out/admin/api-keys/` present. -- [ ] **Step 5: YOU COMMIT** β€” "feat(admin): API Keys page" - ---- - -## Self-Review -- Spec coverage: named/revocable keys βœ…, hash-stored + shown-once βœ…, granular scopes βœ…, `Bearer` + scope-checked middleware βœ…, key mgmt session-only βœ…, admin UI βœ…. (Maintenance scopes omitted β€” maintenance isn't built yet; add `maintenance:*` when it lands.) -- Consistency: scope strings identical across the create UI (Task 6), the `requiredScope` map (Task 4), and the docs table. `keyauth.Hash` used in both create and verify. -- Security: only sha256 hash stored; full key shown once; revoked keys rejected (`GetAPIKeyByHash` filters `revoked_at IS NULL`); API keys cannot reach `/api/keys`, auth, or setup. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md b/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md deleted file mode 100644 index 3bcb334..0000000 --- a/docs/superpowers/plans/2026-06-07-pulse-plan-2c-setup-wizard.md +++ /dev/null @@ -1,559 +0,0 @@ -# Pulse Plan 2c β€” First-Run Setup Wizard Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes. -> -> **PROJECT GIT RULE:** Do NOT run git add/commit/push/mv. At each **YOU COMMIT** marker, stop for the human. Keep test-first discipline. - -**Goal:** Replace the bare username/password setup with a gated, multi-step first-run wizard (Account β†’ Branding β†’ Database β†’ Finish) that the whole app redirects to until configured. - -**Architecture:** A `bootstrap` config file (`/pulse.json`, `{configured, sqlite_path}`) records setup state and the chosen SQLite path. The binary boots in **setup mode** when unconfigured: a swappable DB holder starts empty, the router serves only the wizard + `/api/setup*` + assets, and a gate redirects everything else to `/setup`. The wizard's Finish endpoint writes the bootstrap file, opens+migrates the chosen SQLite DB, creates the admin (name/email/username/password) and branding (logo/site name), then the app flips to configured. SQLite only for now β€” the DB step shows Postgres as "coming soon." - -**Tech Stack:** Go (`net/http`, chi, `encoding/json`, `os`), sqlc/SQLite, golang-migrate, Argon2id (existing `auth` pkg), Next.js static export (wizard page), `go:embed`. - -**Builds on:** Plan 2 (auth: `auth` pkg, sessions, `users`/`sessions` tables, `handlers.NewAuth`). - ---- - -## File Structure - -**Created:** -- `api/internal/bootstrap/bootstrap.go` (+ test) β€” read/write the bootstrap config file -- `api/internal/app/app.go` (+ test) β€” `App` holder with a swappable `*sql.DB`/`*generated.Queries` and `Configured()` state -- `api/internal/handlers/setup.go` (+ test) β€” `GET /api/setup/state`, `POST /api/setup` -- `api/internal/middleware/setupgate.go` (+ test) β€” redirect to `/setup` when unconfigured -- `api/internal/db/migrations/3_user_profile.{up,down}.sql` β€” add `name`, `email` to `users` -- `ui/src/app/setup/page.tsx` β€” multi-step wizard SPA page - -**Modified:** -- `api/internal/db/queries/auth.sql` β€” `CreateUser` includes name/email -- `api/internal/server/server.go` β€” accept the `App` holder; mount `/api/setup*` + setup gate; routes resolve queries from the holder -- `api/cmd/pulse/main.go` β€” boot via bootstrap config; setup vs configured mode -- `ui/src/lib/api.ts` β€” `setupState()` / `runSetup()` client fns - ---- - -## Task 1: `users` gains name + email - -**Files:** `api/internal/db/migrations/3_user_profile.up.sql` / `.down.sql`; `api/internal/db/queries/auth.sql`; regenerate. - -- [ ] **Step 1:** `3_user_profile.up.sql`: -```sql -ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''; -ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''; -``` -- [ ] **Step 2:** `3_user_profile.down.sql`: -```sql -ALTER TABLE users DROP COLUMN email; -ALTER TABLE users DROP COLUMN name; -``` -- [ ] **Step 3:** In `api/internal/db/queries/auth.sql`, replace `CreateUser` with: -```sql --- name: CreateUser :one -INSERT INTO users (username, password_hash, name, email) VALUES (?, ?, ?, ?) RETURNING *; -``` -- [ ] **Step 4:** Regenerate: `cd api/internal/db && sqlc generate` then `cd .. && go build ./...`. Confirm `CreateUserParams` now has `Username, PasswordHash, Name, Email`; `User` has `Name, Email`. -- [ ] **Step 5:** Migration test β€” extend `api/internal/db/db_test.go`: -```go -func TestUsersHaveProfileColumns(t *testing.T) { - conn, err := Open(t.TempDir() + "/p.db") - if err != nil { t.Fatal(err) } - defer conn.Close() - if _, err := conn.Exec(`INSERT INTO users (username, password_hash, name, email) VALUES ('a','h','A','a@b.c')`); err != nil { - t.Fatalf("insert with name/email failed: %v", err) - } -} -``` -Run it β†’ PASS. -- [ ] **Step 6: YOU COMMIT** β€” "feat(db): users.name + users.email" - -> NOTE: The existing `handlers/auth.go` `Setup`/`Login` call `CreateUser` with the old 2-field params and will no longer compile. They are replaced by the new setup flow in Task 4; if you need an intermediate compile, pass `Name: "", Email: ""` until Task 4 rewrites them. - ---- - -## Task 2: bootstrap config file - -**Files:** `api/internal/bootstrap/bootstrap.go` + `bootstrap_test.go` - -- [ ] **Step 1: failing test** `bootstrap_test.go`: -```go -package bootstrap - -import ( - "path/filepath" - "testing" -) - -func TestSaveLoadRoundTrip(t *testing.T) { - dir := t.TempDir() - if c, _ := Load(dir); c.Configured { - t.Fatal("fresh dir must be unconfigured") - } - want := Config{Configured: true, SQLitePath: filepath.Join(dir, "pulse.db")} - if err := Save(dir, want); err != nil { - t.Fatal(err) - } - got, err := Load(dir) - if err != nil { - t.Fatal(err) - } - if !got.Configured || got.SQLitePath != want.SQLitePath { - t.Fatalf("round trip mismatch: %+v", got) - } -} -``` -- [ ] **Step 2:** run β†’ FAIL. -- [ ] **Step 3: implement** `bootstrap.go`: -```go -package bootstrap - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" -) - -// Config is the pre-database bootstrap state, stored as JSON on disk so the -// binary knows whether setup has run and which SQLite file to open. -type Config struct { - Configured bool `json:"configured"` - SQLitePath string `json:"sqlite_path"` -} - -func file(dataDir string) string { return filepath.Join(dataDir, "pulse.json") } - -// Load reads the bootstrap config; a missing file yields a zero Config (unconfigured). -func Load(dataDir string) (Config, error) { - b, err := os.ReadFile(file(dataDir)) - if errors.Is(err, os.ErrNotExist) { - return Config{}, nil - } - if err != nil { - return Config{}, err - } - var c Config - if err := json.Unmarshal(b, &c); err != nil { - return Config{}, err - } - return c, nil -} - -// Save writes the bootstrap config, creating the data dir if needed. -func Save(dataDir string, c Config) error { - if err := os.MkdirAll(dataDir, 0o755); err != nil { - return err - } - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - return os.WriteFile(file(dataDir), b, 0o600) -} -``` -- [ ] **Step 4:** run β†’ PASS. -- [ ] **Step 5: YOU COMMIT** β€” "feat(bootstrap): setup-state config file" - ---- - -## Task 3: App holder (swappable DB + configured state) - -**Files:** `api/internal/app/app.go` + `app_test.go` - -- [ ] **Step 1: failing test** `app_test.go`: -```go -package app - -import ( - "testing" - - "github.com/memetics19/pulse/api/testutil" -) - -func TestAppConfiguredFlips(t *testing.T) { - a := New() - if a.Configured() { - t.Fatal("new app must be unconfigured") - } - if a.Queries() != nil { - t.Fatal("no queries before configuration") - } - db := testutil.NewTestDB(t) - a.SetDB(db) - if !a.Configured() || a.Queries() == nil { - t.Fatal("SetDB must make the app configured with queries") - } -} -``` -- [ ] **Step 2:** run β†’ FAIL. -- [ ] **Step 3: implement** `app.go`: -```go -package app - -import ( - "database/sql" - "sync" - - "github.com/memetics19/pulse/api/internal/generated" -) - -// App holds the (initially absent) database so the server can run in setup -// mode and flip to configured once setup completes. -type App struct { - mu sync.RWMutex - db *sql.DB - q *generated.Queries -} - -func New() *App { return &App{} } - -// SetDB installs an open, migrated database and marks the app configured. -func (a *App) SetDB(db *sql.DB) { - a.mu.Lock() - defer a.mu.Unlock() - a.db = db - a.q = generated.New(db) -} - -func (a *App) Configured() bool { - a.mu.RLock() - defer a.mu.RUnlock() - return a.db != nil -} - -// Queries returns the query handle, or nil when unconfigured. -func (a *App) Queries() *generated.Queries { - a.mu.RLock() - defer a.mu.RUnlock() - return a.q -} -``` -- [ ] **Step 4:** run β†’ PASS. -- [ ] **Step 5: YOU COMMIT** β€” "feat(app): swappable DB holder" - ---- - -## Task 4: Setup API (state + complete) - -**Files:** `api/internal/handlers/setup.go` + `setup_test.go`. Modifies `handlers/auth.go` (CreateUser call signature). - -- [ ] **Step 1: failing test** `setup_test.go`: -```go -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/memetics19/pulse/api/internal/app" -) - -func TestSetupStateAndComplete(t *testing.T) { - dataDir := t.TempDir() - a := app.New() - h := NewSetup(a, dataDir, false) - - rec := httptest.NewRecorder() - h.State(rec, httptest.NewRequest(http.MethodGet, "/api/setup/state", nil)) - var st struct{ Configured bool `json:"configured"` } - json.NewDecoder(rec.Body).Decode(&st) - if st.Configured { - t.Fatal("fresh app: configured should be false") - } - - body, _ := json.Marshal(map[string]any{ - "name": "Shreeda", "email": "s@b.c", "username": "admin", "password": "supersecret", - "site_name": "Acme", "logo_url": "", "sqlite_path": dataDir + "/pulse.db", - }) - rec = httptest.NewRecorder() - h.Complete(rec, httptest.NewRequest(http.MethodPost, "/api/setup", bytes.NewReader(body))) - if rec.Code != http.StatusCreated { - t.Fatalf("setup complete = %d, want 201", rec.Code) - } - if !a.Configured() { - t.Fatal("app should be configured after setup") - } - if len(rec.Result().Cookies()) == 0 { - t.Fatal("setup should log the admin in (session cookie)") - } - - // Second attempt is rejected - rec = httptest.NewRecorder() - h.Complete(rec, httptest.NewRequest(http.MethodPost, "/api/setup", bytes.NewReader(body))) - if rec.Code != http.StatusForbidden { - t.Fatalf("second setup = %d, want 403", rec.Code) - } -} -``` -- [ ] **Step 2:** run β†’ FAIL. -- [ ] **Step 3: implement** `setup.go`: -```go -package handlers - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/memetics19/pulse/api/internal/app" - "github.com/memetics19/pulse/api/internal/auth" - "github.com/memetics19/pulse/api/internal/bootstrap" - "github.com/memetics19/pulse/api/internal/db" - "github.com/memetics19/pulse/api/internal/generated" -) - -type Setup struct { - app *app.App - dataDir string - secure bool -} - -func NewSetup(a *app.App, dataDir string, secure bool) *Setup { - return &Setup{app: a, dataDir: dataDir, secure: secure} -} - -func (s *Setup) State(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "configured": s.app.Configured(), - "default_sqlite_path": s.dataDir + "/pulse.db", - }) -} - -type setupReq struct { - Name, Email, Username, Password string - SiteName string `json:"site_name"` - LogoURL string `json:"logo_url"` - SQLitePath string `json:"sqlite_path"` -} - -func (s *Setup) Complete(w http.ResponseWriter, r *http.Request) { - if s.app.Configured() { - http.Error(w, "already configured", http.StatusForbidden) - return - } - var req setupReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || - req.Username == "" || len(req.Password) < 8 { - http.Error(w, "username and password (min 8) required", http.StatusBadRequest) - return - } - path := req.SQLitePath - if path == "" { - path = s.dataDir + "/pulse.db" - } - - conn, err := db.Open(path) // opens + migrates - if err != nil { - http.Error(w, "could not open database: "+err.Error(), http.StatusBadRequest) - return - } - q := generated.New(conn) - - hash, err := auth.HashPassword(req.Password) - if err != nil { - http.Error(w, "hash error", http.StatusInternalServerError) - return - } - u, err := q.CreateUser(r.Context(), generated.CreateUserParams{ - Username: req.Username, PasswordHash: hash, Name: req.Name, Email: req.Email, - }) - if err != nil { - http.Error(w, "could not create admin", http.StatusInternalServerError) - return - } - - // Branding -> theme config (best-effort). - cfg, _ := json.Marshal(map[string]string{"site_name": req.SiteName, "logo_url": req.LogoURL}) - _ = q.UpdateTheme(r.Context(), generated.UpdateThemeParams{ - Preset: "default-light", CustomCss: "", ConfigJson: string(cfg), - }) - - if err := bootstrap.Save(s.dataDir, bootstrap.Config{Configured: true, SQLitePath: path}); err != nil { - http.Error(w, "could not persist config", http.StatusInternalServerError) - return - } - s.app.SetDB(conn) - - // Log the new admin in. - token, err := auth.NewSessionToken() - if err == nil { - _ = q.CreateSession(r.Context(), generated.CreateSessionParams{ - Token: token, UserID: u.ID, ExpiresAt: time.Now().Add(auth.SessionDuration), - }) - auth.SetSessionCookie(w, token, s.secure) - } - w.WriteHeader(http.StatusCreated) -} -``` -> Before coding: confirm the theme update query/params name (`UpdateTheme` / `UpdateThemeParams`) by reading `api/internal/generated/theme.sql.go`; adjust the call to match. Confirm `db.Open` runs migrations (it does). Update `handlers/auth.go`'s existing `CreateUser` call to pass `Name`/`Email` (empty is fine there) so the package compiles β€” though the legacy `/api/auth/setup` is superseded by this wizard (server wiring in Task 6 drops it). - -- [ ] **Step 4:** run β†’ PASS. -- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): wizard state + complete endpoints" - ---- - -## Task 5: setup-gate middleware - -**Files:** `api/internal/middleware/setupgate.go` + test - -- [ ] **Step 1: failing test** `setupgate_test.go`: -```go -package middleware - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/memetics19/pulse/api/internal/app" -) - -func TestSetupGateRedirectsWhenUnconfigured(t *testing.T) { - a := app.New() - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) - h := SetupGate(a)(next) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/setup/" { - t.Fatalf("unconfigured / should redirect to /setup/, got %d %q", rec.Code, rec.Header().Get("Location")) - } -} -``` -- [ ] **Step 2:** run β†’ FAIL. -- [ ] **Step 3: implement** `setupgate.go`: -```go -package middleware - -import ( - "net/http" - "strings" - - "github.com/memetics19/pulse/api/internal/app" -) - -// SetupGate redirects all traffic to /setup/ until the app is configured. -// Paths needed BY the wizard (the wizard page, its assets, and setup APIs) -// pass through so the wizard can render and submit. -func SetupGate(a *app.App) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if a.Configured() || allowedDuringSetup(r.URL.Path) { - next.ServeHTTP(w, r) - return - } - http.Redirect(w, r, "/setup/", http.StatusFound) - }) - } -} - -func allowedDuringSetup(p string) bool { - return p == "/setup" || strings.HasPrefix(p, "/setup/") || - strings.HasPrefix(p, "/_next/") || strings.HasPrefix(p, "/static/") || - strings.HasPrefix(p, "/api/setup") || p == "/favicon.ico" || p == "/healthz" -} -``` -- [ ] **Step 4:** run β†’ PASS. -- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): gate middleware" - ---- - -## Task 6: Wire the server + entrypoint to the App holder + setup mode - -**Files:** `api/internal/server/server.go`, `api/cmd/pulse/main.go`, `api/cmd/pulse/main_test.go` - -- [ ] **Step 1:** Change `server.New` to take the holder + data dir: `func New(a *app.App, dataDir string) http.Handler`. Inside: - - Apply `middleware.SetupGate(a)` as global middleware (after Logger/Recoverer/CORS). - - Mount setup routes (always): `setupH := handlers.NewSetup(a, dataDir, false)`; `r.Get("/api/setup/state", setupH.State)`; `r.Post("/api/setup", setupH.Complete)`. - - For all routes that need the DB (status, monitors, auth login/logout/status, admin group, public page), resolve the queries from `a.Queries()` **per request** (the handlers currently capture `q` at wiring time). Simplest: wrap DB-dependent handlers so they fetch `a.Queries()` at call time and 503 if nil. Provide a small helper: - ```go - func needDB(a *app.App, fn func(*generated.Queries) http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - q := a.Queries() - if q == nil { http.Error(w, "not configured", http.StatusServiceUnavailable); return } - fn(q)(w, r) - } - } - ``` - and register e.g. `r.Get("/api/status", needDB(a, func(q *generated.Queries) http.HandlerFunc { return handlers.NewStatus(q).Get }))`. Apply the same wrapper to every DB-backed route (monitors, incidents, groups, agents, theme, notifications, checks, incident-updates, auth login/logout/status, public `/`). The setup gate already redirects browsers pre-config, so this is a belt-and-suspenders 503 for API clients. - - Keep `/admin/*`, `/_next/*`, `/static/*`, `/favicon.ico` served as today. -- [ ] **Step 2:** `cmd/pulse/main.go`: - - Determine `dataDir` (env `PULSE_DATA_DIR`, else dir of `SQLITE_PATH`, else `/data`). - - `boot, _ := bootstrap.Load(dataDir)`. - - `a := app.New()`. If `boot.Configured`, open `db.Open(boot.SQLitePath)` and `a.SetDB(conn)`; the worker starts only once configured. If not configured, start in setup mode (no DB, no worker). - - `srv := server.New(a, dataDir)`. Serve as before. - - The worker (`worker.Run`) must only run with a DB β€” start it after `a.SetDB`, or have it poll until configured. Simplest for v1: if configured at boot, start the worker; otherwise start it after setup completes. To keep main simple, start a goroutine that waits for `a.Configured()` to become true (poll every 2s) then runs `worker.Run(ctx, a.DB(), cfg)`. (Add an `App.DB()` accessor.) - - The legacy `/api/auth/setup` route is removed (the wizard replaces it); `/api/auth/login|logout|status` stay (resolved via `needDB`). -- [ ] **Step 3:** Update `main_test.go` (`server.New(db, ...)` β†’ build an `app.App` with the test DB and call `server.New(a, t.TempDir())`); the healthz test still expects 200 (healthz is allowed during setup). -- [ ] **Step 4:** `go build ./... && go test ./... -count=1` β†’ all green. Fix any handler that still captured `q` at wiring time. -- [ ] **Step 5: YOU COMMIT** β€” "feat(setup): server boots in setup mode, gated, App-backed" - ---- - -## Task 7: Wizard UI (multi-step) + client - -**Files:** `ui/src/app/setup/page.tsx`, `ui/src/lib/api.ts` - -- [ ] **Step 1:** Add to `ui/src/lib/api.ts`: -```ts -export type SetupState = { configured: boolean; default_sqlite_path: string } -export async function setupState(): Promise { - const res = await fetch(`${BASE}/api/setup/state`, { cache: 'no-store' }) - return res.json() -} -export async function runSetup(payload: { - name: string; email: string; username: string; password: string - site_name: string; logo_url: string; sqlite_path: string -}): Promise { - const res = await fetch(`${BASE}/api/setup`, { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }) - if (!res.ok) throw new Error(await res.text()) -} -``` -- [ ] **Step 2:** Create `ui/src/app/setup/page.tsx` β€” a `'use client'` 3-step wizard reusing the framed-card style from the admin auth screen: - - **Step 1 Account:** name, email, username, password, confirm password (min 8, must match). - - **Step 2 Branding:** site name; logo upload (FileReader β†’ data URL, same as the Theme page) with a preview. - - **Step 3 Database:** radio "SQLite (recommended)" selected; an editable SQLite path defaulting to `state.default_sqlite_path`; a disabled "PostgreSQL β€” coming soon" radio. A "Finish setup" button calls `runSetup(...)`; on success `window.location.href = '/admin/'`. - - A stepper header (1 Account Β· 2 Branding Β· 3 Database) and Back/Next buttons. On mount, call `setupState()`; if already `configured`, redirect to `/admin/`. - - Use the `Logo` mark (the Pul⚑se bolt) at the top. Match the modern/formal card aesthetic (white card, hairline border, soft shadow, indigo primary button) β€” no gradients/glow. - > Full component code: model it on the existing `AuthScreen` card in `ui/src/app/admin/layout.tsx` (reuse the same card container styles and `.form-input`/`.btn primary` classes); render one step's fields at a time based on a `step` state (1|2|3). -- [ ] **Step 3:** `cd ui && NEXT_PUBLIC_API_URL="" npm run build` β†’ confirm `ui/out/setup/` is exported. Fix any TS errors. -- [ ] **Step 4: YOU COMMIT** β€” "feat(admin): multi-step setup wizard UI" - ---- - -## Task 8: End-to-end verification - -- [ ] **Step 1:** `make build`; run on a SPARE port with a FRESH data dir: -```bash -PULSE_DATA_DIR=/tmp/pulse-setup SQLITE_PATH=/tmp/pulse-setup/pulse.db API_PORT=8090 ./bin/pulse & -``` -(ensure `/tmp/pulse-setup` is empty first: `rm -rf /tmp/pulse-setup`) -- [ ] **Step 2:** Verify gating + flow with curl: -```bash -echo "setup state:" && curl -s localhost:8090/api/setup/state -echo "/ redirects to /setup (expect 302 -> /setup/):" && curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:8090/ -echo "/admin redirects too:" && curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:8090/admin/ -echo "complete setup:" && curl -s -i -c /tmp/sj -X POST localhost:8090/api/setup -H 'Content-Type: application/json' \ - -d '{"name":"Shreeda","email":"s@b.c","username":"admin","password":"supersecret","site_name":"Acme","logo_url":"","sqlite_path":"/tmp/pulse-setup/pulse.db"}' | grep -i 'HTTP/\|set-cookie' -echo "state after (configured true):" && curl -s localhost:8090/api/setup/state -echo "/ now serves public page (200):" && curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/ -echo "bootstrap file:" && cat /tmp/pulse-setup/pulse.json -``` -Expected: state `configured:false` β†’ `/` and `/admin/` 302 β†’ `/setup/`; complete β†’ 201 + Set-Cookie; state `configured:true`; `/` β†’ 200; `pulse.json` shows `configured:true`. -- [ ] **Step 3:** Restart the binary against the same data dir β†’ it boots **configured** (reads `pulse.json`), `/` serves the public page directly, `/setup/` redirects to `/admin/`. -- [ ] **Step 4:** Headless screenshot of `/setup/` on a fresh data dir to confirm the wizard renders. -- [ ] **Step 5: YOU COMMIT** β€” "test: end-to-end setup wizard flow" - ---- - -## Self-Review notes - -- **Spec coverage:** gate-until-setup βœ…, name/email/password βœ…, logo/site-name βœ…, multi-step UI βœ…, SQLite DB step βœ… (Postgres explicitly "coming soon" β€” its own future plan). -- **Supersedes:** the Plan 2 `/api/auth/setup` endpoint + the admin layout's setup branch (the wizard at `/setup` now owns first-run; the admin layout keeps only the login branch). Update `layout.tsx` to drop the `needs_setup` branch (or leave it β€” it'll never trigger once the gate redirects, but removing it is cleaner). -- **Worker:** starts only once configured (poll-for-configured goroutine). -- **Risk:** every DB-backed route must resolve queries from `App` at request time, not capture a nil `q` at wiring. The `needDB` wrapper enforces this β€” audit that no handler is wired with a captured `q`. diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md b/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md deleted file mode 100644 index 60a8eca..0000000 --- a/docs/superpowers/plans/2026-06-07-pulse-plan-3-multipage.md +++ /dev/null @@ -1,137 +0,0 @@ -# Pulse Plan 3 (v1) β€” Multiple Status Pages (core routing + per-page content) - -> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. -> **PROJECT GIT RULE:** no git add/commit/push/mv from the agent. Stop at **YOU COMMIT**. - -**Goal:** Host more than one public status page. Each page has a title, a custom domain, a published flag, and a chosen set of monitor groups. The public page is resolved by the request `Host` header; an unknown host falls back to the **default** page (which shows all groups, preserving today's behaviour). An admin **Pages** section manages them. - -**Architecture:** New `status_pages` (+ `status_page_groups`) tables; a migration seeds one `is_default` page. A resolver maps `Host` β†’ page (or the default). The Go public renderer resolves the page, uses its title, and shows only its groups' monitors β€” with incidents/maintenance scoped to those monitors. Non-default unpublished pages 404. Admin Pages CRUD + UI. - -**Scope / deferred (later passes):** autocert per-domain TLS (host routing works now behind any proxy / via the existing setup); **per-page branding/theme/footer** (branding stays the global Theme config for all pages in v1); per-page **RSS**. These are explicitly out of this pass. - -**Builds on:** Plan 2c (app/server), the Go public renderer (`web/public.go`), groups/monitors, API-key scopes. - ---- - -## Task 1: schema + queries + default-page seed -**Files:** `api/internal/db/migrations/7_status_pages.{up,down}.sql`, `api/internal/db/queries/status_pages.sql`, regenerate. - -- [ ] **Step 1** `7_status_pages.up.sql`: -```sql -CREATE TABLE status_pages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL DEFAULT '' UNIQUE, - title TEXT NOT NULL DEFAULT 'Status', - is_default INTEGER NOT NULL DEFAULT 0, - published INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE TABLE status_page_groups ( - status_page_id INTEGER NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, - group_id INTEGER NOT NULL, - PRIMARY KEY (status_page_id, group_id) -); -INSERT INTO status_pages (domain, title, is_default, published) VALUES ('', 'Status', 1, 1); -``` -`7_status_pages.down.sql`: `DROP TABLE status_page_groups; DROP TABLE status_pages;` -- [ ] **Step 2** `status_pages.sql`: -```sql --- name: GetPageByDomain :one -SELECT * FROM status_pages WHERE domain = ?; - --- name: GetDefaultPage :one -SELECT * FROM status_pages WHERE is_default = 1 LIMIT 1; - --- name: ListStatusPages :many -SELECT * FROM status_pages ORDER BY is_default DESC, created_at ASC; - --- name: GetStatusPage :one -SELECT * FROM status_pages WHERE id = ?; - --- name: CreateStatusPage :one -INSERT INTO status_pages (domain, title, published) VALUES (?, ?, ?) RETURNING *; - --- name: UpdateStatusPage :exec -UPDATE status_pages SET domain = ?, title = ?, published = ? WHERE id = ?; - --- name: DeleteStatusPage :exec -DELETE FROM status_pages WHERE id = ? AND is_default = 0; - --- name: ListPageGroupIDs :many -SELECT group_id FROM status_page_groups WHERE status_page_id = ?; - --- name: ClearPageGroups :exec -DELETE FROM status_page_groups WHERE status_page_id = ?; - --- name: AddPageGroup :exec -INSERT INTO status_page_groups (status_page_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING; -``` -- [ ] **Step 3** regenerate + build. Report generated `StatusPage` fields + `CreateStatusPageParams`, `UpdateStatusPageParams`, `AddPageGroupParams`. -- [ ] **Step 4** smoke test: fresh DB has exactly one `is_default=1` page with `domain=''`. -- [ ] **Step 5: YOU COMMIT** β€” "feat(db): status_pages schema + default seed" - ---- - -## Task 2: page resolver -**Files:** `api/internal/web/pages.go` (+ test). - -- [ ] **Step 1: failing test** `pages_test.go`: with the seeded default page, `ResolvePage(ctx, q, "anything.com")` returns the default page (`IsDefault==1`) and `groupIDs == nil` (meaning "all groups"). After creating a published page `domain="acme.com"` with group 5, `ResolvePage(ctx, q, "acme.com")` returns that page with `groupIDs == [5]`. An unpublished non-default domain resolves to... return `(page, _, published=false)` β€” caller 404s. Design the signature so the caller knows published state, e.g.: -```go -type ResolvedPage struct { - Page generated.StatusPage - GroupIDs []int64 // nil => all groups (default page) -} -func ResolvePage(ctx context.Context, q *generated.Queries, host string) (ResolvedPage, bool) // bool = serve (published) -``` -- [ ] **Step 2** run β†’ FAIL. -- [ ] **Step 3** implement `ResolvePage`: strip port from host (`host, _, _ := net.SplitHostPort(host)` fallback to raw). `GetPageByDomain(host)`; on error β†’ `GetDefaultPage()`. If the resolved page `IsDefault==1` β†’ `GroupIDs=nil` (all), serve=true. Else `GroupIDs = ListPageGroupIDs(page.ID)`, serve = `page.Published==1`. -- [ ] **Step 4** run β†’ PASS; build. **Step 5: YOU COMMIT** β€” "feat(web): hostβ†’page resolver" - ---- - -## Task 3: public renderer uses the resolved page -**Files:** `api/internal/web/public.go`. - -- [ ] **Step 1** Change `Get` to pass `r.Host`; `buildVM(ctx, rng, host)`. At the top of `buildVM`, `rp, serve := ResolvePage(ctx, p.q, host)`; if `!serve`, the `Get` handler should `http.NotFound` (return a sentinel from buildVM or check in Get before building β€” simplest: do `ResolvePage` in `Get`, 404 if `!serve`, else pass `rp` into `buildVM`). -- [ ] **Step 2** In `buildVM`, when `rp.GroupIDs != nil` build a `set := map[int64]bool` of allowed group IDs and **filter** `snap.Groups` to those in the set (default page: no filter). Use `rp.Page.Title` for `SiteName` (falls back to the theme `site_name`/"Status" if title empty). Collect the page's monitor IDs (monitors whose `GroupID` is in the allowed set; for default, all active monitors). -- [ ] **Step 3** **Scope incidents & maintenance**: only include an incident/maintenance window if its `affected_monitor_ids` intersects the page's monitor-ID set (for the default page, include all). Add a small `intersects(affectedJSON string, ids map[int64]bool) bool` helper (parse JSON array; true if any id in set; for default page where set is "all", pass a nil set meaning all-match). -- [ ] **Step 4** `go build ./... && go test ./internal/web/ -count=1` β†’ green. Existing tests pass (default page β†’ all groups, behaviour unchanged on the default host). -- [ ] **Step 5: YOU COMMIT** β€” "feat(public): render the host-resolved page (scoped groups + incidents)" - ---- - -## Task 4: admin Pages CRUD + routes -**Files:** `api/internal/handlers/pages.go` (+ test), `server.go`, `middleware/apikey.go` (add `pages` scope). - -- [ ] **Step 1: failing test**: `NewPages(q)`; Create (`{domain, title, published, group_ids:[]}`) β†’ 201; List β†’ includes default + the new one; Update sets groups; Delete (non-default) β†’ 204; deleting the default page is refused (DeleteStatusPage has `AND is_default=0`, so it no-ops β€” return 400 if the target is default). -- [ ] **Step 2** run β†’ FAIL. -- [ ] **Step 3** implement `pages.go`: `List` (each page with its `group_ids` via `ListPageGroupIDs`), `Create` (CreateStatusPage then AddPageGroup per id), `Update` (UpdateStatusPage + ClearPageGroups + AddPageGroup loop), `Delete` (reject if `GetStatusPage().IsDefault==1` β†’ 400, else DeleteStatusPage). View shape `{id, domain, title, is_default, published, group_ids:[]int64}`. Reuse `writeJSON`. -- [ ] **Step 4** run β†’ PASS; build. -- [ ] **Step 5** `server.go`: in the `RequireSessionOrAPIKey` group add `/api/pages` routes (Get/Post/Put{id}/Delete{id}). Add to `requiredScope`: `case strings.HasPrefix(path, "/api/pages"): resource = "pages"`. (Add `pages:read|write` to the API-key UI scope list too β€” small edit to the api-keys page checklist.) -- [ ] **Step 6** `go build ./... && go test ./... -count=1` green. **Step 7: YOU COMMIT** β€” "feat(api): status-page CRUD" - ---- - -## Task 5: admin Pages UI + nav -**Files:** `ui/src/app/admin/pages/page.tsx` (new), `ui/src/app/admin/layout.tsx` (nav), `ui/src/lib/api.ts`. - -- [ ] **Step 1** `api.ts`: `listPages()`, `createPage(p)`, `updatePage(id, p)`, `deletePage(id)` (cookie). Type `StatusPage { id; domain; title; is_default; published; group_ids: number[] }`. -- [ ] **Step 2** `layout.tsx` NAV: add `{ href: '/admin/pages', label: 'Pages' }` (after Overview). -- [ ] **Step 3** `pages/page.tsx` (v3 look): list pages β€” title, domain (or "default" chip for the default page), published toggle, group count. "New page" modal: title, domain, published checkbox, and a checkbox list of monitor **groups** (from a `adminListGroups()` call β€” confirm it exists in api.ts). Default page row: domain shown as "β€” (default)", not deletable, but its groups/title editable. Non-default: editable + deletable (inline confirm). On save β†’ create/update; refresh. -- [ ] **Step 4** build β†’ succeeds; `ui/out/admin/pages/` present. **Step 5: YOU COMMIT** β€” "feat(admin): Pages management" - ---- - -## Task 6: end-to-end verification -- [ ] **Step 1** `make build`/binary; fresh data dir; setup. Create groups + monitors (or seed). -- [ ] **Step 2** Create a page `domain=acme.test`, published, with group A only. -- [ ] **Step 3** `curl -H 'Host: acme.test' localhost:PORT/` β†’ shows only group A's monitors + page title; `curl localhost:PORT/` (default host) β†’ shows all groups. `curl -H 'Host: unpublished.test'` for an unpublished page β†’ 404. -- [ ] **Step 4** Confirm `/admin/pages` lists default + created page. -- [ ] **Step 5: YOU COMMIT** β€” "test: multi-page host routing verified" - ---- - -## Self-Review -- Spec coverage (v1): status_pages + default βœ…, host routing βœ…, per-page group selection βœ…, published βœ…, admin Pages βœ…, incident/maintenance scoping βœ…. Deferred (noted): autocert TLS, per-page branding/theme/footer, per-page RSS. -- Consistency: default page = `domain=''`, `is_default=1`, all groups; non-default = own domain + selected groups, published-gated. `pages` scope added to apikey middleware + UI. `affected_monitor_ids` parsing reuses the JSON-array convention. -- Safety: default page cannot be deleted (query guard + handler 400). Unknown host β†’ default (never 404 for the default). diff --git a/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md b/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md deleted file mode 100644 index 54c475d..0000000 --- a/docs/superpowers/plans/2026-06-07-pulse-plan-3b-perpage-branding.md +++ /dev/null @@ -1,64 +0,0 @@ -# Pulse Plan 3b β€” Per-Page Branding (logo + theme) - -> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. `- [ ]` checkboxes. -> **PROJECT GIT RULE:** no git add/commit/push/mv. Stop at **YOU COMMIT**. - -**Goal:** Each status page can have its own **logo** and **theme preset** (default-light/dark/terminal-dark), so different domains look like distinct branded sites. Empty values fall back to the global Theme config (today's behaviour). Title + group selection already exist (Plan 3). - -**Scope:** per-page `logo_url` + `theme_preset` only. Per-page favicon / custom-CSS / footer stay global (the existing Theme page) for now. - -**Builds on:** Plan 3 (status_pages, ResolvePage, public renderer, Pages admin). - ---- - -## Task 1: columns + queries + handler view -**Files:** `api/internal/db/migrations/8_page_branding.{up,down}.sql`, `api/internal/db/queries/status_pages.sql` (update Create/Update), regenerate; `api/internal/handlers/pages.go` (view + create/update accept fields). - -- [ ] **Step 1** `8_page_branding.up.sql`: -```sql -ALTER TABLE status_pages ADD COLUMN logo_url TEXT NOT NULL DEFAULT ''; -ALTER TABLE status_pages ADD COLUMN theme_preset TEXT NOT NULL DEFAULT ''; -``` -`8_page_branding.down.sql`: -```sql -ALTER TABLE status_pages DROP COLUMN theme_preset; -ALTER TABLE status_pages DROP COLUMN logo_url; -``` -- [ ] **Step 2** in `status_pages.sql`, update: -```sql --- name: CreateStatusPage :one -INSERT INTO status_pages (domain, title, published, logo_url, theme_preset) VALUES (?, ?, ?, ?, ?) RETURNING *; - --- name: UpdateStatusPage :exec -UPDATE status_pages SET domain = ?, title = ?, published = ?, logo_url = ?, theme_preset = ? WHERE id = ?; -``` -- [ ] **Step 3** regenerate; `go build ./...` will break `pages.go` (Create/Update call sites) β€” that's expected; fix in Step 4. Report new `CreateStatusPageParams`/`UpdateStatusPageParams` fields. -- [ ] **Step 4** `handlers/pages.go`: add `logo_url string` + `theme_preset string` to the request decode and the `pageView`; pass them to `CreateStatusPage`/`UpdateStatusPage`. (The default page may set these too.) -- [ ] **Step 5** `go build ./... && go test ./... -count=1` green (the existing pages_test still passes β€” new fields default to ""). **Step 6: YOU COMMIT** β€” "feat(db): per-page logo + theme_preset" - ---- - -## Task 2: public renderer uses per-page branding -**Files:** `api/internal/web/public.go`, `api/internal/web/pages.go` (if needed). - -- [ ] **Step 1** In `buildVM`, after resolving `rp` and parsing the global theme `cfg`: if `rp.Page.LogoURL != ""` β†’ use it for `vm.LogoURL` (`template.URL(rp.Page.LogoURL)`), overriding the global logo. If `rp.Page.ThemePreset != ""` β†’ set `vm.ThemePreset = rp.Page.ThemePreset` (overriding the global theme preset). (Title precedence already handled.) -- [ ] **Step 2** `go build ./... && go test ./internal/web/ -count=1` green. -- [ ] **Step 3: YOU COMMIT** β€” "feat(public): per-page logo + theme" - ---- - -## Task 3: Pages admin UI β€” logo + theme -**Files:** `ui/src/lib/api.ts` (extend StatusPage + create/update payloads), `ui/src/app/admin/pages/page.tsx`. - -- [ ] **Step 1** `api.ts`: add `logo_url: string; theme_preset: string` to `StatusPage`; add the same two fields to the `createPage`/`updatePage` payload params. -- [ ] **Step 2** `pages/page.tsx` modal: add a **Logo** uploader (file β†’ data URL via `FileReader`, same pattern as the Theme page; with a preview + "Remove") bound to `logo_url`; and a **Theme** `