# 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/` → 200 - `GET /v1/companies/` → 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.