# 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/<other>` 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/<other>`               | 403      | 403 |
| GET source on another tenant              | `/v1/tenants/<other>/sources/{sid}` | 403      | 403 |
| POST source on another tenant             | `/v1/tenants/<other>/sources`       | 403      | 403 |
| POST telegram bot                         | `/v1/tenants/<own>/telegram/bots`   | 403 (role) | 403 |
| POST source on OWN tenant                 | `/v1/tenants/<own>/sources`          | 201 (any-auth, per-tenant scope) | 201 |
| GET source list on OWN tenant (cross-tenant fix) | `/v1/tenants/<own>/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` /
`<see git log>`. 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/<tenant_id>/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.
