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
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).M13a row flipped to ✅.What M13a is NOT:
┌──────────────────────────┐ ┌──────────────────────────┐
│ 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.
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).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.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):
JWKSVerifier stub exists but disabled
via BA_AUTH_ASYMMETRIC=false default.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.ba_authd_login_total{result=ok|error},
ba_authd_refresh_total{result=ok|error},
ba_authd_invite_total{result=ok|error}.authd service up, healthy.cmd/authd/README.md with rotation playbook.Estimated: 3-4 days.
web/ skeletonGoal: 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 pageweb/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/.authd (real network), stores
access token in memory + refresh in httpOnly cookie.any in committed code.web/dist/ < 500 KB gzipped (without feature
code, this is just shell + shadcn primitives).Estimated: 2 days.
Goal: Apply the JWT gate (built in W1/W2) to every HTTP service
that exposes a /v1/* API. Provide a shared helper so each service
doesn't repeat the env-load + new-Authd boilerplate.
Scope (what shipped):
internal/authd/jwkshared.go:
NewFromEnv() — read BA_AUTHD_JWT_SECRET + BA_AUTHD_ISSUER,
construct a verifier-only *Authd (no pool). Used by every
service that only needs to verify tokens.MustNewFromEnv() — panic-on-error variant for main().EnvEnabled() — reports whether the secret is set so the
service can decide whether to wire the gate (backward compat).cmd/admind/main.go:
wireDLQRoutes(mux, br, pool, logger) extracted helper
that decides per-env whether to gate /v1/dlq* routes.BA_AUTHD_JWT_SECRET is set: GETs need any authenticated
user, POSTs (replay/discard) need super_admin or
tenant_admin role.cmd/ingestd: already done in W2. No changes here.Not changed (out of scope for W3):
routerd, archiverd, deliverd-telegram, deliverd-fcm,
telegramd — these are NATS-only consumers, no admin HTTP. The
JWT gate does not apply. (If we add admin HTTP to them in a
future milestone, they use the same authd.NewFromEnv() pattern.)/v1/companies/{id} scoping by tenant — this belongs in a
later W when the M13b CRUD UI ships and the company_id filter
is exercised end-to-end. The W3 gate just stops unauthenticated
access; tenant-scoped reads are tested in the integration suite.JWKSVerifier / asymmetric keys — v2. v1 uses HS256 with a
shared secret (the same one authd uses for signing).Exit criteria (all met):
internal/authd/jwkshared.go ships with NewFromEnv,
MustNewFromEnv, EnvEnabled.cmd/admind/wireDLQRoutes extracts the env-conditional gate
wiring; called from main() with no goto/early-return.BA_AUTHD_JWT_SECRET is unset, /v1/dlq* is open
(backward compat verified by TestWireDLQRoutes_NoSecret_…)./v1/dlq* are 401 without a token
(verified by TestWireDLQRoutes_WithSecret_…).TestDLQGate_RolePolicy × 9 subtests).go build ./... and go vet clean.cmd/admind tests: 13/13 pass (4 tests + 9 role-policy subtests).Estimated: 1 day. No regression risk for the unauthenticated LAN deploy because the gate is opt-in via env.
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:
docker compose up -d)./v1/auth/login with creds → expect 200 + tokens./v1/auth/me with token → expect 200 + super_admin./v1/dlq (any) without token → expect 401./v1/dlq with token → expect 200.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.M13a_VERIFICATION.md published.Estimated: 0.5 day.
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)
| 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 |
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).make m13a-smoke green for 3 consecutive runs.M13a_VERIFICATION.md published with the smoke log and
screenshots of the SPA shell (login page, top bar, sidebar,
"coming soon" pages).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.