# M13b Verification — Admin UI suite (Companies + Sources + Telegram bots) **Ship date:** 2026-06-18 **Milestone:** M13b (W1 + W2 + W3 + W4) **Exit criteria:** all four checked. - [x] `make m13b-smoke` exits 0 from a clean state. - [x] 3 consecutive green runs (verified; see "3 consecutive runs" below). - [x] M13a functionality not regressed (auth gate verified; see "M13a regression"). - [x] This document published. --- ## 1. End-to-end smoke (`make m13b-smoke`) The unified smoke (`scripts/m13b_smoke.sh`) walks through every M13b endpoint on a single tenant in one operator flow. It runs against authd only; ingestd-dependent steps (7, 10) are auto-skipped with a warning if ingestd is not reachable, so the smoke stays green in dev environments that don't have the full pipeline up. | Step | Module | Endpoint | What it asserts | Result | |------|--------|----------|------------------|--------| | 1 | infra | `GET /health` | authd reachable | 200 | | 2 | auth | `POST /v1/auth/login` | super_admin gets JWT | 200 | | 3 | W1 | `POST /v1/tenants` | super creates tenant | 201 | | 4 | infra | SQL upsert | tenant_admin row seeded (no public invite-magic endpoint in v1) | 200 | | 5 | auth | `POST /v1/auth/login` | tenant_admin gets JWT | 200 | | 6 | W2 | `POST /v1/tenants/{id}/sources` | super creates source with HMAC + API key | 201 | | 7 | W2 | `POST /v1/ingest` (HMAC-signed) | ingestd accepts alert | (SKIP if ingestd unreachable) | | 8 | W2 | `GET /v1/tenants/{id}/sources` | list scoped to tenant | total = 1 | | 9 | W2 | `POST .../sources/{sid}/status` suspend | status flips | 200 | | 10 | W2 | `POST /v1/ingest` (suspended) | ingestd rejects with 401 | (SKIP if ingestd unreachable) | | 11 | W1 | `GET /v1/tenants/{id}` as tenant_admin | own tenant readable | 200 | | 12 | W1 | `GET /v1/tenants/` as tenant_admin | **cross-tenant 403** | 403 | | 13 | W2 | `GET .../sources/{sid}` on other tenant | **cross-tenant 403** | 403 | | 14 | W2 | `POST .../sources` on other tenant | **cross-tenant 403** | 403 | | 14b | W2 | `POST .../sources` on own tenant | tenant_admin can manage own (per W2's `RequireAuth` policy) | 201 | | 15 | W3 | `POST .../telegram/bots` | super creates bot | 201 | | 15b | W3 | response body inspection | **bot_token is write-only** (not echoed in response) | absent | | 15c | W3 | `POST .../telegram/bots` as tenant_admin | **cross-role 403** (telegram is super_admin-only) | 403 | | 16 | infra | `POST /v1/users/invite` | super issues magic-link token | 200 + token | | 17 | W1 | `POST .../status archived` | cleanup | 200 | ### 3 consecutive runs | Run | PASS | FAIL | SKIP | |-----|------|------|------| | 1 | 18 | 0 | 2 (ingestd not running locally) | | 2 | 18 | 0 | 2 | | 3 | 18 | 0 | 2 | Full output captured to `/tmp/m13b_smoke_capture.log`. --- ## 2. Cross-tenant isolation test The most important security guarantee M13b promises is that no operator action crosses tenant boundaries. The smoke covers the critical paths: | Action as `tenant_admin` | Path | Expected | Got | |-------------------------------------------|-------------------------------------|----------|-----| | GET another tenant | `/v1/tenants/` | 403 | 403 | | GET source on another tenant | `/v1/tenants//sources/{sid}` | 403 | 403 | | POST source on another tenant | `/v1/tenants//sources` | 403 | 403 | | POST telegram bot | `/v1/tenants//telegram/bots` | 403 (role) | 403 | | POST source on OWN tenant | `/v1/tenants//sources` | 201 (any-auth, per-tenant scope) | 201 | | GET source list on OWN tenant (cross-tenant fix) | `/v1/tenants//sources` | 200, total = own sources only | 200, total = own | The smoke also caught a **real cross-tenant data leak** during W4 development: `listSourcesHandler` was reading `tenantID` from the URL path but never passing it to `SourceFilter`, and the SQL had no `company_id = $N` clause for super_admin calls — so super_admin saw sources from **every** tenant. Fixed in commit `eb06068` / ``. Same pattern as the W3 listTelegramBots leak fixed the day before. The fix: - `SourceFilter` gained a `CompanyID` field - `ListSources` now emits `company_id = $N` unconditionally (was only emitted when `CallerRole != "super_admin"`) - An empty `CompanyID` is a hard error (rejects "list all" misuse) - `listSourcesHandler` always sets `CompanyID: tenantID` - Regression test `TestListSources_RequiresCompanyID` pinned ### Manual cross-tenant verification (also done) After running the smoke 6 times back-to-back (no manual cleanup), a manual `psql` + API cross-check showed: ``` DB: 6 distinct tenants × 1 source each = 6 total API: GET /v1/tenants//sources → total: 1 (per tenant) ``` Before the fix, the API would have returned 6 for every tenant. --- ## 3. Bundle size `cd web && pnpm run build` produces the embedded SPA at `cmd/admind/web-dist/assets/`. Total payload: | File | raw | gzip | notes | |----------------------------|----------|----------|-------| | `react-C4-CelXw.js` | 208 103 B | 67 859 B | React + react-dom + react-router | | `index-WAbnQRoa.js` | 142 349 B | 37 529 B | app shell + all 3 feature modules (companies, sources, telegram) | | `forms-BRRx31Es.js` | 80 260 B | 21 902 B | react-hook-form + zod + resolvers | | `ui-DcUwFLzq.js` | 42 539 B | 11 744 B | Radix UI primitives (shared) | | `query-C_q0vuOo.js` | 36 611 B | 11 022 B | @tanstack/react-query | | `index-BODtBP6q.css` | 21 166 B | 5 053 B | all CSS | | **Total** | **531 028 B (518 KB)** | **155 109 B (151 KB)** | | The per-feature code-splitting exit criterion ("< 30 KB gz per feature") is **not yet met**. All three feature modules (`companies`, `sources`, `telegram`) ship in the main `index-*.js` chunk (37 529 B gz). This is consistent with W1 and W2's behaviour; per-feature dynamic import is a **v1.1 follow-up** and is not blocking M13b. --- ## 4. M13a regression check M13a's auth gate is the foundation M13b builds on. Steps 1-5 of `scripts/m13a_smoke.sh` exercise authd only and were run manually against this build: | M13a step | Endpoint | Expected | Got | |------------------------------------|---------------------------|----------|-----| | 1. authd /health | `GET /health` | 200 | 200 | | 2. super_admin login | `POST /v1/auth/login` | 200 | 200 | | 3. /v1/users/me with Bearer | `GET /v1/users/me` | 200 | 200 (role=super_admin) | | 4. refresh (rotation) | `POST /v1/auth/refresh` | 200 | 200 | | 5. re-use OLD refresh | `POST /v1/auth/refresh` | 401 session_killed | 401 | M13a steps 6+ (`/v1/dlq`, `/v1/admin/ingest`, `/v1/admin/dedupe/*`, `/v1/admin/archiver/run`, etc.) require the full stack (admind, ingestd, routerd, archiverd, deliverd-fcm, deliverd-telegram) which is not running in this environment. Those checks are unchanged in code; the smoke itself wasn't modified by M13b. --- ## 5. Tests | Suite | Command | Result | |-----------------|------------------------------------------|--------| | Go unit | `go test -count=1 ./...` | 22 packages, 0 failures | | Web unit | `cd web && pnpm run test` | 31 tests across 4 files, 0 failures | | Web typecheck | `cd web && pnpm exec tsc --noEmit` | clean | | Web build | `cd web && pnpm run build` | clean (151 KB gz total) | | W1 smoke | `make w1-smoke` | green (per W1 entry in M13b.dlog) | | W2 smoke | `make w2-smoke` | green (per W2 entry in M13b.dlog) | | W3 smoke | `make w3-smoke` | green; **3 consecutive runs, 32/32 OK each** (cross-tenant leak fix) | | W4 smoke | `make m13b-smoke` | green; **3 consecutive runs, 18/18 OK each** (cross-tenant leak fix) | Regression tests for the two cross-tenant leaks: - `internal/authd/telegrambots_test.go::TestListTelegramBots_CompanyID_RequiredForScoping` - `internal/authd/sources_test.go::TestListSources_RequiresCompanyID` Both pin the contract: handler MUST set `CompanyID: tenantID` on the filter, and the store MUST scope by it. An empty `CompanyID` is a hard error (defence in depth against any future caller that forgets the gate). --- ## 6. Screenshots The plan calls for screenshots of the admin UI: - company list, company create form - source list, source create form, one-time secrets modal - telegram config - invites list, bindings list These are **not captured in this verification** because the headless environment has no browser automation. The recommended path for capturing them is `make web-dev` (Vite dev server on :5173 with proxy rules to the local stack) and then driving the UI via Playwright or a real browser. The screenshot script will be added as a v1.1 follow-up alongside the per-feature code split. In the meantime, the smoke covers the same surfaces programmatically: | Plan screenshot | Smoke step that covers it | |--------------------------------|----------------------------| | company list | step 8 (sources list API; companies list has the same shape) | | company create form | step 3 (POST /v1/tenants) | | source list | step 8 | | source create form | step 6 | | one-time secrets modal | step 6 (server returns `hmac_secret` + `api_key` once; the modal enforces "I've saved them") | | telegram config | step 15 (POST bot); step 15b asserts no plaintext echo | | invites list | step 16 (POST invite → token issued); GET /v1/users/invites is **not** yet wired — v1.1 | | bindings list | not in M13b scope; comes in M14 (bindings UI for source ↔ channel routing) | --- ## 7. Open follow-ups These are tracked in `M13b.dlog` (TL;DR + per-W entries), but called out here for the release notes: - **Per-feature code-split** (v1.1). All three features in `index-*.js` (37 KB gz). < 30 KB gz exit criterion not met yet. - **Component tests** for list/create/detail-page UI (v1.1). The W3 smoke covers format helpers and validators only; components need `@testing-library/react`. - **`GET /v1/users/invites`** (v1.1). The invite POST returns a magic-link token; a list endpoint is not yet implemented, so the W4 smoke substitutes step 14 (list invites) with a response-shape assertion on the create. - **`alerts_24h` counter** (v1.1). The W4 plan referenced an `alerts_24h` field on the source row; it's not wired. The smoke logs a note and skips that assertion. - **Browser screenshots** for this doc (v1.1). See §6. - **`canManageTelegram` policy dial**. Currently super_admin only; if tenant_admin should manage their own bot, the switch is in `web/src/lib/scope.ts` + the handler's `RequireRole` check.