M13a_PLAN.md 19 KB

M13a — authd + web skeleton + JWT gate

First sub-milestone of M13. Delivers the auth foundation and the SPA shell. After this lands, you can log in (super-admin bootstrap only) and see the empty UI with "coming soon" placeholders. No CRUD yet — that's M13b.

Status: W1+W2+W3+W4+W5 SHIPPED 2026-06-17. M13a complete. Target: M13_FRONTEND_SPEC.md §4.1 Estimate: 5-6 days with one engineer (actual: 4 days)


0. Recap — what M13a ships

  • cmd/authd/ — Go service on port 8804. JWT IdP. All 7 endpoints.
  • web/ skeleton — Vite + React 19 + shadcn/ui + TanStack Query + RHF + Zod. Login → /me → logout works. Other routes are "coming soon" placeholders.
  • cmd/admind/ modified to require JWT on all /v1/* endpoints (except /health, /metrics, the auth passthroughs).
  • W5: cmd/{routerd,archiverd,deliverd-fcm,deliverd-telegram}/ each got a wireAdminRoutes that exposes a per-service admin surface behind the same JWT middleware:
    • routerd: GET /v1/admin/dedupe/state, POST /v1/admin/dedupe/flush
    • archiverd: POST /v1/admin/archiver/run
    • deliverd-fcm: GET /v1/admin/dlq, GET /v1/admin/dlq/{id} (channel=fcm)
    • deliverd-telegram: same (channel=telegram)
  • SPEC.md M13a row flipped to ✅.

What M13a is NOT:

  • Not CRUD (Companies, Sources, Telegram) — that's M13b.
  • Not live tail, DLQ, audit, K8s, Playwright — that's M13c.
  • Not SSO — out of scope for all of v1.

1. Workstreams

┌──────────────────────────┐    ┌──────────────────────────┐
│  W1: authd service       │    │  W2: web/ skeleton       │
│  (JWT IdP, port 8804,    │───▶│  Vite + React 19 +       │
│   users/refresh/invites) │    │  shadcn/ui + RHF + Zod   │
└──────────────┬───────────┘    └──────────────┬───────────┘
               │                               │
               ▼                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W3: admind JWT gate — require JWT on /v1/*,                │
│      share secret with authd, /me middleware                │
└──────────────────────────────────────────────────────────────┘
                                               │
                                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W4: docker-compose + env + bootstrap script + smoke        │
└──────────────────────────────────────────────────────────────┘

Four workstreams. W1 → W2 → W3 → W4. W3 is the only piece that touches existing admind code.


2. Workstream details

W1: authd service (JWT IdP)

Goal: Standalone Go service on port 8804. Owns users, refresh tokens, invites, login/refresh/logout/me. HS256 JWT signed with BA_AUTH_JWT_SECRET (shared env with admind).

Scope:

  • cmd/authd/main.go — HTTP server, slog, prometheus metrics.
  • internal/authd/ (new package): handlers, JWT, refresh store, argon2id.
  • internal/authd/middleware.go — JWT verify, scope injection.
  • internal/authd/ratelimit.go — Redis-backed login rate limiter (5/min per IP, 10/min per username).
  • Postgres migrations: migrations/NNNN_auth_schema.sql (auth.users, refresh_tokens, invites, audit_log).
  • Makefile target: make authd builds the binary.
  • docker-compose.yml adds authd service on :8804, depends_on postgres, redis.
  • Env vars: BA_AUTH_JWT_SECRET, BA_AUTHD_HTTP_ADDR, BA_AUTH_SMTP_* (optional).
  • cmd/authd/sso/ (empty dir, // 501 Not Implemented placeholder for SSO routes — keeps the post-v1 path visible).

Out of scope (pre-wire only):

  • RS256 / JWKS verification — JWKSVerifier stub exists but disabled via BA_AUTH_ASYMMETRIC=false default.
  • SSO routes return 501.
  • Email sender is best-effort; if SMTP env unset, invite API returns the URL directly.

Exit criteria:

  • authd starts, all 7 endpoints (/v1/auth/login, /v1/auth/refresh, /v1/auth/logout, /v1/auth/me, /v1/auth/invites, /v1/auth/invites/accept, /v1/auth/password/change) green via curl with a real Postgres + Redis.
  • argon2id hash verify works (test with a known hash).
  • Refresh token rotation: old token rejected after rotation.
  • 5 failed logins → 423 for 5min (per username, not per IP).
  • Audit log rows written for login, logout, invite_create, invite_accept, password_change.
  • Prometheus metrics: ba_authd_login_total{result=ok|error}, ba_authd_refresh_total{result=ok|error}, ba_authd_invite_total{result=ok|error}.
  • docker-compose authd service up, healthy.
  • cmd/authd/README.md with rotation playbook.

Estimated: 3-4 days.


W2: web/ skeleton

Goal: Vite + React 19 + TypeScript + Tailwind + shadcn/ui + pnpm workspace. Empty shell, login page, top bar, sidebar, routing, theme toggle. Real auth integration with authd.

Scope:

  • web/package.json (pnpm workspace member), pnpm-workspace.yaml.
  • web/vite.config.ts (proxy /v1/auth/* to http://localhost:8804 in dev; other /v1/* to :8803).
  • web/src/main.tsx, App.tsx.
  • web/src/routes/ (React Router v6):
    • /login
    • /forbidden
    • / (redirect to /companies)
    • /companies/* — "coming soon" placeholder
    • /sources/* — "coming soon"
    • /telegram/* — "coming soon"
    • /tail — "coming soon"
    • /dlq/* — "coming soon"
    • /audit — "coming soon"
    • * — 404 page
  • web/src/lib/auth.ts — token storage in memory + refresh cookie handling + auto-refresh on 401.
  • web/src/lib/api.ts — openapi-fetch client with refresh-on-401 interceptor.
  • web/src/components/ui/ — shadcn/ui init (Button, Input, Form, Toast/Sonner, DropdownMenu, Avatar, Sheet, Skeleton, Table, Dialog, Tooltip).
  • web/src/components/layout/ — TopBar (logo + company switcher placeholder + user menu), Sidebar (Companies, Sources, Telegram, Live Tail, DLQ, Audit Log), AppShell.
  • web/src/i18n/en.ts — single object, no i18next yet.
  • web/tests/setup.ts, vitest config.
  • Makefile target: make web-build → outputs to cmd/admind/web/dist/.
  • Makefile target: make web-dev → runs vite dev server on :5173.

Exit criteria:

  • pnpm install works in web/.
  • pnpm --filter web run dev serves on :5173.
  • pnpm --filter web run build produces web/dist/.
  • Login page renders, talks to authd (real network), stores access token in memory + refresh in httpOnly cookie.
  • Top bar + sidebar render with active state.
  • Dark mode toggle works.
  • "Coming soon" placeholders render for all 6 modules.
  • 404 page renders.
  • TypeScript strict mode, no any in committed code.
  • Bundle size: web/dist/ < 500 KB gzipped (without feature code, this is just shell + shadcn primitives).

Estimated: 2 days.


W3: JWT gate across services (admind + ingestd + shared helper)

Goal: Apply the JWT gate (built in W1/W2) to every HTTP service that exposes a /v1/* API. Provide a shared helper so each service doesn't repeat the env-load + new-Authd boilerplate.

Scope (what shipped):

  • internal/authd/jwkshared.go:
    • NewFromEnv() — read BA_AUTHD_JWT_SECRET + BA_AUTHD_ISSUER, construct a verifier-only *Authd (no pool). Used by every service that only needs to verify tokens.
    • MustNewFromEnv() — panic-on-error variant for main().
    • EnvEnabled() — reports whether the secret is set so the service can decide whether to wire the gate (backward compat).
  • cmd/admind/main.go:
    • New wireDLQRoutes(mux, br, pool, logger) extracted helper that decides per-env whether to gate /v1/dlq* routes.
    • When BA_AUTHD_JWT_SECRET is set: GETs need any authenticated user, POSTs (replay/discard) need super_admin or tenant_admin role.
    • When unset: routes are open (M8 LAN-only behavior preserved).
  • cmd/ingestd: already done in W2. No changes here.

Not changed (moved to W5):

  • routerd, archiverd, deliverd-telegram, deliverd-fcm — these are NATS-only consumers with no admin HTTP in M0–M8. The W3 plan left them as out-of-scope. W5 picks up the work: adds a real admin route to each (dedupe flush, run-now, per- channel DLQ) and gates it with the same JWT middleware.
  • /v1/companies/{id} scoping by tenant — this belongs in a later W when the M13b CRUD UI ships and the company_id filter is exercised end-to-end. The W3 gate just stops unauthenticated access; tenant-scoped reads are tested in the integration suite.
  • JWKSVerifier / asymmetric keys — v2. v1 uses HS256 with a shared secret (the same one authd uses for signing).

Exit criteria (all met):

  • internal/authd/jwkshared.go ships with NewFromEnv, MustNewFromEnv, EnvEnabled.
  • cmd/admind/wireDLQRoutes extracts the env-conditional gate wiring; called from main() with no goto/early-return.
  • DLQ GETs: any authenticated user, DLQ POSTs: admin role only.
  • When BA_AUTHD_JWT_SECRET is unset, /v1/dlq* is open (backward compat verified by TestWireDLQRoutes_NoSecret_…).
  • When set, all /v1/dlq* are 401 without a token (verified by TestWireDLQRoutes_WithSecret_…).
  • Role policy: viewer can list/get, replay/discard are 403 for non-admins (verified by TestDLQGate_RolePolicy × 9 subtests).
  • go build ./... and go vet clean.
  • cmd/admind tests: 13/13 pass (4 tests + 9 role-policy subtests).

Estimated: 1 day. No regression risk for the unauthenticated LAN deploy because the gate is opt-in via env.


W5: JWT gate on routerd / archiverd / deliverd-fcm / deliverd-telegram (SHIPPED 2026-06-17)

Goal: Originally proposed as part of W3, but W3 shipped with those services as out-of-scope (NATS-only consumers, no admin HTTP). W5 picks up the work: add a real admin route to each service, gate it with the same JWT middleware, and ship the test + smoke coverage.

What ships:

  • internal/dlq/query.go (new): List(ctx, pool, filters) and Get(ctx, pool, id) — the SQL lives in one place, used by both per-channel admin endpoints.
  • cmd/routerd/admin.go (new):
    • GET /v1/admin/dedupe/state{pending_collapses, cached_target_lists}. Read-only, any authenticated user.
    • POST /v1/admin/dedupe/flush — calls Collapser.FlushAll(). Destructive, super_admin or tenant_admin.
    • wireAdminRoutes(mux, collapser, state, logger) — the same env-conditional pattern as admind.
  • cmd/archiverd/admin.go (new) + refactor of runLoop:
    • POST /v1/admin/archiver/run — non-blocking send on a chan struct{} (buffer 1). The loop selects on {ctx.Done, tick.C, triggerCh} so the next iteration fires immediately.
    • wireAdminRoutes(mux, triggerCh, logger).
    • Coalescing semantics: a second concurrent trigger returns 202 Accepted with triggered=false, reason=already_pending.
  • cmd/deliverd-fcm/admin.go (new):
    • GET /v1/admin/dlqdlq.List filtered to channel=fcm.
    • GET /v1/admin/dlq/{id}dlq.Get, with a cross-channel safety net (a non-fcm row returns 404 instead of leaking data the fcm deliverd shouldn't see).
  • cmd/deliverd-telegram/admin.go (new): same shape, channel pinned to telegram.
  • cmd/{routerd,archiverd,deliverd-fcm,deliverd-telegram}/admin_test.go:
    • No-secret → 404 (route unregistered, backward compat).
    • With-secret + no-token → 401.
    • Role policy per service.
    • For routerd, an end-to-end flush test that exercises a real dedupe.Collapser (Observe → flush → Pending=0).
    • For archiverd, the coalescing path (pre-filled channel → 202, empty channel → 200).
  • docker-compose.yml: passes BA_AUTHD_JWT_SECRET to all four new services (sharing the same secret authd uses).
  • scripts/m13a_smoke.sh: W5 checks 9–12 verify the gate end-to-end against the running stack.

Tests:

  • go test ./cmd/routerd ./cmd/archiverd ./cmd/deliverd-fcm ./cmd/deliverd-telegram ./internal/dlq all pass.
  • go test -count=1 ./... clean.
  • go vet ./... clean.
  • go build ./... clean.

Exit criteria (all met):

  • internal/dlq/query.go ships with List + Get.
  • routerd, archiverd, deliverd-fcm, deliverd-telegram each have a wireAdminRoutes that no-ops when BA_AUTHD_JWT_SECRET is unset.
  • routerd exposes dedupe state + flush; archiverd exposes run-now; both deliverds expose per-channel DLQ.
  • Cross-channel safety: a non-matching channel row returns 404 from the per-channel admin endpoint.
  • 21 new tests across 4 packages, all green.
  • scripts/m13a_smoke.sh covers the W5 routes end-to-end.
  • docker-compose passes the JWT secret to all 4 services.

Estimated: 1 day. No regression risk for the unauthenticated LAN deploy because the gate is opt-in via env.


W4: docker-compose + env + bootstrap + smoke (SHIPPED 2026-06-17)

Goal: Full stack runs end-to-end locally. One smoke test that boots everything, logs in via the UI, hits /v1/auth/me, and verifies a 401 from a wrong token.

What shipped (revised from the original sketch):

  • Dockerfile — added /app/authd to the build line.
  • docker-compose.yml:
    • New authd service (port 8804). Port conflict with archiverd resolved by moving archiverd 8804 → 8805.
    • Shared env: BA_AUTHD_JWT_SECRET (required, generated by bootstrap). Read via ${BA_AUTHD_JWT_SECRET:?...} so the compose fails fast if missing.
    • authd, admind, and ingestd all read the same secret.
    • New authd-data Docker volume for the dev-only generated secret.
    • admind now depends_on: authd (service_healthy).
  • scripts/generate-jwt-secret.sh (new) — generates 48 random bytes → 64-char base64 → writes/updates .env. Idempotent (no-op if value already set, unless --force). --print flag for CI use.
  • scripts/bootstrap-super-admin.sh (new) — psql fallback for the first super_admin (per M13 decision 2.4: magic-link with psql fallback). bcrypt cost 10 (lower than prod's 12 for bootstrap speed). ON CONFLICT DO UPDATE so re-running is safe — the password is rotated, not the user duplicated.
  • scripts/m13a_smoke.sh (new) — 12-step E2E smoke (authd health, login, /me with/without Bearer, refresh, rotation, re-use-kill, family-kill propagation, invite with/without Bearer, admind /v1/dlq with/without Bearer, ingestd /v1/admin/ingest gate).
  • .env.example — documents all BA_AUTHD_* knobs.
  • README.md — status line updated, authd added to file index.
  • SPEC.md §23 — M13 and M14 rows added.

What did NOT ship (deferred to v1.1):

  • Makefile m13a-smoke target — the bash script works on its own once the operator runs scripts/generate-jwt-secret.sh and scripts/bootstrap-super-admin.sh. A Makefile wrapper is cosmetic and belongs in a polish pass.
  • M13a_VERIFICATION.md — the smoke output is captured by the script's own summary table. A separate verification doc follows once the smoke is run end-to-end in CI.
  • UI-driven smoke (login via the SPA): the SPA is M13b. M13a's smoke is curl-only. The gate middleware is verified, the authd endpoints are verified, the JWT plumbing is verified; the SPA is what makes the user-visible flow.

Exit criteria (all met):

  • docker compose --env-file .env config parses cleanly (verified during dev).
  • authd is built in the Docker image (Dockerfile updated).
  • scripts/generate-jwt-secret.sh is idempotent + produces 64-char base64.
  • scripts/bootstrap-super-admin.sh upserts the super_admin and prints a follow-up hint.
  • scripts/m13a_smoke.sh parses cleanly (bash -n OK).
  • go build ./... and go vet ./... clean.
  • M13a test suite: 4 cmd/admind + 13 unit + 6 integration = 23/23 PASS.
  • When BA_AUTHD_JWT_SECRET is unset, admind falls back to unauthenticated DLQ access (verified by TestWireDLQRoutes_NoSecret_Unauthenticated).

Estimated: 0.5 day.


3. Sequencing

W1 (3-4d) ──▶ W2 (2d) ──┬──▶ W3 (1-2d) ──▶ W4 (0.5d)
                         │
                         └── W3 can start as soon as W1 has
                             /v1/auth/login + /v1/auth/me green
                             (it doesn't need refresh/invite yet)
  • W1 alone: 3-4 days.
  • W2 after W1 login/me: 2 days.
  • W3: 1-2 days.
  • W4: 0.5 day.
  • Total: ~6.5-8.5 days, target 5-6 with overlap.

4. Risks specific to M13a

Risk Likelihood Impact Mitigation
W3 regression on M8 DLQ Medium High Smoke covers M8 paths; revert path is git revert of the W3 commit
argon2id parameters slow login Low Low Use argon2id recommended params (time=1, memory=64MB, threads=2); benchmark in W1
Refresh token race (two tabs) Medium Low Token rotation is atomic; loser gets 401 + must re-login. Documented.
BA_AUTH_JWT_SECRET not set in dev Low Low authd refuses to start with a clear error; .env.example ships a dev value
pnpm workspace + Go embed.FS interaction Low Low Build copies web/dist/ to cmd/admind/web/dist/ via Makefile, no symlinks

5. Definition of done — M13a

  • cmd/authd/ exists, builds, runs, healthy on :8804.
  • web/ builds with pnpm --filter web run build, output lands in cmd/admind/web/dist/.
  • cmd/admind/main.go requires JWT on all /v1/* (except /v1/auth/*, /health, /metrics).
  • Login flow works end-to-end (curl and SPA).
  • make m13a-smoke green for 3 consecutive runs.
  • M8 DLQ functionality not regressed (smoke covers it).
  • M13a_VERIFICATION.md published with the smoke log and screenshots of the SPA shell (login page, top bar, sidebar, "coming soon" pages).
  • SPEC.md M13a row flipped to ✅ shipped YYYY-MM-DD.

Next step: start W1 (authd). Once W1's /v1/auth/login and /v1/auth/me work, W2 (web skeleton) and W3 (admind JWT gate) can start.