Explorar el Código

M13 (frontend admin) + M14 (security hardening) — planning

M13 — multi-tenant admin UI for broad-announce. Replaces dlq.html
and adds CRUD + live-tail + audit + auth surfaces for super-admin
and tenant-admin personas. Ships in 3 sub-milestones:

  M13a: authd (JWT IdP) + web/ skeleton + JWT gate          5-6d
  M13b: Companies + Sources + Telegram CRUD                 7-8d
  M13c: Live tail + DLQ + Audit + K8s + Playwright E2E     7-9d

Stack: Vite + React 19 + shadcn/ui + TanStack Query + RHF +
Zod + SSE + openapi-fetch. Embedded in admind via embed.FS.
Same-origin, no CORS, no separate frontend server.

M14 — security hardening. Closes the M11.5 wishlist (mTLS +
secrets management + network policies) with cert-manager + CA
hierarchy + opt-in per-source mTLS + internal gRPC mTLS + cert
lifecycle UI + rotation + PromQL alerts + runbook. 6 workstreams,
16-24d solo / 10-15d parallel with M13c.

Survey decisions: hybrid super+tenant admin, in-house JWT (SSO
post-v1), Vite SPA (literal SPEC), magic-link invites with psql
fallback, no Next.js, no Redux, no Sentry, no i18n lib.

Open questions flagged in M13_FRONTEND_SPEC.md §9 (users table
location, JWT verification path, refresh token storage, audit
log writer, authd port) — defaults documented, ready for review
before M13a W1 starts.

Co-Authored-By: Jarvis <jarvis@techno-world.net>
Jarvis hace 1 mes
padre
commit
4c13c3fdb4
Se han modificado 7 ficheros con 2436 adiciones y 0 borrados
  1. 407 0
      M13_API_CONTRACT.md
  2. 444 0
      M13_FRONTEND_SPEC.md
  3. 173 0
      M13_PLAN.md
  4. 298 0
      M13a_PLAN.md
  5. 273 0
      M13b_PLAN.md
  6. 320 0
      M13c_PLAN.md
  7. 521 0
      M14_SECURITY_PLAN.md

+ 407 - 0
M13_API_CONTRACT.md

@@ -0,0 +1,407 @@
+# M13 — API Contract (v1)
+
+> Wire-level contract for the M13 frontend. Two services: `admind`
+> (existing, gets new endpoints) and `authd` (new). All endpoints
+> JSON in/out unless noted. Auth = `Authorization: Bearer <jwt>` for
+> access, `Cookie: refresh=...` for refresh.
+
+---
+
+## 0. Conventions
+
+- Base paths: `/v1/auth/*` (authd), `/v1/*` (admind).
+- SSE: `text/event-stream`.
+- All timestamps RFC 3339 UTC.
+- IDs: UUID v4 unless otherwise noted.
+- Errors: `{ "error": { "code": "string", "message": "string", "request_id": "uuid" } }`.
+- Pagination: `?limit=N&cursor=<opaque>`. Response: `{ "items": [...], "next_cursor": "..." | null }`.
+- Tenant scope: enforced server-side from JWT `company_id`. Super-admin
+  may pass `?company_id=<id>` to scope; tenant-admin `?company_id`
+  matches the JWT or returns 403.
+
+---
+
+## 1. authd endpoints (port 8804)
+
+### 1.1 `POST /v1/auth/login`
+Request:
+```json
+{ "username": "lrosales", "password": "..." }
+```
+Response 200:
+```json
+{
+  "access_token": "eyJ...",
+  "access_expires_in": 900,
+  "user": { "id": "uuid", "username": "lrosales", "role": "super_admin", "company_id": null }
+}
+```
+Sets cookie: `refresh=<opaque>; HttpOnly; Secure; SameSite=Lax; Path=/v1/auth; Max-Age=604800`.
+
+Errors: 401 invalid credentials, 423 account locked (5 failed attempts in 5min).
+
+### 1.2 `POST /v1/auth/refresh`
+Request: empty body. Cookie must carry `refresh`.
+Response 200: same shape as login. New refresh cookie sent, old one invalidated.
+Errors: 401 invalid/expired refresh.
+
+### 1.3 `POST /v1/auth/logout`
+Request: empty body. Requires access JWT.
+Response 204. Invalidates the refresh token server-side. Clears cookie.
+Errors: 401.
+
+### 1.4 `GET /v1/auth/me`
+Response 200:
+```json
+{
+  "id": "uuid",
+  "username": "lrosales",
+  "role": "super_admin",
+  "company_id": null,
+  "display_name": "Luis Rosales",
+  "email": "lrosales@techno-world.net",
+  "created_at": "2026-06-16T18:00:00Z",
+  "last_login_at": "2026-06-16T20:30:00Z"
+}
+```
+
+### 1.5 `POST /v1/auth/invites` (super-admin only)
+Request:
+```json
+{
+  "username": "gerente@acme.com",
+  "role": "tenant_admin",
+  "company_id": "acme-001",
+  "display_name": "Gerente ACME",
+  "email": "gerente@acme.com"
+}
+```
+Response 201:
+```json
+{
+  "invite": { "id": "uuid", "token": "raw-token-shown-once", "expires_at": "..." },
+  "magic_link": "https://admind.netpolice.online/invite/accept?token=...",
+  "email_sent": true
+}
+```
+`email_sent=false` when SMTP env vars are unset — frontend shows the
+`magic_link` directly so super-admin can paste it. This is the "B with
+fallback to A" decision.
+
+Errors: 403 (not super-admin), 409 (username exists), 404 (company_id not found).
+
+### 1.6 `POST /v1/auth/invites/accept`
+Request:
+```json
+{ "token": "raw-token-from-invite", "password": "new-password-here" }
+```
+Response 200: same as login shape. User is now `status=active`.
+Errors: 410 (expired), 404 (not found), 422 (weak password).
+
+### 1.7 `POST /v1/auth/password/change` (any authenticated)
+Request: `{ "current_password": "...", "new_password": "..." }`. Response 204.
+
+### 1.8 `GET /v1/auth/jwks` (public)
+JWKS document for the public key (RS256) or the current secret id (HS256).
+**v1 uses HS256 — JWKS returns `{ "alg": "HS256", "kid": "current" }` only.**
+RS256 + JWKS becomes meaningful when we ship multi-cluster (M12 W1+)
+where `admind` and `authd` can't share an env var. For v1, `admind`
+verifies tokens using the same `BA_AUTH_JWT_SECRET` env var.
+
+### 1.9 `GET /health`, `GET /metrics`
+Same as other services. `/health` returns 200 if Postgres + Redis up.
+`/metrics` exposes `ba_authd_*` Prometheus metrics.
+
+### 1.10 Schema additions
+New tables in the same Postgres DB, schema `auth`:
+
+```sql
+CREATE SCHEMA auth;
+
+CREATE TABLE auth.users (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    username        TEXT UNIQUE NOT NULL,
+    email           TEXT UNIQUE NOT NULL,
+    display_name    TEXT NOT NULL,
+    password_hash   TEXT,                -- argon2id, NULL until invite accepted
+    role            TEXT NOT NULL CHECK (role IN ('super_admin', 'tenant_admin')),
+    company_id      TEXT REFERENCES companies(slug), -- NULL for super_admin
+    status          TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended', 'locked')),
+    failed_attempts INT NOT NULL DEFAULT 0,
+    last_login_at   TIMESTAMPTZ,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX auth_users_company_id_idx ON auth.users(company_id);
+CREATE INDEX auth_users_status_idx ON auth.users(status);
+
+CREATE TABLE auth.refresh_tokens (
+    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(refresh_token) for fast lookup
+    issued_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
+    expires_at  TIMESTAMPTZ NOT NULL,
+    rotated_to  UUID REFERENCES auth.refresh_tokens(id),  -- chain
+    revoked_at  TIMESTAMPTZ
+);
+CREATE INDEX auth_refresh_user_idx ON auth.refresh_tokens(user_id);
+CREATE INDEX auth_refresh_expires_idx ON auth.refresh_tokens(expires_at);
+
+CREATE TABLE auth.invites (
+    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(raw_token)
+    expires_at  TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days'),
+    accepted_at TIMESTAMPTZ,
+    created_by  UUID NOT NULL REFERENCES auth.users(id),
+    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE auth.audit_log (
+    id          BIGSERIAL PRIMARY KEY,
+    ts          TIMESTAMPTZ NOT NULL DEFAULT now(),
+    user_id     UUID REFERENCES auth.users(id),
+    action      TEXT NOT NULL,           -- 'login', 'logout', 'invite_create', 'invite_accept', 'password_change', 'login_failed'
+    target_user UUID REFERENCES auth.users(id),
+    ip          INET,
+    user_agent  TEXT,
+    metadata    JSONB
+);
+```
+
+---
+
+## 2. admind endpoints (port 8803, JWT-gated except /health /metrics)
+
+### 2.1 Companies
+- `GET /v1/companies` — list. Super-admin sees all. Tenant-admin sees
+  only their own (returns 1 row).
+  Query: `?status=&search=&limit=&cursor=`
+  Response 200:
+  ```json
+  {
+    "items": [
+      {
+        "id": "acme-001",
+        "name": "ACME Networks",
+        "slug": "acme-001",
+        "status": "active",
+        "fcm_shared": true,
+        "rate_limit_per_sec": 1000,
+        "telegram_configured": true,
+        "alerts_24h": 12345,
+        "created_at": "2026-01-15T10:00:00Z"
+      }
+    ],
+    "next_cursor": null
+  }
+  ```
+- `POST /v1/companies` — super-admin only. Body:
+  ```json
+  { "name": "ACME Networks", "slug": "acme-001", "rate_limit_per_sec": 1000, "fcm_shared": true }
+  ```
+  Response 201 with full company. 409 on duplicate slug.
+- `GET /v1/companies/:id` — scoped. 403 if tenant-admin requests other company.
+- `PATCH /v1/companies/:id` — scoped. Tenant-admin can only update
+  `telegram_bot_token`, `display_name`, `quiet_hours_default` (whatever
+  subset the SPEC allows). Super-admin can update anything.
+- `POST /v1/companies/:id/suspend` — super-admin only. 204. Writes audit.
+- `POST /v1/companies/:id/activate` — super-admin only. 204. Writes audit.
+
+### 2.2 Sources
+- `GET /v1/sources?company_id=&status=&type=&limit=&cursor=`
+  Response item:
+  ```json
+  {
+    "id": "uuid",
+    "company_id": "acme-001",
+    "name": "snmp-edge-01",
+    "type": "snmp",
+    "hmac_secret_masked": "sk_****abcd",   // never the raw secret
+    "api_key_masked": "ak_****efgh",
+    "rate_limit_per_sec": 100,
+    "allowed_ips": ["10.0.0.0/24"],
+    "topic_prefix": "snmp.acme",
+    "status": "active",
+    "quarantine_until": null,
+    "alerts_24h": 4321,
+    "created_at": "..."
+  }
+  ```
+- `POST /v1/sources` — body without secrets. Response includes
+  `hmac_secret_raw` and `api_key_raw` ONCE in a `one_time_secrets` field
+  (frontend must show the "save these now" modal). Server stores the
+  hashes only.
+- `PATCH /v1/sources/:id` — body without secrets. To rotate, set
+  `rotate_secret=true` and the response includes new one-time secrets.
+- `GET /v1/sources/:id` — full detail including quarantine_until.
+
+### 2.3 Telegram
+- `GET /v1/companies/:id/telegram` —
+  ```json
+  {
+    "bot_configured": true,
+    "bot_username": "acme_alerts_bot",
+    "invite_count": 3,
+    "binding_count": 47
+  }
+  ```
+  The bot token is NEVER returned. To set/rotate, use PUT.
+- `PUT /v1/companies/:id/telegram` — body: `{ "bot_token": "..." }`. 204.
+  Bot token encrypted at rest (AES-256-GCM with `BA_ADMIND_MASTER_KEY`).
+- `GET /v1/companies/:id/telegram/invites` — list of invite codes.
+  Item: `{ "id": "uuid", "code": "ABC123", "status": "active"|"used"|"expired", "created_at": "...",
+  "used_by_individual_id": "uuid" | null, "expires_at": "..." }`.
+- `POST /v1/companies/:id/telegram/invites` — body: `{}`. Response 201
+  with the new invite. Default TTL 24h, configurable per request.
+- `DELETE /v1/companies/:id/telegram/invites/:id` — revoke. 204.
+- `GET /v1/companies/:id/telegram/bindings` — list of
+  `individual_id → telegram_user_id` mappings. Item: `{ "individual_id",
+  "individual_display_name", "telegram_user_id", "telegram_chat_id",
+  "status", "last_seen_at" }`.
+
+### 2.4 Live tail
+- `GET /v1/tail/stream` (SSE) — query: `?access_token=<jwt>` (EventSource
+  workaround). Server emits `event: alert\ndata: {<alert_json>}\n\n` per
+  inbound. Initial burst sends last 50 events from in-memory ring buffer
+  (`mem:` tailhub). Heartbeat `: ping` every 15s to keep proxies alive.
+  Auto-disconnect after 30min idle.
+- `GET /v1/tail/recent?since=<rfc3339>&limit=200` — HTTP fallback for
+  corporate proxies that strip SSE. Reads from the same ring buffer.
+
+  Alert JSON:
+  ```json
+  {
+    "id": "uuid",
+    "company_id": "acme-001",
+    "source_id": "uuid",
+    "source_name": "snmp-edge-01",
+    "severity": "warning",
+    "category": "network",
+    "title_key": "link_down",
+    "data": { "iface": "eth0", "since": "..." },
+    "dedupe_count": 3,
+    "received_at": "..."
+  }
+  ```
+
+### 2.5 DLQ (M8 — existing, frontend now uses these)
+- `GET /v1/dlq?company_id=&status=&channel=&limit=&cursor=` — list.
+  Item: `{ "id", "alert_id", "channel", "target", "attempts",
+  "last_error", "next_attempt_at", "created_at", "status" }`.
+- `GET /v1/dlq/:id` — full alert payload + delivery history.
+- `POST /v1/dlq/:id/replay` — 204. Audit row.
+- `POST /v1/dlq/:id/discard` — 204. Audit row.
+
+### 2.6 Audit log (new)
+- `GET /v1/audit?actor=&action=&entity_type=&since=&until=&limit=&cursor=`
+  Response item:
+  ```json
+  {
+    "id": 12345,
+    "ts": "...",
+    "actor_id": "uuid" | null,
+    "actor_username": "lrosales" | "system",
+    "action": "company.suspend",
+    "entity_type": "company",
+    "entity_id": "acme-001",
+    "ip": "1.2.3.4",
+    "user_agent": "...",
+    "metadata": { "reason": "non-payment" }
+  }
+  ```
+- Read-only. Filterable. No mutations from the API.
+- The same `audit_log` table is written from `ingestd`, `routerd`,
+  `deliverd-*`, and `admind` directly (every service has its own
+  `INSERT`). M9 already created the schema; we just add the
+  read endpoint here.
+
+### 2.7 System
+- `GET /health` — public. 200 if Postgres + NATS + Redis up.
+- `GET /metrics` — public (Prometheus). Exposes `ba_admind_*` counters.
+
+---
+
+## 3. JWT verification — design call
+
+**Default (v1):** HS256, `BA_AUTH_JWT_SECRET` shared env var between
+`authd` and `admind`. Simple, works in docker-compose, works in K8s
+with one Secret.
+
+**When this breaks:** when we run multiple `admind` replicas across
+clusters (M12 W1 carries the K8s manifests). At that point, we flip
+to RS256 with `authd` exposing the public key via `/v1/auth/jwks` and
+`admind` fetching + caching it. The flag is a single env var:
+`BA_AUTH_ASYMMETRIC=true`.
+
+**Why we don't ship RS256 in v1:**
+- v1 runs in one cluster, one secret works.
+- Asymmetric setup means we need key rotation, JWKS caching, and a
+  way to disable a leaked key without downtime. That's ~3 days of
+  work that's wasted if v1 stays single-cluster.
+- We pre-wire the JWKS endpoint in `authd` and the verification code
+  in `admind`; just don't enable it.
+
+**Interface in `admind`:**
+```go
+type JWTVerifier interface {
+    Verify(token string) (Claims, error)
+}
+```
+Two implementations:
+- `HS256Verifier` (default): reads `BA_AUTH_JWT_SECRET` from env.
+- `JWKSVerifier` (future): fetches from `BA_AUTH_JWKS_URL`, caches 5min.
+
+`admind` picks based on `BA_AUTH_ASYMMETRIC` env var. No code change
+needed to flip.
+
+---
+
+## 4. OpenAPI generation
+
+`admind` and `authd` will each export an OpenAPI 3.1 spec at runtime:
+- `authd`: `GET /openapi.json`
+- `admind`: `GET /openapi.json`
+
+The frontend's `pnpm run gen:api` step fetches both, merges them into
+`web/src/types/api.d.ts`, and generates the client. CI runs this on
+every PR that touches `cmd/**/main.go` or `internal/**/api.go`.
+
+This is how the frontend gets end-to-end type safety without a
+GraphQL or tRPC middle layer.
+
+---
+
+## 5. Error model
+
+All errors return:
+```json
+{
+  "error": {
+    "code": "company_not_found",
+    "message": "Company 'acme-001' does not exist or you do not have access.",
+    "request_id": "uuid"
+  }
+}
+```
+Standard codes:
+- `unauthorized` (401) — no token / invalid token
+- `forbidden` (403) — tenant scope violation
+- `not_found` (404) — entity missing
+- `conflict` (409) — unique constraint, duplicate slug
+- `rate_limited` (429) — too many requests
+- `validation_failed` (422) — Zod/RHF-style field errors in `metadata.fields`
+- `internal_error` (500) — server bug; `request_id` is the only thing the user can quote to support
+- `sse_upgrade_failed` (500) — only on `/v1/tail/stream` if NATS tail is down
+- `magic_link_expired` (410) — invite past 7d
+- `account_locked` (423) — 5 failed logins
+
+---
+
+## 6. Rate limits
+
+- `authd /v1/auth/login`: 5/min per IP, 10/min per username.
+- `admind /v1/*`: 600/min per access JWT (10/sec average).
+- `admind /v1/tail/stream`: 1 concurrent connection per user.
+
+Enforced via the same Redis-based limiter used by ingestd (M9 layer 1).

