# M13b — CRUD (Companies + Sources + Telegram)

> Second sub-milestone of M13. Delivers the operator's main value:
> real CRUD on the entities. After this lands, a super-admin can
> onboard a customer end-to-end (company → source → telegram bot)
> without touching psql. Live tail, DLQ, audit, K8s, E2E come in M13c.

**Status:** planning (post-M13a)
**Target:** `M13_FRONTEND_SPEC.md` §4.2
**Estimate:** 7-8 days with one engineer

---

## 0. Recap — what M13b ships

- **Companies CRUD** (super-admin full, tenant-admin read-only).
- **Sources CRUD** with one-time secrets modal.
- **Telegram bots** per-company: config, invite codes, bindings.
- All forms validate (Zod), audit log writes, error toasts.
- SPEC.md `M13b` row flipped to ✅.

**What M13b is NOT:**
- Not live tail, DLQ, audit viewer, K8s, Playwright E2E — M13c.
- Not groups, individuals, subscriptions, routing rules — out of
  scope for v1 (see `M13_FRONTEND_SPEC.md` §3.8).

---

## 1. Workstreams

```
┌──────────────────────────┐    ┌──────────────────────────┐
│  W1: Companies CRUD      │    │  W2: Sources CRUD        │
│  (super: full,           │───▶│  (HMAC, rate limits,     │
│   tenant: read-only)     │    │   allowed IPs, secret    │
│                          │    │   rotation modal)        │
└──────────────┬───────────┘    └──────────────┬───────────┘
               │                               │
               ▼                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W3: Telegram bots — config, invite codes, bindings list     │
└──────────────────────────────────────────────────────────────┘
                                               │
                                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W4: smoke + verification + screenshots                      │
└──────────────────────────────────────────────────────────────┘
```

Four workstreams. W1 → W2 → W3 → W4. W2 and W3 can overlap if a
second engineer is available; otherwise sequential.

---

## 2. Workstream details

### W1: Companies CRUD

**Goal:** Super-admin can create, list, edit, suspend, activate
companies. Tenant-admin sees only their own company (read-only).

**Scope:**
- Routes: `/companies`, `/companies/:id`, `/companies/new`.
- Feature folder: `web/src/features/companies/`.
- Components: `CompanyList`, `CompanyDetail`, `CompanyForm`,
  `SuspendDialog` (typed confirmation: type slug to suspend).
- React Hook Form + Zod validation:
  - `name`: required, 1-100 chars.
  - `slug`: required, lowercase, `^[a-z0-9-]{3,40}$`.
  - `rate_limit_per_sec`: positive int.
  - `fcm_shared`: boolean.
  - `telegram_bot_token`: optional, set on first save, encrypt at rest.
- TanStack Query hooks: `useCompanies`, `useCompany(id)`,
  `useCreateCompany`, `useUpdateCompany`, `useSuspendCompany`,
  `useActivateCompany`.
- `web/src/lib/api-client.ts` regenerated from `admind` OpenAPI.
- Audit log entries appear for company.create, company.update,
  company.suspend, company.activate.
- Tenant-admin view: only their own company, no edit/suspend buttons.
- `web/src/lib/scope.ts` — utility to read current user from auth
  context, gate UI by role.

**Exit criteria:**
- [ ] Super-admin can log in, see companies list, create one, edit one.
- [ ] Tenant-admin (created via psql or magic-link from M13a)
      logs in, sees only their own company (read-only).
- [ ] Cross-tenant 403: tenant-admin tries
      `GET /v1/companies/other-company-id` → 403.
- [ ] Suspend: company `status=suspended`, ingestd rejects (verify
      via curl).
- [ ] Reactivate: company `status=active`, ingestd accepts again.
- [ ] All forms validate with Zod, errors show inline + summary
      banner.
- [ ] Optimistic updates for suspend/activate with rollback on
      error.
- [ ] Audit log: each mutation produces a row visible in
      `audit_log` table.
- [ ] Bundle: companies feature code-split (< 30 KB gzipped).

**Estimated:** 2-3 days.

---

### W2: Sources CRUD

**Goal:** Sources list + create + edit + rotate secret + suspend.
Includes the "save these now" modal for HMAC secrets and API keys on
create/rotate.

