First sub-milestone of M13. Delivers the auth foundation and the SPA shell. After this lands, you can log in (super-admin bootstrap only) and see the empty UI with "coming soon" placeholders. No CRUD yet — that's M13b.
Status: W1+W2+W3+W4+W5 SHIPPED 2026-06-17. M13a complete.
Target: M13_FRONTEND_SPEC.md §4.1
Estimate: 5-6 days with one engineer (actual: 4 days)
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).cmd/{routerd,archiverd,deliverd-fcm,deliverd-telegram}/
each got a wireAdminRoutes that exposes a per-service
admin surface behind the same JWT middleware:
GET /v1/admin/dedupe/state, POST /v1/admin/dedupe/flushPOST /v1/admin/archiver/runGET /v1/admin/dlq, GET /v1/admin/dlq/{id} (channel=fcm)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 (moved to W5):
routerd, archiverd, deliverd-telegram, deliverd-fcm
— these are NATS-only consumers with no admin HTTP in M0–M8.
The W3 plan left them as out-of-scope. W5 picks up the work:
adds a real admin route to each (dedupe flush, run-now, per-
channel DLQ) and gates it with the same JWT middleware./v1/companies/{id} scoping by tenant — this belongs in a
later W when the M13b CRUD UI ships and the company_id filter
is exercised end-to-end. The W3 gate just stops unauthenticated
access; tenant-scoped reads are tested in the integration suite.JWKSVerifier / asymmetric keys — v2. v1 uses HS256 with a
shared secret (the same one authd uses for signing).Exit criteria (all met):
internal/authd/jwkshared.go ships with NewFromEnv,
MustNewFromEnv, EnvEnabled.cmd/admind/wireDLQRoutes extracts the env-conditional gate
wiring; called from main() with no goto/early-return.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: Originally proposed as part of W3, but W3 shipped with those services as out-of-scope (NATS-only consumers, no admin HTTP). W5 picks up the work: add a real admin route to each service, gate it with the same JWT middleware, and ship the test + smoke coverage.
What ships:
internal/dlq/query.go (new): List(ctx, pool, filters) and
Get(ctx, pool, id) — the SQL lives in one place, used by
both per-channel admin endpoints.cmd/routerd/admin.go (new):
GET /v1/admin/dedupe/state — {pending_collapses, cached_target_lists}.
Read-only, any authenticated user.POST /v1/admin/dedupe/flush — calls Collapser.FlushAll().
Destructive, super_admin or tenant_admin.wireAdminRoutes(mux, collapser, state, logger) — the
same env-conditional pattern as admind.cmd/archiverd/admin.go (new) + refactor of runLoop:
POST /v1/admin/archiver/run — non-blocking send on a
chan struct{} (buffer 1). The loop selects on
{ctx.Done, tick.C, triggerCh} so the next iteration
fires immediately.wireAdminRoutes(mux, triggerCh, logger).202 Accepted with triggered=false, reason=already_pending.cmd/deliverd-fcm/admin.go (new):
GET /v1/admin/dlq — dlq.List filtered to channel=fcm.GET /v1/admin/dlq/{id} — dlq.Get, with a cross-channel
safety net (a non-fcm row returns 404 instead of leaking
data the fcm deliverd shouldn't see).cmd/deliverd-telegram/admin.go (new): same shape, channel
pinned to telegram.cmd/{routerd,archiverd,deliverd-fcm,deliverd-telegram}/admin_test.go:
dedupe.Collapser (Observe → flush → Pending=0).docker-compose.yml: passes BA_AUTHD_JWT_SECRET to all
four new services (sharing the same secret authd uses).scripts/m13a_smoke.sh: W5 checks 9–12 verify the gate
end-to-end against the running stack.Tests:
go test ./cmd/routerd ./cmd/archiverd ./cmd/deliverd-fcm
./cmd/deliverd-telegram ./internal/dlq all pass.go test -count=1 ./... clean.go vet ./... clean.go build ./... clean.Exit criteria (all met):
internal/dlq/query.go ships with List + Get.wireAdminRoutes that no-ops when
BA_AUTHD_JWT_SECRET is unset.scripts/m13a_smoke.sh covers the W5 routes end-to-end.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.
What shipped (revised from the original sketch):
Dockerfile — added /app/authd to the build line.docker-compose.yml:
authd service (port 8804). Port conflict with archiverd
resolved by moving archiverd 8804 → 8805.BA_AUTHD_JWT_SECRET (required, generated by
bootstrap). Read via ${BA_AUTHD_JWT_SECRET:?...} so the
compose fails fast if missing.authd, admind, and ingestd all read the same secret.authd-data Docker volume for the dev-only generated secret.admind now depends_on: authd (service_healthy).scripts/generate-jwt-secret.sh (new) — generates 48 random
bytes → 64-char base64 → writes/updates .env. Idempotent
(no-op if value already set, unless --force). --print flag
for CI use.scripts/bootstrap-super-admin.sh (new) — psql fallback for
the first super_admin (per M13 decision 2.4: magic-link with
psql fallback). bcrypt cost 10 (lower than prod's 12 for
bootstrap speed). ON CONFLICT DO UPDATE so re-running is
safe — the password is rotated, not the user duplicated.scripts/m13a_smoke.sh (new) — 12-step E2E smoke (authd
health, login, /me with/without Bearer, refresh, rotation,
re-use-kill, family-kill propagation, invite with/without
Bearer, admind /v1/dlq with/without Bearer, ingestd
/v1/admin/ingest gate)..env.example — documents all BA_AUTHD_* knobs.README.md — status line updated, authd added to file index.SPEC.md §23 — M13 and M14 rows added.What did NOT ship (deferred to v1.1):
Makefile m13a-smoke target — the bash script works on its own
once the operator runs scripts/generate-jwt-secret.sh and
scripts/bootstrap-super-admin.sh. A Makefile wrapper is
cosmetic and belongs in a polish pass.M13a_VERIFICATION.md — the smoke output is captured by the
script's own summary table. A separate verification doc
follows once the smoke is run end-to-end in CI.Exit criteria (all met):
docker compose --env-file .env config parses cleanly
(verified during dev).authd is built in the Docker image (Dockerfile updated).scripts/generate-jwt-secret.sh is idempotent + produces
64-char base64.scripts/bootstrap-super-admin.sh upserts the super_admin
and prints a follow-up hint.scripts/m13a_smoke.sh parses cleanly (bash -n OK).go build ./... and go vet ./... clean.BA_AUTHD_JWT_SECRET is unset, admind falls back to
unauthenticated DLQ access (verified by
TestWireDLQRoutes_NoSecret_Unauthenticated).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.