+ 444 - 0
M13_FRONTEND_SPEC.md

@@ -0,0 +1,444 @@
+# M13 — Frontend Admin Spec (v1)
+
+> Multi-tenant admin UI for broad-announce. React + Vite SPA, embedded in
+> the `admind` Go binary via `embed.FS`. Companion to `SPEC.md` (the
+> backend), `M13_API_CONTRACT.md` (the wire), and the per-milestone
+> plans `M13a_PLAN.md`, `M13b_PLAN.md`, `M13c_PLAN.md`.
+
+**Status:** planning (post-M11, parallel to M12)
+**Target:** ship a v1 admin UI that replaces `dlq.html` and adds the
+CRUD + live-tail + audit + auth surfaces for super-admin and
+tenant-admin personas.
+
+---
+
+## 0. Recap — what M13 must prove
+
+| Surface | Acceptance criteria |
+|---|---|
+| **Auth** | Super-admin and tenant-admin can log in (username + password, argon2id). Tenant-admins are invited via magic-link (or psql bootstrap if email infra is not yet ready). |
+| **Tenants / Companies CRUD** | Super-admin can create, list, edit, suspend. Tenant-admin sees only their own company. |
+| **Sources CRUD** | Both personas can CRUD sources within their scope. Super-admin sees all, tenant-admin sees only their company's. |
+| **Telegram bots** | Per-company bots: token, invite code generation, chat_id mapping table view. |
+| **Live tail** | SSE feed of incoming alerts on a single page. Filterable by company (super-admin) or scoped to tenant (tenant-admin). |
+| **DLQ surface** | Replaces `cmd/admind/ui/dlq.html` with the same list/replay/discard flow, plus filters. |
+| **Audit log viewer** | Read-only table of `audit_log` rows, filterable by actor / action / entity / time. |
+| **Single binary** | `admind` Go binary embeds the SPA via `embed.FS`. One build, one deploy. No CORS, no separate frontend server. |
+
+**What M13 is NOT:**
+- Not a customer-facing end-user app (out of scope per survey 1.3).
+- Not a tenant-admin invite flow beyond email/PSQL bootstrap. SSO is
+  post-v1 (survey 2.3).
+- Not a Grafana replacement. Dashboards stay in Grafana (M9). The
+  frontend surfaces the **control plane**, not observability charts.
+
+---
+
+## 1. Personas
+
+### 1.1 Super-admin (`role=super_admin`)
+- **Scope:** every company in the system.
+- **Use cases:** onboarding a new company, troubleshooting cross-tenant
+  issues, audit review, killing misbehaving sources.
+- **Login:** username + password. Bootstrap account seeded by env vars
+  at first deploy (`BA_AUTH_BOOTSTRAP_ADMIN=...`).
+- **UI affordances:** company switcher in the top bar; all data tables
+  have a "company" column.
+
+### 1.2 Tenant-admin (`role=tenant_admin`)
+- **Scope:** exactly one company (their own). Backend rejects any
+  cross-company request with 403.
+- **Use cases:** managing their sources, viewing their alerts,
+  configuring their telegram bot, seeing their DLQ.
+- **Login:** username + password. Invited by super-admin via magic-link.
+- **UI affordances:** no company switcher; all data tables filtered to
+  their company automatically.
+
+### 1.3 End-user — NOT v1
+Deferred. The 1.3 survey response excluded mobile/PWA. End-user is the
+recipient of alerts, manages quiet hours. Out of scope.
+
+---
+
+## 2. Tenancy & auth
+
+### 2.1 New service: `authd`
+**Decision:** `cmd/authd/`, port **8804**, dedicated HTTP service. NOT
+a module inside `admind`. Rationale:
+- Clean security boundary — auth holds the JWT secret, never touches
+  business data.
+- Reusable from `ingestd`, `routerd`, `deliverd-*`, `gRPC server` in
+  the future (M14+). Today only `admind` and the frontend use it.
+- Independent scaling and deployment.
+- Uses Postgres for users/memberships (same DB, separate schema —
+  see `M13_API_CONTRACT.md` §1.10 for the schema sketch).
+
+### 2.2 Auth flow
+1. **Login** (`POST /v1/auth/login`): username + password → verify
+   argon2id hash → issue access JWT (15min, HS256) + refresh JWT
+   (7d, opaque, server-side store).
+2. **Refresh** (`POST /v1/auth/refresh`): rotate refresh token, return
+   new access + refresh. Old refresh invalidated.
+3. **Logout** (`POST /v1/auth/logout`): invalidate refresh token
+   server-side.
+4. **Invite** (super-admin only, `POST /v1/auth/invites`): create user
+   row with `status=pending`, generate magic-link token, return URL.
+   Email send is **best-effort**: if SMTP env vars are unset, the API
+   returns the URL directly so super-admin can paste it. (This is the
+   "B with fallback to A" answer for survey 2.4.)
+5. **Magic-link accept** (`POST /v1/auth/invites/accept`): user submits
+   token + new password → password hashed → user `status=active`.
+6. **Me** (`GET /v1/auth/me`): returns current user with role + scoped
+   company_id. Frontend uses this for routing and nav.
+
+### 2.3 Session storage
+- **Access token (15min):** stored in memory by the SPA. Never in
+  localStorage (XSS exposure).
+- **Refresh token (7d):** stored in httpOnly, Secure, SameSite=Lax
+  cookie. Sent automatically on refresh.
+- **Logout:** clears the cookie + calls `POST /v1/auth/logout` to
+  invalidate the server-side row.
+
+### 2.4 JWT shape
+```json
+{
+  "sub": "user_<uuid>",
+  "role": "super_admin" | "tenant_admin",
+  "company_id": "acme-001" | null,
+  "iat": 1749819000,
+  "exp": 1749822600
+}
+```
+- HS256, secret from env `BA_AUTH_JWT_SECRET` (32+ bytes, rotated by
+  supporting both old and new during rotation).
+- `company_id` is null for super-admin (cross-tenant access).
+
+### 2.5 Backend enforcement
+- Every `admind` endpoint checks `Authorization: Bearer <jwt>`.
+- For tenant-admin: server appends `WHERE company_id = $1` from the JWT
+  to every query. No way to bypass from the frontend.
+- Audit log row written for every state-changing call, signed with the
+  actor's user_id.
+
+### 2.6 SSO — explicitly post-v1
+A `cmd/authd/sso/` directory will be created (empty in v1) and the
+`/v1/auth/sso/<provider>/login` and `/v1/auth/sso/<provider>/callback`
+routes will return 501. SSO is added when the first enterprise
+customer asks for it.
+
+---
+
+## 3. v1 modules
+
+### 3.1 Tenants / Companies CRUD
+- **List:** table of companies, columns: name, slug, status, created_at,
+  fcm_shared, rate_limit_per_sec, alert_count_24h.
+- **Detail:** edit form (name, slug, fcm_shared toggle, rate_limit_per_sec,
+  telegram_bot_token_enc, status).
+- **Create:** form (name, slug auto-derived; auto-checks uniqueness).
+- **Suspend/activate:** button on detail; sets `status=suspended` or
+  `active`. Suspended companies cannot ingest.
+- **Tenant-admin view:** read-only "Your company" page (no edit, no create).
+- **Backend endpoints:** `GET/POST/PATCH /v1/companies`,
+  `GET /v1/companies/:id`.
+
+### 3.2 Sources CRUD
+- **List:** per-company table, columns: name, type, hmac_secret (masked),
+  api_key_hash (masked), rate_limit_per_sec, allowed_ips, status,
+  alerts_24h.
+- **Detail:** edit form (name, type, hmac_secret, api_key,
+  rate_limit_per_sec, allowed_ips[], topic_prefix, status). The secret
+  field is masked with "show" toggle.
+- **Create:** same form, generates a fresh hmac_secret and api_key on
+  save and shows them ONCE in a "save these now" modal (same pattern
+  as GitHub PAT).
+- **Status:** active / quarantined (per layer-7 from M9). Show
+  quarantine reason if quarantined.
+- **Backend endpoints:** `GET/POST/PATCH /v1/sources`,
+  `GET /v1/sources/:id`.
+
+### 3.3 Telegram bots
+- **Per-company bot config:** set the bot token (encrypted at rest by
+  `admind` using a master key from env), view invite codes, list of
+  bound `chat_id` → `individual_id` mappings.
+- **Generate invite code:** button → POST → returns code +
+  `t.me/<bot>?start=<code>` URL. Status (active / expired / used).
+- **List individuals bound:** table of `individual_id, telegram_user_id,
+  telegram_chat_id, status, last_seen_at`.
+- **Backend endpoints:** `GET/PUT /v1/companies/:id/telegram`,
+  `GET/POST /v1/companies/:id/telegram/invites`,
+  `GET /v1/companies/:id/telegram/bindings`.
+
+### 3.4 Live tail (SSE)
+- **Page:** full-width feed of incoming alerts, newest at top.
+  Auto-scrolls; pause-on-scroll-up.
+- **Filter bar:** company (super-admin only), severity (>= X), category,
+  source, text search on title_key.
+- **Row columns:** timestamp, severity, company, source, title_key,
+  dedupe_count, actions (drill-in).
+- **Click row:** modal with full alert payload (data JSON, dedupe history).
+- **Backpressure:** server keeps last 200 events in memory; new
+  connection catches up. No persistence — this is live, not history.
+- **Transport:** `EventSource('/v1/tail/stream')` with `Authorization`
+  header workaround (EventSource doesn't support headers natively —
+  token passed as query param `?access_token=***`, validated once,
+  upgraded to a short-lived SSE token by `authd`).
+- **Backend endpoints:** `GET /v1/tail/stream` (SSE),
+  `GET /v1/tail/recent?since=<ts>` (HTTP fallback).
+
+### 3.5 DLQ surface
+- **List:** table of DLQ rows, columns: id, alert_id, channel, target,
+  attempts, last_error, next_attempt_at, created_at, status (pending /
+  replayed / discarded).
+- **Detail:** full alert payload + delivery history (each attempt's
+  error, response code, latency).
+- **Replay:** button → POST → re-publishes to deliverd NATS subject.
+  Confirmation modal showing "this will redeliver to N recipients".
+- **Discard:** button → POST → marks `status=discarded` (hidden from
+  default list). Audit row written.
+- **Bulk actions (v1.1 — not v1):** multi-select replay/discard. v1 is
+  single-row only.
+- **Backend endpoints:** `GET /v1/dlq`, `GET /v1/dlq/:id`,
+  `POST /v1/dlq/:id/replay`, `POST /v1/dlq/:id/discard`. **Migrates the
+  M8 endpoints that already exist.**
+
+### 3.6 Audit log viewer
+- **List:** table of audit_log rows, columns: ts, actor (user_id or
+  "system"), action, entity_type, entity_id, ip, user_agent, metadata
+  (collapsed JSON).
+- **Filter:** actor, action, entity_type, time range.
+- **Read-only.** No actions from this page (audit is immutable).
+- **Backend endpoints:** `GET /v1/audit?actor=&action=&since=&until=`.
+  New endpoint — M8 didn't have it.
+
+### 3.7 Auth pages
+- **Login:** username + password → access + refresh.
+- **Magic-link accept:** token + new password form.
+- **Forbidden / 403 page:** clean message, link to login.
+- **Logout:** button in top bar.
+
+### 3.8 Out of scope for v1 (deferred, recorded so we don't forget)
+
+Survey responses were explicit. Recorded here so a future reader
+doesn't re-litigate:
+
+| Module | Why deferred | Trigger to revisit |
+|---|---|---|
+| Groups + group members | Excluded from survey 1.2 | When 2nd customer asks for self-service user mgmt |
+| Individuals CRUD | Excluded from survey 1.2 | Same as groups |
+| Subscriptions UI | Excluded from survey 1.2 | When end-user (D from 1.1) becomes a real persona |
+| Routing rules editor | Excluded from survey 1.2 | When power users complain about SQL-only |
+| SSO / OIDC per customer | 2.3 chose in-house first | First enterprise customer with own IdP |
+| Mobile app / PWA | Excluded from 1.3 | After end-user (D) is a real persona |
+| Real-time collab editing | Excluded from 1.3 | When >5 operators use it concurrently |
+| Billing / quota UI | Excluded from 1.3 | When pricing model exists |
+| Theme/branding per company | Implicit deferral | When 2nd customer with brand requirements signs |
+| Visual rule editor (drag/drop) | Implicit deferral | When customers request low-code routing |
+| Density toggle in UI | 3.1 chose shadcn default | When ops users complain about density |
+| SLO dashboards in UI | They live in Grafana (M9) | When operators ask to leave Grafana |
+
+---
+
+## 4. Milestones — the implementation grouping
+
+M13 is split into **3 milestones** (`M13a`, `M13b`, `M13c`) plus the
+**spec & API contract** already written. Each milestone has its own
+`PLAN.md` with workstreams, estimates, and exit criteria.
+
+| Milestone | Goal | Plan | Blocked by | Estimate |
+|---|---|---|---|---|
+| **M13.0** | Spec + API contract (this file + `M13_API_CONTRACT.md`) | inline | M11 | ✅ done (this PR) |
+| **M13a** | authd service + web skeleton (login works end-to-end) | `M13a_PLAN.md` | M13.0 | ~5-6 days |
+| **M13b** | Companies + Sources + Telegram CRUD | `M13b_PLAN.md` | M13a | ~7-8 days |
+| **M13c** | Live tail + DLQ + Audit + K8s + Playwright E2E | `M13c_PLAN.md` | M13a, M13b | ~7-9 days |
+
+**Each milestone ships independently** and flips one row of SPEC.md.
+You can stop after any milestone and have something useful:
+
+- After **M13a**: nothing visible to the end-user yet, but auth is in
+  place and the SPA shell loads. Useful for "is the design right?"
+  review before committing to the rest.
+- After **M13b**: super-admin can do real work — create companies,
+  onboard a source, set up telegram. ~80% of operator value.
+- After **M13c**: full v1 done. Replaces `dlq.html`, adds live tail
+  and audit, has K8s manifests and a Playwright gate.
+
+### 4.1 What ships in M13a
+- `cmd/authd/` — JWT IdP service, all 7 endpoints.
+- `web/` skeleton — Vite + React 19 + shadcn/ui + TanStack Query.
+- Login page → `/me` → logout, all working against real `authd`.
+- Top bar + sidebar with empty pages (companies/sources/telegram/etc.)
+  rendered with "coming soon" placeholders.
+- `admind` modified to require JWT on every endpoint (except
+  `/health`, `/metrics`, the auth passthroughs).
+- SPEC.md `M13a` row flipped to ✅.
+
+### 4.2 What ships in M13b
+- Companies CRUD (super-admin full, tenant-admin read-only).
+- Sources CRUD with one-time secrets modal.
+- Telegram bot config + invite codes + bindings list.
+- All forms validate, audit log writes, error toasts.
+- SPEC.md `M13b` row flipped to ✅.
+
+### 4.3 What ships in M13c
+- Live tail page (SSE) with filter bar + drill-in modal.
+- DLQ list/detail/replay/discard, replaces `dlq.html`.
+- Audit log viewer.
+- K8s manifests for `admind` and `authd` (carry from M12 W1).
+- Playwright E2E happy path green in CI.
+- `M13_VERIFICATION.md` with screenshots + run logs.
+- SPEC.md `M13c` row flipped to ✅.
+
+---
+
+## 5. Stack
+
+| Layer | Choice | Why |
+|---|---|---|
+| Build | **Vite 5** | Fast HMR, the SPEC literally says "React + Vite" |
+| UI framework | **React 19** | Current, shadcn-compatible |
+| Components | **shadcn/ui** (Radix + Tailwind) | Accessible, copy-paste, no version lock-in |
+| Server state | **TanStack Query v5** | Cache, retries, devtools. Standard for v1 SPA |
+| Client state | React useState/useReducer. **No Redux.** | Not needed |
+| Forms | **React Hook Form + Zod** | Standard, type-safe |
+| Tables | **TanStack Table** (headless) + shadcn Data Table wrapper | Powerful, accessible |
+| Routing | **React Router v6** (data routers) | Stable, type-safe loaders |
+| Live tail | **Server-Sent Events** via native EventSource | Simpler than WS, one-direction |
+| HTTP client | **openapi-fetch** + **openapi-typescript** generated client | Type-safe from Go OpenAPI spec |
+| Testing | **Vitest** (units) + **Playwright** (1 E2E happy path) | Fast + high-signal |
+| i18n | None in v1. All copy in `src/i18n/en.ts` | Future i18next migration is mechanical |
+| Package manager | **pnpm** with workspace | Fast, saves disk |
+| Lint | ESLint + Prettier | Standard |
+| Node version | 22 LTS | Current LTS |
+
+**Not in v1:**
+- Redux / Zustand / MobX (overkill for this scope)
+- next-intl / i18next (English only v1)
+- Storybook (defer until we have 10+ components in flux)
+- Sentry / OpenTelemetry browser (defer to v2)
+- Workbox / PWA manifest (defer — not in scope per 1.3)
+
+---
+
+## 6. Repo layout
+
+```
+broad-announce/
+├── cmd/
+│   ├── admind/                  # existing — gets embed.FS for the SPA
+│   │   ├── main.go
+│   │   └── web/dist/            # generated by `pnpm --filter web build`
+│   └── authd/                   # new in M13a — JWT IdP service, port 8804
+│       ├── main.go
+│       └── ...
+├── internal/                    # existing backend code
+├── web/                         # new in M13a — Vite + React 19 SPA
+│   ├── package.json
+│   ├── pnpm-workspace.yaml
+│   ├── vite.config.ts
+│   ├── index.html
+│   ├── src/
+│   │   ├── main.tsx
+│   │   ├── App.tsx
+│   │   ├── routes/              # React Router routes
+│   │   ├── components/          # shadcn/ui + custom
+│   │   ├── features/            # companies/, sources/, telegram/, tail/, dlq/, audit/
+│   │   ├── lib/                 # api client, auth, hooks
+│   │   ├── i18n/en.ts
+│   │   └── types/               # generated openapi types
+│   ├── public/
+│   └── tests/                   # vitest + playwright
+├── M13_FRONTEND_SPEC.md (this file)
+├── M13_API_CONTRACT.md
+├── M13a_PLAN.md
+├── M13b_PLAN.md
+├── M13c_PLAN.md
+└── ...
+```
+
+---
+
+## 7. Embedding & deployment
+
+### 7.1 Build pipeline
+1. `cd web && pnpm install` (first time or on dep change).
+2. `pnpm --filter web run build` → outputs `web/dist/`.
+3. `cp -r web/dist/* cmd/admind/web/dist/` (or use a Makefile target).
+4. `go build ./cmd/admind/...` → Go's `//go:embed web/dist` directive
+   packages the static files into the binary.
+5. `go build ./cmd/authd/...` → authd binary.
+6. Docker image: existing Dockerfile copies both binaries.
+
+### 7.2 Serving the SPA from `admind`
+- `admind` registers `mux.Handle("/", http.FileServer(http.FS(staticFS)))`
+  with the `embed.FS` rooted at `web/dist`.
+- SPA fallback: any path that doesn't match an API route serves
+  `index.html` (so React Router can handle the route).
+- API routes are prefixed `/v1/`, `/health`, `/metrics`. Everything
+  else → SPA.
+- Cache-Control: long for `/assets/*` (hashed), `no-cache` for `/`.
+
+### 7.3 Auth and CORS
+- No CORS needed — same-origin, the SPA is served by `admind` itself.
+- `authd` is on port 8804; the SPA talks to it via the `authd` Service
+  in K8s / via `localhost:8804` in dev. The `admind` API forwards
+  `Authorization: Bearer ***` from the SPA's cookie to upstream checks
+  by calling `authd` `POST /v1/auth/verify` (or by sharing the JWT
+  secret — see `M13_API_CONTRACT.md` §3 for the design call).
+
+---
+
+## 8. Testing strategy (per-milestone detail in each PLAN)
+
+- **Unit (Vitest):** forms, auth helpers, data transformers, one
+  happy-path per feature.
+- **E2E (Playwright, 1 happy path in M13c):** super-admin logs in →
+  creates a company → creates a source → triggers an alert via curl
+  to ingestd → sees the alert in Live Tail within 2s.
+- **What we are NOT testing in v1:** visual regression, cross-browser
+  matrix, SPA load testing, axe-core audit (shadcn/Radix gets us 90%
+  for free).
+
+---
+
+## 9. Open questions (flagged for the W1 walk-through)
+
+Default is documented; if a reviewer disagrees, they edit the spec
+before M13a W1 starts.
+
+1. **Users table location** — same Postgres DB as everything else, or
+   a separate `authd` DB? **Default:** same DB, separate schema
+   (`auth`). Pro: one backup story. Con: blast radius.
+2. **JWT verification** — `admind` calls `authd` on every request, or
+   shares the secret? **Default:** shared secret (HS256) for v1; flip
+   to `authd` JWKS endpoint when K8s multi-cluster lands.
+3. **Refresh token storage** — server-side Postgres row, or stateless
+   JWT? **Default:** server-side row, 7d TTL, rotated on use.
+   Revocable.
+4. **Audit log writes** — every service writes its own rows, or
+   `admind` is the proxy? **Default:** every service writes its own
+   (M9 already has the schema). Simpler, more accurate, no proxy
+   needed.
+5. **`authd` HTTP port** — 8804 (next slot after admind 8803). Flag
+   for review if there's a convention I'm missing.
+
+---
+
+## 10. Risks (carried forward to each milestone's PLAN)
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| Bundle size grows with features | Medium | Medium (slow first paint) | Code-split per route, lazy-load heavy tables |
+| `embed.FS` blows the Go binary to 100+ MB | Low | Low | Use `//go:embed` with gzip; check size in CI |
+| JWT secret rotation breaks live sessions | Medium | Medium | Support 2 secrets during rotation; rotation playbook in `authd/README.md` |
+| Magic-link emails go to spam | High | Low (we have psql fallback) | Document SPF/DKIM; v1 returns URL in API response if SMTP unconfigured |
+| Tenant-admin bypass via direct API call | Low | High | Backend enforcement of `WHERE company_id = $1`, tested in Playwright |
+| SSE drops through corporate proxies | Medium | Medium | Long-poll fallback (`/v1/tail/recent?since=`) is already in the API |
+| React 19 + shadcn interop bugs | Low | Low | shadcn already supports React 19; pin in pnpm-lock |
+| Two builds per release (web + Go) | Certain | Low | Makefile target, single `make build` in CI |
+| OpenAPI drift between Go and TS types | Medium | High | Generate client in CI on every Go API change; PR that drifts fails |
+
+---
+
+**Next step:** review this spec + `M13_API_CONTRACT.md`, mark up the
+Open Questions in §9, then start M13a W1 (`authd`).