**Scope:**
- Routes: `/sources`, `/sources/:id`, `/sources/new`.
- Feature folder: `web/src/features/sources/`.
- Components: `SourceList`, `SourceDetail`, `SourceForm`,
  `OneTimeSecretsModal`, `QuarantineBadge`.
- React Hook Form + Zod validation:
  - `name`: required, 1-100 chars.
  - `type`: enum (http, websocket, mqtt, snmp, grpc, custom).
  - `hmac_secret`: required, ≥ 32 chars (auto-generated on create
    if not provided).
  - `api_key`: optional, ≥ 32 chars.
  - `rate_limit_per_sec`: positive int.
  - `allowed_ips`: array of CIDR, validated.
  - `topic_prefix`: optional.
  - `status`: active | suspended.
- One-time secrets modal: when create/rotate succeeds, show the raw
  HMAC + API key in copy-to-clipboard fields. Modal blocks
  dismissal until the user clicks "I've saved them" (the spec for
  the modal lives in `M13_API_CONTRACT.md` §2.2).
- Quarantine badge: if `quarantine_until` is in the future, show
  a red badge with the remaining time.
- Filter by company (super), type, status.
- **Cert lifecycle sub-feature (M14-ui, formerly M14 W4):**
  - Tabs in Source detail: "Auth" (HMAC + API key) and "Cert"
    (mTLS).
  - `CertStatusBadge` — green/amber/red based on expiration.
  - `CertUploadCSRDialog` — paste a CSR, get a signed cert back.
  - `CertAutoGenerateDialog` — server generates key + CSR, signs
    it, returns a downloadable `.zip` with cert + chain + key.
  - `CertRevokeDialog` — typed confirmation.
  - `CertExpirationBanner` — global banner in top bar when any
    source cert is < 30 days from expiry.
  - New endpoints (already in M14-backend):
    - `POST /v1/sources/:id/cert/csr`
    - `GET /v1/sources/:id/cert`
    - `DELETE /v1/sources/:id/cert`
    - `GET /v1/certs/expiring?days=30`
  - Only renders if `source.mtls_required=true` OR the source
    already has a cert. Else, the Cert tab shows "mTLS not
    enabled for this source — enable in the Auth tab".
  - Tenant-admin sees cert info for their sources (read-only,
    no revoke).

**Exit criteria:**
- [ ] Create source: form validates, one-time secrets shown once
      via modal.
- [ ] List: sortable, paginated, filterable.
- [ ] Edit: changes rate limit and allowed IPs persist.
- [ ] Rotate secret: new one-time secrets shown, old HMAC rejected
      within 60s (verify via curl with old key).
- [ ] Suspend: source goes to `status=suspended`, ingestd rejects
      (verified by curl).
- [ ] Quarantine badge visible when `quarantine_until` is set.
- [ ] Tenant-admin sees only their company's sources.
- [ ] Cert tab: super-admin can upload a CSR, get a signed cert.
- [ ] Cert tab: "Auto-generate" returns a `.zip` with cert +
      chain + private key (one-time download).
- [ ] Cert tab: revoke removes the cert; subsequent requests with
      that cert fail within 60s.
- [ ] CertExpirationBanner appears 30 days before any cert expires.
- [ ] Tenant-admin sees cert info for their sources (read-only,
      no revoke).
- [ ] M14-ui smoke: `make m13b-smoke` includes the cert path
      (mTLS-required source + cert issued + revoke + 60s window).
- [ ] Bundle: sources feature code-split (< 50 KB gzipped,
      includes cert sub-feature).

**Estimated:** 4-6 days (was 2-3 before M14-ui was added).

---

### W3: Telegram bots

**Goal:** Per-company telegram bot config, invite code generation,
binding list.

**Scope:**
- Routes: `/companies/:id/telegram`,
  `/companies/:id/telegram/invites`,
  `/companies/:id/telegram/bindings`.
- Feature folder: `web/src/features/telegram/`.
- Components: `TelegramConfig`, `TelegramInviteForm`,
  `TelegramInvitesList`, `TelegramBindingsList`,
  `BotTokenRotateDialog`.
