Session log — Zoom Companion, v0.10.0 dual-tenant attempt reverted, v0.11.0 broadcast overlay shipped

← Overview

Session log — Zoom Companion · Session, 17 May 2026, v0.10.0 dual-tenant ZAK and participant-stats line

17 May 2026 · Hasmukh with Claude · auto-published from the local journal entry. A polished narrative version can be requested in any future Claude session.

Summary

  • Resumed Camera Monitor work to action the v0.10.0 plan drafted at end of yesterday's session. Goal was to add medilearn.africa as a second ZAK tenant alongside Mobilearn, so that auth-required meetings hosted on the medilearn account can be joined by the bot, plus a small UX tweak (participant-stats line above the roster).
  • Worked through the three-phase plan in [NEXT-SESSION-medilearn-setup.md](../NEXT-SESSION-medilearn-setup.md). Phase A (Zoom-side admin) hit a scope rename quirk on the medilearn account. Phase B (curl verification) confirmed credentials work after the scope fix. Phase C (code changes) was a refactor of the join logic from one-tier-with-one-fallback into a generic attempt chain, plus the participant-stats line.
  • v0.10.0 DMG built cleanly. Ready for live verification on a medilearn-hosted meeting.

Decisions

  • Bot user on the new tenant named cameramonitor@medilearn.africa rather than the originally suggested cameraonoff@medilearn.africa, for naming symmetry with the existing Mobilearn bot. Easier to keep straight when debugging later.
  • Refactored the renderer's join logic into a generic chain rather than continuing to nest try/catches. Pre-fetch of Mobilearn ZAK before the loop was removed (the chain fetches lazily inside each tier's getParams). Cleaner code surface for adding future tenants (just push another attempt to the array).
  • Bumped to v0.10.0 (minor version, not patch) since this is a new feature, not a bug fix.
  • Kept [NEXT-SESSION-medilearn-setup.md](../NEXT-SESSION-medilearn-setup.md) in place rather than deleting, as a reference for any future tenant additions.

Changes made

Phase A and B (Zoom-side, no code)

  • Created bot user cameramonitor@medilearn.africa as a managed Basic user on the medilearn.africa Zoom account.
  • Created Server-to-Server OAuth app "Camera Monitor S2S — Medilearn" on the medilearn account.
  • Initially added scope user:read:zak:admin (the scope name that works on Mobilearn). Zoom rejected the ZAK fetch with code 4711: Invalid access token, does not contain scopes:[user:read:token, user:read:token:admin]. Added scope user:read:token:admin and re-activated. Lesson: Zoom renamed the ZAK scope for newer accounts. Existing Mobilearn app keeps working with the legacy name; new apps need the new name.
  • Curl test confirmed: S2S access token fetched cleanly, ZAK fetched cleanly (412-char JWT starting eyJ0eXAiOiJK…).

Phase C (code)

  • [.env.example](../.env.example) — documented ZOOM_S2S_ACCOUNT_ID_MEDILEARN, ZOOM_S2S_CLIENT_ID_MEDILEARN, ZOOM_S2S_CLIENT_SECRET_MEDILEARN, BOT_EMAIL_MEDILEARN, with a note about the scope rename gotcha.
  • [main.js](../main.js) — added getMedilearnS2SAccessToken() and getBotZakMedilearn() mirroring the existing Mobilearn equivalents (same caching: ~59 min S2S token, 90 min ZAK, in-memory, lost on app restart). Added IPC handler zoom:get-zak-medilearn. Returns null if env vars are absent, so unconfigured tenants cost nothing.
  • [renderer/renderer.js](../renderer/renderer.js) — replaced the v0.9.2 single try/catch fallback with a generic attempt chain. Builds attempts as an array of {label, status, params or getParams} records, walks them in order, retries on errorCode 3051 only. OBF tier is eagerly evaluated (its presence determines whether tier 1 exists); both ZAK tiers are lazily fetched inside their getParams(). Removed the pre-loop ZAK fetch since the chain handles it.
  • [renderer/index.html](../renderer/index.html) and [renderer/renderer.js](../renderer/renderer.js) and [renderer/styles.css](../renderer/styles.css) — added the participant-stats line above the roster. Text format: 5 participants · 60% camera off (3 of 5). Coloured amber when percentage off is over 50, red when over 75. Updates each time render() runs (so it tracks every camera toggle near real-time, same event-driven mechanism as the per-participant badges).
  • [package.json](../package.json) bumped to v0.10.0.
  • [OPERATOR-GUIDE.md](../OPERATOR-GUIDE.md) and [OPERATOR-GUIDE.html](../OPERATOR-GUIDE.html) version references bumped to v0.10.0 (content unchanged — no operator-facing rule changes since v0.9.2).
  • [CLAUDE.md](../CLAUDE.md) updated: version line, join preference order rewritten as a four-tier chain, Marketplace apps section now lists all three (Camera Status Companion + both S2S apps).
  • [DEVLOG.md](../DEVLOG.md) entry for v0.10.0 with the full coverage matrix and the scope rename gotcha noted.
  • Project memory file at ~/.claude/projects/-Users-...-Zoom-Apps/memory/project_camera_status_app.md — frontmatter, status, version history, lesson list, "how to resume" all refreshed.

Build

  • Ran npm run dist:dmg and produced dist/Camera Monitor-0.10.0-arm64.dmg (130 MB, exit code 0, electron-builder 25.1.8 + electron 33.4.11). Unsigned as designed for internal distribution.

Live testing exposed architectural blocker — dual-tenant reverted