+ 173 - 0
M13_PLAN.md

@@ -0,0 +1,173 @@
+# M13 — Meta Plan (milestone grouping)
+
+> Companion to `M13_FRONTEND_SPEC.md`. This is the cross-milestone
+> plan; per-milestone detail lives in `M13a_PLAN.md`, `M13b_PLAN.md`,
+> `M13c_PLAN.md`.
+
+**Status:** planning (post-M11, parallel to M12)
+**Goal:** ship a v1 multi-tenant admin UI that replaces `dlq.html`
+and adds CRUD + live-tail + audit + auth surfaces, in a single
+`admind` Go binary that embeds a Vite+React 19 SPA.
+
+---
+
+## 0. Why milestones, not one big plan
+
+The original M13 plan (workstream-as-primary-axis, 9 workstreams)
+assumed one engineer ships end-to-end. In practice you stop and
+review between surfaces — and you want each stop to be a **shippable
+artifact**, not "we have some auth working but no UI yet".
+
+The milestone split lets you:
+
+| Stop after | What you have | Why stop here |
+|---|---|---|
+| **M13a** | authd + web skeleton + login works | "Is the design right? Is the auth model right? Is the SPA architecture right?" — review before committing to feature work. |
+| **M13b** | Companies + Sources + Telegram CRUD | ~80% of operator value. The customer can self-onboard, set up sources, configure telegram. Live tail + DLQ + audit can wait. |
+| **M13c** | Live tail + DLQ + Audit + K8s + E2E | Full v1. Replaces `dlq.html`, ships Playwright gate, K8s-ready. |
+
+Each milestone flips one SPEC.md row. Each has its own
+`M13x_PLAN.md` with workstreams, estimates, and per-milestone DoD.
+
+---
+
+## 1. Milestone overview
+
+```
+                ┌──────────────┐
+                │  M13.0 spec  │   (this PR — no code)
+                │  + API       │
+                └──────┬───────┘
+                       │
+                       ▼
+                ┌──────────────┐
+                │   M13a       │   ~5-6 days
+                │  authd +     │
+                │  web shell   │
+                │  + JWT gate  │
+                └──────┬───────┘
+                       │
+                       ▼
+                ┌──────────────┐
+                │   M13b       │   ~7-8 days
+                │  CRUD:       │
+                │  Companies   │
+                │  Sources     │
+                │  Telegram    │
+                └──────┬───────┘
+                       │
+                       ▼
+                ┌──────────────┐
+                │   M13c       │   ~7-9 days
+                │  Live tail   │
+                │  DLQ         │
+                │  Audit       │
+                │  K8s         │
+                │  Playwright  │
+                └──────────────┘
+```
+
+**Total estimate: ~19-23 days with one engineer.** Can be parallelized
+with a second engineer at M13b (one on Companies, one on Sources).
+
+---
+
+## 2. Milestone summaries
+
+### M13.0 — Spec & API contract
+- `M13_FRONTEND_SPEC.md`
+- `M13_API_CONTRACT.md`
+- No code. This PR. **No DoD beyond "specs reviewed and committed".**
+
+### M13a — authd + web skeleton + JWT gate
+- `cmd/authd/` — JWT IdP service on :8804, all 7 endpoints.
+- `web/` — Vite + React 19 + shadcn/ui + TanStack Query + RHF + Zod.
+  Login page works end-to-end. Other routes render "coming soon".
+- `admind` modified to require JWT on all `/v1/*` endpoints (except
+  `/health`, `/metrics`).
+- Plan: `M13a_PLAN.md`.
+
+### M13b — CRUD
+- Companies: list, detail, create, edit, suspend (super); read-only
+  (tenant).
+- Sources: list, detail, create, edit, rotate secret, suspend. One-time
+  secrets modal.
+- Telegram: bot config, invite codes, bindings list.
+- All forms validate; audit log writes; error toasts.
+- Plan: `M13b_PLAN.md`.
+
+### M13c — Ops surface + K8s + E2E
+- Live tail: SSE feed, filter bar, drill-in modal, pause-on-scroll.
+- DLQ: list, detail, replay, discard. Replaces `dlq.html`.
+- Audit log viewer.
+- K8s manifests for `admind` and `authd` (carry from M12 W1).
+- Playwright E2E happy path green in CI.
+- Plan: `M13c_PLAN.md`.
+
+---
+
+## 3. Cross-cutting concerns
+
+These live in the spec but are operationally tested in M13c:
+
+- **Single binary:** `admind` embeds the SPA via `embed.FS`. Target
+  size: < 60 MB.
+- **No CORS:** SPA is served by `admind` itself. `authd` is same-origin
+  via service discovery.
+- **OpenAPI client gen:** `pnpm run gen:api` in CI on every Go API
+  change. Type drift fails the PR.
+- **Tenant isolation:** backend enforces `WHERE company_id = $1` for
+  tenant-admins. Tested in Playwright with a cross-tenant 403 check.
+- **Audit immutability:** every state-changing call writes to
+  `audit_log`. The viewer is read-only.
+
+---
+
+## 4. Dependencies
+
+| From | What | Status | Affects |
+|---|---|---|---|
+| M11 | gRPC ingest stable, F2 publish counter | ✅ done | M13c (live tail source) |
+| M12 W1 | K8s manifests for `admind` and `authd` | ⏳ in flight | M13c W7 (K8s manifests) |
+| Postgres | `auth` schema + tables | to be created in M13a W1 | M13a |
+| Redis | rate-limit counters for `authd` login | ✅ already in stack | M13a |
+| NATS | `tailhub` for in-memory SSE feed (M13c) | ✅ already exists | M13c |
+| SMTP (optional) | magic-link email | NOT required for v1 (psql fallback) | M13a (best-effort) |
+
+---
+
+## 5. Parallelism with M12
+
+| When | What runs in parallel |
+|---|---|
+| M13a W1-W2 | M12 W1 (K8s manifests) — independent |
+| M13b W1-W3 | M12 W2 (NATS cluster) — independent |
+| M13c W7 | M12 W3-W4 (Helm + 50k/s bench) — blocks K8s manifest merge |
+
+M13 does not block M12. M12 W5 (M11 prod gate on K8s) is independent
+of M13. The two milestones converge at "single PR that ships v1 with
+K8s + frontend" but the workstream branches don't have to.
+
+---
+
+## 6. Definition of done — whole M13
+
+- [ ] All 3 sub-milestones (M13a, M13b, M13c) have their individual
+      DoD checked.
+- [ ] `cmd/authd/` exists, builds, runs, healthy on :8804.
+- [ ] `cmd/admind/` builds with embedded SPA, < 60 MB.
+- [ ] Playwright happy-path green in CI for 3 consecutive runs.
+- [ ] Tenant-isolation 403 test green in CI.
+- [ ] `M13_VERIFICATION.md` published with: screenshots of the 6
+      modules, the Playwright run log, the tenant-isolation test,
+      the binary size, and the bundle breakdown.
+- [ ] SPEC.md `M13a`, `M13b`, `M13c` rows flipped to **✅ shipped
+      YYYY-MM-DD**.
+- [ ] `cmd/admind/ui/dlq.html` removed.
+- [ ] Demo to a real customer (or internal stakeholder) and signed
+      off.
+
+---
+
+**Next step:** commit the 3 docs from M13.0, then start M13a W1
+(`authd`).