- React Hook Form + Zod:
  - `bot_token`: required, format validated
    (`<bot_id>:<secret>`, regex `^\d+:[A-Za-z0-9_-]{35}$`).
  - On rotate: typed confirmation ("type the company slug to
    confirm rotation" — same pattern as suspend).
- Bot token is NEVER displayed after save. Set and rotate are the
  only actions.
- Invite code list with status badges (active/used/expired) and
  revoke button.
- Bindings table: individual_id, telegram_user_id, last_seen_at.
- Magic link generator: button → POST → returns code +
  `t.me/<bot>?start=<code>` URL, copy-to-clipboard.

**Exit criteria:**
- [ ] Set bot token: encrypted at rest in `admind`, decryptable
      for emit (verify via direct API call from deliverd).
- [ ] Generate invite: returns code + magic link, copies to
      clipboard.
- [ ] Bindings list: paginated, shows last_seen_at.
- [ ] Revoke invite: 204, invite goes to `status=revoked`.
- [ ] Rotate token: old token rejected by telegram bot (verify by
      sending a message and seeing it fail).
- [ ] Tenant-admin sees only their own company's telegram config.
- [ ] Bundle: telegram feature code-split (< 30 KB gzipped).

**Estimated:** 2 days.

---

### W4: smoke + verification + screenshots

**Goal:** End-to-end smoke that exercises all 3 modules. Screenshots
for the verification doc.

**Scope:**
- `scripts/m13b_smoke.sh` — bash + curl:
  1. Bring up stack.
  2. Bootstrap super-admin + tenant-admin.
  3. Login as super-admin.
  4. Create a company.
  5. Create a source (capture one-time secrets from response).
  6. Send 1 alert via curl to ingestd using the source's HMAC.
  7. List sources → expect 1 with alerts_24h=1.
  8. Suspend the source → expect 200.
  9. Send 1 alert with suspended source → expect 401.
  10. Login as tenant-admin → expect to see only the new company.
  11. Cross-tenant 403 attempt → expect 403.
  12. Set telegram bot token → expect 204.
  13. Generate invite → expect 201.
  14. List invites → expect 1.
- `scripts/m13b_smoke.sh` exits 0/1, green required.
- `M13b_VERIFICATION.md` with:
  - Smoke log (curl + responses).
  - Screenshots of: company list, company create form, source list,
    source create form, one-time secrets modal, telegram config,
    invites list, bindings list.
  - Bundle size breakdown.
  - Tenant-isolation 403 test.

**Exit criteria:**
- [ ] `make m13b-smoke` exits 0 from a clean state.
- [ ] 3 consecutive green runs.
- [ ] M13a functionality not regressed.
- [ ] `M13b_VERIFICATION.md` published.

**Estimated:** 0.5-1 day.

---

## 3. Sequencing

```
W1 (2-3d) ──▶ W2 (2-3d) ──▶ W3 (2d) ──▶ W4 (0.5-1d)
```

- **W1 alone:** 2-3 days.
- **W2 after W1:** 2-3 days. Can start in parallel with W1's last
  day if a 2nd engineer.
- **W3 after W2:** 2 days. Can start in parallel with W2's last
  day if a 2nd engineer.
- **W4:** 0.5-1 day.
- **Total: ~6.5-9 days, target 7-8 with overlap.**

---

## 4. Risks specific to M13b

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `embed.FS` binary size creeps up | Medium | Medium (slow first paint) | Per-feature code splitting; CI size gate per feature (< 30 KB gz) |
| Tenant-admin bypasses via direct API | Low | High | W1 has the cross-tenant 403 test in the smoke; W2/W3 inherit the same enforcement |
| One-time secrets leaked in browser cache | Low | High | Modal forces explicit "I've saved them" click; secrets never written to localStorage/sessionStorage; clear React Query cache on success |
| `bot_token` accidentally logged | Low | High | Custom logger that redacts known sensitive fields (`bot_token`, `hmac_secret`, `api_key`); CI lint for new logging calls |
| Quarantine state stale in UI | Medium | Low | Source list refetches on focus + every 30s while page is open |
| `telegram` token format changes (Telegram rotates) | Low | Low | Regex lenient enough; show validation error with the format hint |

---

## 5. Definition of done — M13b

- [ ] All 3 modules (Companies, Sources, Telegram) have their
      per-workstream exit criteria checked.
- [ ] `make m13b-smoke` green for 3 consecutive runs.
- [ ] M13a functionality not regressed.
- [ ] `M13b_VERIFICATION.md` published.
- [ ] SPEC.md `M13b` row flipped to **✅ shipped YYYY-MM-DD**.

---

**Next step:** start W1 (Companies). After W1 lands with the
tenant-isolation test, W2 can run in parallel.
