@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
313 lines (312 loc) • 13.8 kB
JavaScript
/**
* cf authentication lifecycle and call scheduling (spec §F3, §F5).
*
* cf refreshes an OAuth token only once it has expired (`now >= expiry`), per
* process and without a lock, so concurrent cf processes near expiry could
* race a rotating refresh token. The scheduler therefore:
* - runs one serial `cf auth whoami` before any fan-out (it performs any due
* refresh and reports `expiresAt`; `tokenValid` is ignored, cf issue #21);
* - in profile and default modes, stops dispatching when `now ≥ expiresAt − 60 s`,
* lets in-flight calls drain (each is capped at 45 s), sleeps until
* `expiresAt + 1 s` and runs up to two serial `whoami` calls, then resumes.
* The first performs the single refresh; cf 1.0.0-beta.13 reads the
* credential file before its token getter refreshes, so that call prints the
* old (passed) expiry and a second serial `whoami` reads the new one;
* - on a 401 pauses dispatch, runs one serial `whoami` (plus the re-read when
* it reports a passed expiry) and retries once;
* - on a 429 pauses every worker for 30 s and retries once; a second 429
* aborts with CF_RATE_LIMITED;
* - retries 5xx, network errors and timeouts twice (after 1 s, then 3 s);
* - spends the request budget for every API call, including `whoami`.
*/
import { z } from 'zod';
import { RECOMMENDED_CF_SCOPES } from '../contract.js';
import { LocalCheckupError, ENV_TOKEN_AUTH_HINT } from '../errors.js';
import { classifyCfStderr } from './run-cf.js';
export const PRE_EXPIRY_MARGIN_MS = 60_000;
export const RATE_LIMIT_PAUSE_MS = 30_000;
export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000];
export const MAX_CF_INVOCATIONS = 6_000;
const WhoamiSchema = z.object({
authenticated: z.boolean(),
scopes: z.array(z.string().max(128)).max(1_000).optional(),
expiresAt: z.union([z.string().max(64), z.number()]).optional(),
}).passthrough();
/** Reduce cf's whoami JSON; email, account names and the credential file path are dropped. */
export function parseWhoami(stdout) {
let raw;
try {
raw = JSON.parse(stdout);
}
catch {
return null;
}
const parsed = WhoamiSchema.safeParse(raw);
if (!parsed.success)
return null;
const summary = { authenticated: parsed.data.authenticated };
if (parsed.data.expiresAt !== undefined) {
const time = typeof parsed.data.expiresAt === 'number' ? parsed.data.expiresAt : Date.parse(parsed.data.expiresAt);
if (Number.isFinite(time))
summary.expiresAt = new Date(time).toISOString();
}
if (parsed.data.scopes)
summary.scopes = parsed.data.scopes.filter((scope) => /^[A-Za-z0-9_.:-]{1,64}$/.test(scope));
return summary;
}
export function isReadOnlyScope(scope) {
return /(?:^|[.:])read$/.test(scope) || scope === 'offline' || scope === 'offline_access';
}
export function authSourceFor(mode) {
return mode === 'profile' ? 'cf_profile' : mode === 'env' ? 'cf_env_token' : 'cf_default_profile';
}
/**
* Doctor's auth view. In env mode cf reports `scopes` and `expiresAt` from the
* active profile's stored OAuth file even while the API token is in use, so
* both are ignored: API token permissions cannot be introspected.
*/
export function doctorAuth(summary, mode, profile) {
const base = { source: authSourceFor(mode), profile: mode === 'profile' ? profile : null, authenticated: summary.authenticated };
if (mode === 'env' || !summary.scopes)
return { ...base, scopes: 'unknown' };
return {
...base,
...(summary.expiresAt ? { expiresAt: summary.expiresAt } : {}),
scopes: 'known',
missingRecommendedScopes: RECOMMENDED_CF_SCOPES.filter((scope) => !summary.scopes.includes(scope)),
writeScopeCount: summary.scopes.filter((scope) => !isReadOnlyScope(scope)).length,
};
}
export function authRequiredError(mode, message) {
return new LocalCheckupError('CF_AUTH_REQUIRED', message ?? (mode === 'env'
? 'cf could not authenticate with the API token from this shell.'
: 'cf is not signed in for this checkup (the profile is missing or its login expired).'), mode === 'env' ? { hint: ENV_TOKEN_AUTH_HINT } : {});
}
/** Dispatch gate shared by every worker. */
export class CfScheduler {
options;
invocations = 0;
dispatched = 0;
inFlight = 0;
drainWaiters = [];
gate = null;
refreshing = null;
expiresAtMs;
preExpiryEnabled = true;
/** Dispatch sequence number when the first 429 paused the run. */
rateLimitPauseSeq = null;
abortError = null;
constructor(options) {
this.options = options;
}
get aborted() {
return this.abortError;
}
/** The serial whoami (re-read once if it refreshed) before fan-out. Throws CF_AUTH_REQUIRED when cf is not signed in. */
async start() {
let summary = await this.serialWhoami();
this.setExpiry(summary);
if (this.expiryHasPassed()) {
// This whoami refreshed an expired token but printed the pre-refresh expiry (see the header).
summary = await this.serialWhoami();
this.setExpiry(summary);
}
return summary;
}
/** Abort the run: no further dispatches, live children are killed. */
abort(error) {
if (this.abortError)
return;
this.abortError = error;
void this.options.terminateAll?.();
}
throwIfAborted() {
if (this.abortError)
throw this.abortError;
}
/** One cf API call with the §F5 retry policy. */
async call(argv, options = {}) {
let transientRetries = 0;
let authRetried = false;
let rateLimitRetried = false;
for (;;) {
const seq = await this.dispatch();
let result;
try {
result = await this.options.invoke(argv, options);
}
finally {
this.release();
}
this.throwIfAborted();
const failure = toFailure(result);
if (!failure)
return { ok: true, stdout: result.stdout };
if (failure.kind === 'http' && failure.status === 429) {
// The first 429 pauses every worker for 30 s; calls already in flight
// when it began join that pause and retry once. A 429 on a call
// dispatched after the pause, or a second 429 on the same call, aborts.
if (rateLimitRetried || (this.rateLimitPauseSeq !== null && seq > this.rateLimitPauseSeq)) {
const error = new LocalCheckupError('CF_RATE_LIMITED', 'Cloudflare rate-limited the collection twice (HTTP 429). No file was written.', { status: 429 });
this.abort(error);
throw error;
}
rateLimitRetried = true;
if (this.rateLimitPauseSeq === null) {
this.rateLimitPauseSeq = this.dispatched;
await this.setGate(() => this.options.clock.sleep(RATE_LIMIT_PAUSE_MS));
}
else if (this.gate) {
await this.gate;
}
continue;
}
if ((failure.kind === 'http' && failure.status === 401) || failure.kind === 'auth') {
if (authRetried)
return { ok: false, failure };
authRetried = true;
await this.refresh('reactive');
continue;
}
const transient = failure.kind === 'timeout' || failure.kind === 'network' || (failure.kind === 'http' && failure.status >= 500);
if (transient && transientRetries < TRANSIENT_RETRY_DELAYS_MS.length) {
await this.options.clock.sleep(TRANSIENT_RETRY_DELAYS_MS[transientRetries]);
transientRetries++;
continue;
}
return { ok: false, failure };
}
}
/** Wait for a request slot and an open gate; returns the dispatch sequence number. */
async dispatch() {
this.throwIfAborted();
await this.options.budget.acquire();
for (;;) {
this.throwIfAborted();
if (this.gate) {
await this.gate;
continue;
}
if (this.shouldRefreshBeforeExpiry()) {
void this.refresh('expiry');
continue;
}
if (this.invocations >= (this.options.maxInvocations ?? MAX_CF_INVOCATIONS)) {
const error = new LocalCheckupError('ACCOUNT_TOO_LARGE', 'Collection would exceed 6,000 cf requests; this account is too large for a local checkup.');
this.abort(error);
throw error;
}
this.invocations++;
this.inFlight++;
return ++this.dispatched;
}
}
release() {
this.inFlight--;
if (this.inFlight === 0) {
const waiters = this.drainWaiters;
this.drainWaiters = [];
for (const resolve of waiters)
resolve();
}
}
drain() {
if (this.inFlight === 0)
return Promise.resolve();
return new Promise((resolve) => this.drainWaiters.push(resolve));
}
shouldRefreshBeforeExpiry() {
return this.options.authMode !== 'env' && this.preExpiryEnabled && this.expiresAtMs !== undefined &&
this.options.clock.now() >= this.expiresAtMs - PRE_EXPIRY_MARGIN_MS;
}
/** Close the dispatch gate until `work` (queued after any current gate) finishes. Failures abort the run. */
setGate(work) {
const previous = this.gate ?? Promise.resolve();
const gate = previous.then(work).catch((error) => {
this.abort(error instanceof Error ? error : new Error(String(error)));
}).finally(() => {
if (this.gate === gate)
this.gate = null;
});
this.gate = gate;
return gate;
}
/** Pause dispatch, drain in-flight calls, run up to two serial whoami calls, resume. */
refresh(reason) {
if (this.refreshing)
return this.refreshing;
const refreshing = this.setGate(async () => {
await this.drain();
if (reason === 'expiry' && this.expiresAtMs !== undefined) {
const wait = this.expiresAtMs + 1_000 - this.options.clock.now();
if (wait > 0)
await this.options.clock.sleep(wait);
}
const previous = this.expiresAtMs;
this.setExpiry(await this.serialWhoami());
// cf 1.0.0-beta.13 `auth whoami` reads the credential file before its token getter
// refreshes, so the whoami that performs the refresh prints the old expiry. Re-read once
// (also after a 401, so a passed expiry does not trigger a second pause straight away).
if (this.expiryHasPassed()) {
this.setExpiry(await this.serialWhoami());
}
// A refresh that did not move the expiry forward falls back to the reactive 401 path.
if (reason === 'expiry' && (this.expiresAtMs === undefined || this.expiresAtMs === previous ||
this.expiresAtMs - PRE_EXPIRY_MARGIN_MS <= this.options.clock.now())) {
this.preExpiryEnabled = false;
}
}).finally(() => {
if (this.refreshing === refreshing)
this.refreshing = null;
});
this.refreshing = refreshing;
return refreshing;
}
expiryHasPassed() {
return this.expiresAtMs !== undefined && this.expiresAtMs <= this.options.clock.now();
}
setExpiry(summary) {
if (this.options.authMode === 'env')
return;
const time = summary.expiresAt === undefined ? Number.NaN : Date.parse(summary.expiresAt);
this.expiresAtMs = Number.isFinite(time) ? time : undefined;
}
async serialWhoami() {
this.throwIfAborted();
await this.options.budget.acquire();
this.invocations++;
const result = await this.options.whoami();
if (result.exitCode === 0 && !result.timedOut && !result.stdoutTooLarge) {
const summary = parseWhoami(result.stdout);
if (!summary)
throw new LocalCheckupError('CF_ERROR', 'cf auth whoami printed unexpected output.');
if (!summary.authenticated)
throw authRequiredError(this.options.authMode);
return summary;
}
const failure = toFailure(result);
if (failure?.kind === 'auth' || (failure?.kind === 'http' && (failure.status === 401 || failure.status === 403))) {
throw authRequiredError(this.options.authMode);
}
if (failure?.kind === 'http' && failure.status === 429) {
throw new LocalCheckupError('CF_RATE_LIMITED', 'Cloudflare rate-limited cf auth whoami (HTTP 429).', { status: 429 });
}
throw new LocalCheckupError('CF_ERROR', 'cf auth whoami failed; Cloudflare could not be reached or returned an error.');
}
}
/** `null` for a clean exit; otherwise the classified failure. */
export function toFailure(result) {
if (result.stdoutTooLarge)
return { kind: 'output_too_large' };
if (result.timedOut)
return { kind: 'timeout' };
if (result.spawnFailed)
return { kind: 'cf_error' };
if (result.exitCode === 0)
return null;
const classified = classifyCfStderr(result.stderr);
if (classified.kind === 'node_unsupported') {
throw new LocalCheckupError('NODE_UNSUPPORTED', 'cf refused to run under this Node.js version.');
}
return classified;
}