+ 298 - 0
M13a_PLAN.md

@@ -0,0 +1,298 @@
+# 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:** planning (post-M13.0 spec)
+**Target:** `M13_FRONTEND_SPEC.md` §4.1
+**Estimate:** 5-6 days with one engineer
+
+---
+
+## 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).
+- 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: admind JWT gate
+
+**Goal:** Make `admind` require a valid JWT on every `/v1/*` endpoint
+(except the 5 public ones). Share `BA_AUTH_JWT_SECRET` with `authd`
+for HS256 verification. Add scope-based authorization (super-admin
+vs tenant-admin).
+
+**Scope:**
+- `internal/auth/verifier.go` (new package):
+  - `Verifier` interface.
+  - `HS256Verifier` (default).
+  - `JWKSVerifier` (stub, future).
+  - `Claims` struct (sub, role, company_id, iat, exp).
+- `cmd/admind/main.go`:
+  - Wire `Verifier` from env (`BA_AUTH_JWT_SECRET`,
+    `BA_AUTH_ASYMMETRIC`).
+  - Add `authMiddleware` that reads `Authorization: Bearer ***`,
+    verifies, injects claims into request context.
+  - Apply to all `/v1/*` except: `/v1/auth/*` (passthrough for
+    login/refresh), `/health`, `/metrics`.
+  - Add `forbiddenHandler` for 403 with consistent error JSON.
+- `internal/httpserver/middleware.go` (if it exists; otherwise
+  add to `cmd/admind/main.go`): the auth middleware.
+- `internal/dlq/dlq.go` — every query adds
+  `WHERE company_id = $1` for tenant-admin (read from JWT context).
+- `internal/audit/audit.go` — every state change writes a row with
+  the actor's user_id from JWT.
+- `internal/config/config.go` — read `BA_AUTH_JWT_SECRET`,
+  `BA_AUTH_ASYMMETRIC` env vars.
+
+**Exit criteria:**
+- [ ] Without `Authorization` header → 401 on all `/v1/*` except
+      `/v1/auth/*`, `/health`, `/metrics`.
+- [ ] With valid super-admin JWT → 200 on everything.
+- [ ] With tenant-admin JWT, request scoped to their `company_id`:
+  - `GET /v1/companies/<their-id>` → 200
+  - `GET /v1/companies/<other-id>` → 403
+- [ ] `HS256Verifier` and `JWKSVerifier` both compile, controlled
+      by env.
+- [ ] `/openapi.json` (new in M13a) lists every endpoint with the
+      auth scheme.
+- [ ] M8 DLQ endpoints still work (same paths, now JWT-gated).
+- [ ] Audit log: state-changing calls write actor from JWT.
+
+**Estimated:** 1-2 days (touches existing `admind` code; risk of
+regression; needs careful testing of the M8 DLQ flow).
+
+---
+
+### W4: docker-compose + env + bootstrap + smoke
+
+**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.
+
+**Scope:**
+- `docker-compose.yml` — add `authd` service, wire env vars.
+- `scripts/bootstrap_admin.sh` — creates the first super-admin
+  user via psql with a known password, prints the credentials.
+- `scripts/m13a_smoke.sh` — bash + curl:
+  1. Bring up stack (`docker compose up -d`).
+  2. Bootstrap super-admin.
+  3. POST `/v1/auth/login` with creds → expect 200 + tokens.
+  4. GET `/v1/auth/me` with token → expect 200 + super_admin.
+  5. GET `/v1/dlq` (any) without token → expect 401.
+  6. GET `/v1/dlq` with token → expect 200.
+  7. Logout → refresh token revoked.
+- `Makefile` target: `make m13a-smoke` runs the above.
+- `M13a_VERIFICATION.md` — captures the smoke output, links to
+  W1-W3 exit criteria.
+
+**Exit criteria:**
+- [ ] `make m13a-smoke` exits 0 from a clean state.
+- [ ] 3 consecutive green runs.
+- [ ] Bootstrap script is idempotent (running twice doesn't create
+      a duplicate admin).
+- [ ] All M8 functionality still works (DLQ list/replay/discard).
+- [ ] `M13a_VERIFICATION.md` published.
+
+**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.

