================================================================================ 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 W3 (Telegram bot CRUD: backend + UI) is SHIPPED. All tests green (go 22/22, web 31/31, smoke 32/32 OK). W4 (smoke + verification + screenshots) is next. 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 14:56 EDT — W3 Telegram bot CRUD shipped Commit: 98158da 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 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).