M13b.dlog 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. ================================================================================
  2. M13b.dlog — M13b (admin UI) deployment log
  3. ================================================================================
  4. Project: broad-announce
  5. Milestone: M13b — admin console UI (M13a was the auth gate; M13b is the SPA)
  6. Owner: Luis Rosales
  7. Last update: 2026-06-17 18:44 EDT
  8. Purpose: Resume point after any session/model interruption. Read this
  9. file first; it tells you where the work is, what's done, what
  10. was tested, and what's next. Git history shows WHAT changed;
  11. this file shows WHY and HOW to verify it.
  12. ================================================================================
  13. STANDING RULE — DEPLOYMENT PROTOCOL (binding for every session)
  14. ================================================================================
  15. Rule: Update this file at the END of every deployment. This is a
  16. hard rule, not a suggestion. It applies to every session,
  17. every model, every operator.
  18. What counts as a "deployment" (UPDATE the file):
  19. • Code changes that pass tests and are committed
  20. • Schema migrations (up OR down)
  21. • New HTTP routes / new endpoints
  22. • New UI features / new pages
  23. • Smoke scripts that exercise real endpoints
  24. • Bug fixes that change behavior
  25. • Any change to deliverd-*, routerd, archiverd, ingestd, authd, admind
  26. • Config / env changes that affect runtime behavior
  27. What does NOT count (DO NOT update the file just for these):
  28. • Documentation changes (README, comments, this file itself)
  29. • Architecture decisions / plans / surveys
  30. • Pure refactors with no behavior change
  31. • Test-only changes that don't exercise new code paths
  32. • Cosmetic UI tweaks that don't add a feature
  33. What an entry must contain (use the template at the bottom):
  34. 1. Timestamp (EDT)
  35. 2. One-line summary of what shipped
  36. 3. Commit hash
  37. 4. Top-level files added/changed (paths only, not full diffs)
  38. 5. Tests run + result (go test / pnpm test / smoke)
  39. 6. Verification commands the next session can run
  40. 7. Known issues / next step
  41. Why: Luis needs to resume after any interruption — model crash,
  42. rate limit, gateway restart, end of session — by reading
  43. THIS file instead of analyzing the codebase. The first 40
  44. lines must be enough to know exactly where we are.
  45. When to update: at the END of the session, OR when an obvious
  46. natural break appears (milestone shipped, smoke
  47. verified). Never mid-implementation; wait until the
  48. commit is in.
  49. ================================================================================
  50. TL;DR — where we are right now
  51. ================================================================================
  52. - M13a (auth gate, JWT, refresh, role middleware) is SHIPPED. See commit
  53. fa84398 ("M13a W5: route admin endpoints through the JWT gate").
  54. - M13b W0 (SPA shell, embed into admind, scaffold) is SHIPPED.
  55. - M13b W1 (Companies CRUD: backend + UI) is SHIPPED at c5e15f7.
  56. - M13b W2 (Sources CRUD: backend + UI) is SHIPPED at f618098.
  57. - M13b W3 (Telegram bot CRUD: backend + UI) is SHIPPED.
  58. All tests green (go 22/22, web 31/31, smoke 32/32 OK).
  59. W4 (smoke + verification + screenshots) is next.
  60. If you only have 60 seconds: read the W1 block below; everything before it is in production.
  61. ================================================================================
  62. W0 — SPA shell + embed into admind [SHIPPED]
  63. ================================================================================
  64. Commit: b2c4365 "M13b W0: SPA shell + embed into admind"
  65. Goal: a runnable React+TS SPA that admind can serve, so the W1-W3
  66. features have a place to live.
  67. Files added (under web/):
  68. - React 18 + Vite 5 + TypeScript 5 + Tailwind 3 + Radix + TanStack Query
  69. - Routes: /login, /forbidden, /, /companies/*, /sources/*, /telegram/*,
  70. /tail, /dlq, /audit, *
  71. - All non-auth routes wrapped in <RequireAuth> and <AppShell>
  72. - Non-shipped routes render <ComingSoon> with the W1/W2/W3 badge
  73. - Auth: AuthProvider with /v1/auth/refresh-then-/v1/auth/me boot,
  74. in-memory access token, httpOnly refresh cookie, refresh-on-401
  75. with single-flight guard
  76. - API helpers: fetchWithAuth, apiGet, apiSend, ApiError
  77. - Theme: light/dark/system, persisted in localStorage
  78. - Role-based sidebar
  79. - 1 vitest: tests/login.test.tsx (form renders + accepts input)
  80. Files changed (under cmd/admind/):
  81. - main.go: //go:embed web-dist (empty allowed; 503 stub if no
  82. index.html), wireSPA() serves SPA history for all M13b routes
  83. - /assets/* immutable cache; / no-cache so deploys pick up new bundles
  84. - /dlq keeps the M8 HTML UI; /v1/* keeps the JWT gate
  85. Build tooling (Makefile):
  86. - web-install, web-build, web-dev, web-test, web-typecheck
  87. - build-with-web alias: web-build then go build
  88. Verification at W0:
  89. pnpm run build clean (vite v5.4.21, ~328 kB total, ~104 kB gz)
  90. go build ./... clean
  91. go vet ./... clean
  92. go test ./cmd/admind/ clean
  93. vitest 2 passed
  94. ================================================================================
  95. W1 — Companies CRUD [SHIPPED]
  96. ================================================================================
  97. Commit: c5e15f7 ("M13b W1: Companies CRUD (backend + UI)")
  98. Goal: operators can list, create, edit, suspend, activate, and archive
  99. tenants. tenant_admin gets a read-only view of their own tenant.
  100. --- Schema (migrations/010_tenants_fields.{up,down}.sql) -------------------
  101. Adds two columns to auth.tenants:
  102. rate_limit_per_sec INTEGER NOT NULL DEFAULT 10000 (CHECK 1..1000000)
  103. fcm_shared BOOLEAN NOT NULL DEFAULT TRUE
  104. Both have safe defaults, so applying to a populated DB is a no-op for
  105. existing rows. Reversible (down migration drops both columns).
  106. Verified the migration applies and rolls back cleanly against PG 17.
  107. --- Backend (Go) ----------------------------------------------------------
  108. New files:
  109. internal/authd/tenants.go
  110. - type Tenant (wire shape, snake_case JSON)
  111. - ErrTenantNotFound, ErrTenantSlugTaken, ErrTenantInvalid
  112. - TenantFilter (q, status, limit, offset, scope)
  113. - ListTenants(ctx, filter) (items, total, err)
  114. - GetTenant(ctx, id)
  115. - CreateTenantInput + Validate() (slug regex, email, rate limit)
  116. - UpdateTenantInput (pointer fields = PATCH semantics)
  117. - CreateTenant (writes audit_log "tenant.create")
  118. - UpdateTenant (writes audit_log "tenant.update", enforces
  119. actorScopeAll for restricted fields)
  120. - SetTenantStatus (active<->suspended, any->archived; archived is
  121. terminal; writes audit_log "tenant.status" with {from,to})
  122. - Inline validators: validSlug, looksLikeEmail
  123. internal/authd/tenants_test.go
  124. - TestValidSlug, TestLooksLikeEmail, TestCreateTenantInput_Validate,
  125. TestUpdateTenantInput_Validate (pure Go, no DB needed)
  126. - Note: tests caught a real bug in looksLikeEmail (didn't reject
  127. leading/trailing dot in domain). Fixed.
  128. cmd/authd/tenants.go
  129. - HTTP handlers for /v1/tenants/* (see routes below)
  130. - canAccessTenant(claims, id) — super_admin any, others own only
  131. - isUUID(s) — lenient format check so 400s stay 400s
  132. Routes wired in cmd/authd/main.go (RequireAuth / RequireRole):
  133. GET /v1/tenants — any auth (scope: super_admin all,
  134. tenant_admin own only)
  135. POST /v1/tenants — super_admin only
  136. GET /v1/tenants/{id} — any auth, per-id scope check
  137. PATCH /v1/tenants/{id} — any auth; tenant_admin only
  138. display_name + contact_email on
  139. own tenant
  140. POST /v1/tenants/{id}/status — super_admin only
  141. Errors:
  142. 400 — bad input (validation, JSON parse, bad UUID)
  143. 403 — role not allowed, or tenant_admin trying another tenant
  144. 404 — tenant id not found
  145. 409 — duplicate slug on create
  146. 500 — unexpected DB error
  147. --- Smoke (scripts/m13b_w1_smoke.sh) --------------------------------------
  148. End-to-end, bash + curl + jq-less python. Covers:
  149. 1. authd /health
  150. 2. super_admin login
  151. 3. GET /v1/tenants (initial)
  152. 4. POST /v1/tenants (create) → 201
  153. 5. GET /v1/tenants/{id} → 200
  154. 6. PATCH /v1/tenants/{id} → 200
  155. 7. POST /v1/tenants/{id}/status suspend → 200
  156. 8. POST /v1/tenants/{id}/status activate → 200
  157. 9. POST /v1/tenants (dup slug) → 409
  158. 10. POST /v1/tenants (bad slug) → 400
  159. 11. tenant_admin can login + GET own
  160. 11c. tenant_admin can PATCH own display_name → 200
  161. 11d. tenant_admin CANNOT change rate_limit → 400
  162. 12. tenant_admin GET other tenant → 403
  163. 13. tenant_admin POST /v1/tenants → 403
  164. 14. tenant_admin POST /v1/tenants/{id}/status → 403
  165. 15. POST /v1/tenants/{id}/status (bad value) → 400
  166. 16. cleanup: super_admin archives
  167. Syntax-verified (bash -n); not yet run end-to-end (needs live stack).
  168. --- Frontend (web/) -------------------------------------------------------
  169. New feature folder web/src/features/companies/:
  170. types.ts — Tenant, TenantStatus, ListTenantsResponse,
  171. CreateTenantInput, UpdateTenantInput
  172. api.ts — useTenantsList, useTenant, useCreateTenant,
  173. useUpdateTenant, useSetTenantStatus (TanStack Query),
  174. getErrorMessage(err)
  175. format.tsx — statusLabel, statusVariant, StatusBadge,
  176. formatRateLimit, formatDate
  177. list.tsx — table + debounced search + status filter buttons,
  178. empty state, link to detail; super_admin sees
  179. "New company" button
  180. create-dialog.tsx — Radix Dialog + react-hook-form + zod
  181. (slug regex, email, rate limit 1..1e6, fcm_shared)
  182. duplicate-slug surfaces as a field error
  183. detail-page.tsx — form + Suspend/Activate/Archive actions
  184. (Archive requires typed confirmation dialog)
  185. Metadata panel; tenant_admin sees form but
  186. rate_limit and fcm_shared are disabled
  187. New UI primitives (web/src/components/ui/):
  188. badge.tsx — variants: default, secondary, outline, success,
  189. warning, danger, muted
  190. dialog.tsx — Radix Dialog wrapper (Overlay, Content, Header,
  191. Footer, Title, Description, Trigger, Close, Portal)
  192. textarea.tsx — matching Input style
  193. table.tsx — Table, TableHeader, TableBody, TableRow,
  194. TableHead, TableCell
  195. empty-state.tsx — icon + title + description + action
  196. Updated:
  197. routes/companies.tsx — replaced ComingSoon with a nested Routes
  198. (index → list, :id → detail)
  199. lib/scope.ts — added canViewCompanies, canViewSources,
  200. canManageTelegram, canViewTelegram; kept
  201. canManageCompanies as super_admin-only
  202. Test added (web/tests/companies.test.tsx):
  203. - statusLabel, statusVariant, formatRateLimit, formatDate
  204. - Note: caught a real bug in formatDate (try/catch around
  205. toLocaleDateString doesn't catch "Invalid Date" string).
  206. Replaced with Number.isNaN(d.getTime()).
  207. Bundle delta (W1 vs W0): index chunk +0.08 kB, +1 module
  208. (1733 → 1734 modules transformed).
  209. --- Verification (run from /root/.openclaw/workspace/broad-announce) -----
  210. go build ./... clean
  211. go vet ./... clean
  212. go test -count=1 ./... 22 packages, 0 failures
  213. cd web && pnpm run test 8 tests, 2 files, 0 failures
  214. cd web && pnpm run build clean
  215. psql -f migrations/009_auth.up.sql apply
  216. psql -f migrations/010_tenants_fields.up.sql apply
  217. psql -f migrations/010_tenants_fields.down.sql rollback
  218. bash -n scripts/m13b_w1_smoke.sh syntax OK (not run E2E)
  219. ================================================================================
  220. Next — M13b W2 (Sources CRUD) and W3 (Telegram bot CRUD)
  221. ================================================================================
  222. Per the W2/W3 badges already rendered in the UI:
  223. W2: Sources CRUD
  224. - /v1/sources endpoints in authd (or a new sourcerd package? the
  225. existing `public.sources` table is the canonical source-of-truth
  226. per M4; M13b needs a new layer that scopes by tenant)
  227. - SPA: web/src/features/sources/ (list, create, detail)
  228. - Fields: name, type (FCM|Telegram|WebHook), destination URL/token,
  229. rate_limit, status, optional signing_secret
  230. - Reuse the same patterns from W1 (Query hooks, zod, scope helpers)
  231. W3: Telegram bot CRUD
  232. - /v1/telegram/bots endpoints in authd
  233. - SPA: web/src/features/telegram/
  234. - Fields: bot token (write-only), display name, welcome message,
  235. default source id
  236. - The token is write-only (write hashes a secret, never returned
  237. on read). The deliverd-telegram service consumes the bot list.
  238. ================================================================================
  239. Quick resume instructions
  240. ================================================================================
  241. If you start a new session, run these commands to verify state:
  242. cd /root/.openclaw/workspace/broad-announce
  243. git log --oneline -5 # confirm W0 is at HEAD
  244. # (W1 commit is next)
  245. git status --short # should be empty after
  246. # W1 commit
  247. go test -count=1 ./... # all green
  248. cd web && pnpm run test # all green
  249. cat M13b.dlog # this file
  250. To pick up W2:
  251. 1. Re-read web/src/features/companies/ to copy the patterns
  252. 2. The authd.Tenant and the public.sources table are the references
  253. 3. The route stubs in web/src/routes/sources.tsx and telegram.tsx
  254. still render ComingSoon — replace them the same way as
  255. web/src/routes/companies.tsx
  256. ================================================================================
  257. ENTRY TEMPLATE (copy this for each new deployment)
  258. ================================================================================
  259. YYYY-MM-DD HH:MM EDT — <one-line summary>
  260. Commit: <hash>
  261. Files: <top-level paths added/changed>
  262. Tests: <go test / pnpm test / smoke> → <result>
  263. Verify: <one or more commands the next session can run to confirm>
  264. Notes: <known issues, follow-ups, or 'none'>
  265. ================================================================================
  266. ENTRY LOG (most recent first; append new entries at the TOP of this block)
  267. ================================================================================
  268. 2026-06-18 15:09 EDT — W3 Telegram bot CRUD shipped (with cross-tenant leak fix)
  269. Commit: eb06068 (W3 + security fix; 4c956fd = W3 main)
  270. # placeholder, will be filled by amend
  271. Files: internal/authd/telegrambots.{go,_test.go},
  272. cmd/authd/telegrambots.go, cmd/authd/main.go (6 routes wired),
  273. migrations/012_telegram_bot_fields.{up,down}.sql,
  274. web/src/features/telegram/{types,api,format,list,create-dialog,detail-page}.{ts,tsx},
  275. web/src/routes/telegram.tsx (now wired; was ComingSoon),
  276. web/src/components/layout/sidebar.tsx (W3 nav entry),
  277. web/tests/telegram/format.test.ts,
  278. scripts/m13b_w3_smoke.sh,
  279. M13b.dlog
  280. What: 6 routes under /v1/tenants/{id}/telegram/bots (list/create/get/
  281. patch/status/rotate-token). All RequireRole("super_admin").
  282. Migration 012 adds bot_token_hash (bcrypt), welcome_message,
  283. default_source_id, description, last_rotated_at to
  284. public.telegram_bots (already-existing table from M4);
  285. idx_telegram_bots_company (active-only) and
  286. idx_telegram_bots_default_source added; trg_telegram_bots_
  287. touch_updated_at trigger installed. Bot token is write-only:
  288. server returns `bot_token_set: bool` instead of the plaintext
  289. on every read. Rotate returns the same shape (no plaintext
  290. echo). Plaintext is stored alongside the bcrypt hash so
  291. telegramd can read it for outbound calls.
  292. Same bridge as W2: CreateTelegramBot calls
  293. ensurePublicCompanyRow(tenant_id, tenant.display_name) before
  294. INSERT, since public.telegram_bots.company_id FKs
  295. public.companies(id) and the auth.tenants row doesn't
  296. auto-create that legacy row.
  297. Smoke fixed: bot_token_set assertions compared to "True"
  298. (Python repr) but json_field() json-dumps bools as lowercase
  299. "true". Now compared to "true" — server response was always
  300. correct.
  301. Cleanup note: smoke's archive step archives the auth.tenant
  302. but leaves public.telegram_bots rows behind. Not a bug
  303. (telegram_bots.company_id has ON DELETE CASCADE to
  304. public.companies, and the smoke never deletes the company
  305. row), but repeated smoke runs accumulate rows. Future smoke
  306. passes should DELETE FROM public.telegram_bots first or
  307. add a cleanup step that removes the public.companies row.
  308. Tests: go test -count=1 ./... 22 packages, 0 failures
  309. cd web && pnpm run test 31 tests, 4 files, 0 failures
  310. cd web && pnpm run build clean
  311. psql -f migrations/012_telegram_bot_fields.up.sql apply (already done)
  312. psql -f migrations/012_telegram_bot_fields.down.sql rollback verified
  313. bash scripts/m13b_w3_smoke.sh 32/32 OK (authd running)
  314. Verify: cd /root/.openclaw/workspace/broad-announce
  315. go test -count=1 ./... && cd web && pnpm run test
  316. psql -f migrations/012_telegram_bot_fields.up.sql
  317. bash scripts/m13b_w3_smoke.sh
  318. Security: listTelegramBotsHandler initially read tenantID from the
  319. path but never passed it to TelegramBotFilter, and
  320. ListTelegramBots had no CompanyID field — so LIST returned
  321. bots across all tenants (cross-tenant data leak). Fixed:
  322. added CompanyID to TelegramBotFilter, scoped SQL with
  323. company_id = $N, and the handler now sets
  324. CompanyID: tenantID. The smoke's step 4 (initially-empty)
  325. caught this when the test was re-run multiple times
  326. without manual cleanup. Regression test
  327. TestListTelegramBots_CompanyID_RequiredForScoping
  328. documents the contract. Verified: 3 consecutive smoke
  329. runs (no cleanup between) all pass 32/32; manual check
  330. showed 6 tenants × 1 bot each via API = correct,
  331. vs. DB has 6 total (no cross-tenant inflation).
  332. Notes: Bundle: telegram feature code-split (exit criterion "< 30 KB
  333. gzipped") NOT done. Same as W2: all features ship in the
  334. main chunk (index-WAbnQRoa.js = 37 KB gz, +forms-BRRx31Es.js
  335. = 22 KB gz). Per-feature dynamic import is a v1.1 follow-up;
  336. not blocking W3.
  337. Like W2, list/create/detail UI tests are format-only; the
  338. v1.1 follow-up adds a component test or two.
  339. Open policy dial (same as W2's tenant_admin suspend):
  340. canManageTelegram in web/src/lib/scope.ts is super_admin
  341. only, matching W3 plan. If tenant_admin should manage their
  342. own bot, the dial is in scope.ts + the handler's role check.
  343. W4 is next: scripts/m13b_smoke.sh (full W1+W2+W3 walkthrough)
  344. + verification doc with screenshots.
  345. 2026-06-18 02:00 EDT — W2 Sources CRUD shipped (one-time secrets + rotate)
  346. Commit: <this commit>
  347. Files: internal/authd/sources.{go,_test.go},
  348. cmd/authd/sources.go, cmd/authd/main.go (6 routes wired),
  349. migrations/011_sources_secrets.{up,down}.sql,
  350. web/src/features/sources/{types,api,format,list,create-dialog,detail-page}.{ts,tsx},
  351. web/src/components/ui/checkbox.tsx (new),
  352. web/src/routes/sources.tsx (now wired; was ComingSoon),
  353. web/tests/sources/format.test.ts,
  354. scripts/m13b_w2_smoke.sh,
  355. M13b.dlog
  356. What: Pick (a) per 01:10 — reuse public.sources directly via authd.
  357. CRUD + status + rotate-secrets. Migration 011 adds hmac_secret_hash,
  358. api_key_hash, mtls_required, description (all nullable so pre-existing
  359. rows still load). One-time secrets: CreateSource / RotateSecrets
  360. return plaintext EXACTLY ONCE in `secrets`; only bcrypt hashes
  361. persist. auto_secrets: true by default in the UI. tenant_admin
  362. can manage their own sources (PATCH allowed; status changes are
  363. an open feature, see Notes).
  364. Cert UI: placeholder card on detail page (M14 — coming).
  365. Bridge: public.sources.company_id (TEXT) FKs public.companies.id (TEXT);
  366. auth.tenants.id is UUID. W2's CreateSource does an idempotent
  367. INSERT INTO public.companies ... ON CONFLICT DO NOTHING keyed
  368. by auth.tenants.id::text so the FK is satisfied on first source
  369. create. No change to W1's CreateTenant.
  370. Tests: go test ./internal/authd/ -count=1 ok (all tests, incl. new
  371. TestValidSourceID / TestValidSecretFormat / TestValidAPIKeyFormat
  372. / TestCreateSourceInput_Validate / TestUpdateSourceInput_Validate
  373. / TestGenerateSecret)
  374. web tsc -b clean
  375. web vitest run 17/17 ok (9 new + 6 companies + 2 login)
  376. web vite build clean
  377. bash -n scripts/m13b_w2_smoke.sh clean
  378. Verify: cd /root/.openclaw/workspace/broad-announce
  379. go test -count=1 ./... && cd web && pnpm run test
  380. psql -f migrations/011_sources_secrets.up.sql (apply 011)
  381. bash scripts/m13b_w2_smoke.sh (run against stack)
  382. Notes: Smoke script not yet run E2E (same as W1: needs running authd +
  383. Postgres). 17 web tests pass but the W2 component tests are
  384. format-only — list/create/detail-page UI tests are a v1.1 add.
  385. Decision logged: public.sources reused as-is (no auth.sources
  386. view layer in W2; can refactor later if the auth/admin split
  387. needs to harden).
  388. Open follow-up for v1.1: tenant_admin status-changes (suspend
  389. / activate their own source). The route is RequireAuth today
  390. and the store allows any caller; the policy dial is whether
  391. tenant_admin should be allowed to suspend their own. Not
  392. blocking W2; documenting in the .dlog.
  393. W3 (Telegram bot CRUD) is next; same pattern as W2.
  394. 2026-06-17 18:44 EDT — W1 Companies CRUD shipped + this .dlog created
  395. Commit: c5e15f7 (W1), 63202f2 (.dlog creation)
  396. Files: internal/authd/tenants.{go,_test.go}, cmd/authd/tenants.go,
  397. cmd/authd/main.go (routes wired),
  398. migrations/010_tenants_fields.{up,down}.sql,
  399. web/src/features/companies/*.{ts,tsx},
  400. web/src/components/ui/{badge,dialog,textarea,table,empty-state}.tsx,
  401. web/src/lib/scope.ts, web/src/routes/companies.tsx,
  402. web/tests/companies.test.tsx,
  403. scripts/m13b_w1_smoke.sh,
  404. M13b.dlog
  405. Tests: go build/vet ./... clean; go test -count=1 ./... 22/22 ok;
  406. pnpm run test 8/8 ok; pnpm run build clean;
  407. migrations apply + roll back cleanly on PG 17
  408. Verify: cd /root/.openclaw/workspace/broad-announce
  409. go test -count=1 ./... && cd web && pnpm run test
  410. bash -n scripts/m13b_w1_smoke.sh (syntax only; needs live stack)
  411. psql -f migrations/009_auth.up.sql
  412. psql -f migrations/010_tenants_fields.up.sql
  413. Notes: Smoke script not yet run E2E (requires running authd + Postgres).
  414. W2 (Sources) and W3 (Telegram bot) are next.
  415. Test for sort + order; the W1 test caught a real bug in
  416. looksLikeEmail and formatDate (both fixed).