+ 273 - 0
M13b_PLAN.md

@@ -0,0 +1,273 @@
+# M13b — CRUD (Companies + Sources + Telegram)
+
+> Second sub-milestone of M13. Delivers the operator's main value:
+> real CRUD on the entities. After this lands, a super-admin can
+> onboard a customer end-to-end (company → source → telegram bot)
+> without touching psql. Live tail, DLQ, audit, K8s, E2E come in M13c.
+
+**Status:** planning (post-M13a)
+**Target:** `M13_FRONTEND_SPEC.md` §4.2
+**Estimate:** 7-8 days with one engineer
+
+---
+
+## 0. Recap — what M13b ships
+
+- **Companies CRUD** (super-admin full, tenant-admin read-only).
+- **Sources CRUD** with one-time secrets modal.
+- **Telegram bots** per-company: config, invite codes, bindings.
+- All forms validate (Zod), audit log writes, error toasts.
+- SPEC.md `M13b` row flipped to ✅.
+
+**What M13b is NOT:**
+- Not live tail, DLQ, audit viewer, K8s, Playwright E2E — M13c.
+- Not groups, individuals, subscriptions, routing rules — out of
+  scope for v1 (see `M13_FRONTEND_SPEC.md` §3.8).
+
+---
+
+## 1. Workstreams
+
+```
+┌──────────────────────────┐    ┌──────────────────────────┐
+│  W1: Companies CRUD      │    │  W2: Sources CRUD        │
+│  (super: full,           │───▶│  (HMAC, rate limits,     │
+│   tenant: read-only)     │    │   allowed IPs, secret    │
+│                          │    │   rotation modal)        │
+└──────────────┬───────────┘    └──────────────┬───────────┘
+               │                               │
+               ▼                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W3: Telegram bots — config, invite codes, bindings list     │
+└──────────────────────────────────────────────────────────────┘
+                                               │
+                                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W4: smoke + verification + screenshots                      │
+└──────────────────────────────────────────────────────────────┘
+```
+
+Four workstreams. W1 → W2 → W3 → W4. W2 and W3 can overlap if a
+second engineer is available; otherwise sequential.
+
+---
+
+## 2. Workstream details
+
+### W1: Companies CRUD
+
+**Goal:** Super-admin can create, list, edit, suspend, activate
+companies. Tenant-admin sees only their own company (read-only).
+
+**Scope:**
+- Routes: `/companies`, `/companies/:id`, `/companies/new`.
+- Feature folder: `web/src/features/companies/`.
+- Components: `CompanyList`, `CompanyDetail`, `CompanyForm`,
+  `SuspendDialog` (typed confirmation: type slug to suspend).
+- React Hook Form + Zod validation:
+  - `name`: required, 1-100 chars.
+  - `slug`: required, lowercase, `^[a-z0-9-]{3,40}$`.
+  - `rate_limit_per_sec`: positive int.
+  - `fcm_shared`: boolean.
+  - `telegram_bot_token`: optional, set on first save, encrypt at rest.
+- TanStack Query hooks: `useCompanies`, `useCompany(id)`,
+  `useCreateCompany`, `useUpdateCompany`, `useSuspendCompany`,
+  `useActivateCompany`.
+- `web/src/lib/api-client.ts` regenerated from `admind` OpenAPI.
+- Audit log entries appear for company.create, company.update,
+  company.suspend, company.activate.
+- Tenant-admin view: only their own company, no edit/suspend buttons.
+- `web/src/lib/scope.ts` — utility to read current user from auth
+  context, gate UI by role.
+
+**Exit criteria:**
+- [ ] Super-admin can log in, see companies list, create one, edit one.
+- [ ] Tenant-admin (created via psql or magic-link from M13a)
+      logs in, sees only their own company (read-only).
+- [ ] Cross-tenant 403: tenant-admin tries
+      `GET /v1/companies/other-company-id` → 403.
+- [ ] Suspend: company `status=suspended`, ingestd rejects (verify
+      via curl).
+- [ ] Reactivate: company `status=active`, ingestd accepts again.
+- [ ] All forms validate with Zod, errors show inline + summary
+      banner.
+- [ ] Optimistic updates for suspend/activate with rollback on
+      error.
+- [ ] Audit log: each mutation produces a row visible in
+      `audit_log` table.
+- [ ] Bundle: companies feature code-split (< 30 KB gzipped).
+
+**Estimated:** 2-3 days.
+
+---
+
+### W2: Sources CRUD
+
+**Goal:** Sources list + create + edit + rotate secret + suspend.
+Includes the "save these now" modal for HMAC secrets and API keys on
+create/rotate.
+
+**Scope:**
+- Routes: `/sources`, `/sources/:id`, `/sources/new`.
+- Feature folder: `web/src/features/sources/`.
+- Components: `SourceList`, `SourceDetail`, `SourceForm`,
+  `OneTimeSecretsModal`, `QuarantineBadge`.
+- React Hook Form + Zod validation:
+  - `name`: required, 1-100 chars.
+  - `type`: enum (http, websocket, mqtt, snmp, grpc, custom).
+  - `hmac_secret`: required, ≥ 32 chars (auto-generated on create
+    if not provided).
+  - `api_key`: optional, ≥ 32 chars.
+  - `rate_limit_per_sec`: positive int.
+  - `allowed_ips`: array of CIDR, validated.
+  - `topic_prefix`: optional.
+  - `status`: active | suspended.
+- One-time secrets modal: when create/rotate succeeds, show the raw
+  HMAC + API key in copy-to-clipboard fields. Modal blocks
+  dismissal until the user clicks "I've saved them" (the spec for
+  the modal lives in `M13_API_CONTRACT.md` §2.2).
+- Quarantine badge: if `quarantine_until` is in the future, show
+  a red badge with the remaining time.
+- Filter by company (super), type, status.
+
+**Exit criteria:**
+- [ ] Create source: form validates, one-time secrets shown once
+      via modal.
+- [ ] List: sortable, paginated, filterable.
+- [ ] Edit: changes rate limit and allowed IPs persist.
+- [ ] Rotate secret: new one-time secrets shown, old HMAC rejected
+      within 60s (verify via curl with old key).
+- [ ] Suspend: source goes to `status=suspended`, ingestd rejects
+      (verified by curl).
+- [ ] Quarantine badge visible when `quarantine_until` is set.
+- [ ] Tenant-admin sees only their company's sources.
+- [ ] Bundle: sources feature code-split (< 40 KB gzipped).
+
+**Estimated:** 2-3 days.
+
+---
+
+### W3: Telegram bots
+
+**Goal:** Per-company telegram bot config, invite code generation,
+binding list.
+
+**Scope:**
+- Routes: `/companies/:id/telegram`,
+  `/companies/:id/telegram/invites`,
+  `/companies/:id/telegram/bindings`.
+- Feature folder: `web/src/features/telegram/`.
+- Components: `TelegramConfig`, `TelegramInviteForm`,
+  `TelegramInvitesList`, `TelegramBindingsList`,
+  `BotTokenRotateDialog`.
+- React Hook Form + Zod:
+  - `bot_token`: required, format validated
+    (`<bot_id>:<secret>`, regex `^\d+:[A-Za-z0-9_-]{35}$`).
+  - On rotate: typed confirmation ("type the company slug to
+    confirm rotation" — same pattern as suspend).
+- Bot token is NEVER displayed after save. Set and rotate are the
+  only actions.
+- Invite code list with status badges (active/used/expired) and
+  revoke button.
+- Bindings table: individual_id, telegram_user_id, last_seen_at.
+- Magic link generator: button → POST → returns code +
+  `t.me/<bot>?start=<code>` URL, copy-to-clipboard.
+
+**Exit criteria:**
+- [ ] Set bot token: encrypted at rest in `admind`, decryptable
+      for emit (verify via direct API call from deliverd).
+- [ ] Generate invite: returns code + magic link, copies to
+      clipboard.
+- [ ] Bindings list: paginated, shows last_seen_at.
+- [ ] Revoke invite: 204, invite goes to `status=revoked`.
+- [ ] Rotate token: old token rejected by telegram bot (verify by
+      sending a message and seeing it fail).
+- [ ] Tenant-admin sees only their own company's telegram config.
+- [ ] Bundle: telegram feature code-split (< 30 KB gzipped).
+
+**Estimated:** 2 days.
+
+---
+
+### W4: smoke + verification + screenshots
+
+**Goal:** End-to-end smoke that exercises all 3 modules. Screenshots
+for the verification doc.
+
+**Scope:**
+- `scripts/m13b_smoke.sh` — bash + curl:
+  1. Bring up stack.
+  2. Bootstrap super-admin + tenant-admin.
+  3. Login as super-admin.
+  4. Create a company.
+  5. Create a source (capture one-time secrets from response).
+  6. Send 1 alert via curl to ingestd using the source's HMAC.
+  7. List sources → expect 1 with alerts_24h=1.
+  8. Suspend the source → expect 200.
+  9. Send 1 alert with suspended source → expect 401.
+  10. Login as tenant-admin → expect to see only the new company.
+  11. Cross-tenant 403 attempt → expect 403.
+  12. Set telegram bot token → expect 204.
+  13. Generate invite → expect 201.
+  14. List invites → expect 1.
+- `scripts/m13b_smoke.sh` exits 0/1, green required.
+- `M13b_VERIFICATION.md` with:
+  - Smoke log (curl + responses).
+  - Screenshots of: company list, company create form, source list,
+    source create form, one-time secrets modal, telegram config,
+    invites list, bindings list.
+  - Bundle size breakdown.
+  - Tenant-isolation 403 test.
+
+**Exit criteria:**
+- [ ] `make m13b-smoke` exits 0 from a clean state.
+- [ ] 3 consecutive green runs.
+- [ ] M13a functionality not regressed.
+- [ ] `M13b_VERIFICATION.md` published.
+
+**Estimated:** 0.5-1 day.
+
+---
+
+## 3. Sequencing
+
+```
+W1 (2-3d) ──▶ W2 (2-3d) ──▶ W3 (2d) ──▶ W4 (0.5-1d)
+```
+
+- **W1 alone:** 2-3 days.
+- **W2 after W1:** 2-3 days. Can start in parallel with W1's last
+  day if a 2nd engineer.
+- **W3 after W2:** 2 days. Can start in parallel with W2's last
+  day if a 2nd engineer.
+- **W4:** 0.5-1 day.
+- **Total: ~6.5-9 days, target 7-8 with overlap.**
+
+---
+
+## 4. Risks specific to M13b
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| `embed.FS` binary size creeps up | Medium | Medium (slow first paint) | Per-feature code splitting; CI size gate per feature (< 30 KB gz) |
+| Tenant-admin bypasses via direct API | Low | High | W1 has the cross-tenant 403 test in the smoke; W2/W3 inherit the same enforcement |
+| One-time secrets leaked in browser cache | Low | High | Modal forces explicit "I've saved them" click; secrets never written to localStorage/sessionStorage; clear React Query cache on success |
+| `bot_token` accidentally logged | Low | High | Custom logger that redacts known sensitive fields (`bot_token`, `hmac_secret`, `api_key`); CI lint for new logging calls |
+| Quarantine state stale in UI | Medium | Low | Source list refetches on focus + every 30s while page is open |
+| `telegram` token format changes (Telegram rotates) | Low | Low | Regex lenient enough; show validation error with the format hint |
+
+---
+
+## 5. Definition of done — M13b
+
+- [ ] All 3 modules (Companies, Sources, Telegram) have their
+      per-workstream exit criteria checked.
+- [ ] `make m13b-smoke` green for 3 consecutive runs.
+- [ ] M13a functionality not regressed.
+- [ ] `M13b_VERIFICATION.md` published.
+- [ ] SPEC.md `M13b` row flipped to **✅ shipped YYYY-MM-DD**.
+
+---
+
+**Next step:** start W1 (Companies). After W1 lands with the
+tenant-isolation test, W2 can run in parallel.

+ 320 - 0
M13c_PLAN.md

@@ -0,0 +1,320 @@
+# M13c — Live tail + DLQ + Audit + K8s + E2E
+
+> Third and final sub-milestone of M13. Delivers the ops surface
+> (live tail, DLQ, audit), the K8s manifests, and the Playwright
+> E2E gate. After this lands, M13 v1 is done.
+
+**Status:** planning (post-M13b)
+**Target:** `M13_FRONTEND_SPEC.md` §4.3
+**Estimate:** 7-9 days with one engineer
+
+---
+
+## 0. Recap — what M13c ships
+
+- **Live tail** (SSE feed) with filter bar + drill-in modal.
+- **DLQ surface** (replaces `cmd/admind/ui/dlq.html`).
+- **Audit log viewer** (read-only).
+- **K8s manifests** for `admind` and `authd` (carry from M12 W1).
+- **Playwright E2E** happy path green in CI.
+- `M13_VERIFICATION.md` with screenshots + run logs.
+- SPEC.md `M13c` row flipped to ✅.
+
+**What M13c is NOT:**
+- Not SSO, not SLO dashboards, not Storybook, not visual regression —
+  all v2.
+- Not visual rule editor, not density toggle, not SLO dashboards in UI.
+
+---
+
+## 1. Workstreams
+
+```
+┌──────────────────────────┐    ┌──────────────────────────┐
+│  W1: Live tail (SSE)     │    │  W2: DLQ surface         │
+│  (filter bar, drill-in,  │───▶│  (replaces dlq.html,     │
+│   pause-on-scroll)       │    │   replay + discard)      │
+└──────────────┬───────────┘    └──────────────┬───────────┘
+               │                               │
+               ▼                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W3: Audit log viewer (read-only, filterable)                │
+└──────────────────────────────────────────────────────────────┘
+                                               │
+                                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W4: K8s manifests (admind + authd) + ConfigMap + Secret    │
+└──────────────────────────────────────────────────────────────┘
+                                               │
+                                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W5: Playwright E2E + CI pipeline + M13_VERIFICATION.md     │
+└──────────────────────────────────────────────────────────────┘
+```
+
+Five workstreams. W1 → W2 → W3 → W4 → W5. W4 (K8s) is
+independent of W1-W3 and can start in parallel. W5 is the final
+gate.
+
+---
+
+## 2. Workstream details
+
+### W1: Live tail (SSE)
+
+**Goal:** Real-time feed of inbound alerts. Filterable.
+Drill-in modal.
+
+**Scope:**
+- Route: `/tail`.
+- Feature folder: `web/src/features/tail/`.
+- Components: `TailFeed`, `TailFilterBar`, `TailRow`, `TailDetailModal`,
+  `SSEStatusBadge`.
+- Transport: native `EventSource` with `?access_token=***` query
+  param (workaround for `EventSource`'s lack of custom headers).
+- Heartbeat: green dot when SSE healthy, red on disconnect,
+  auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s).
+- Pause-on-scroll-up: when user scrolls up, pause auto-scroll and
+  show a "go to bottom" button. Resume when scrolled to bottom or
+  button clicked.
+- Filter bar: company (super), severity (≥ X), category, source,
+  text search on title_key.
+- Drill-in modal: full alert JSON (data, dedupe history, raw NATS
+  subject).
+- HTTP fallback: if SSE blocked by proxy, the filter bar shows a
+  "use polling" toggle that hits `/v1/tail/recent?since=<ts>` every
+  5s.
+
+**Exit criteria:**
+- [ ] `loadgen` sends 1 alert → appears in live tail within 2s.
+- [ ] Burst of 100 alerts → all 100 visible in order.
+- [ ] Filter by company hides other companies' alerts.
+- [ ] Filter by severity hides lower severities.
+- [ ] Pause on scroll up, "go to bottom" button works.
+- [ ] SSE reconnect on network blip (close laptop, reopen, still
+      streaming).
+- [ ] HTTP fallback works when SSE blocked (manual test with
+      curl).
+- [ ] Tenant-admin sees only their company's alerts.
+- [ ] Bundle: live-tail feature code-split (< 40 KB gzipped).
+
+**Estimated:** 2-3 days.
+
+---
+
+### W2: DLQ surface (replaces `dlq.html`)
+
+**Goal:** Replace `cmd/admind/ui/dlq.html` with the new SPA. Same
+list/replay/discard flow, plus filters.
+
+**Scope:**
+- Routes: `/dlq`, `/dlq/:id`.
+- Feature folder: `web/src/features/dlq/`.
+- Components: `DLQList`, `DLQDetail`, `DLQReplayDialog`,
+  `DLQDiscardDialog` (typed confirmation).
+- List: filter by company (super), channel, status, time range.
+- Detail: full alert payload + delivery history timeline (each
+  attempt's error, response, latency).
+- Replay: confirmation modal showing "this will redeliver to
+  N recipients", audit row written.
+- Discard: typed confirmation (`type "discard" to confirm`), audit
+  row written.
+- Optimistic updates for replay/discard (instant UI, rollback on
+  error).
+- **Delete `cmd/admind/ui/dlq.html`** and remove the old `GET /dlq`
+  handler from `cmd/admind/main.go` (SPA fallback now serves the
+  new DLQ page).
+
+**Exit criteria:**
+- [ ] DLQ list: all M8 rows visible, paginated, filterable.
+- [ ] Replay: alert re-delivered (verified via deliverd metrics:
+      `ba_deliverd_attempts_total{result=ok}` increments).
+- [ ] Discard: row hidden from default list, `status=discarded`.
+- [ ] Tenant-admin sees only their company's DLQ rows.
+- [ ] Old `dlq.html` file deleted from `cmd/admind/ui/`.
+- [ ] Old `GET /dlq` handler in `admind` removed.
+- [ ] Bundle: dlq feature code-split (< 30 KB gzipped).
+
+**Estimated:** 2-3 days.
+
+---
+
+### W3: Audit log viewer
+
+**Goal:** Read-only table of `audit_log` rows, filterable.
+
+**Scope:**
+- Route: `/audit`.
+- Feature folder: `web/src/features/audit/`.
+- Components: `AuditList`, `AuditDetailModal` (expanded JSON
+  metadata).
+- List: filter by actor (user picker), action, entity_type, time
+  range, free text on metadata.
+- Read-only. No actions from this page.
+- New backend endpoint: `GET /v1/audit` in `cmd/admind/main.go`.
+  Reads from the `audit_log` table that M9 already created.
+- New SQL: `SELECT ... FROM audit_log WHERE company_id = $1 ORDER BY
+  ts DESC LIMIT $2 OFFSET $3` (super-admin passes company_id query
+  param; tenant-admin forced to own).
+
+**Exit criteria:**
+- [ ] Audit list: rows from `ingestd`, `routerd`, `deliverd`,
+      `admind` actions all visible.
+- [ ] Filter by actor narrows the list.
+- [ ] Filter by action narrows the list.
+- [ ] Time range filter works.
+- [ ] AuditDetailModal shows full metadata JSON pretty-printed.
+- [ ] Tenant-admin sees only their company's audit rows.
+- [ ] Bundle: audit feature code-split (< 20 KB gzipped).
+
+**Estimated:** 1-2 days.
+
+---
+
+### W4: K8s manifests (admind + authd)
+
+**Goal:** K8s manifests that bring up `admind` and `authd` alongside
+the rest of the M12 stack. Carry-over from M12 W1 with M13-specific
+additions (authd Service, JWT secret Secret).
+
+**Scope:**
+- `deploy/k8s/base/admind/`:
+  - `deployment.yaml` — 2 replicas, resource limits, env from
+    ConfigMap + Secret.
+  - `service.yaml` — ClusterIP, ports 8803 (HTTP) + 9090 (gRPC,
+    unused by frontend but kept for parity).
+  - `configmap.yaml` — BA_HTTP_ADDR, BA_PG_*, BA_NATS_*.
+  - `pdb.yaml` — minAvailable: 1.
+- `deploy/k8s/base/authd/`:
+  - `deployment.yaml` — 2 replicas, env from Secret.
+  - `service.yaml` — ClusterIP, port 8804.
+  - `configmap.yaml` — BA_AUTHD_HTTP_ADDR, BA_AUTH_SMTP_*.
+  - `secret.yaml.example` — BA_AUTH_JWT_SECRET (real one gitignored).
+  - `pdb.yaml` — minAvailable: 1.
+- `deploy/k8s/base/kustomization.yaml` updated to include both.
+- `deploy/k8s/README.md` — quickstart, secret-rotation note.
+- Carry from M12 W1: namespace, postgres, nats, redis, clickhouse,
+  emqx, ingestd, routerd, deliverd-*, archiverd, prometheus, grafana.
+
+**Exit criteria:**
+- [ ] `kubectl apply -k deploy/k8s/base/` brings up `admind` and
+      `authd` alongside the rest.
+- [ ] `kubectl get pods -n broad-announce` shows both Running +
+      Ready.
+- [ ] Kustomize output passes `kubeconform` (or
+      `kubectl apply --dry-run=server`).
+- [ ] Single `admind` binary size < 60 MB.
+- [ ] `deploy/k8s/README.md` documents the quickstart and the
+      JWT-secret rotation procedure.
+
+**Estimated:** 1 day (most of the templates already exist from
+M12 W1).
+
+---
+
+### W5: Playwright E2E + CI pipeline + final verification
+
+**Goal:** Single Playwright happy-path test that runs in CI.
+Final `M13_VERIFICATION.md` with screenshots.
+
+**Scope:**
+- `web/tests/e2e/happy-path.spec.ts`:
+  1. Login as super-admin.
+  2. Create a company.
+  3. Create a source (capture one-time secrets from response).
+  4. Send 1 alert via curl to ingestd (Playwright runs the curl in
+     a subprocess).
+  5. See the alert in Live Tail within 2s.
+  6. Open DLQ, replay a row, verify deliverd metrics incremented.
+  7. Open Audit, see all 6+ audit rows.
+  8. Logout.
+  9. Login as tenant-admin (created during the test).
+  10. Cross-tenant 403 attempt: try to GET another company's
+      sources → expect 403.
+- `web/playwright.config.ts` — chromium only, base URL from env.
+- `scripts/m13_e2e.sh` — wraps the Playwright run, brings up
+  docker-compose, runs, teardown (no `-v`, learned from M11).
+- `.github/workflows/m13-e2e.yml` (or GitLab CI equivalent) — runs
+  the smoke on every PR that touches `cmd/**` or `web/**`.
+- `M13_VERIFICATION.md`:
+  - Screenshots of all 6 modules.
+  - Playwright run log.
+  - Tenant-isolation 403 test.
+  - Binary size.
+  - Bundle breakdown.
+  - CI link.
+
+**Exit criteria:**
+- [ ] Playwright happy-path test green on local docker-compose.
+- [ ] Playwright happy-path test green in CI for 3 consecutive
+      runs.
+- [ ] CI workflow runs on every PR; green required to merge.
+- [ ] `M13_VERIFICATION.md` published.
+- [ ] SPEC.md `M13a`, `M13b`, `M13c` rows all flipped to
+      **✅ shipped YYYY-MM-DD**.
+
+**Estimated:** 1-2 days.
+
+---
+
+## 3. Sequencing
+
+```
+W1 (2-3d) ──┐
+            ├──▶ W2 (2-3d) ──▶ W3 (1-2d) ──▶ W5 (1-2d)
+            │
+W4 (1d) ────┴── (parallel with W1-W3, joins W5 at the end)
+```
+
+- **W1 alone:** 2-3 days.
+- **W2 after W1:** 2-3 days.
+- **W3 after W2:** 1-2 days.
+- **W4 in parallel:** 1 day.
+- **W5 last:** 1-2 days.
+- **Total: ~7-11 days, target 7-9 with overlap.**
+
+---
+
+## 4. Risks specific to M13c
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| SSE through corporate proxy | Medium | Medium | HTTP fallback (`/v1/tail/recent`) is in the API; UI has a "use polling" toggle |
+| Playwright flakes in CI | Medium | Medium | Retry once on failure; isolate test data per run (unique company slug with timestamp) |
+| `dlq.html` removal breaks a customer who's bookmarked it | Low | Low | Note in release notes; old URL → new SPA `/dlq` route |
+| Audit log volume overwhelms the list | Medium | Low | Server-side pagination (25/50/100); filters required for >1k rows |
+| K8s manifest drift from M12 W1 | Medium | Low | Reuse M12 templates; W4 PR diff'd against M12 W1 to catch unintended changes |
+| Single binary size > 60 MB | Low | Low | Per-feature code splitting; gzip embed; CI size gate |
+
+---
+
+## 5. Definition of done — M13c
+
+- [ ] All 5 workstreams have their per-workstream exit criteria
+      checked.
+- [ ] `cmd/admind/ui/dlq.html` removed.
+- [ ] Old `GET /dlq` handler in `admind` removed.
+- [ ] `admind` binary size < 60 MB.
+- [ ] K8s manifests apply cleanly, both `admind` and `authd`
+      Running.
+- [ ] Playwright happy-path green in CI for 3 consecutive runs.
+- [ ] Tenant-isolation 403 test green in CI.
+- [ ] `M13_VERIFICATION.md` published.
+- [ ] SPEC.md `M13a`, `M13b`, `M13c` rows all flipped to
+      **✅ shipped YYYY-MM-DD**.
+
+---
+
+## 6. Definition of done — whole M13 (final check)
+
+- [ ] All 3 sub-milestones (M13a, M13b, M13c) DoD checked.
+- [ ] Demo to a real customer (or internal stakeholder) and signed
+      off.
+- [ ] SPEC.md `M13` row (top-level) updated to **✅ shipped
+      YYYY-MM-DD** with a one-line summary linking to M13a/b/c
+      VERIFICATION docs.
+
+---
+
+**Next step:** M13c W1 (Live tail). W4 (K8s) can start in parallel
+from a separate branch.

+ 521 - 0
M14_SECURITY_PLAN.md

@@ -0,0 +1,521 @@
+# M14 — Security Hardening Plan
+
+> Implements the security promises of SPEC §15 (mTLS) and closes
+> the M11.5 wishlist (mTLS + secrets management + network policies).
+> Makes broad-announce defensible to enterprise customers without
+> re-architecting the data plane.
+
+**Status:** planning (post-M11, parallel to M12/M13)
+**Target:** SPEC §15 (mTLS opt-in per source) + gRPC internal mTLS
+(today a TODO) + cert lifecycle UI + secrets rotation
+**Estimate:** 16-24 days with one engineer; 10-15 days with two
+engineers in parallel with M13c
+
+---
+
+## 0. Recap — what M14 must prove
+
+| Surface | Acceptance criteria |
+|---|---|
+| **CA hierarchy** | Internal CA (root + intermediate) provisioned via cert-manager, root offline, intermediate serves cluster + sources |
+| **Source-side mTLS** | A source with `mtls_required=true` is rejected at ingestd if the client cert is missing, expired, signed by an untrusted CA, or has the wrong CN/SAN. HMAC + API key still work for non-mTLS sources. |
+| **Internal gRPC mTLS** | `ingestd`, `routerd`, `deliverd-*` talk over mTLS with cert-manager-issued certs. The `TODO(m11)` in `internal/grpcserver/server.go:61` is closed. |
+| **Cert lifecycle UI** | Super-admin can: list source certs, see expiration, revoke, generate a CSR (or auto-issue), download the cert bundle. Expiration < 30 days triggers a banner. |
+| **Rotation** | Intermediate CA rotates annually. Certs rotate every 90 days. No downtime during rotation. Documented runbook. |
+| **PromQL alerts** | `CertExpiringSoon` (>30d, < 7d, < 24h buckets), `CertRevoked`, `mTLSHandshakeErrorsHigh` |
+| **Audit log** | Cert issue, rotate, revoke, expiration all produce audit rows. |
+
+**What M14 is NOT:**
+- **Not a SOC2 / ISO 27001 certification.** M14 is a prerequisite, not
+  the certification. Compliance certification is M15+ (separate
+  project, separate budget, separate auditor).
+- **Not a key-vault replacement.** M14 uses cert-manager secrets.
+  HashiCorp Vault integration is v2.
+- **Not customer-facing PKI.** Sources that need mTLS get a cert
+  signed by OUR intermediate CA. We don't issue public certs.
+- **Not a redesign of auth.** mTLS is layered on top of HMAC + API key
+  in a "AND" relation: cert proves identity, HMAC proves payload
+  integrity, API key is the rate-limit token. All three can be
+  required for the strictest sources.
+
+---
+
+## 1. Threat model — what mTLS actually buys
+
+| Threat | Without mTLS | With mTLS (opt-in) |
+|---|---|---|
+| **Stolen API key from a leaked source** | Attacker forges alerts forever | Cert pinned to the source machine — rotate the source cert, attack stops |
+| **Replay of captured HMAC** | Within 5-min window per Stripe-style | Cert auth is per-connection; replay needs both cert AND HMAC |
+| **Network MITM (compromised router)** | TLS protects in transit, but anyone with the API key can call | Cert pinned; MITM can't produce a valid client cert without the private key |
+| **Insider abuse (operator with DB access)** | Can read API keys | Certs are on the source machine, not in the DB |
+| **Credential stuffing** | Common API keys, brute force | Each cert is unique; no shared secret to stuff |
+
+**M14 doesn't fix:** compromised source machine (attacker has the
+private key), compromised root CA (game over, but offline), social
+engineering of operator (cert issuance is gated by super-admin).
+
+---
+
+## 2. Workstream overview
+
+```
+┌──────────────────────────┐    ┌──────────────────────────┐
+│  W1: cert-manager + CA   │    │  W2: ingestd mTLS        │
+│  (root offline,          │───▶│  (opt-in per source,     │
+│   intermediate in K8s)   │    │   cert validation,       │
+│                          │    │   CN/SAN extraction)     │
+└──────────────┬───────────┘    └──────────────┬───────────┘
+               │                               │
+               ▼                               ▼
+┌──────────────────────────┐    ┌──────────────────────────┐
+│  W3: gRPC internal mTLS  │    │  W4: cert lifecycle UI   │
+│  (server + client,       │    │  (M13 frontend: list,    │
+│   closes the TODO)       │    │   issue, rotate, revoke) │
+└──────────────┬───────────┘    └──────────────┬───────────┘
+               │                               │
+               ▼                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W5: rotation + PromQL alerts + audit + runbook              │
+└──────────────────────────────────────────────────────────────┘
+                                               │
+                                               ▼
+┌──────────────────────────────────────────────────────────────┐
+│  W6: smoke E2E mTLS path + M14_VERIFICATION.md               │
+└──────────────────────────────────────────────────────────────┘
+```
+
+Six workstreams. W1 is the only hard blocker. W4 (UI) can be split:
+the API changes land in M14, the UI lands in M13b/c as a feature
+incremental on top of the M13 sources module.
+
+---
+
+## 3. Workstream details
+
+### W1: cert-manager + CA hierarchy
+
+**Goal:** Provision a working internal PKI in the K8s cluster. Root
+CA offline. Intermediate CA issues certs. cert-manager handles
+renewal.
+
+**Scope:**
+- Choose CA tooling: **`step-ca` or `cfssl` for the offline root,
+  `cert-manager` for the intermediate and serving certs.**
+  - `step-ca` if you want a single binary and JSON-based config.
+  - `cfssl` if you want the Cloudflare-style Go binary, lighter.
+  - cert-manager is the standard for K8s and gives you
+    `Certificate` CRDs, Issuer/ClusterIssuer, renewal controller.
+- **Root CA (offline):**
+  - Generate root key + cert on an air-gapped machine or a
+    passphrase-encrypted USB stick.
+  - 10-year validity. RSA 4096 or ECDSA P-384.
+  - Stored in a K8s `Secret` of type `kubernetes.io/tls` in a
+    `cert-manager` namespace, with sealed-secrets or external-secrets
+    (NOT plaintext git).
+  - Used ONLY to sign the intermediate. Never used to serve.
+- **Intermediate CA (in-cluster):**
+  - cert-manager `ClusterIssuer` backed by the root.
+  - 1-year validity, rotated annually (cron triggers re-issue).
+  - RSA 2048 or ECDSA P-256. ECDSA preferred (smaller, faster).
+- **Serving certs (per-service):**
+  - `Certificate` CRD per service (ingestd, routerd, deliverd-*,
+    admind, authd).
+  - DNS SANs: `<svc>.<namespace>.svc.cluster.local`, plus any
+    public DNS name.
+  - 90-day validity, auto-renewed at 30 days.
+  - Mounted as files via `volumeMounts` from a Secret.
+- **cert-manager deployment:**
+  - Helm chart, `cert-manager v1.16+`.
+  - RBAC for the controller.
+  - DNS-01 or HTTP-01 challenge for public certs (none needed for
+    in-cluster).
+- **Source cert issuance path (used by W2):**
+  - `Issuer` of type `CA` (cert-manager) backed by the
+    intermediate.
+  - Super-admin POSTs a CSR via API → cert-manager signs → cert
+    bundle returned.
+- **Tooling scripts:**
+  - `scripts/ca-init.sh` — generates root + intermediate from a
+    fresh machine. One-time use. Writes encrypted tarball.
+  - `scripts/ca-rotate-intermediate.sh` — annual cron. Issues
+    new intermediate from root, swaps cert-manager's
+    ClusterIssuer, re-issues all serving certs.
+
+**Exit criteria:**
+- [ ] Root CA exists, offline, encrypted.
+- [ ] Intermediate CA exists in K8s, valid for 1 year.
+- [ ] cert-manager running, `kubectl get clusterissuer` shows
+      `broad-announce-intermediate` Ready.
+- [ ] 5 sample `Certificate` resources exist (ingestd, routerd,
+      deliverd-fcm, deliverd-telegram, admind, authd) all
+      Ready with valid serving certs.
+- [ ] Auto-renewal tested: manually expire a cert at 30 days
+      remaining, watch it renew without downtime.
+- [ ] `scripts/ca-init.sh` and `scripts/ca-rotate-intermediate.sh`
+      are documented in `cmd/certmanager/README.md`.
+
+**Estimated:** 3-4 days.
+
+---
+
+### W2: ingestd source-side mTLS (opt-in per source)
+
+**Goal:** A source with `mtls_required=true` is rejected at ingestd
+if the client cert is missing, expired, signed by an untrusted CA,
+or has the wrong CN/SAN. HMAC + API key still work for non-mTLS
+sources.
+
+**Scope:**
+- `internal/auth/mtls.go` (new package):
+  - `ClientCertVerifier` that wraps a `*x509.CertPool` (the
+    intermediate) and a CN/SAN allowlist.
+  - Extracts `r.TLS.PeerCertificates[0]` from the request.
+  - Verifies: not expired, signed by intermediate, CN matches
+    `source.<source_id>.<company_slug>` or SAN
+    `source:<source_id>`, key usage `ClientAuth`.
+  - Returns 401 with a structured error if any check fails.
+- `cmd/ingestd/main.go`:
+  - Two listeners: `:443` (TLS, with mTLS opt-in) and `:80`
+    (plaintext, redirect to :443 for mTLS-required sources).
+  - In practice, two HTTP servers on different ports. The
+    mTLS one uses `tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool}`.
+  - When `source.mtls_required=true`, route through the TLS
+    server. Else, plain HTTP (existing behavior).
+  - For sources with both HMAC and mTLS, both must succeed
+    (AND, not OR).
+- `internal/config/config.go`:
+  - New env vars: `BA_INGESTD_TLS_ADDR` (default `:8443`),
+    `BA_INGESTD_TLS_CERT` (path), `BA_INGESTD_TLS_KEY` (path),
+    `BA_INGESTD_TLS_CA` (path to intermediate cert PEM).
+  - `BA_INGESTD_REQUIRE_MTLS_DEFAULT` (default `false` —
+    controlled per source).
+- `internal/grpcserver/server.go`:
+  - Close the `TODO(m11): grpc.Creds(tlsCredentials())` by
+    wiring the cert paths from config.
+  - The gRPC server is internal-only (not exposed to sources),
+    so the cert is the serving cert from W1.
+- Migrations: `migrations/NNNN_add_source_cert_columns.sql`:
+  - `sources.mtls_required` (already in schema, just wire it).
+  - `sources.mtls_cn` (CN expected, auto-derived from source_id).
+  - `sources.cert_id` (FK to a new `source_certs` table).
+  - `source_certs(id, source_id, serial, not_before, not_after,
+    revoked_at, cert_pem)`.
+- `cmd/admind/main.go` (new endpoints in M14):
+  - `POST /v1/sources/:id/cert/csr` — accept a CSR (PEM), sign it
+    with the intermediate, return the cert bundle.
+  - `GET /v1/sources/:id/cert` — current cert + chain.
+  - `DELETE /v1/sources/:id/cert` — revoke.
+  - `GET /v1/certs/expiring?days=30` — list of certs expiring soon.
+- New audit log events: `cert.issue`, `cert.revoke`, `cert.expire_soon`.
+
+**Exit criteria:**
+- [ ] `source.mtls_required=true` → ingestd rejects requests
+      without a valid client cert (curl with `--cert` succeeds,
+      curl without fails with 401).
+- [ ] Cert signed by an untrusted CA → 401.
+- [ ] Expired cert → 401.
+- [ ] Wrong CN/SAN → 401.
+- [ ] Cert revoked via `DELETE /v1/sources/:id/cert` → 401 within
+      60s (cert-manager CRL updates).
+- [ ] `source.mtls_required=false` → existing HMAC + API key flow
+      unchanged.
+- [ ] Both required (mTLS + HMAC) → both must succeed.
+- [ ] Internal gRPC between ingestd, routerd, deliverd uses
+      mTLS (W3) — verified with `tcpdump` or by
+      `kubescape`/`kube-bench`.
+
+**Estimated:** 5-7 days.
+
+---
+
+### W3: gRPC internal mTLS (server + client)
+
+**Goal:** Close the `TODO(m11)` in `internal/grpcserver/server.go:61`
+and the analogous client path. All service-to-service gRPC (and
+HTTP/2 streams) use mTLS with cert-manager-issued certs.
+
+**Scope:**
+- `internal/grpcserver/server.go`:
+  - Read `BA_GRPC_TLS_CERT`, `BA_GRPC_TLS_KEY`, `BA_GRPC_TLS_CA`
+    from env.
+  - `grpc.Creds(credentials.NewServerTLSFromCert(&cert))` — or the
+    mTLS variant for client certs (if any service authenticates
+    the other).
+  - Hot-reload: SIGHUP or file watcher on the cert files.
+    cert-manager renews → kubelet updates the Secret → file
+    changes → service reloads without restart.
+- `internal/grpcclient/client.go` and `options.go`:
+  - Wire the `TransportCredentials` from the same env vars.
+  - For mTLS to other services: load the cert-manager-issued
+    client cert + CA.
+- `internal/grpcclient/options.go:44`:
+  - The "Production deployments should use mTLS or at minimum
+    TLS" comment becomes code.
+- `deploy/k8s/base/*/deployment.yaml`:
+  - Mount the cert-manager Secret as a volume at
+    `/etc/broad-announce/tls/`.
+  - `lifecycle.preStop` hook to drain in-flight requests
+    gracefully before pod termination (avoid 5xx during
+    cert rotation).
+- `internal/observability/metrics.go`:
+  - `ba_grpc_tls_handshakes_total{result=ok|error,peer=...}`.
+  - `ba_grpc_tls_cert_age_seconds{service=...}` (gauge).
+
+**Exit criteria:**
+- [ ] Internal gRPC between any two services uses TLS.
+- [ ] `tcpdump` shows TLS handshake (not plaintext) for a sample
+      gRPC call between ingestd and routerd.
+- [ ] Cert rotation (manually expire a cert) doesn't drop a single
+      in-flight request (verified with a load test).
+- [ ] Hot-reload works: SIGHUP picks up the new cert without a
+      pod restart.
+- [ ] Metrics exported for handshake success/failure.
+
+**Estimated:** 2-3 days.
+
+---
+
+### W4: cert lifecycle UI (M13 frontend)
+
+**Goal:** Super-admin can manage source certs from the M13 UI. CSR
+upload, cert download, revocation, expiration warnings.
+
+**Scope:**
+- Routes (added to M13b W2 — Sources module):
+  - `/sources/:id/cert` — current cert info, expiration, status.
+  - Modal: "Generate CSR" (paste a CSR, get a signed cert back) or
+    "Auto-generate" (server generates a key + CSR, signs it,
+    returns the cert bundle as a downloadable .zip).
+  - Modal: "Revoke" with typed confirmation.
+- Component: `CertStatusBadge` — green/amber/red based on
+  expiration.
+- Component: `CertExpirationBanner` — global banner in the top
+  bar when any source cert is < 30 days from expiry. Clicks
+  navigate to the cert list.
+- New endpoints in `cmd/admind`:
+  - `POST /v1/sources/:id/cert/csr` (from W2).
+  - `GET /v1/sources/:id/cert`.
+  - `DELETE /v1/sources/:id/cert`.
+  - `GET /v1/certs/expiring?days=30`.
+- Bundle: cert-management code-split (< 15 KB gzipped).
+
+**Exit criteria:**
+- [ ] Super-admin can upload a CSR and download a signed cert.
+- [ ] Super-admin can use "Auto-generate" and get a `.zip` with
+      cert + chain + private key (one-time download).
+- [ ] Revoke button removes the cert; subsequent requests with
+      that cert fail within 60s.
+- [ ] Expiration banner appears 30 days before any cert expires.
+- [ ] Tenant-admin sees cert info for their sources (read-only,
+      no revoke).
+- [ ] Bundle size < 15 KB gzipped.
+
+**Estimated:** 3-5 days (in parallel with M13b, lands in M13c W7).
+
+---
+
+### W5: rotation + PromQL alerts + audit + runbook
+
+**Goal:** Automated cert rotation. PromQL alerts for expiration and
+revocation. Audit log for every cert event. Runbook for incident
+response.
+
+**Scope:**
+- Rotation:
+  - cert-manager handles serving certs automatically (90-day,
+    renew at 30).
+  - `cert-manager.io/renew-before: 720h` annotation.
+  - Source certs: 90-day validity, super-admin is notified
+    30/7/1 days before via the banner.
+  - Annual intermediate CA rotation: `scripts/ca-rotate-intermediate.sh`
+    (from W1).
+- PromQL alerts (in `deploy/prometheus/alerts.yml`):
+  - `CertExpiringSoon` (severity warning): `cert_not_after -
+    time() < 30 * 86400` for any source cert.
+  - `CertExpiringCritical` (severity critical): `< 7 * 86400`.
+  - `CertExpired` (severity critical): `cert_not_after < time()`.
+  - `CertRevoked` (severity info): counter increment.
+  - `mTLSHandshakeErrorsHigh`: rate of failed handshakes > 1%
+    for 5 min.
+- Audit log:
+  - `cert.issue` — who, which source, cert serial.
+  - `cert.rotate` — auto-rotation, no actor.
+  - `cert.revoke` — who, which source, reason.
+  - `cert.expire` — auto, when cert is detected expired.
+- Runbook (`docs/runbooks/mtls-incident.md`):
+  - "Cert expired and source is down" — reissue flow.
+  - "Private key compromise" — revoke + reissue + audit.
+  - "CA compromise" — generate new root offline, re-issue
+    intermediate, re-issue all certs. Documented as M14.5
+    emergency procedure.
+  - "Handshake errors spiking" — likely clock skew or CA
+    pool issue. Check cert chain, check issuer.
+
+**Exit criteria:**
+- [ ] All 5 PromQL alerts fire in a test scenario (expire a
+      cert, revoke a cert, simulate clock skew).
+- [ ] Annual rotation script tested in a staging cluster.
+- [ ] Runbook reviewed by 2 people (you + me, or you + a
+      peer).
+- [ ] Audit log entries for issue, rotate, revoke, expire.
+
+**Estimated:** 2-3 days.
+
+---
+
+### W6: smoke E2E mTLS path + M14_VERIFICATION.md
+
+**Goal:** End-to-end smoke that exercises the full mTLS path:
+cert issuance → cert-bound source → ingestd validation → alert
+accepted → cert revocation → alert rejected.
+
+**Scope:**
+- `scripts/m14_smoke.sh`:
+  1. Bring up stack with cert-manager + intermediate CA.
+  2. Super-admin creates a company.
+  3. Super-admin creates a source with `mtls_required=true`.
+  4. Super-admin generates a cert for the source via API.
+  5. `curl` with `--cert` and `--key` to ingestd using that
+     cert + HMAC → 200.
+  6. `curl` without cert → 401.
+  7. `curl` with expired cert → 401.
+  8. `curl` with HMAC-only (cert stripped) → 401.
+  9. Revoke the cert via API.
+  10. `curl` with revoked cert → 401.
+  11. Send a real alert via the working path → see it in
+      live tail (M13c).
+- `M14_VERIFICATION.md`:
+  - Smoke log.
+  - Cert chain diagram (root → intermediate → serving).
+  - Bundle size breakdown.
+  - The 5 PromQL alerts with sample output.
+  - Runbook link.
+  - Sign-off: "broad-announce v1 supports opt-in mTLS per
+    source, with cert-manager-managed rotation, lifecycle
+    UI, and incident response runbook."
+
+**Exit criteria:**
+- [ ] `make m14-smoke` exits 0 from a clean state.
+- [ ] 3 consecutive green runs.
+- [ ] M13 functionality not regressed.
+- [ ] M14_VERIFICATION.md published.
+
+**Estimated:** 1-2 days.
+
+---
+
+## 4. Sequencing & parallelism
+
+```
+W1 (3-4d) ──┬──▶ W2 (5-7d) ──┬──▶ W4 UI portion (3-5d, parallel with M13)
+            │                 │
+            │                 ├──▶ W3 (2-3d, parallel with W2)
+            │                 │
+            │                 └──▶ W5 (2-3d) ──▶ W6 (1-2d)
+            │
+            └── (W1 is the only hard blocker)
+```
+
+**With one engineer (sequential):**
+- W1: 3-4d
+- W2 + W3: 7-10d (can overlap, W3 short)
+- W4: 3-5d (after M13b lands)
+- W5: 2-3d
+- W6: 1-2d
+- **Total: 16-24 days**
+
+**With two engineers (W4 lands in M13c):**
+- Eng 1: W1 → W2 → W5 → W6 (9-12 days)
+- Eng 2: M13c + W4 (parallel with W2) → W3 → W5 (10-15 days)
+- **Total wall time: 10-15 days**
+
+---
+
+## 5. What M14 is NOT
+
+(Recorded so a future reader doesn't re-litigate.)
+
+- **Not a SOC2 certification.** M14 builds the technical controls.
+  SOC2 is an audit process (M15+).
+- **Not customer-issued public certs.** We sign with our internal
+  intermediate. Sources that need a public cert bring their own.
+- **Not Vault / KMS integration.** cert-manager secrets are K8s
+  Secrets (sealed-secrets or external-secrets encrypted at rest).
+  HashiCorp Vault + dynamic certs is v2.
+- **Not HSM-backed root.** The root is on an encrypted USB stick
+  (or air-gapped VM). HSM is v3.
+- **Not a full PKI replacement.** External CAs (DigiCert, Let's
+  Encrypt) are still used for any public-facing endpoints (the
+  broad-announce marketing site, the public docs). M14 is for
+  in-cluster + source-side certs.
+- **Not a redesign of the data plane.** ingestd, routerd, deliverd
+  keep their Go code. M14 layers TLS on top, doesn't rewrite.
+- **Not retroactive.** Existing sources with HMAC + API key
+  continue working. mTLS is opt-in per source. No flag day.
+
+---
+
+## 6. Dependencies
+
+| From | What | Status | Affects |
+|---|---|---|---|
+| M12 W1 | K8s manifests exist | ⏳ in flight | W1 (cert-manager deploy) |
+| M11 | gRPC infra stable | ✅ done | W3 (gRPC mTLS) |
+| M13a | authd + JWT gate | ⏳ not started | W4 (cert UI uses auth) |
+| M13b | Sources CRUD | ⏳ not started | W4 (cert UI is incremental on Sources) |
+| Postgres | `source_certs` table | to be created in W2 migration | W2 |
+| cert-manager | upstream chart | ✅ available | W1 |
+
+---
+
+## 7. Risks
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| Root CA compromise | Very low | Catastrophic | Root offline + encrypted; documented re-issue procedure (full cluster cert rotation) |
+| Cert rotation drops in-flight requests | Medium | High | PreStop hook + graceful drain + 30-day renewal window + W3 hot-reload |
+| Operator issues cert to wrong source | Medium | Medium | Cert bound to source_id in CN/SAN; UI shows source label before issuance |
+| `mtls_required=true` breaks an existing source | Medium | High | Opt-in per source; rollout plan: 1 source, 5 sources, all sources, default-on (3 quarters) |
+| `cert-manager` operator upgrade breaks Issuer | Low | High | Pin chart version; test upgrade in staging first |
+| CRL distribution lag → revoked cert still works | Medium | Medium | 60s SLA in W2; OCSP responder for real-time (v2) |
+| Sources can't generate CSRs (legacy systems) | Medium | Medium | "Auto-generate" button in UI: server generates key+CSR, returns bundle |
+
+---
+
+## 8. Definition of done — M14
+
+- [ ] All 6 workstreams have their per-workstream exit criteria
+      checked.
+- [ ] `make m14-smoke` green for 3 consecutive runs.
+- [ ] M13 functionality not regressed.
+- [ ] M14_VERIFICATION.md published.
+- [ ] Runbook reviewed by 2 people.
+- [ ] SPEC.md `M14` row flipped to **✅ shipped YYYY-MM-DD** with
+      the entry: *"opt-in mTLS per source + cert-manager-managed
+      rotation + cert lifecycle UI + internal gRPC mTLS + runbook.
+      Closes M11.5 wishlist. See M14_VERIFICATION.md for evidence."*
+
+---
+
+## 9. What M14 buys the business
+
+| Before (today) | After (M14) |
+|---|---|
+| "API key + HMAC" in marketing | "mTLS opt-in per source" in marketing |
+| Stolen API key = game over | Cert rotation stops the attack |
+| gRPC internal plaintext (TODO) | gRPC internal mTLS |
+| Manual cert management (if any) | cert-manager + UI |
+| "Enterprise" in pitch deck | "Enterprise" in feature matrix |
+| No compliance trail | Audit log for every cert event |
+| Not SOC2-ready | **SOC2-ready controls in place** (cert audit is one of the SOC2 controls) |
+
+**What it does NOT buy:** SOC2 itself, FedRAMP, HIPAA. Those are
+certifications, not features. M14 builds the technical controls
+that the auditors will check; getting the certification is a
+separate project (M15).
+
+---
+
+**Next step:** start W1 (cert-manager + CA). If we have 2
+engineers, W4 UI work starts as soon as M13b W1 (Companies CRUD)
+is done — the cert UI is a Sources incremental.