src/dashboard/devin-cards.js
Rama master · réplica de lectura del repositorio original.
/**
* DevinMap cards — the four account-scoped cards that sit on the dashboard's
* account drawer:
*
* 1. ACU / credit ledger — lifetime spend + balance + observed debits
* 2. Model entitlement — available / blocked / not-entitled / unknown
* 3. API key & rotation — masked key, created, rotated, expiry state
* 4. Capacity preflight — selector + upstream capacity before a run
*
* Every builder is PURE and takes plain data, so the card payload can be
* asserted without a server. The readers are thin: they select one account by
* id, hand the stored account state to the builder, and (for the preflight
* only) make ONE upstream call that is cached for a minute.
*
* Two honesty rules drive the shapes below, both from #239 / DevinMap:
*
* - The chat wire carries no per-turn billing tag. `acuCost` is decoded only
* when DEVIN_CONNECT_BILLING_TAGS names its coordinate, so an absent ACU is
* `{known:false, reason:'no_billing_tag'}` — never a settled-looking 0.
* - "Not observable" is its own state on every card (ACU, key expiry,
* unprobed entitlement, unknown upstream capacity). Zeros are only ever
* reported for counters the proxy actually owns.
*
* No raw credential is read by a builder: the key card takes an already-masked
* value, and the preflight never echoes the account's key.
*/
import {
getAccountInternal,
getCreditLedger,
getRpmStats,
getAccountAvailability,
maskApiKey,
isModelAllowedForAccount,
isConnectSelectorAllowedForAccount,
isAllTemporarilyUnavailable,
isConnectSelectorCurrentlyFree,
hasConnectEntitledAccount,
getConnectPricingMap,
} from '../auth.js';
import { MODELS, getTierModels } from '../models.js';
import {
getLiveCatalog,
hasLiveCatalog,
resolveConnectSelector,
} from '../devin-connect-models.js';
import { checkMessageRateLimit } from '../windsurf-api.js';
import { accountRef as accountRefOf } from '../log-safety.js';
import { getEffectiveProxy } from './proxy-config.js';
import { cacheKey } from './devin-read.js';
/** Upstream capacity answers are cached for one minute (DevinMap §6.4). */
export const PREFLIGHT_TTL_MS = 60_000;
/** Why an ACU figure is absent. Not an error: the coordinate is opt-in. */
export const ACU_REASON_NO_BILLING_TAG = 'no_billing_tag';
/** Why a key expiry is absent. Session keys carry no expiry field on the wire. */
export const KEY_EXPIRY_REASON = 'not_observable';
export const MODEL_STATUS_VALUES = Object.freeze(['available', 'blocked', 'not_entitled', 'unknown']);
/** Local preflight states, most severe first. */
export const LOCAL_STATE_VALUES = Object.freeze([
'disabled',
'error',
'quota_cooled',
'rate_limited',
'model_limited',
'rpm_exhausted',
'tier_expired',
'not_entitled',
'ok',
]);
const DEBIT_ROW_CAP = 8;
const ATTENTION_ROW_CAP = 12;
const HISTORY_ROW_CAP = 40;
function num(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function str(value, max = 120) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
}
function obj(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}
/** `cur - prev`, or null when either side is not a finite number. */
function delta(cur, prev) {
const a = num(cur);
const b = num(prev);
if (a == null || b == null) return null;
return Math.round((a - b) * 1e6) / 1e6;
}
// ─── 1. ACU / credit ledger ────────────────────────────────────────────────
/**
* Build the ACU ledger payload.
*
* @param {object} opts
* @param {string|null} [opts.accountRef] hashed account reference
* @param {object|null} [opts.totalSpend] lifetime accumulator from the account
* @param {object|null} [opts.credits] last credit snapshot (`refreshCredits`)
* @param {Array} [opts.history] raw credit snapshots, oldest first
* @param {number} [opts.now]
*/
export function buildAcuLedger({
accountRef = null,
totalSpend = null,
credits = null,
history = [],
now = Date.now(),
} = {}) {
const spend = obj(totalSpend) || {};
const cr = obj(credits) || {};
const acuValue = num(spend.acuCost);
const acuKnown = acuValue != null && acuValue > 0;
const snapshots = (Array.isArray(history) ? history : [])
.filter((row) => obj(row) && num(row.ts) != null)
.map((row) => ({
ts: num(row.ts),
balance: num(row.balance),
overageBalance: num(row.overageBalance),
promptRemaining: num(row.promptRemaining),
promptUsed: num(row.promptUsed),
flexRemaining: num(row.flexRemaining),
flexUsed: num(row.flexUsed),
dailyPercent: num(row.dailyPercent),
weeklyPercent: num(row.weeklyPercent),
}))
.sort((a, b) => a.ts - b.ts)
.slice(-HISTORY_ROW_CAP);
const debits = [];
for (let i = 1; i < snapshots.length; i++) {
const prev = snapshots[i - 1];
const cur = snapshots[i];
const balanceUsd = delta(cur.balance, prev.balance);
const promptCredits = delta(cur.promptRemaining, prev.promptRemaining);
const flexCredits = delta(cur.flexRemaining, prev.flexRemaining);
const dailyPercent = delta(cur.dailyPercent, prev.dailyPercent);
const weeklyPercent = delta(cur.weeklyPercent, prev.weeklyPercent);
const quotaDebit = (promptCredits != null && promptCredits < 0)
|| (flexCredits != null && flexCredits < 0)
|| (dailyPercent != null && dailyPercent < 0)
|| (weeklyPercent != null && weeklyPercent < 0);
const balanceDebit = balanceUsd != null && balanceUsd < 0;
const kind = quotaDebit && balanceDebit ? 'mixed' : balanceDebit ? 'balance' : quotaDebit ? 'quota' : 'none';
if (kind === 'none') continue;
debits.push({
ts: cur.ts,
kind,
delta: { balanceUsd, promptCredits, flexCredits, dailyPercent, weeklyPercent },
after: {
balance: cur.balance,
promptRemaining: cur.promptRemaining,
flexRemaining: cur.flexRemaining,
dailyPercent: cur.dailyPercent,
weeklyPercent: cur.weeklyPercent,
},
});
}
debits.reverse(); // newest first
const latest = snapshots.length ? snapshots[snapshots.length - 1] : null;
const prompt = obj(cr.prompt) || {};
const flex = obj(cr.flex) || {};
const balanceUsd = num(cr.balance);
return {
accountRef,
fetchedAt: now,
acu: {
value: acuKnown ? acuValue : null,
known: acuKnown,
reason: acuKnown ? null : ACU_REASON_NO_BILLING_TAG,
},
lifetime: {
requests: num(spend.requests) ?? 0,
totalTokens: num(spend.totalTokens) ?? 0,
creditCost: num(spend.creditCost) ?? 0,
},
balance: {
usd: balanceUsd,
overageUsd: num(cr.overageBalance),
known: balanceUsd != null,
},
cycle: {
planName: str(cr.planName),
dailyPercent: num(cr.dailyPercent),
weeklyPercent: num(cr.weeklyPercent),
dailyResetAt: num(cr.dailyResetAt),
weeklyResetAt: num(cr.weeklyResetAt),
prompt: {
limit: num(prompt.limit),
used: num(prompt.used),
remaining: num(prompt.remaining),
},
flex: {
limit: num(flex.limit),
used: num(flex.used),
remaining: num(flex.remaining),
},
fetchedAt: num(cr.fetchedAt),
lastError: str(cr.lastError, 160),
},
debits: debits.slice(0, DEBIT_ROW_CAP),
debitCount: debits.length,
snapshotCount: snapshots.length,
latestSnapshotAt: latest ? latest.ts : null,
};
}
// ─── 2. Model entitlement ──────────────────────────────────────────────────
function entitlementOf(key, cap, blocked) {
if (blocked.has(key)) return 'blocked';
if (cap) {
// GetUserStatus writes both arms: `user_status` (allowed) and `not_entitled`
// (denied). A denied capability is upstream truth, not a transient failure.
if (cap.reason === 'not_entitled') return 'not_entitled';
if (cap.ok === false && cap.reason === 'user_status') return 'not_entitled';
if (cap.ok === true) return 'available';
}
return 'unknown';
}
/**
* Selectors the model-status card can price.
*
* Every MODELS row it renders, resolved the same display-only way buildModelStatus
* resolves it, plus whatever the account's own rate table names (a selector can be
* priced without having a MODELS row).
*/
function modelStatusSelectors(rateTable = null) {
const out = new Set();
for (const key of Object.keys(MODELS)) {
const resolution = resolveConnectSelector(key, { warnOnFallback: false, allowSnapshotFallback: true });
if (resolution?.mapped) out.add(resolution.selector);
}
for (const selector of Object.keys(obj(rateTable) || {})) out.add(selector);
return [...out];
}
/**
* Build the per-account model entitlement matrix.
*
* Sources, all account-local (no network):
* - `capabilities` — last GetUserStatus allowlist / canary probes
* - `tierModels` — the local tier table (labelled `tierListed`)
* - `blockedModels` — operator blocklist (always wins)
* - `credits.rateTable` — selector → credit multiplier, when paired
* - `pricing` — live free/promo verdicts, when the caller has them
*
* `unknown` is the honest state for a model that was never probed; it is never
* folded into `available`.
*/
export function buildModelStatus({
accountRef = null,
tier = 'unknown',
tierManual = false,
tierModels = [],
blockedModels = [],
capabilities = null,
rateTable = null,
pricing = null,
userStatus = null,
userStatusLastFetched = 0,
now = Date.now(),
} = {}) {
const blocked = new Set((Array.isArray(blockedModels) ? blockedModels : []).filter((k) => typeof k === 'string' && k));
const caps = obj(capabilities) || {};
const table = obj(rateTable);
// selector → live pricing (auth.getConnectPricingMap). Optional so the builder
// stays pure and a test can assert the rate-table-only path; when present it
// is the authority, because a promo can be free with no zero in the table.
const live = obj(pricing) || {};
const tierList = new Set((Array.isArray(tierModels) ? tierModels : []).filter((k) => typeof k === 'string' && k));
const us = obj(userStatus) || {};
const keys = new Set([
...Object.keys(MODELS),
...Object.keys(caps),
...blocked,
]);
const rows = [];
for (const key of keys) {
const info = MODELS[key] || null;
const cap = obj(caps[key]);
if (!info && !cap && !blocked.has(key)) continue;
// Display-only resolution: the committed snapshot is a legitimate fallback
// for ATTRIBUTING a rate-table entry on a cold start. It is never used to
// route a request — the chat path keeps its live-catalog-only resolver.
const resolution = info
? resolveConnectSelector(key, { warnOnFallback: false, allowSnapshotFallback: true })
: null;
const selector = resolution && resolution.mapped ? resolution.selector : null;
const creditNow = table && selector && num(table[selector]) != null ? num(table[selector]) : null;
const priced = selector ? obj(live[selector]) : null;
// `freeNow` stays boolean here (the card's counts and the chip copy read it
// that way): a strict zero in the account's own table OR the live sync's own
// free verdict. `pricingKnown` carries the tri-state separately, so an
// unpriced model is not silently promoted to "paid" in the reason fields.
const freeNow = priced?.freeNow === true || creditNow === 0;
rows.push({
id: key,
provider: str(info?.provider, 32) || 'other',
status: entitlementOf(key, cap, blocked),
tierListed: tierList.has(key),
selector,
creditNow,
freeNow,
freeReason: priced?.freeReason || null,
promoEndsAt: priced?.promoEndsAt || null,
promoLabel: priced?.promoLabel || null,
pricingKnown: priced ? priced.freeNow !== null : null,
pricingStale: priced ? priced.pricingStale === true : false,
reason: str(cap?.reason, 32),
probedAt: num(cap?.lastCheck),
});
}
const order = { blocked: 0, not_entitled: 1, unknown: 2, available: 3 };
rows.sort((a, b) => (order[a.status] - order[b.status]) || a.id.localeCompare(b.id));
const count = (status) => rows.filter((row) => row.status === status).length;
const attention = rows.filter((row) => row.status !== 'available').slice(0, ATTENTION_ROW_CAP);
return {
accountRef,
fetchedAt: now,
tier: str(tier, 32) || 'unknown',
tierManual: !!tierManual,
entitlementFetchedAt: num(userStatusLastFetched) || 0,
allowedModelCount: Array.isArray(us.allowedModels) ? us.allowedModels.length : null,
planName: str(us.planName, 64),
counts: {
total: rows.length,
available: count('available'),
blocked: count('blocked'),
notEntitled: count('not_entitled'),
unknown: count('unknown'),
freeNow: rows.filter((row) => row.freeNow).length,
tierListed: rows.filter((row) => row.tierListed).length,
},
attention,
rows,
source: {
kind: 'capabilities',
probed: num(userStatusLastFetched) > 0 || Object.keys(caps).length > 0,
rateTable: !!table,
},
};
}
// ─── 3. API key & rotation ─────────────────────────────────────────────────
/**
* Build the key card. The caller passes `apiKeyMasked`; this builder never sees
* the raw key and the payload has no field that could carry one.
*/
export function buildKeyInfo({
accountRef = null,
apiKeyMasked = null,
createdAt = null,
rotatedAt = null,
lastProbed = null,
entitlementFetchedAt = null,
hasRefreshToken = false,
method = null,
state = 'ok',
reauth = null,
now = Date.now(),
} = {}) {
const created = num(createdAt);
const rotated = num(rotatedAt);
const reauthRequired = state === 'reauth';
return {
accountRef,
fetchedAt: now,
masked: str(apiKeyMasked, 64),
createdAt: created,
rotatedAt: rotated && rotated > 0 ? rotated : null,
expires: {
known: false,
at: null,
reason: KEY_EXPIRY_REASON,
},
// SessionReauth: whether upstream still accepts the token. This is NOT an
// expiry — a session token has no readable lifetime — so `expires` stays
// exactly as it was and this block answers the only question the operator
// can act on: does this key work, and if not, when was that confirmed?
state: reauthRequired ? 'reauth' : (state === 'unknown' ? 'unknown' : 'ok'),
reauth: {
required: reauthRequired,
at: reauthRequired ? (num(reauth?.at) || null) : null,
source: reauthRequired ? str(reauth?.source, 24) : null,
code: reauthRequired ? str(reauth?.code, 64) : null,
},
lastProbed: num(lastProbed) || null,
entitlementFetchedAt: num(entitlementFetchedAt) || null,
hasRefreshToken: !!hasRefreshToken,
method: str(method, 32),
// The login flow is the only path that mints a new session key; there is no
// separate "rotate key" RPC in this build.
rotation: 'relogin',
};
}
// ─── 4. Capacity preflight ─────────────────────────────────────────────────
const AVAILABILITY_STATE = {
disabled: 'disabled',
'status:disabled': 'disabled',
'status:error': 'error',
quota_exhausted: 'quota_cooled',
rate_limited: 'rate_limited',
model_rate_limited: 'model_limited',
rpm_full: 'rpm_exhausted',
tier_expired: 'tier_expired',
model_not_available: 'not_entitled',
};
function localStateOf(reasons) {
for (const state of LOCAL_STATE_VALUES) if (reasons.includes(state)) return state;
return 'ok';
}
/**
* Build the preflight payload from already-collected facts.
*
* `upstream` is the raw `checkMessageRateLimit` result. It fails OPEN on a
* network error (`hasCapacity:true`), so the `error` field is what separates
* "upstream said yes" from "we could not ask" — the latter is `unknown`, never
* `ok`.
*/
export function buildCapacityPreflight({
accountRef = null,
model = null,
selector = null,
selectorMapped = false,
selectorInCatalog = null,
selectorFreeNow = null,
poolEntitled = null,
poolAllUnavailable = false,
poolRetryAfterMs = null,
availability = null,
selectorAllowed = null,
modelAllowed = null,
rpmUsed = null,
rpmLimit = null,
rateLimitedUntil = 0,
quotaResetAt = 0,
modelRateLimitUntil = 0,
upstream = null,
cached = false,
now = Date.now(),
} = {}) {
const reasons = [];
const availabilityReason = typeof availability?.reason === 'string' ? availability.reason : '';
const mapped = AVAILABILITY_STATE[availabilityReason];
if (mapped && mapped !== 'ok') reasons.push(mapped);
if (availability && availability.available === false && !mapped && availabilityReason) {
reasons.push(availabilityReason.startsWith('status:') ? 'error' : 'not_entitled');
}
if (modelAllowed === false && !reasons.includes('not_entitled')) reasons.push('not_entitled');
if (selectorAllowed === false && !reasons.includes('not_entitled')) reasons.push('not_entitled');
const localState = localStateOf(reasons);
const localRetryAfterMs = num(availability?.retryAfterMs);
const up = obj(upstream);
let upstreamState = 'unknown';
if (up && !up.error) {
if (up.hasCapacity === false) upstreamState = 'limited';
else if (up.hasCapacity === true) upstreamState = 'ok';
}
const verdict = localState !== 'ok'
? 'blocked'
: upstreamState === 'limited'
? 'throttled'
: upstreamState === 'ok'
? 'ok'
: 'unknown';
const messagesRemaining = num(up?.messagesRemaining);
const maxMessages = num(up?.maxMessages);
return {
ok: true,
accountRef,
fetchedAt: now,
cached: !!cached,
verdict,
model: str(model, 200),
selector: selectorMapped ? str(selector, 200) : null,
selectorMapped: !!selectorMapped,
local: {
state: localState,
eligible: localState === 'ok',
reasons,
reason: availabilityReason || null,
retryAfterMs: localRetryAfterMs,
rpmUsed: num(rpmUsed),
rpmLimit: num(rpmLimit),
rpmHeadroom: num(rpmLimit) != null && num(rpmUsed) != null ? Math.max(0, rpmLimit - rpmUsed) : null,
rateLimitedUntil: num(rateLimitedUntil) || 0,
quotaResetAt: num(quotaResetAt) || 0,
modelRateLimitUntil: num(modelRateLimitUntil) || 0,
modelAllowed,
selectorAllowed,
},
upstream: {
state: upstreamState,
hasCapacity: up ? (typeof up.hasCapacity === 'boolean' ? up.hasCapacity : null) : null,
messagesRemaining: messagesRemaining != null && messagesRemaining >= 0 ? messagesRemaining : null,
maxMessages: maxMessages != null && maxMessages >= 0 ? maxMessages : null,
// -1 is the wire's "unlimited". A fail-open probe also returns -1, so it
// must not claim unlimited — the error flag above is the discriminator.
unlimited: !up?.error && messagesRemaining === -1,
retryAfterMs: num(up?.retryAfterMs),
error: str(up?.error, 64),
},
pool: {
selectorInCatalog,
catalogSynced: hasLiveCatalog(),
freeNow: selectorFreeNow,
entitled: poolEntitled,
allUnavailable: !!poolAllUnavailable,
retryAfterMs: num(poolRetryAfterMs),
},
};
}
const _preflightCache = new Map();
/** Test seam: inject the upstream capacity check and drop cached answers. */
export function __setPreflightDepsForTest(deps = {}) {
_preflightDeps = deps && typeof deps === 'object' ? deps : {};
_preflightCache.clear();
}
let _preflightDeps = {};
function liveSelectorSet() {
const set = new Set();
for (const row of getLiveCatalog()) {
const selector = typeof row === 'string' ? row : row?.selector;
if (typeof selector === 'string' && selector) set.add(selector);
}
return set;
}
/**
* Run one on-demand preflight for an account (optionally for a model).
*
* The upstream probe is the existing `checkMessageRateLimit` — the same call
* the per-row button has always made — memoized for PREFLIGHT_TTL_MS so a
* double-click cannot burn two upstream requests.
*/
export async function preflightCapacity({ id, model = '', force = false, now = Date.now() } = {}) {
const account = getAccountInternal(id);
if (!account) return { ok: false, error: 'account_not_found', httpStatus: 404 };
const requested = String(model || '').trim();
const resolution = requested ? resolveConnectSelector(requested, { warnOnFallback: false }) : null;
const selectorMapped = !!resolution?.mapped;
const selector = selectorMapped ? resolution.selector : null;
// `isModelAllowedForAccount` / `getAccountAvailability` speak the MODELS
// namespace (enum keys), while the preflight accepts either a MODELS key or a
// raw connect selector. Only ask the MODELS gate when the caller named a key it
// understands; otherwise the selector gate below is the authority.
const modelKey = requested && MODELS[requested] ? requested : null;
const modelAllowed = modelKey ? isModelAllowedForAccount(account, modelKey) : null;
const selectorAllowed = selectorMapped && selector
? isConnectSelectorAllowedForAccount(account, selector)
: null;
const pool = selector ? isAllTemporarilyUnavailable(null, selector) : { allUnavailable: false, retryAfterMs: null };
const rpm = getRpmStats()[account.id] || {};
const availability = getAccountAvailability(account.apiKey, modelKey);
const cacheId = cacheKey({ accountId: account.id, token: account.apiKey, name: `preflight:${selector || '*'}` });
const hit = _preflightCache.get(cacheId);
let cached = false;
let upstream;
if (!force && hit && now - hit.at < PREFLIGHT_TTL_MS) {
cached = true;
upstream = hit.value;
} else {
const check = typeof _preflightDeps.rateLimit === 'function' ? _preflightDeps.rateLimit : checkMessageRateLimit;
try {
upstream = await check(account.apiKey, getEffectiveProxy(account.id) || null);
} catch {
// checkMessageRateLimit is documented to fail open; if a replacement
// throws anyway the honest answer is "could not ask", not "ok".
upstream = { hasCapacity: true, messagesRemaining: -1, maxMessages: -1, retryAfterMs: null, error: 'upstream_unreachable' };
}
if (!obj(upstream)) {
upstream = { hasCapacity: true, messagesRemaining: -1, maxMessages: -1, retryAfterMs: null, error: 'upstream_unreachable' };
}
_preflightCache.set(cacheId, { at: now, value: upstream });
}
const catalog = liveSelectorSet();
const selectorInCatalog = selector
? (hasLiveCatalog() ? catalog.has(selector) : null)
: null;
return buildCapacityPreflight({
accountRef: accountRefOf(account.id),
model: requested || null,
selector,
selectorMapped,
selectorInCatalog,
selectorFreeNow: selector ? isConnectSelectorCurrentlyFree(selector) : null,
poolEntitled: selector ? hasConnectEntitledAccount(selector) : null,
poolAllUnavailable: pool.allUnavailable,
poolRetryAfterMs: pool.retryAfterMs,
availability,
selectorAllowed,
modelAllowed,
rpmUsed: rpm.used,
rpmLimit: rpm.limit,
rateLimitedUntil: account.rateLimitedUntil || 0,
quotaResetAt: account.quotaResetAt || 0,
modelRateLimitUntil: selector ? (account._modelRateLimits?.[selector] || 0) : 0,
upstream,
cached,
now,
});
}
// ─── Aggregate reader ──────────────────────────────────────────────────────
/**
* Build all three local cards for one account. No network: every field comes
* from the stored account record plus the in-process credit ledger.
*
* Returns `{ok:false, error:'account_not_found'}` for an unknown id; a card whose
* source is missing still returns its explicit "not observable" state.
*/
export function buildAccountCards(id, { now = Date.now() } = {}) {
const account = getAccountInternal(id);
if (!account) return { ok: false, error: 'account_not_found', httpStatus: 404 };
const credits = obj(account.credits);
const tierModels = getTierModels(account.tier || 'unknown', account.id);
const ref = accountRefOf(account.id);
return {
ok: true,
accountRef: ref,
fetchedAt: now,
ledger: buildAcuLedger({
accountRef: ref,
totalSpend: account._totalSpend || null,
credits,
history: getCreditLedger(account.id),
now,
}),
modelStatus: buildModelStatus({
accountRef: ref,
tier: account.tier,
tierManual: !!account.tierManual,
tierModels,
blockedModels: account.blockedModels || [],
capabilities: account.capabilities || {},
rateTable: credits?.rateTable || null,
// One bulk lookup, not one per row: same snapshot of the pool-wide free
// set for every row, so a promo cannot render as free on one line and
// paid on the next.
pricing: getConnectPricingMap(modelStatusSelectors(credits?.rateTable)),
userStatus: account.userStatus || null,
userStatusLastFetched: account.userStatusLastFetched || 0,
now,
}),
key: buildKeyInfo({
accountRef: ref,
apiKeyMasked: maskApiKey(account.apiKey),
createdAt: account.addedAt || null,
rotatedAt: account.keyRotatedAt || null,
lastProbed: account.lastProbed || null,
entitlementFetchedAt: account.userStatusLastFetched || null,
hasRefreshToken: !!account.refreshToken,
method: account.method,
state: account.status === 'reauth' ? 'reauth' : 'ok',
reauth: account.reauth || null,
now,
}),
};
}