# 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):**
- [x] `internal/authd/jwkshared.go` ships with `NewFromEnv`,
      `MustNewFromEnv`, `EnvEnabled`.
- [x] `cmd/admind/wireDLQRoutes` extracts the env-conditional gate
      wiring; called from main() with no goto/early-return.
- [x] DLQ GETs: any authenticated user, DLQ POSTs: admin role only.
- [x] When `BA_AUTHD_JWT_SECRET` is unset, `/v1/dlq*` is open
      (backward compat verified by `TestWireDLQRoutes_NoSecret_…`).
- [x] When set, all `/v1/dlq*` are 401 without a token
      (verified by `TestWireDLQRoutes_WithSecret_…`).
- [x] Role policy: viewer can list/get, replay/discard are 403 for
      non-admins (verified by `TestDLQGate_RolePolicy` × 9 subtests).
- [x] `go build ./...` and `go vet` clean.
- [x] `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/dlq` — `dlq.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):**
- [x] `internal/dlq/query.go` ships with `List` + `Get`.
- [x] routerd, archiverd, deliverd-fcm, deliverd-telegram
      each have a `wireAdminRoutes` that no-ops when
      `BA_AUTHD_JWT_SECRET` is unset.
- [x] routerd exposes dedupe state + flush; archiverd exposes
      run-now; both deliverds expose per-channel DLQ.
- [x] Cross-channel safety: a non-matching channel row returns
      404 from the per-channel admin endpoint.
- [x] 21 new tests across 4 packages, all green.
- [x] `scripts/m13a_smoke.sh` covers the W5 routes end-to-end.
- [x] 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):**

- [x] `docker compose --env-file .env config` parses cleanly
      (verified during dev).
- [x] `authd` is built in the Docker image (Dockerfile updated).
- [x] `scripts/generate-jwt-secret.sh` is idempotent + produces
      64-char base64.
- [x] `scripts/bootstrap-super-admin.sh` upserts the super_admin
      and prints a follow-up hint.
- [x] `scripts/m13a_smoke.sh` parses cleanly (bash -n OK).
- [x] `go build ./...` and `go vet ./...` clean.
- [x] M13a test suite: 4 cmd/admind + 13 unit + 6 integration
      = 23/23 PASS.
- [x] 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.
