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>
140 lines
6.6 KiB
JavaScript
140 lines
6.6 KiB
JavaScript
// Pure helpers extracted from index.js. Everything here is state-free: callers
|
|
// pass in whatever piece of config, tokens, or env they want to evaluate
|
|
// against. That keeps the functions trivially unit-testable and lets the
|
|
// server module stay the single place that owns mutable runtime state.
|
|
|
|
// Default number of days that completed jobs are retained before the daily
|
|
// cleanup cron prunes them. Exported so callers and tests share the constant.
|
|
export const COMPLETED_RETENTION_DAYS = 30;
|
|
|
|
// Composite key for the "draft job being built" bucket. Keying by cookieId +
|
|
// appName means a user authorized on multiple bots can build one draft per
|
|
// bot without them colliding on top of each other.
|
|
export function buildingKey(cookieId, appName) {
|
|
return String(cookieId) + ':' + String(appName);
|
|
}
|
|
|
|
// Filters one of the global job arrays (running/scheduled/completed) down to
|
|
// the jobs that belong to the requested bot. Null-safe so callers can hand
|
|
// in an uninitialized array.
|
|
export function jobsForApp(arr, appName) {
|
|
return (arr || []).filter(function (j) { return j && j.appName === appName; });
|
|
}
|
|
|
|
// Returns the bot's raw access token from the botTokens map, honoring the
|
|
// enabled flag. Returns null when the bot is unknown, explicitly disabled,
|
|
// or missing a token. Tolerates a flat "appName -> tokenString" shape too,
|
|
// which is what older configs used before the enabled flag was introduced.
|
|
export function getBotToken(botTokens, appName) {
|
|
if (!botTokens) return null;
|
|
var entry = botTokens[appName];
|
|
if (!entry) return null;
|
|
if (typeof entry === 'string') return entry;
|
|
if (entry.enabled === false) return null;
|
|
return entry.token || null;
|
|
}
|
|
|
|
export function isBotEnabled(botTokens, appName) {
|
|
return getBotToken(botTokens, appName) !== null;
|
|
}
|
|
|
|
// Returns the bot's config block only if the bot is defined in config.json
|
|
// AND has an enabled token entry. Returns null otherwise. Every caller should
|
|
// go through this instead of reaching into config.webex.bot[...] directly,
|
|
// so unknown or disabled bots produce a clean 404 rather than a crash.
|
|
export function getBotConfig(config, botTokens, appName) {
|
|
if (!appName) return null;
|
|
var cfg = config && config.webex && config.webex.bot && config.webex.bot[appName];
|
|
if (!cfg) return null;
|
|
if (!isBotEnabled(botTokens, appName)) return null;
|
|
return cfg;
|
|
}
|
|
|
|
// Returns the "per-user record" for (appName, personId) if one exists in the
|
|
// authorized doc, otherwise null. Kept here as a single lookup point so
|
|
// callers never reach into authorized.bot[...] directly and every consumer
|
|
// (isAuthorized, favorites, admin routes) shares one nullability contract.
|
|
export function getAuthorizedEntry(authorized, appName, personId) {
|
|
if (!authorized || !authorized.bot || !appName || !personId) return null;
|
|
var bucket = authorized.bot[appName];
|
|
return (bucket && bucket[personId]) || null;
|
|
}
|
|
|
|
// True iff `personId` is authorized on the bot AND the bot is enabled. The
|
|
// three-argument shape (config, tokens, authorized, app, id) lets the same
|
|
// pure function serve both server code and unit tests without mocking file
|
|
// I/O. Missing config, tokens, or authorized yields false so a partially-
|
|
// loaded process fails closed rather than granting access.
|
|
export function isAuthorized(config, botTokens, authorized, appName, personId) {
|
|
if (!getBotConfig(config, botTokens, appName)) return false;
|
|
return getAuthorizedEntry(authorized, appName, personId) !== null;
|
|
}
|
|
|
|
// True iff `personId` appears in authorized.admins. Admin authority is
|
|
// intentionally cross-bot: one admin manages authorization for every bot
|
|
// the process serves. Missing personId or missing admins list yields false
|
|
// so an unconfigured deployment fails closed.
|
|
export function isAdmin(authorized, personId) {
|
|
if (!personId) return false;
|
|
var admins = authorized && authorized.admins;
|
|
if (!Array.isArray(admins)) return false;
|
|
return admins.indexOf(personId) !== -1;
|
|
}
|
|
|
|
// Replaces the `:app` placeholder in the OAuth callback URL template with the
|
|
// appName. Empty template returns an empty string so callers can detect the
|
|
// misconfiguration.
|
|
export function getOAuthRedirectUri(template, appName) {
|
|
return (template || '').replace(':app', appName);
|
|
}
|
|
|
|
// Builds the Webex OAuth authorize URL for a bot. Returns null when the
|
|
// required inputs (clientId / template) are missing, so callers can serve an
|
|
// actionable 500 instead of a malformed URL.
|
|
export function buildAuthUrl({ clientId, template, appName }) {
|
|
if (!clientId || !template) return null;
|
|
var params = new URLSearchParams();
|
|
params.append('client_id', clientId);
|
|
params.append('response_type', 'code');
|
|
params.append('redirect_uri', getOAuthRedirectUri(template, appName));
|
|
params.append('scope', 'spark:kms spark:people_read');
|
|
params.append('state', '');
|
|
return 'https://webexapis.com/v1/authorize?' + params.toString();
|
|
}
|
|
|
|
// Drops completed jobs older than `retentionDays` from `jobs.completed`.
|
|
// Mutates `jobs` in place (matching the pre-extraction behavior) and returns
|
|
// a report so callers can log the counts without importing a logger. Falls
|
|
// back through endTime → startTime → created for the age comparison, and
|
|
// keeps any job that has no timestamp at all as a safety measure.
|
|
export function cleanCompletedJobs(jobs, retentionDays = COMPLETED_RETENTION_DAYS, now = Date.now()) {
|
|
var cutoffDate = new Date(now - (retentionDays * 24 * 60 * 60 * 1000));
|
|
if (!Array.isArray(jobs.completed)) {
|
|
return { jobs, removed: 0, cutoff: cutoffDate, retentionDays };
|
|
}
|
|
var originalCount = jobs.completed.length;
|
|
jobs.completed = jobs.completed.filter(function (job) {
|
|
var jobDateStr = job.endTime || job.startTime || job.created;
|
|
if (!jobDateStr) return true;
|
|
return new Date(jobDateStr) >= cutoffDate;
|
|
});
|
|
return {
|
|
jobs,
|
|
removed: originalCount - jobs.completed.length,
|
|
cutoff: cutoffDate,
|
|
retentionDays,
|
|
};
|
|
}
|
|
|
|
// Formats a millisecond duration as "Nh Nm N.Ns" (or just "N.Ns" for < 1min,
|
|
// or "Nm N.Ns" for < 1hr). Matches the original stat display in job cards.
|
|
export function msToTime(duration) {
|
|
var milliseconds = parseInt((duration % 1000) / 100)
|
|
, seconds = parseInt((duration / 1000) % 60)
|
|
, minutes = parseInt((duration / (1000 * 60)) % 60)
|
|
, hours = parseInt((duration / (1000 * 60 * 60)) % 24);
|
|
|
|
if (hours > 0) return hours + "h " + minutes + "m " + seconds + "." + milliseconds + "s";
|
|
if (minutes > 0) return minutes + "m " + seconds + "." + milliseconds + "s";
|
|
return seconds + "." + milliseconds + "s";
|
|
}
|