================================================================================ M13b.dlog — M13b (admin UI) deployment log ================================================================================ Project: broad-announce Milestone: M13b — admin console UI (M13a was the auth gate; M13b is the SPA) Owner: Luis Rosales Last update: 2026-06-17 18:44 EDT Purpose: Resume point after any session/model interruption. Read this file first; it tells you where the work is, what's done, what was tested, and what's next. Git history shows WHAT changed; this file shows WHY and HOW to verify it. ================================================================================ STANDING RULE — DEPLOYMENT PROTOCOL (binding for every session) ================================================================================ Rule: Update this file at the END of every deployment. This is a hard rule, not a suggestion. It applies to every session, every model, every operator. What counts as a "deployment" (UPDATE the file): • Code changes that pass tests and are committed • Schema migrations (up OR down) • New HTTP routes / new endpoints • New UI features / new pages • Smoke scripts that exercise real endpoints • Bug fixes that change behavior • Any change to deliverd-*, routerd, archiverd, ingestd, authd, admind • Config / env changes that affect runtime behavior What does NOT count (DO NOT update the file just for these): • Documentation changes (README, comments, this file itself) • Architecture decisions / plans / surveys • Pure refactors with no behavior change • Test-only changes that don't exercise new code paths • Cosmetic UI tweaks that don't add a feature What an entry must contain (use the template at the bottom): 1. Timestamp (EDT) 2. One-line summary of what shipped 3. Commit hash 4. Top-level files added/changed (paths only, not full diffs) 5. Tests run + result (go test / pnpm test / smoke) 6. Verification commands the next session can run 7. Known issues / next step Why: Luis needs to resume after any interruption — model crash, rate limit, gateway restart, end of session — by reading THIS file instead of analyzing the codebase. The first 40 lines must be enough to know exactly where we are. When to update: at the END of the session, OR when an obvious natural break appears (milestone shipped, smoke verified). Never mid-implementation; wait until the commit is in. ================================================================================ TL;DR — where we are right now ================================================================================ - M13a (auth gate, JWT, refresh, role middleware) is SHIPPED. See commit fa84398 ("M13a W5: route admin endpoints through the JWT gate"). - M13b W0 (SPA shell, embed into admind, scaffold) is SHIPPED. - M13b W1 (Companies CRUD: backend + UI) is SHIPPED at c5e15f7. - M13b W2 (Sources CRUD: backend + UI) is SHIPPED at f618098. - M13b W1 (Companies CRUD: backend + UI) is SHIPPED at c5e15f7. - M13b W2 (Sources CRUD: backend + UI) is SHIPPED at f618098. - M13b W3 (Telegram bot CRUD: backend + UI) is SHIPPED. - M13b W4 (smoke + verification) is SHIPPED. make m13b-smoke green 3 consecutive runs, 18/18 OK each. M13b_VERIFICATION.md published; M13a auth gate not regressed. Second cross-tenant leak caught by W4 (sources list, same pattern as W3 telegram-bots list). Both fixed in this milestone. W4 plan steps 7/10 (ingestd) and 14 (list invites) are skipped/ adapted — ingestd requires the full stack (NATS not in dev); the invite list endpoint is a v1.1 add. If you only have 60 seconds: read the W1 block below; everything before it is in production. ================================================================================ W0 — SPA shell + embed into admind [SHIPPED] ================================================================================ Commit: b2c4365 "M13b W0: SPA shell + embed into admind" Goal: a runnable React+TS SPA that admind can serve, so the W1-W3 features have a place to live. Files added (under web/): - React 18 + Vite 5 + TypeScript 5 + Tailwind 3 + Radix + TanStack Query - Routes: /login, /forbidden, /, /companies/*, /sources/*, /telegram/*, /tail, /dlq, /audit, * - All non-auth routes wrapped in and - Non-shipped routes render with the W1/W2/W3 badge - Auth: AuthProvider with /v1/auth/refresh-then-/v1/auth/me boot, in-memory access token, httpOnly refresh cookie, refresh-on-401 with single-flight guard - API helpers: fetchWithAuth, apiGet, apiSend, ApiError - Theme: light/dark/system, persisted in localStorage - Role-based sidebar - 1 vitest: tests/login.test.tsx (form renders + accepts input) Files changed (under cmd/admind/): - main.go: //go:embed web-dist (empty allowed; 503 stub if no index.html), wireSPA() serves SPA history for all M13b routes - /assets/* immutable cache; / no-cache so deploys pick up new bundles - /dlq keeps the M8 HTML UI; /v1/* keeps the JWT gate Build tooling (Makefile): - web-install, web-build, web-dev, web-test, web-typecheck - build-with-web alias: web-build then go build Verification at W0: pnpm run build clean (vite v5.4.21, ~328 kB total, ~104 kB gz) go build ./... clean go vet ./... clean go test ./cmd/admind/ clean vitest 2 passed ================================================================================ W1 — Companies CRUD [SHIPPED] ================================================================================ Commit: c5e15f7 ("M13b W1: Companies CRUD (backend + UI)") Goal: operators can list, create, edit, suspend, activate, and archive tenants. tenant_admin gets a read-only view of their own tenant. --- Schema (migrations/010_tenants_fields.{up,down}.sql) ------------------- Adds two columns to auth.tenants: rate_limit_per_sec INTEGER NOT NULL DEFAULT 10000 (CHECK 1..1000000) fcm_shared BOOLEAN NOT NULL DEFAULT TRUE Both have safe defaults, so applying to a populated DB is a no-op for existing rows. Reversible (down migration drops both columns). Verified the migration applies and rolls back cleanly against PG 17. --- Backend (Go) ---------------------------------------------------------- New files: internal/authd/tenants.go - type Tenant (wire shape, snake_case JSON) - ErrTenantNotFound, ErrTenantSlugTaken, ErrTenantInvalid - TenantFilter (q, status, limit, offset, scope) - ListTenants(ctx, filter) (items, total, err) - GetTenant(ctx, id) - CreateTenantInput + Validate() (slug regex, email, rate limit) - UpdateTenantInput (pointer fields = PATCH semantics) - CreateTenant (writes audit_log "tenant.create") - UpdateTenant (writes audit_log "tenant.update", enforces actorScopeAll for restricted fields) - SetTenantStatus (active<->suspended, any->archived; archived is terminal; writes audit_log "tenant.status" with {from,to}) - Inline validators: validSlug, looksLikeEmail internal/authd/tenants_test.go - TestValidSlug, TestLooksLikeEmail, TestCreateTenantInput_Validate, TestUpdateTenantInput_Validate (pure Go, no DB needed) - Note: tests caught a real bug in looksLikeEmail (didn't reject leading/trailing dot in domain). Fixed. cmd/authd/tenants.go - HTTP handlers for /v1/tenants/* (see routes below) - canAccessTenant(claims, id) — super_admin any, others own only - isUUID(s) — lenient format check so 400s stay 400s Routes wired in cmd/authd/main.go (RequireAuth / RequireRole): GET /v1/tenants — any auth (scope: super_admin all, tenant_admin own only) POST /v1/tenants — super_admin only GET /v1/tenants/{id} — any auth, per-id scope check PATCH /v1/tenants/{id} — any auth; tenant_admin only display_name + contact_email on own tenant POST /v1/tenants/{id}/status — super_admin only Errors: 400 — bad input (validation, JSON parse, bad UUID) 403 — role not allowed, or tenant_admin trying another tenant 404 — tenant id not found 409 — duplicate slug on create 500 — unexpected DB error --- Smoke (scripts/m13b_w1_smoke.sh) -------------------------------------- End-to-end, bash + curl + jq-less python. Covers: 1. authd /health 2. super_admin login 3. GET /v1/tenants (initial) 4. POST /v1/tenants (create) → 201 5. GET /v1/tenants/{id} → 200 6. PATCH /v1/tenants/{id} → 200 7. POST /v1/tenants/{id}/status suspend → 200 8. POST /v1/tenants/{id}/status activate → 200 9. POST /v1/tenants (dup slug) → 409 10. POST /v1/tenants (bad slug) → 400 11. tenant_admin can login + GET own 11c. tenant_admin can PATCH own display_name → 200 11d. tenant_admin CANNOT change rate_limit → 400 12. tenant_admin GET other tenant → 403 13. tenant_admin POST /v1/tenants → 403 14. tenant_admin POST /v1/tenants/{id}/status → 403 15. POST /v1/tenants/{id}/status (bad value) → 400 16. cleanup: super_admin archives Syntax-verified (bash -n); not yet run end-to-end (needs live stack). --- Frontend (web/) ------------------------------------------------------- New feature folder web/src/features/companies/: types.ts — Tenant, TenantStatus, ListTenantsResponse, CreateTenantInput, UpdateTenantInput api.ts — useTenantsList, useTenant, useCreateTenant, useUpdateTenant, useSetTenantStatus (TanStack Query), getErrorMessage(err) format.tsx — statusLabel, statusVariant, StatusBadge, formatRateLimit, formatDate list.tsx — table + debounced search + status filter buttons, empty state, link to detail; super_admin sees "New company" button create-dialog.tsx — Radix Dialog + react-hook-form + zod (slug regex, email, rate limit 1..1e6, fcm_shared) duplicate-slug surfaces as a field error detail-page.tsx — form + Suspend/Activate/Archive actions (Archive requires typed confirmation dialog) Metadata panel; tenant_admin sees form but rate_limit and fcm_shared are disabled New UI primitives (web/src/components/ui/): badge.tsx — variants: default, secondary, outline, success, warning, danger, muted dialog.tsx — Radix Dialog wrapper (Overlay, Content, Header, Footer, Title, Description, Trigger, Close, Portal) textarea.tsx — matching Input style table.tsx — Table, TableHeader, TableBody, TableRow, TableHead, TableCell empty-state.tsx — icon + title + description + action Updated: routes/companies.tsx — replaced ComingSoon with a nested Routes (index → list, :id → detail) lib/scope.ts — added canViewCompanies, canViewSources, canManageTelegram, canViewTelegram; kept canManageCompanies as super_admin-only Test added (web/tests/companies.test.tsx): - statusLabel, statusVariant, formatRateLimit, formatDate - Note: caught a real bug in formatDate (try/catch around toLocaleDateString doesn't catch "Invalid Date" string). Replaced with Number.isNaN(d.getTime()). Bundle delta (W1 vs W0): index chunk +0.08 kB, +1 module (1733 → 1734 modules transformed). --- Verification (run from /root/.openclaw/workspace/broad-announce) ----- go build ./... clean go vet ./... clean go test -count=1 ./... 22 packages, 0 failures cd web && pnpm run test 8 tests, 2 files, 0 failures cd web && pnpm run build clean psql -f migrations/009_auth.up.sql apply psql -f migrations/010_tenants_fields.up.sql apply psql -f migrations/010_tenants_fields.down.sql rollback bash -n scripts/m13b_w1_smoke.sh syntax OK (not run E2E) ================================================================================ Next — M13b W2 (Sources CRUD) and W3 (Telegram bot CRUD) ================================================================================ Per the W2/W3 badges already rendered in the UI: W2: Sources CRUD - /v1/sources endpoints in authd (or a new sourcerd package? the existing `public.sources` table is the canonical source-of-truth per M4; M13b needs a new layer that scopes by tenant) - SPA: web/src/features/sources/ (list, create, detail) - Fields: name, type (FCM|Telegram|WebHook), destination URL/token, rate_limit, status, optional signing_secret - Reuse the same patterns from W1 (Query hooks, zod, scope helpers) W3: Telegram bot CRUD - /v1/telegram/bots endpoints in authd - SPA: web/src/features/telegram/ - Fields: bot token (write-only), display name, welcome message, default source id - The token is write-only (write hashes a secret, never returned on read). The deliverd-telegram service consumes the bot list. ================================================================================ Quick resume instructions ================================================================================ If you start a new session, run these commands to verify state: cd /root/.openclaw/workspace/broad-announce git log --oneline -5 # confirm W0 is at HEAD # (W1 commit is next) git status --short # should be empty after # W1 commit go test -count=1 ./... # all green cd web && pnpm run test # all green cat M13b.dlog # this file To pick up W2: 1. Re-read web/src/features/companies/ to copy the patterns 2. The authd.Tenant and the public.sources table are the references 3. The route stubs in web/src/routes/sources.tsx and telegram.tsx still render ComingSoon — replace them the same way as web/src/routes/companies.tsx ================================================================================ ENTRY TEMPLATE (copy this for each new deployment) ================================================================================ YYYY-MM-DD HH:MM EDT — Commit: Files: Tests: Verify: Notes: ================================================================================ ENTRY LOG (most recent first; append new entries at the TOP of this block) ================================================================================ 2026-06-18 19:05 EDT — W4 integration smoke + verification shipped Commit: Files: scripts/m13b_smoke.sh, Makefile (m13b-smoke / w1-smoke / w2-smoke / w3-smoke / m13b-full targets), M13b_VERIFICATION.md, M13b.dlog (this entry), internal/authd/sources.go (SourceFilter.CompanyID; ListSources unconditional company scope + empty-CompanyID hard error), internal/authd/sources_test.go (regression test), cmd/authd/sources.go (listSourcesHandler sets CompanyID: tenantID) What: Single-tenant end-to-end smoke covering all 3 M13b modules in one operator flow. 17 numbered steps + sub-steps (15b, 14b, 15c). Run: make m13b-smoke. Exits 0 if all runnable steps pass. ingestd-dependent steps (7, 10) auto-skip when ingestd is unreachable so the smoke stays useful in dev environments without the full pipeline. Plan vs reality: Step 7 (send alert via ingestd) — gated on INGESTD reachable Step 10 (rejected on suspended) — gated on INGESTD reachable Step 14 (list invites → 1) — adapted; no GET /v1/users/invites endpoint yet (v1.1). Substituted with response-shape assertion on POST /v1/users/invite. Step 14b (tenant_admin own-tenant 201) — added; the plan didn't pin the per-W2 RequireAuth policy. Step 15b (bot_token NOT in response) — added; W4 doc implicitly relied on it but didn't assert. Step 15c (tenant_admin POST telegram 403)— added; cross-role gate. Security (this entry's main event): Writing the W4 smoke caught a SECOND cross-tenant data leak, same pattern as the W3 listTelegramBots one but in sources: listSourcesHandler read tenantID from the URL path but never passed it to SourceFilter. ListSources' WHERE clause only added company_id = $N when CallerRole != "super_admin". So when super_admin called GET /v1/tenants//sources, the SQL had NO company filter and returned sources from every tenant. Caught by step 8: "expected 1, got 2" — the second row was a leak from a different tenant in the same DB. Fix: - SourceFilter gained CompanyID field - ListSources emits company_id = $N UNCONDITIONALLY when CompanyID is set (was: only when CallerRole != "super_admin") - Empty CompanyID is now a hard error (rejects "list all" misuse by any caller — defence in depth) - listSourcesHandler always sets CompanyID: tenantID - TestListSources_RequiresCompanyID pins the contract Verified: 3 consecutive smoke runs (no manual cleanup) → 18/18 OK each. Manual psql+API cross-check: 6 tenants × 1 source each in DB, API returns 1 per tenant (correct, no inflation). This is the SAME PATTERN as the W3 leak fixed the day before: read tenantID from path, drop it on the floor before the filter. Two handlers, same bug. The smoke catching both suggests a systematic issue — the W4 cross-tenant matrix should be a standing template going forward (see /v1/.../X list handlers in other services for similar bugs). M13a not regressed: smoke steps 1-5 (authd-only) re-run by hand against this build → 200/200/200/200/401 as expected. Steps 6+ require the full stack (admind/ingestd/routerd/archiverd/deliverd-*) which is not running in this env. Smoke code itself unchanged. Tests: go test -count=1 ./... 22 packages, 0 failures cd web && pnpm run test 31 tests, 4 files, 0 failures cd web && pnpm run build clean make m13b-smoke 18/18 OK × 3 runs Verify: cd /root/.openclaw/workspace/broad-announce make m13b-smoke # Cross-tenant manual check: # for tid in $(psql ... SELECT id FROM auth.tenants...); do # curl -H "Authorization: Bearer $SUPER" .../tenants/$tid/sources?limit=10 # done # Each call must return only sources for $tid. Notes: Bundle code-split (< 30 KB gz per feature) NOT done. All three features ship in index-WAbnQRoa.js (37 KB gz). v1.1 follow-up — matches W1/W2/W3 behaviour, not blocking M13b exit. Screenshots not captured (no browser automation in this env). Substituted with smoke coverage matrix in M13b_VERIFICATION.md §6. v1.1 follow-up to add real screenshots via Playwright. Per-W smokes (w1-smoke / w2-smoke / w3-smoke) are unchanged from their respective commits. m13b-full target chains all four (w1 + w2 + w3 + m13b) for release gating. Definition-of-done line: "M13b VERIFICATION.md published" → done; "SPEC.md M13b row flipped to ✅ shipped YYYY-MM-DD" → SPEC.md update is the v1.0 release step (separate commit, after this dlog entry lands). 2026-06-18 15:09 EDT — W3 Telegram bot CRUD shipped (with cross-tenant leak fix) Commit: eb06068 (W3 + security fix; 4c956fd = W3 main) # placeholder, will be filled by amend Files: internal/authd/telegrambots.{go,_test.go}, cmd/authd/telegrambots.go, cmd/authd/main.go (6 routes wired), migrations/012_telegram_bot_fields.{up,down}.sql, web/src/features/telegram/{types,api,format,list,create-dialog,detail-page}.{ts,tsx}, web/src/routes/telegram.tsx (now wired; was ComingSoon), web/src/components/layout/sidebar.tsx (W3 nav entry), web/tests/telegram/format.test.ts, scripts/m13b_w3_smoke.sh, M13b.dlog What: 6 routes under /v1/tenants/{id}/telegram/bots (list/create/get/ patch/status/rotate-token). All RequireRole("super_admin"). Migration 012 adds bot_token_hash (bcrypt), welcome_message, default_source_id, description, last_rotated_at to public.telegram_bots (already-existing table from M4); idx_telegram_bots_company (active-only) and idx_telegram_bots_default_source added; trg_telegram_bots_ touch_updated_at trigger installed. Bot token is write-only: server returns `bot_token_set: bool` instead of the plaintext on every read. Rotate returns the same shape (no plaintext echo). Plaintext is stored alongside the bcrypt hash so telegramd can read it for outbound calls. Same bridge as W2: CreateTelegramBot calls ensurePublicCompanyRow(tenant_id, tenant.display_name) before INSERT, since public.telegram_bots.company_id FKs public.companies(id) and the auth.tenants row doesn't auto-create that legacy row. Smoke fixed: bot_token_set assertions compared to "True" (Python repr) but json_field() json-dumps bools as lowercase "true". Now compared to "true" — server response was always correct. Cleanup note: smoke's archive step archives the auth.tenant but leaves public.telegram_bots rows behind. Not a bug (telegram_bots.company_id has ON DELETE CASCADE to public.companies, and the smoke never deletes the company row), but repeated smoke runs accumulate rows. Future smoke passes should DELETE FROM public.telegram_bots first or add a cleanup step that removes the public.companies row. Tests: go test -count=1 ./... 22 packages, 0 failures cd web && pnpm run test 31 tests, 4 files, 0 failures cd web && pnpm run build clean psql -f migrations/012_telegram_bot_fields.up.sql apply (already done) psql -f migrations/012_telegram_bot_fields.down.sql rollback verified bash scripts/m13b_w3_smoke.sh 32/32 OK (authd running) Verify: cd /root/.openclaw/workspace/broad-announce go test -count=1 ./... && cd web && pnpm run test psql -f migrations/012_telegram_bot_fields.up.sql bash scripts/m13b_w3_smoke.sh Security: listTelegramBotsHandler initially read tenantID from the path but never passed it to TelegramBotFilter, and ListTelegramBots had no CompanyID field — so LIST returned bots across all tenants (cross-tenant data leak). Fixed: added CompanyID to TelegramBotFilter, scoped SQL with company_id = $N, and the handler now sets CompanyID: tenantID. The smoke's step 4 (initially-empty) caught this when the test was re-run multiple times without manual cleanup. Regression test TestListTelegramBots_CompanyID_RequiredForScoping documents the contract. Verified: 3 consecutive smoke runs (no cleanup between) all pass 32/32; manual check showed 6 tenants × 1 bot each via API = correct, vs. DB has 6 total (no cross-tenant inflation). Notes: Bundle: telegram feature code-split (exit criterion "< 30 KB gzipped") NOT done. Same as W2: all features ship in the main chunk (index-WAbnQRoa.js = 37 KB gz, +forms-BRRx31Es.js = 22 KB gz). Per-feature dynamic import is a v1.1 follow-up; not blocking W3. Like W2, list/create/detail UI tests are format-only; the v1.1 follow-up adds a component test or two. Open policy dial (same as W2's tenant_admin suspend): canManageTelegram in web/src/lib/scope.ts is super_admin only, matching W3 plan. If tenant_admin should manage their own bot, the dial is in scope.ts + the handler's role check. W4 is next: scripts/m13b_smoke.sh (full W1+W2+W3 walkthrough) + verification doc with screenshots. 2026-06-18 02:00 EDT — W2 Sources CRUD shipped (one-time secrets + rotate) Commit: Files: internal/authd/sources.{go,_test.go}, cmd/authd/sources.go, cmd/authd/main.go (6 routes wired), migrations/011_sources_secrets.{up,down}.sql, web/src/features/sources/{types,api,format,list,create-dialog,detail-page}.{ts,tsx}, web/src/components/ui/checkbox.tsx (new), web/src/routes/sources.tsx (now wired; was ComingSoon), web/tests/sources/format.test.ts, scripts/m13b_w2_smoke.sh, M13b.dlog What: Pick (a) per 01:10 — reuse public.sources directly via authd. CRUD + status + rotate-secrets. Migration 011 adds hmac_secret_hash, api_key_hash, mtls_required, description (all nullable so pre-existing rows still load). One-time secrets: CreateSource / RotateSecrets return plaintext EXACTLY ONCE in `secrets`; only bcrypt hashes persist. auto_secrets: true by default in the UI. tenant_admin can manage their own sources (PATCH allowed; status changes are an open feature, see Notes). Cert UI: placeholder card on detail page (M14 — coming). Bridge: public.sources.company_id (TEXT) FKs public.companies.id (TEXT); auth.tenants.id is UUID. W2's CreateSource does an idempotent INSERT INTO public.companies ... ON CONFLICT DO NOTHING keyed by auth.tenants.id::text so the FK is satisfied on first source create. No change to W1's CreateTenant. Tests: go test ./internal/authd/ -count=1 ok (all tests, incl. new TestValidSourceID / TestValidSecretFormat / TestValidAPIKeyFormat / TestCreateSourceInput_Validate / TestUpdateSourceInput_Validate / TestGenerateSecret) web tsc -b clean web vitest run 17/17 ok (9 new + 6 companies + 2 login) web vite build clean bash -n scripts/m13b_w2_smoke.sh clean Verify: cd /root/.openclaw/workspace/broad-announce go test -count=1 ./... && cd web && pnpm run test psql -f migrations/011_sources_secrets.up.sql (apply 011) bash scripts/m13b_w2_smoke.sh (run against stack) Notes: Smoke script not yet run E2E (same as W1: needs running authd + Postgres). 17 web tests pass but the W2 component tests are format-only — list/create/detail-page UI tests are a v1.1 add. Decision logged: public.sources reused as-is (no auth.sources view layer in W2; can refactor later if the auth/admin split needs to harden). Open follow-up for v1.1: tenant_admin status-changes (suspend / activate their own source). The route is RequireAuth today and the store allows any caller; the policy dial is whether tenant_admin should be allowed to suspend their own. Not blocking W2; documenting in the .dlog. W3 (Telegram bot CRUD) is next; same pattern as W2. 2026-06-17 18:44 EDT — W1 Companies CRUD shipped + this .dlog created Commit: c5e15f7 (W1), 63202f2 (.dlog creation) Files: internal/authd/tenants.{go,_test.go}, cmd/authd/tenants.go, cmd/authd/main.go (routes wired), migrations/010_tenants_fields.{up,down}.sql, web/src/features/companies/*.{ts,tsx}, web/src/components/ui/{badge,dialog,textarea,table,empty-state}.tsx, web/src/lib/scope.ts, web/src/routes/companies.tsx, web/tests/companies.test.tsx, scripts/m13b_w1_smoke.sh, M13b.dlog Tests: go build/vet ./... clean; go test -count=1 ./... 22/22 ok; pnpm run test 8/8 ok; pnpm run build clean; migrations apply + roll back cleanly on PG 17 Verify: cd /root/.openclaw/workspace/broad-announce go test -count=1 ./... && cd web && pnpm run test bash -n scripts/m13b_w1_smoke.sh (syntax only; needs live stack) psql -f migrations/009_auth.up.sql psql -f migrations/010_tenants_fields.up.sql Notes: Smoke script not yet run E2E (requires running authd + Postgres). W2 (Sources) and W3 (Telegram bot) are next. Test for sort + order; the W1 test caught a real bug in looksLikeEmail and formatDate (both fixed).