Tested v0.10.0 on a medilearn-hosted meeting with auth ON, hosted by admin@medilearn.africa. Result:

  • Chain ran OBF → 3051 (expected) → Mobilearn ZAK → errorCode 200 (unexpected, not the 3051 we'd assumed). Without a fallback trigger for 200, chain short-circuited and Medilearn ZAK was never tried.
  • Broadened the retry condition to {3051, 200} and retested. Chain now reached Medilearn ZAK → also rejected with errorCode 200.
  • Cause #1 (SDK state contamination from too many consecutive joins) ruled out by flipping chain order so Medilearn ZAK was the clean first attempt — still rejected with 200.
  • Cause #2 (bot user not activated or auth profile rejects identity) ruled out by Hasmukh manually signing into Zoom Workplace as cameramonitor@medilearn.africa on a separate device and successfully joining the same meeting. The identity satisfies the meeting's auth; it's the ZAK-via-SDK path that's being rejected.

Diagnosis: the Meeting SDK key signed into the per-meeting JWT is Zoom-account-bound. Our ZOOM_SDK_KEY belongs to the Camera Status Companion General App on the Mobilearn Marketplace. It can act on behalf of Mobilearn users but not on behalf of users from other Zoom accounts, regardless of how valid that user's ZAK is. There is no path to "borrow" the Mobilearn SDK key's authority for Medilearn users.

Decision (Hasmukh's preference, architecturally correct): revert the dual-tenant code. Multi-tenant support is via per-tenant packaged apps — one Camera Monitor distributable per Zoom account, each with its own Meeting SDK Marketplace app, S2S app, and bot user. Two clean apps with no shared multi-tenant code at runtime.

Revert details:

  • Removed getMedilearnS2SAccessToken(), getBotZakMedilearn(), and the zoom:get-zak-medilearn IPC handler from [main.js](../main.js).
  • Removed the Medilearn ZAK tier from the chain in [renderer/renderer.js](../renderer/renderer.js).
  • Removed the 200-as-retry-trigger broadening (back to strict 3051-only — appropriate for single-tenant use; 200 is otherwise a real failure that shouldn't be swallowed).
  • Removed *_MEDILEARN placeholders from [.env.example](../.env.example). Left the real values in Hasmukh's local .env untouched for future re-use.
  • Added cautionary comments in main.js and renderer.js so future readers don't re-attempt the same approach without understanding why it failed.
  • Bumped lesson #20 into the project memory file: Meeting SDK key is Zoom-account-bound.

What stays in v0.10.0 (genuine wins from this session):

  • Chain-based join logic (same effective behaviour as v0.9.2, but expressed as a generic attempts array — makes per-tenant forks trivial).
  • Participant-stats line above the roster.

Rebuild: dist/Camera Monitor-0.10.0-arm64.dmg rebuilt 14:33 (130 MB, exit code 0). Shipped binary no longer contains the dead medilearn path.

Follow-ups

  • Live-verify the rolled-back v0.10.0 on a Mobilearn-hosted meeting. Confirm no regression in the chain refactor and that the participant-stats line renders + colour-codes correctly at >50% and >75% off.
  • Medilearn becomes its own packaged app when Hasmukh is ready. The Medilearn S2S app + cameramonitor@medilearn.africa bot user are already provisioned from today's attempt; the missing piece is a parallel General App (Meeting SDK + OAuth) on the medilearn.africa Zoom Marketplace to provide a medilearn-specific ZOOM_SDK_KEY. Then fork this codebase, change appId to africa.medilearn.cameramonitor, product name to "Camera Monitor (Medilearn)", point at the medilearn credentials, rebuild. Same recipe.
  • Cosmetic: quote BOT_DISPLAY_NAME value in .env to silence the bash-source warning during future curl tests. Not critical (Node's dotenv handles it fine), just noise.
  • Operators who upgrade from any prior version need to re-Authorise the OAuth in the new app install — packaged-app userData dirs are per-install.

Later in the same session — v0.11.0 broadcast overlay

After v0.10.0 was verified, Hasmukh asked for a beautifully-styled live HTML view of the participant-stats data, pullable via URL by H2R Graphics for use as a broadcast graphic. Same in-memory data source as the in-app stats line, just served as HTML over local HTTP.

Built [renderer/stats-overlay.html](../renderer/stats-overlay.html) as a self-contained page (transparent body for OBS compositing, semi-transparent blurred card panel, huge bold percentage in tabular numerals, "cameras off" label, summary line, red pill chips for off-camera names with pop-in animation, small connection indicator dot). Added an embedded node:http server in [main.js](../main.js) listening on 127.0.0.1:8766 with three routes: / serves the HTML, /data returns the latest stats as JSON for instant initial paint, /events is a Server-Sent Events stream. Added a stats:update IPC handler in main.js that sanitises incoming data and rebroadcasts to all SSE clients. Wired [renderer.js](../renderer/renderer.js) to push stats via that IPC at the end of render(). No UI toggle to enable/disable the server — it's just always on; operator opens http://localhost:8766/ when they want it.

Bumped to v0.11.0, OPERATOR-GUIDE version references updated. Built dist/Camera Monitor-0.11.0-arm64.dmg (130 MB, exit code 0). v0.10.0 DMG remains in dist as the prior "no overlay" version.

v0.11.0 follow-ups

  • Live-verify the overlay: install v0.11.0, join a meeting, open http://localhost:8766/ in Safari/Chrome, confirm it renders and updates as cameras toggle. Then test as an H2R Graphics URL source.
  • Update [RELEASE-EMAIL-v0.10.0.md](../RELEASE-EMAIL-v0.10.0.md) — either rename to v0.11.0 and add a paragraph about the overlay, or split into a separate broadcast-overlay-focused announcement for the production team.
  • Visual iteration on the overlay is easy — typography, sizing, colours, animation timing all in the single self-contained HTML file.