Session log — Zoom Companion, v0.10.0 dual-tenant attempt reverted, v0.11.0 broadcast overlay shipped
Session log — Zoom Companion · Session, 17 May 2026, v0.10.0 dual-tenant ZAK and participant-stats line
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.africarather than the originally suggestedcameraonoff@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.africaas 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 withcode 4711: Invalid access token, does not contain scopes:[user:read:token, user:read:token:admin]. Added scopeuser:read:token:adminand 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()andgetBotZakMedilearn()mirroring the existing Mobilearn equivalents (same caching: ~59 min S2S token, 90 min ZAK, in-memory, lost on app restart). Added IPC handlerzoom: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
attemptsas an array of{label, status, params or getParams}records, walks them in order, retries onerrorCode 3051only. OBF tier is eagerly evaluated (its presence determines whether tier 1 exists); both ZAK tiers are lazily fetched inside theirgetParams(). 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 timerender()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:dmgand produceddist/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.africaon 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 thezoom:get-zak-medilearnIPC 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
*_MEDILEARNplaceholders from [.env.example](../.env.example). Left the real values in Hasmukh's local.envuntouched for future re-use. - Added cautionary comments in
main.jsandrenderer.jsso 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
attemptsarray — 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.africabot 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-specificZOOM_SDK_KEY. Then fork this codebase, changeappIdtoafrica.medilearn.cameramonitor, product name to "Camera Monitor (Medilearn)", point at the medilearn credentials, rebuild. Same recipe. - Cosmetic: quote
BOT_DISPLAY_NAMEvalue in.envto 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.