Every add/remove of a favorite group or authorized user was rewriting
config.json — the same file that carries structural bot metadata and
was committed to git. This split ends the git-noise and lets ops
deploy fresh installs without a pre-populated user list.
Split
- config.json (committed) stays structural: server, per-bot labels,
integration + service-account ids, languages.
- config/authorized.json (gitignored) is the new mutable source of
truth: { admins: [personId...], bot: { <appName>: { <personId>:
{ id, displayName, email, avatar, groups: [...] } } } }.
- Seeded authorized.json with the current admins list and all
authorized users (3 on novi, 4 on techupdates) so this commit is
a pure move — no data lost, no downtime.
Helpers (lib/helpers.js)
- New getAuthorizedEntry(authorized, app, id) as the single lookup
point every consumer goes through, so nullability is uniform.
- isAuthorized() gains an authorized-doc arg (pure signature stays
testable): fails closed when the doc is missing / partially
loaded, so a broken deploy grants no access.
- isAdmin() now reads authorized.admins instead of config.admins.
Runtime (index.js)
- loadAuthorized() with an ENOENT fallback to { admins: [], bot: {} }
so a fresh deploy can bootstrap via the admin page instead of
requiring a hand-crafted authorized.json.
- All 8 previous config.webex.bot[app].authorized sites (favorites
read/add/remove, admin list/add/delete, isAuthorized) now go
through the authorized doc.
- Every mutation writes to config/authorized.json instead of
config/config.json.
Latent-bug fixup (uncovered while smoke-testing this refactor)
- The /user/:scope/:action fallthroughs used res.status(4xx)
without .send(...), so unknown scopes / unauthorized callers got
a hung request instead of a response. Added ".send(...)" bodies
so the response actually completes.
Docs + tests
- README updated: new "Authorized users" step in "Adding a new bot",
updated file-layout section, docker mount list adds
authorized.json.
- Test suite expanded from 48 → 53 with a new getAuthorizedEntry
group and the existing isAuthorized/isAdmin cases reshaped for
the new signatures.
Smoke tested the auth matrix end-to-end (admin + non-admin + signed-
out across /info, /admin/users, /user/groups/list): every path
returns the expected code and body.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two features stitched together because they touch the same building-
job data path.
--- 1. Retract preview DM on cancel -----------------------------------
The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.
- /jobs/edit now stores the returned message id as
jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
saves the cancel first, then fires a best-effort
DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
Uses the bot token (bots own their messages) and never throws — a
Webex hiccup logs but doesn't fail the cancel that already
succeeded on our side. Called fire-and-forget so the HTTP response
isn't blocked on a slow Webex round trip.
--- 2. Admin: manage authorized users -------------------------------
Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.
Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
the "not in list" case, missing personId, non-array admins, and
missing config. Total suite is now 48 assertions across 11 groups.
Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
using the service account token, returns
{ id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
403 for signed-in-but-not-admin (distinct codes so the frontend
can render distinct panels).
- GET /CollabCentral/:app/admin/users → list users
- POST /CollabCentral/:app/admin/users → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id → remove
- Shared adminUserRow / adminUsersList shape so every response is an
authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
without rewriting config.json.
Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
swap between not-signed-in / not-admin / admin. Add-user form
takes an email; user list renders as rows with Webex avatar
(fallback initials), name, email, favorite-group count, and a
Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
user label with a proper button + dropdown menu when the caller
is an admin. Menu is keyboard-friendly (Escape to close), closes
on outside-click, and currently exposes one item ("Admin" →
admin.html). Non-admins get the plain-text label unchanged, so
the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
dropdown, .inlineFieldRow for the email-plus-button pattern, and
.userRow* rules for the admin user list.
Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
instance (an "AV Team" favorite removed from Joe's techupdates
authorized entry via the favorites UI). Rolling that into this
commit so the file stops drifting from origin.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Move buildingKey, jobsForApp, getBotToken, isBotEnabled, getBotConfig,
isAuthorized, getOAuthRedirectUri, buildAuthUrl, cleanCompletedJobs,
and msToTime into lib/helpers.js as state-free functions that accept
config, botTokens, or env as parameters. COMPLETED_RETENTION_DAYS also
lives there so callers and tests share the constant.
- Replace the bodies in index.js with thin wrappers that pass the module-
level state into the pure helpers. Call sites and behavior are
unchanged; index.js shrinks by ~60 lines.
- Move the cleanCompletedJobs logging into the cron caller so the pure
helper returns a result object (jobs, removed, cutoff) that tests can
assert on without capturing stdout.
- Add test/helpers.test.js with 43 assertions across 10 suites covering
the enable/disable gating, per-bot draft isolation, authorization,
OAuth URL construction, retention filter (including endTime -> startTime
-> created fallback and the safety default for jobs missing a
timestamp), and the duration formatter.
- Wire `npm test` to `node --test test/*.test.js` (no new deps, uses the
built-in node:test runner) and document it in the README.
Smoke test confirms unchanged HTTP behavior for /info (known + unknown
bots), the requireBot 404 gate, and the 401 path on jobs/list/completed.
Co-authored-by: Cursor <cursoragent@cursor.com>