Files
小夜 cd31096486 fix: improve Roxy cleanup and account operations
Update Roxy session cleanup, bounded proxy retries and Team authorization selection. Persist mailbox export usage and strengthen scoped member removal. Update isolated Roxy fixtures; all 808 tests pass.
2026-10-11 14:18:15 +08:00

9088 lines
532 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http';
import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { OPENAI_OAUTH_CLIENT_ID, accessTokenClaims, isOpenAiAuthFailure, refreshOpenAiAccessToken } from './openai-auth.mjs';
import { clearOAuthCallback, consumeOAuthCallback, loginFreeAccount, parseValidOAuthCallback, storeOAuthCallback } from './openai-login.mjs';
import { createProxyFetch, maskProxyUrl, parseProxyInput, publicProxySettings } from './proxy.mjs';
import { normalizeLocalProxyUrl } from './proxy-chain.mjs';
import { createStateStorage, isLoopbackHost, secureTokenEqual } from './security.mjs';
import { reconcileAcceptedSeatUsage, seatCapacityByType, selectInviteSeatType, shouldRetainSeatClaim, totalSeatAvailability } from './seat-policy.mjs';
import { excludePreviouslyRemovedMembers, manualKickCooldownAt, manualKickTimerExpired, manualKickTimerState, parseManualKickTimerInput } from './manual-kick-timer.mjs';
import { groupTeamAccounts } from '../shared/team-members.mjs';
import { teamQuotaState } from '../shared/team-quota-ui.mjs';
import { isChallenge, managementHealth, quotaHealth } from '../shared/team-health.mjs';
import { selectTeamSub2ApiRecords } from './sub2api-scope.mjs';
import { managerLoginCooldown } from './team-manager-recovery.mjs';
import { automaticRotationTeams, normalizeTeamRotationEnabled } from './team-rotation-policy.mjs';
import { currentSeatQuantity, normalizeBillingPreview } from './billing-preview.mjs';
import { clearQuotaKickDelay, normalizeDelayedKickMinutes, quotaKickDelayDue, updateQuotaKickDelay } from './quota-kick-delay.mjs';
import { createDashboardAuth } from './dashboard-auth.mjs';
import { DEFAULT_ROXY_CONFIG, RoxyBrowserError, createRoxyApiClient, deleteClosedRoxyProfile, normalizeRoxyConfig, openRoxyBrowserSession, publicRoxyConfig, resolveRoxyAccountProfile, resolveRoxyPoolProfile, roxyProxyInfo } from './roxy-browser.mjs';
import { completeBrowserOAuth } from './openai-browser-login.mjs';
import { buildAccountExportCandidates, selectAccountExports } from './account-exports.mjs';
import { createConcurrencyQueue } from './concurrency-queue.mjs';
import { MotherVaultError, createMotherVaultRecord, exportMotherVaultRecords, fullMotherVaultRecord, importMotherVaultRecords, publicMotherVaultRecord, selectMotherVaultRecords, updateMotherVaultRecord } from './mother-vault.mjs';
import { MailboxVaultError, createMailboxVaultRecord, exportMailboxVaultRecords, fullMailboxVaultRecord, importMailboxVaultRecords, markMailboxVaultRecordsExported, publicMailboxVaultRecord, selectMailboxVaultRecords, updateMailboxVaultRecord } from './mailbox-vault.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dataDir = path.resolve(process.env.TEAM_ROTATION_DATA_DIR || path.join(root, 'data'));
const stateFile = path.join(dataDir, 'state.json');
const port = Number(process.env.PORT || 8786);
const host = process.env.HOST || '127.0.0.1';
const apiAuthToken = String(process.env.TEAM_ROTATION_API_TOKEN || '').trim();
const loginPassword = process.env.TEAM_ROTATION_LOGIN_PASSWORD ?? 'daixuteam';
const configuredDataKey = String(process.env.TEAM_ROTATION_DATA_KEY || '').trim();
const CHATGPT_BASE_URL = process.env.CHATGPT_BASE_URL || 'https://chatgpt.com';
const OPENAI_REQUEST_TIMEOUT_MS = Number(process.env.OPENAI_REQUEST_TIMEOUT_MS || 15000);
if (!isLoopbackHost(host) && !apiAuthToken) {
throw new Error('TEAM_ROTATION_API_TOKEN is required when HOST is not loopback');
}
if (!isLoopbackHost(host) && !configuredDataKey) {
throw new Error('TEAM_ROTATION_DATA_KEY is required when HOST is not loopback');
}
if (!loginPassword) throw new Error('TEAM_ROTATION_LOGIN_PASSWORD must not be empty');
const stateStorage = await createStateStorage(dataDir);
const dashboardAuth = createDashboardAuth({ password: loginPassword });
const { version: currentVersion } = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const emptyState = {
version: 1,
settings: {
autoRefill: true,
threshold: 10,
checkInterval: 60,
kickOnExhausted: true,
kickWindow: '5h',
kickAfterHours: 12,
delayedKickEnabled: false,
delayedKickMinutes: 10,
promoteJoinedAccounts: true,
joinMode: 'request',
concurrency: 3,
integrations: {
sub2api: { baseUrl: '', apiKey: '', groupId: null, groupName: '', enabled: false },
sub2apis: [],
mailbox: { serviceType: 'manual', endpoint: '', apiKey: '', enabled: false },
roxy: { ...DEFAULT_ROXY_CONFIG, profiles: [] },
},
proxy: { enabled: false, strategy: 'failover', timeoutMs: 15000, maxRetries: 2,
localProxyEnabled: false, localProxyUrl: 'http://127.0.0.1:7897', entries: [] },
},
mothers: [],
motherVault: [],
mailboxVault: [],
children: [],
history: [],
updatedAt: new Date().toISOString(),
};
async function loadState() {
await mkdir(dataDir, { recursive: true });
if (!existsSync(stateFile)) return structuredClone(emptyState);
try {
const parsed = stateStorage.decode(await readFile(stateFile, 'utf8'));
const storedSettings = { ...parsed.settings };
delete storedSettings.githubRepository;
return {
...structuredClone(emptyState),
...parsed,
settings: {
...emptyState.settings,
...storedSettings,
integrations: {
...emptyState.settings.integrations,
...(parsed.settings?.integrations || {}),
sub2api: { ...emptyState.settings.integrations.sub2api, ...(parsed.settings?.integrations?.sub2api || {}) },
mailbox: { ...emptyState.settings.integrations.mailbox, ...(parsed.settings?.integrations?.mailbox || {}) },
roxy: (() => {
try {
return normalizeRoxyConfig(parsed.settings?.integrations?.roxy || {}, { ...DEFAULT_ROXY_CONFIG, profiles: [] });
} catch { return structuredClone(emptyState.settings.integrations.roxy); }
})(),
},
proxy: {
...emptyState.settings.proxy,
...(parsed.settings?.proxy || {}),
entries: Array.isArray(parsed.settings?.proxy?.entries) ? parsed.settings.proxy.entries : [],
},
},
};
} catch (error) {
if (error?.code === 'STATE_DECRYPTION_FAILED' || String(error?.message || '').startsWith('state_encryption_')) throw error;
return structuredClone(emptyState);
}
}
let state = await loadState();
// Storage-only records never enter Team management, authentication, or rotation.
state.motherVault = Array.isArray(state.motherVault) ? state.motherVault : [];
state.mailboxVault = Array.isArray(state.mailboxVault) ? state.mailboxVault : [];
const DEFAULT_SUB2API_ID = 'sub2api_default';
function normalizeSub2ApiConfig(input = {}, index = 0) {
const groupId = Number(input.groupId);
return {
id: String(input.id || (index === 0 ? DEFAULT_SUB2API_ID : `sub2api_${index + 1}`)).trim(),
name: String(input.name || input.label || (index === 0 ? '默认 Sub2API' : `Sub2API ${index + 1}`)).trim(),
baseUrl: String(input.baseUrl || '').trim(),
apiKey: String(input.apiKey || '').trim(),
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
groupName: String(input.groupName || '').trim(),
enabled: input.enabled === true,
};
}
function normalizeSub2ApiIntegrations() {
const integrations = state.settings.integrations || (state.settings.integrations = structuredClone(emptyState.settings.integrations));
const configured = Array.isArray(integrations.sub2apis) && integrations.sub2apis.length
? integrations.sub2apis
: [{ ...(integrations.sub2api || {}), id: DEFAULT_SUB2API_ID, name: integrations.sub2api?.name || '默认 Sub2API' }];
const seen = new Set();
integrations.sub2apis = configured.map((item, index) => {
const normalized = normalizeSub2ApiConfig(item, index);
if (!normalized.id || seen.has(normalized.id)) normalized.id = `sub2api_${index + 1}`;
seen.add(normalized.id);
return normalized;
});
integrations.sub2api = { ...integrations.sub2apis[0] };
const fallbackId = integrations.sub2apis[0]?.id || DEFAULT_SUB2API_ID;
for (const mother of state.mothers || []) {
if (!String(mother.sub2apiIntegrationId || '').trim()) mother.sub2apiIntegrationId = fallbackId;
}
}
function sub2ApiConfigs() {
return state.settings?.integrations?.sub2apis || [];
}
function sub2ApiConfigById(id) {
const target = String(id || '').trim();
return sub2ApiConfigs().find((config) => config.id === target) || null;
}
function sub2ApiConfigForMother(mother) {
const selectedId = String(mother?.sub2apiIntegrationId || '').trim();
return selectedId ? sub2ApiConfigById(selectedId) : sub2ApiConfigs()[0] || null;
}
function publicSub2ApiConfig(config = {}) {
return {
id: config.id || DEFAULT_SUB2API_ID,
name: config.name || 'Sub2API',
baseUrl: config.baseUrl || '',
enabled: config.enabled === true,
groupId: Number.isFinite(Number(config.groupId)) ? Number(config.groupId) : null,
groupName: config.groupName || '',
apiKeySet: Boolean(config.apiKey),
};
}
function replaceSub2ApiConfigs(inputs) {
const current = new Map(sub2ApiConfigs().map((config) => [config.id, config]));
const source = Array.isArray(inputs) && inputs.length
? inputs
: [{ id: DEFAULT_SUB2API_ID, name: '默认 Sub2API', enabled: false }];
state.settings.integrations.sub2apis = source.map((input, index) => {
const existing = current.get(String(input?.id || '')) || {};
const apiKey = input?.apiKey === undefined ? existing.apiKey : input.apiKey;
return normalizeSub2ApiConfig({ ...existing, ...(input || {}), apiKey }, index);
});
state.settings.integrations.sub2api = { ...state.settings.integrations.sub2apis[0] };
normalizeSub2ApiIntegrations();
}
normalizeSub2ApiIntegrations();
function normalizeConcurrency(value, fallback = 3) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.min(10, Math.max(1, Math.floor(numeric))) : fallback;
}
function normalizeKickWindow(value, fallback = '5h') {
return ['5h', '7d', 'time'].includes(String(value || '').trim()) ? String(value).trim() : fallback;
}
function normalizeJoinMode(value) {
return value === 'invite' ? 'invite' : 'request';
}
function normalizeDefaultSeatType(value, fallback = 'default') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'default' || normalized === 'prolite') return normalized;
const normalizedFallback = String(fallback || '').trim().toLowerCase();
return normalizedFallback === 'prolite' ? 'prolite' : 'default';
}
function observedSeatType(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'default' || normalized === 'prolite' ? normalized : null;
}
function selectTeamSeatType(mother, snapshot, reservations) {
const preferred = normalizeDefaultSeatType(mother?.defaultSeatType);
if (preferred === 'prolite') {
const selected = selectInviteSeatType('prolite', snapshot, reservations);
if (selected.ok) return { ...selected, requested: 'auto', source: 'team_default' };
if (!['seat_type_capacity_exhausted', 'seat_type_not_entitled'].includes(selected.code)) return selected;
}
return selectInviteSeatType('auto', snapshot, reservations);
}
function emailDomain(email) {
const value = String(email || '').trim().toLowerCase();
const match = /^[^@\s]+@([^@\s]+)$/.exec(value);
return match?.[1] || null;
}
function canJoinByEmailDomain(mother, child) {
const ownerDomain = emailDomain(mother?.email);
return Boolean(ownerDomain && emailDomain(child?.email) === ownerDomain);
}
function normalizeKickAfterHours(value, fallback = 12) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.min(720, Math.max(1, Math.floor(numeric))) : fallback;
}
state.settings.kickWindow = normalizeKickWindow(state.settings.kickWindow);
state.settings.kickAfterHours = normalizeKickAfterHours(state.settings.kickAfterHours);
state.settings.delayedKickEnabled = state.settings.delayedKickEnabled === true;
state.settings.delayedKickMinutes = normalizeDelayedKickMinutes(state.settings.delayedKickMinutes);
state.settings.concurrency = normalizeConcurrency(state.settings.concurrency);
state.settings.joinMode = normalizeJoinMode(state.settings.joinMode);
const storedProxyTimeout = Number(state.settings.proxy?.timeoutMs);
const storedProxyRetries = Number(state.settings.proxy?.maxRetries);
const storedProxyEntries = (Array.isArray(state.settings.proxy?.entries) ? state.settings.proxy.entries : []).flatMap((entry) => {
try {
const raw = typeof entry === 'string' ? entry : entry?.url;
const parsed = parseProxyInput(raw);
return [{
...parsed,
id: entry?.id || randomUUID(),
label: String(entry?.label || '').trim() || (parsed.sourceType === 'local' ? '本机代理' : '家宽代理'),
display: maskProxyUrl(parsed.url),
createdAt: entry?.createdAt || now(),
}];
} catch {
return [];
}
});
state.settings.proxy = {
enabled: state.settings.proxy?.enabled === true,
strategy: state.settings.proxy?.strategy === 'round_robin' ? 'round_robin' : 'failover',
timeoutMs: Number.isFinite(storedProxyTimeout) ? Math.min(120000, Math.max(1000, storedProxyTimeout)) : 15000,
maxRetries: Number.isFinite(storedProxyRetries) ? Math.min(5, Math.max(0, storedProxyRetries)) : 2,
localProxyEnabled: state.settings.proxy?.localProxyEnabled === true,
localProxyUrl: (() => {
try { return normalizeLocalProxyUrl(state.settings.proxy?.localProxyUrl || emptyState.settings.proxy.localProxyUrl); }
catch { return emptyState.settings.proxy.localProxyUrl; }
})(),
entries: storedProxyEntries,
};
state.mothers = (Array.isArray(state.mothers) ? state.mothers : []).map((mother) => {
const { inviteSeatType, ...stored } = mother;
return {
...stored,
team: mother.accountId || mother.team || mother.id,
teamName: mother.teamName || mother.displayName || '',
rotationMode: mother.rotationMode === 'rotating' ? 'rotating' : 'fixed',
rotationEnabled: normalizeTeamRotationEnabled(mother.rotationEnabled),
defaultSeatType: normalizeDefaultSeatType(mother.defaultSeatType, inviteSeatType),
dailyRotationLimit: normalizeDailyRotationLimit(mother.dailyRotationLimit),
primaryOwnerEmail: mother.primaryOwnerEmail || mother.email || '',
tokenScope: 'team',
planType: mother.planType || 'team',
seatClaims: (Array.isArray(mother.seatClaims) ? mother.seatClaims : []).filter((claim) => claim && (claim.childId || claim.email)).map((claim) => ({
id: claim.id || randomUUID(),
workspaceId: String(claim.workspaceId || mother.accountId || mother.team || '').trim() || null,
childId: claim.childId || null,
email: String(claim.email || '').trim(),
inviteId: claim.inviteId || null,
seatType: claim.seatType === 'prolite' ? 'prolite' : 'default',
phase: claim.phase || 'accepted_pending_sync',
createdAt: claim.createdAt || now(),
updatedAt: claim.updatedAt || claim.createdAt || now(),
acceptedAt: claim.acceptedAt || null,
error: claim.error || null,
})),
};
});
let activeOutboundRequests = 0;
const outboundRequestQueue = [];
function drainOutboundRequestQueue() {
const limit = normalizeConcurrency(state.settings.concurrency);
while (activeOutboundRequests < limit && outboundRequestQueue.length) {
activeOutboundRequests += 1;
outboundRequestQueue.shift()();
}
}
function withOutboundConcurrency(operation) {
return new Promise((resolve, reject) => {
outboundRequestQueue.push(async () => {
try {
resolve(await operation());
} catch (error) {
reject(error);
} finally {
activeOutboundRequests = Math.max(0, activeOutboundRequests - 1);
drainOutboundRequestQueue();
}
});
drainOutboundRequestQueue();
});
}
async function mapWithConcurrency(items, operation, limit = state.settings.concurrency) {
const values = Array.from(items || []);
if (!values.length) return [];
const results = new Array(values.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(values.length, normalizeConcurrency(limit)) }, async () => {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
results[index] = await operation(values[index], index);
}
});
await Promise.all(workers);
return results;
}
const authorizationNetwork = new AsyncLocalStorage();
const activeAuthorizationProxies = new Map();
const rawProxyFetch = createProxyFetch(() => authorizationNetwork.getStore()?.proxySettings || state.settings.proxy);
const proxyFetch = (...args) => {
const network = authorizationNetwork.getStore();
// The queue can be drained by a different account's request. Restore the
// caller's context explicitly before selecting its network route.
return withOutboundConcurrency(() => authorizationNetwork.run(network, () => rawProxyFetch(...args)));
};
state.children = (Array.isArray(state.children) ? state.children : []).map((child) => ({
...child,
// Free credentials stay independent from short-lived Team workspace tokens.
workspaceTokens: child.workspaceTokens && typeof child.workspaceTokens === 'object' && !Array.isArray(child.workspaceTokens)
? child.workspaceTokens
: {},
teamAuthSessions: child.teamAuthSessions && typeof child.teamAuthSessions === 'object' && !Array.isArray(child.teamAuthSessions)
? child.teamAuthSessions
: {},
tokenScope: 'free',
accountType: 'free',
plan: String(child.plan || '').toLowerCase() === 'team' ? 'free' : (child.plan || 'free'),
}));
let writeQueue = Promise.resolve();
function persist() {
state.updatedAt = new Date().toISOString();
const snapshot = stateStorage.encode(state);
writeQueue = writeQueue.then(async () => {
const tmp = `${stateFile}.${process.pid}.tmp`;
await writeFile(tmp, snapshot, 'utf8');
try {
await rename(tmp, stateFile);
} catch (error) {
if (process.platform !== 'win32' || !['EPERM', 'EEXIST'].includes(error?.code)) throw error;
await writeFile(stateFile, snapshot, 'utf8');
await unlink(tmp).catch(() => {});
}
});
return writeQueue;
}
if (stateStorage.legacyPlaintextLoaded) await persist();
for (const entry of await readdir(dataDir).catch(() => [])) {
if (!/^state\.json\.\d+\.tmp$/.test(entry)) continue;
await unlink(path.join(dataDir, entry)).catch(() => {});
}
function now() { return new Date().toISOString(); }
function normalizeDailyRotationLimit(value, fallback = 3) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.min(100, Math.max(1, Math.floor(numeric))) : fallback;
}
function localDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function dailyRotationUsage(mother, { mutate = false } = {}) {
const date = localDateKey();
const stored = mother?.dailyRotationUsage && typeof mother.dailyRotationUsage === 'object'
? mother.dailyRotationUsage
: {};
const count = stored.date === date && Number.isFinite(Number(stored.count))
? Math.max(0, Math.floor(Number(stored.count)))
: 0;
const usage = { date, count };
if (mutate && mother) mother.dailyRotationUsage = usage;
return usage;
}
function dailyRotationBudget(mother) {
const limit = normalizeDailyRotationLimit(mother?.dailyRotationLimit);
const usage = dailyRotationUsage(mother);
return { ...usage, limit, remaining: Math.max(0, limit - usage.count) };
}
function consumeDailyRotation(mother) {
const usage = dailyRotationUsage(mother, { mutate: true });
usage.count += 1;
mother.dailyRotationUsage = usage;
return dailyRotationBudget(mother);
}
function addHistory(action, detail, result = 'success', meta = {}) {
const safeMeta = meta && typeof meta === 'object' ? meta : {};
state.history = [{ id: randomUUID(), time: now(), action, detail, result, ...safeMeta }, ...state.history].slice(0, 200);
}
function preview(value) {
if (!value || typeof value !== 'string') return '';
return value.length <= 12 ? `${value.slice(0, 3)}...` : `${value.slice(0, 8)}...${value.slice(-4)}`;
}
function membershipFor(child, teamId, create = false) {
if (!child || !teamId) return null;
if (!Array.isArray(child.workspaceHistory)) child.workspaceHistory = [];
const teamKey = workspaceIdKey(teamId);
let entry = child.workspaceHistory.find((item) => workspaceIdKey(item.team || item.workspaceId) === teamKey && item.status === 'active');
if (!entry && create) {
entry = { team: teamId, joinedAt: now(), status: 'active' };
child.workspaceHistory.push(entry);
}
return entry || null;
}
function publicManualKickTimerFields(membership) {
const timer = manualKickTimerState(membership);
return {
manualKickEnabled: timer.enabled,
manualKickStartedAt: timer.startedAt,
manualKickAt: timer.kickAt,
manualKickDurationMinutes: timer.durationMinutes,
manualKickExpired: timer.expired,
manualKickRemainingSeconds: timer.remainingSeconds,
lastManualKick: membership?.lastManualKick && typeof membership.lastManualKick === 'object'
? { ...membership.lastManualKick }
: null,
};
}
function clearManualKickTimer(membership, outcome, completedAt = now()) {
if (!membership) return null;
const timer = manualKickTimerState(membership);
if (timer.enabled) {
membership.lastManualKick = {
startedAt: timer.startedAt,
kickAt: timer.kickAt,
durationMinutes: timer.durationMinutes,
completedAt,
outcome,
};
}
membership.manualKickEnabled = false;
membership.manualKickStartedAt = null;
membership.manualKickAt = null;
membership.manualKickDurationMinutes = null;
return timer;
}
function workspaceTokenFor(child, workspaceId) {
if (!child || !workspaceId) return null;
const candidate = child.workspaceTokens?.[workspaceId];
const stored = childTokenIdentityMatches(child, candidate?.accessToken) ? candidate : null;
const legacy = membershipFor(child, workspaceId)?.teamAccessToken;
const storedToken = String(stored?.accessToken || '').trim();
const legacyToken = childTokenIdentityMatches(child, legacy) ? String(legacy || '').trim() : '';
if (teamTokenDetails(storedToken, workspaceId)) return { ...stored, accessToken: storedToken };
if (teamTokenDetails(legacyToken, workspaceId)) return { accessToken: legacyToken };
const accountToken = accountTeamTokenFor(child, workspaceId);
if (accountToken) return accountToken;
// Keep expired workspace credentials available to the regular RT recovery.
const accessToken = storedToken || legacyToken;
return accessToken ? { ...stored, accessToken } : null;
}
function accountTeamTokenFor(child, workspaceId, { allowExpired = false } = {}) {
const accessToken = String(child?.accessToken || '').trim();
if (!accessToken || !workspaceId) return null;
const claims = accessTokenClaims(accessToken);
// Business premium seats return this exact plan in the OAuth token, not
// "team". Keep a narrow allowlist; the workspace and identity checks below
// still apply before adopting the member's own credentials.
if (claims.accountId !== String(workspaceId).trim() || !['team', 'business', 'self_serve_business_prolite'].includes(claims.planType.toLowerCase())) return null;
const expiresAt = Date.parse(claims.expiresAt || '');
if (!Number.isFinite(expiresAt) || (!allowExpired && expiresAt <= Date.now() + 30_000)) return null;
const expectedUserId = String(child.chatgptUserId || '').trim();
const userMatches = Boolean(expectedUserId && claims.userId === expectedUserId);
if (expectedUserId && !userMatches) return null;
const emailMatches = Boolean(claims.email && workspaceIdKey(claims.email) === workspaceIdKey(child.email));
if (claims.email && !emailMatches) return null;
if (!userMatches && !emailMatches) return null;
return {
accessToken,
accountId: claims.accountId,
userId: claims.userId,
expiresAt: claims.expiresAt,
refreshToken: child.refreshToken || '',
idToken: child.idToken || '',
clientId: child.clientId || '',
acquiredAt: child.authAt || null,
source: 'account_team_token',
};
}
function childTokenIdentityMatches(child, accessToken) {
const claims = accessTokenClaims(accessToken);
const userId = String(child?.chatgptUserId || '').trim();
return (!userId || claims.userId === userId)
&& (!claims.email || workspaceIdKey(claims.email) === workspaceIdKey(child?.email));
}
function teamSessionAuthorizationUrl(session, explicitUrl = '') {
if (!session?.state) return null;
const candidate = explicitUrl || session.authorization?.authUrl;
if (candidate) {
try {
const url = new URL(candidate);
if (url.origin === 'https://auth.openai.com' && url.pathname === '/oauth/authorize'
&& url.searchParams.get('state') === session.state) return url.toString();
} catch { /* Only the original official OAuth URL is public. */ }
return null;
}
if (!session.codeVerifier || session.authorization) return null;
// Rebuild only the original local PKCE request, never a Free pool link or
// guessed Team ID. The returned token still has to match the target Team.
const query = new URLSearchParams({
client_id: OPENAI_OAUTH_CLIENT_ID,
response_type: 'code',
redirect_uri: 'http://localhost:1455/auth/callback',
scope: 'openid email profile offline_access',
state: session.state,
code_challenge: createHash('sha256').update(session.codeVerifier).digest('base64url'),
code_challenge_method: 'S256',
prompt: 'login',
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
});
return `https://auth.openai.com/oauth/authorize?${query.toString()}`;
}
function teamLoginFor(child, workspaceId) {
if (!child || !workspaceId) return null;
const session = child.teamAuthSessions?.[workspaceId];
const stored = child.teamLoginStates?.[workspaceId];
const legacyBrowserPending = !stored && session?.state
&& /授权页面未返回空间列表|浏览器验证|协议验证/.test(child.teamLoginMessage || '');
const source = stored || (legacyBrowserPending ? {
status: 'browser_verification_required',
message: child.teamLoginMessage,
code: /授权页面未返回空间列表/.test(child.teamLoginMessage || '') ? 'workspace_list_unavailable' : 'browser_verification_required',
browserRequired: true,
needsInput: true,
attemptedAt: child.teamLoginAttemptedAt || null,
retryAfter: membershipFor(child, workspaceId)?.teamAuthRetryAfter || null,
} : null);
if (!source) return null;
const attemptedAt = source.attemptedAt || null;
return {
workspaceId,
status: source.status || 'login_required',
message: source.message || '',
code: source.code || null,
stage: source.stage || null,
httpStatus: source.httpStatus || null,
browserRequired: Boolean(source.browserRequired),
needsInput: Boolean(source.needsInput),
authUrl: source.browserRequired ? teamSessionAuthorizationUrl(session, source.authUrl || '') : null,
attemptedAt,
lastAttemptAt: attemptedAt,
retryAfter: source.retryAfter || null,
};
}
function setTeamLoginState(child, workspaceId, fields = {}) {
if (!child.teamLoginStates || typeof child.teamLoginStates !== 'object' || Array.isArray(child.teamLoginStates)) child.teamLoginStates = {};
child.teamLoginStates[workspaceId] = {
status: fields.status || 'login_required',
message: fields.message || '',
code: fields.code || null,
stage: fields.stage || null,
httpStatus: fields.httpStatus || null,
browserRequired: Boolean(fields.browserRequired),
needsInput: Boolean(fields.needsInput),
authUrl: fields.browserRequired ? teamSessionAuthorizationUrl(child.teamAuthSessions?.[workspaceId], fields.authUrl || '') : null,
attemptedAt: fields.attemptedAt || child.teamLoginAttemptedAt || null,
retryAfter: fields.retryAfter || null,
};
}
function resetTeamLoginState(child, workspaceId, message = 'Team 授权已就绪') {
const session = child.teamAuthSessions?.[workspaceId];
if (session?.state) clearOAuthCallback(session.state, session.callbackUrl || '');
if (child.teamAuthSessions) delete child.teamAuthSessions[workspaceId];
setTeamLoginState(child, workspaceId, { status: 'ready', code: 'ready', message });
}
function adoptAccountTeamToken(mother, child, candidate = workspaceTokenFor(child, mother?.accountId)) {
if (!mother || !child || candidate?.source !== 'account_team_token') return null;
if (!isChildMemberOfTeam(child, mother.team)) return null;
const workspaceId = String(mother.accountId || '').trim();
const validated = accountTeamTokenFor({ ...child, accessToken: candidate.accessToken }, workspaceId);
if (!validated) return null;
const previous = child.workspaceTokens?.[workspaceId];
const activeMembership = membershipFor(child, mother.team);
resetTeamLoginState(child, workspaceId, '已复用该成员现有的 Team 授权');
if (previous?.accessToken === candidate.accessToken && activeMembership?.workspaceTokenStatus === 'ready'
&& previous.refreshToken === candidate.refreshToken && previous.idToken === candidate.idToken && previous.clientId === candidate.clientId
&& !activeMembership.teamAuthLastError && !activeMembership.teamAuthRetryAfter
&& !['member_confirmed_token_pending', 'owner_confirmed_token_pending', 'team_token_failed'].includes(activeMembership.joinStatus)) return previous;
const token = saveWorkspaceToken(child, workspaceId, candidate.accessToken, accessTokenClaims(candidate.accessToken), candidate);
const membership = membershipFor(child, mother.team, true);
membership.workspaceTokenStatus = 'ready';
membership.workspaceTokenUpdatedAt = token.acquiredAt;
membership.teamAuthRetryAfter = null;
membership.teamAuthLastError = null;
if (['member_confirmed_token_pending', 'owner_confirmed_token_pending', 'team_token_failed'].includes(membership.joinStatus)) {
membership.joinStatus = childIsWorkspaceOwner(child, mother) ? 'owner_confirmed' : 'member_confirmed';
}
if (workspaceIdKey(child.team) === workspaceIdKey(mother.team)
&& ['member_confirmed_token_pending', 'owner_confirmed_token_pending', 'team_token_failed'].includes(child.joinStatus)) {
child.joinStatus = membership.joinStatus;
}
child.teamLoginStatus = 'ready';
child.teamLoginMessage = '已复用该成员现有的 Team 授权';
if (!childIsBanned(child) && ['login_required', 'login_pending', 'ready'].includes(child.status)) child.status = 'active';
return token;
}
function teamTokenDetails(accessToken, workspaceId) {
const token = String(accessToken || '').trim();
const target = String(workspaceId || '').trim();
if (!token || !target) return null;
const claims = accessTokenClaims(token);
if (claims.accountId !== target) return null;
const expiresAt = Date.parse(claims.expiresAt || '');
if (Number.isFinite(expiresAt) && expiresAt <= Date.now() + 30_000) return null;
return { accessToken: token, claims };
}
function teamManagerContexts(mother) {
const workspaceId = String(mother?.accountId || mother?.team || '').trim();
if (!mother || !workspaceId) return [];
const candidates = [
{ ...mother, source: 'team_primary' },
...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts.map((owner) => ({ ...owner, source: 'team_owner' })) : []),
];
for (const child of state.children) {
if (childIsBanned(child)) continue;
if (!isChildMemberOfTeam(child, mother.team) || (!childIsWorkspaceOwner(child, mother) && !childMatchesKnownTeamOwner(child, mother))) continue;
const workspaceToken = workspaceTokenFor(child, workspaceId);
if (!workspaceToken?.accessToken) continue;
candidates.push({
accessToken: workspaceToken.accessToken,
expiresAt: workspaceToken.expiresAt,
email: child.email,
deviceId: child.deviceId,
cookie: child.cookie,
source: 'free_account_team_owner',
});
}
const usable = candidates.flatMap((candidate) => {
const details = teamTokenDetails(candidate.accessToken, workspaceId);
return details ? [{ ...candidate, ...details }] : [];
});
usable.sort((left, right) => Date.parse(right.claims.expiresAt || '') - Date.parse(left.claims.expiresAt || ''));
return usable.map((selected) => ({
...mother,
accessToken: selected.accessToken,
deviceId: selected.deviceId || mother.deviceId,
cookie: selected.cookie || mother.cookie,
managerSource: selected.source,
managerEmail: selected.email || mother.email || '',
}));
}
function teamManagerContext(mother) {
return teamManagerContexts(mother)[0] || null;
}
function teamHasManagementPath(mother) {
if (!mother?.accountId) return false;
if (teamManagerContext(mother)) return true;
if (mother.accessToken || mother.refreshToken || (mother.email && mother.password)
|| (mother.ownerAccounts || []).some((owner) => owner?.accessToken || owner?.refreshToken || (owner?.email && owner?.password))) return true;
return state.children.some((child) => (
isChildMemberOfTeam(child, mother.team)
&& (childIsWorkspaceOwner(child, mother) || childMatchesKnownTeamOwner(child, mother))
&& Boolean(child.accessToken || child.refreshToken || (child.email && child.password))
));
}
async function withTeamManager(mother, operation, { email = null } = {}) {
const targetEmail = email == null ? null : String(email).trim().toLowerCase();
const managers = teamManagerContexts(mother).filter((manager) => targetEmail === null
|| String(manager.managerEmail || '').trim().toLowerCase() === targetEmail);
if (!managers.length) return { manager: null, result: null };
let attempted = null;
for (const manager of managers) {
attempted = { manager, result: await operation(manager) };
if (attempted.result?.status !== 401 && attempted.result?.status !== 403) return attempted;
}
return attempted;
}
function teamOwnerCandidateChildren(mother) {
const ownerEmails = new Set([
mother?.email,
...(Array.isArray(mother?.ownerAccounts) ? mother.ownerAccounts.map((owner) => owner?.email) : []),
].filter(Boolean).map((email) => String(email).trim().toLowerCase()));
const linkedIds = new Set((mother?.ownerAccounts || []).map((owner) => String(owner?.linkedFreeAccountId || '')).filter(Boolean));
return state.children.filter((child) => {
if (childIsBanned(child)) return false;
if (!isChildMemberOfTeam(child, mother?.team)) return false;
const email = String(child.email || '').trim().toLowerCase();
const isOwner = childIsWorkspaceOwner(child, mother) || childMatchesKnownTeamOwner(child, mother) || ownerEmails.has(email) || linkedIds.has(String(child.id));
return isOwner && Boolean(child.accessToken || child.refreshToken || (child.email && child.password));
});
}
async function recoverTeamManagerToken(mother, { force = false, allowCredentialLogin = true, primaryOnly = false, bypassCooldown = false } = {}) {
if (!mother?.accountId) return { ok: false, status: 400, message: 'workspace_id_required' };
const workspaceId = mother.accountId;
if (primaryOnly && !force && !teamTokenDetails(mother.accessToken, workspaceId)) {
const primaryEmail = String(mother.primaryOwnerEmail || mother.email || '').trim().toLowerCase();
const linkedChild = state.children.find((child) => (
!childIsBanned(child)
&& String(child.email || '').trim().toLowerCase() === primaryEmail
&& isChildMemberOfTeam(child, mother.team)
));
const childToken = workspaceTokenFor(linkedChild, workspaceId);
const owner = (mother.ownerAccounts || []).find((item) => String(item?.email || '').trim().toLowerCase() === primaryEmail);
const candidates = [
childToken ? { source: 'linked_primary_child_token', record: childToken } : null,
owner?.accessToken ? { source: 'linked_primary_owner_token', record: owner } : null,
].filter(Boolean);
const selected = candidates.find((candidate) => teamTokenDetails(candidate.record.accessToken, workspaceId));
if (selected) {
const claims = accessTokenClaims(selected.record.accessToken);
mother.accessToken = selected.record.accessToken;
mother.refreshToken = selected.record.refreshToken || mother.refreshToken || '';
mother.idToken = selected.record.idToken || mother.idToken || '';
mother.clientId = selected.record.clientId || mother.clientId || '';
mother.chatgptUserId = selected.record.userId || selected.record.chatgptUserId || mother.chatgptUserId || '';
mother.expiresAt = claims.expiresAt || selected.record.expiresAt || mother.expiresAt || null;
mother.token = preview(selected.record.accessToken);
addHistory('恢复 Team 管理凭据', `${mother.email || primaryEmail} 已复用关联账号的新 Team Token`);
await persist();
return { ok: true, status: 200, source: selected.source };
}
}
if (!force && (primaryOnly ? teamTokenDetails(mother.accessToken, workspaceId) : teamManagerContext(mother))) {
return { ok: true, status: 200, source: 'stored_team_token' };
}
const records = primaryOnly ? [mother] : [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])];
let lastFailure = null;
// Periodic quota checks must never start an interactive OAuth login. They can
// still use a stored refresh token; credential login is reserved for manual
// setup/refill actions.
for (const record of records) {
if (!record?.refreshToken) continue;
const refreshed = await refreshOpenAiAccessToken(record.refreshToken, record.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
if (!refreshed.ok) continue;
const claims = refreshed.claims || accessTokenClaims(refreshed.accessToken);
if (claims.accountId !== workspaceId) continue;
record.refreshToken = refreshed.refreshToken || record.refreshToken;
record.idToken = refreshed.idToken || record.idToken;
record.accessToken = refreshed.accessToken;
record.expiresAt = claims.expiresAt || record.expiresAt || null;
addHistory('刷新 Team 管理凭据', `${record.email || mother.email} 已通过 refresh token 恢复 ${mother.team}`);
await persist();
return { ok: true, status: 200, source: 'team_refresh_token' };
}
if (!allowCredentialLogin) {
return { ok: false, status: 401, message: 'workspace_owner_token_required', code: 'stored_token_unavailable' };
}
const retryAfterMs = bypassCooldown ? 0 : managerLoginCooldown(mother.lastManagerRecoveryAttemptAt);
if (retryAfterMs) {
return { ok: false, status: 429, code: 'workspace_manager_recovery_cooldown', message: 'workspace_manager_recovery_cooldown', retryAfterMs };
}
mother.lastManagerRecoveryAttemptAt = now();
await persist();
if (!primaryOnly) {
for (const child of teamOwnerCandidateChildren(mother)) {
const exchanged = await switchWorkspaceWithFreeRecovery(child, mother.accountId);
if (exchanged.ok) {
addHistory('恢复 Team 管理凭据', `${child.email} 已重新取得 ${mother.team} 的管理 Token`);
await persist();
return { ok: true, status: 200, source: exchanged.freeAuth?.source || 'workspace_exchange', childId: child.id };
}
lastFailure = exchanged;
}
}
for (const record of records) {
if (!record?.email || !record.password || (!record.totp && !record.secret)) continue;
const temporary = {
...record,
workspaceTokens: {},
workspaceHistory: [],
teamAuthSessions: record.teamAuthSessions && typeof record.teamAuthSessions === 'object' ? record.teamAuthSessions : {},
};
// Roxy mappings belong to Free-pool accounts, not temporary owner records.
const authenticated = await acquireTeamAuth(mother, temporary, { force: true, browserLogin: false });
record.teamAuthSessions = temporary.teamAuthSessions;
if (!authenticated.ok) {
lastFailure = authenticated;
await persist();
continue;
}
const workspaceToken = workspaceTokenFor(temporary, workspaceId);
if (!workspaceToken?.accessToken) continue;
record.accessToken = workspaceToken.accessToken;
record.refreshToken = workspaceToken.refreshToken || record.refreshToken || '';
record.idToken = workspaceToken.idToken || record.idToken || '';
record.expiresAt = workspaceToken.expiresAt || record.expiresAt || null;
record.teamAuthSessions = {};
addHistory('重新登录 Team 所有者', `${record.email} 已通过邮箱、密码和 2FA 恢复 ${mother.team}`);
await persist();
return { ok: true, status: 200, source: 'team_email_password_2fa' };
}
return { ok: false, status: lastFailure?.status || 401, message: lastFailure?.message || 'workspace_owner_token_required', code: lastFailure?.code || null };
}
function saveWorkspaceToken(child, workspaceId, accessToken, claims = {}, oauth = {}) {
if (!child || !workspaceId || !accessToken) return null;
if (!child.workspaceTokens || typeof child.workspaceTokens !== 'object' || Array.isArray(child.workspaceTokens)) child.workspaceTokens = {};
const previous = child.workspaceTokens[workspaceId] || {};
const record = {
...previous,
accessToken,
accountId: claims.accountId || workspaceId,
userId: claims.userId || child.chatgptUserId || null,
expiresAt: claims.expiresAt || null,
refreshToken: oauth.refreshToken ?? previous.refreshToken ?? '',
idToken: oauth.idToken ?? previous.idToken ?? '',
clientId: oauth.clientId ?? previous.clientId ?? child.clientId ?? '',
sub2api: oauth.sub2api ?? previous.sub2api ?? null,
acquiredAt: now(),
source: oauth.source || 'workspace_session_exchange',
};
child.workspaceTokens[workspaceId] = record;
return record;
}
function membershipHistoryFor(child, teamId) {
if (!child || !teamId || !Array.isArray(child.workspaceHistory)) return [];
const teamKey = workspaceIdKey(teamId);
return child.workspaceHistory.filter((item) => item && workspaceIdKey(item.team || item.workspaceId) === teamKey);
}
function latestMembershipHistoryFor(child, teamId) {
const entries = membershipHistoryFor(child, teamId);
return entries.length ? entries[entries.length - 1] : null;
}
function canRejoinTeam(child, teamId) {
if (!child || !teamId) return false;
if (child.banStatus === 'banned' || child.status === 'banned') return false;
if (membershipHistoryFor(child, teamId).some((entry) => entry.status === 'active')) return false;
const last = [...membershipHistoryFor(child, teamId)].reverse().find((entry) => ['kicked', 'cooldown'].includes(entry.status));
if (!last) return true;
const retryAt = Date.parse(last.retryAfter || '');
if (Number.isFinite(retryAt)) return retryAt <= Date.now();
// A quota removal without a server-provided reset must be held until the
// account is explicitly rechecked; a manual removal remains rejoinable.
if (last.reason === 'quota_5h' || last.reason === 'quota_7d') return last.rejoinEligible === true;
return last.rejoinEligible !== false;
}
function isChildMemberOfTeam(child, teamId) {
if (!child || !teamId) return false;
if (membershipHistoryFor(child, teamId).some((entry) => entry.status === 'active')) return true;
const history = membershipHistoryFor(child, teamId);
return workspaceIdKey(child.team) === workspaceIdKey(teamId) && child.status !== 'kicked' && !history.some((entry) => ['kicked', 'cooldown'].includes(entry.status));
}
function seatClaimsForMother(mother) {
if (!mother) return [];
if (!Array.isArray(mother.seatClaims)) mother.seatClaims = [];
return mother.seatClaims;
}
function workspaceIdKey(value) {
return String(value || '').trim().toLowerCase();
}
function configuredTeamKey(mother) {
return workspaceIdKey(configuredTeamId(mother));
}
function workspaceVisibleMemberCount(mother) {
const teamKey = configuredTeamKey(mother);
const records = teamKey
? state.mothers.filter((candidate) => configuredTeamKey(candidate) === teamKey)
: [mother];
return records.reduce((maximum, candidate) => {
const count = Array.isArray(candidate?.members) ? candidate.members.filter(memberIsActive).length : 0;
return Math.max(maximum, count);
}, 0);
}
function seatClaimEntriesForWorkspace(mother) {
const teamKey = configuredTeamKey(mother);
if (!teamKey) return seatClaimsForMother(mother).map((claim) => ({ mother, claim }));
return state.mothers.flatMap((candidate) => seatClaimsForMother(candidate)
.filter((claim) => workspaceIdKey(claim.workspaceId || configuredTeamId(candidate)) === teamKey)
.map((claim) => ({ mother: candidate, claim })));
}
function seatClaimMatchesChild(claim, child) {
const childId = String(child?.id || '');
const email = String(child?.email || '').trim().toLowerCase();
return Boolean(
(childId && String(claim?.childId || '') === childId)
|| (email && String(claim?.email || '').trim().toLowerCase() === email)
);
}
function seatClaimForChild(mother, child) {
return seatClaimEntriesForWorkspace(mother).find(({ claim }) => seatClaimMatchesChild(claim, child))?.claim || null;
}
function anySeatClaimForChild(child) {
return state.mothers.some((mother) => seatClaimsForMother(mother).some((claim) => seatClaimMatchesChild(claim, child)));
}
function seatClaimReservations(mother) {
const claims = seatClaimEntriesForWorkspace(mother).map(({ claim }) => claim);
return claims.reduce((result, claim) => {
const type = claim?.seatType === 'prolite' ? 'prolite' : 'default';
result[type] += 1;
return result;
}, { default: 0, prolite: 0 });
}
function reserveSeatClaim(mother, child, seatType, inviteId, workspaceId = configuredTeamId(mother)) {
const existing = seatClaimForChild(mother, child);
const timestamp = now();
if (existing) {
Object.assign(existing, {
inviteId: inviteId || existing.inviteId || null,
seatType: seatType === 'prolite' ? 'prolite' : 'default',
phase: 'approval_pending',
updatedAt: timestamp,
});
return existing;
}
const claim = {
id: randomUUID(),
workspaceId: String(workspaceId || '').trim() || null,
childId: child?.id || null,
email: String(child?.email || '').trim(),
inviteId: inviteId || null,
seatType: seatType === 'prolite' ? 'prolite' : 'default',
phase: 'approval_pending',
createdAt: timestamp,
updatedAt: timestamp,
};
seatClaimsForMother(mother).push(claim);
return claim;
}
function releaseSeatClaim(mother, claim) {
if (!mother || !claim) return;
for (const candidate of state.mothers) {
candidate.seatClaims = seatClaimsForMother(candidate).filter((entry) => entry.id !== claim.id);
}
}
function reconcileSeatClaimsWithMembers(mother, members) {
if (!mother || !Array.isArray(members)) return;
const emails = new Set(members.filter(memberIsActive).map((member) => String(member.email || '').trim().toLowerCase()).filter(Boolean));
const ids = new Set(members.filter(memberIsActive).flatMap((member) => [member.id, member.accountUserId]).filter(Boolean).map(String));
const teamKey = configuredTeamKey(mother);
for (const candidate of state.mothers) {
candidate.seatClaims = seatClaimsForMother(candidate).filter((claim) => {
if (workspaceIdKey(claim.workspaceId || configuredTeamId(candidate)) !== teamKey) return true;
const email = String(claim.email || '').trim().toLowerCase();
const child = claim.childId ? findChild(claim.childId) : null;
return !(
(email && emails.has(email))
|| (child?.memberId && ids.has(String(child.memberId)))
|| (child?.accountUserId && ids.has(String(child.accountUserId)))
);
});
}
}
function invitationForTeam(child, mother) {
const workspaceId = configuredTeamId(mother);
const stored = (Array.isArray(child?.teamInvitations) ? child.teamInvitations : [])
.find((entry) => workspaceIdKey(entry.workspaceId) === workspaceIdKey(workspaceId));
if (stored) return stored;
if (workspaceId && workspaceIdKey(child?.pendingWorkspaceId) === workspaceIdKey(workspaceId) && child.pendingInviteId) {
return { motherId: mother.id, workspaceId, status: 'invited', inviteId: child.pendingInviteId, email: child.email, invitedAt: null };
}
return null;
}
function saveTeamInvitation(child, mother, fields = {}) {
if (!Array.isArray(child.teamInvitations)) child.teamInvitations = [];
const workspaceId = configuredTeamId(mother);
let entry = child.teamInvitations.find((item) => workspaceIdKey(item.workspaceId) === workspaceIdKey(workspaceId));
if (!entry) { entry = {}; child.teamInvitations.push(entry); }
Object.assign(entry, { motherId: mother.id, workspaceId, teamName: teamDisplayName(mother), email: child.email, invitedAt: entry.invitedAt || now(), ...fields });
return entry;
}
function completeTeamJsonFor(child, mother, { allowExpired = false } = {}) {
const workspaceId = configuredTeamId(mother);
const record = child?.workspaceTokens?.[workspaceId];
const claims = accessTokenClaims(record?.accessToken);
const plan = String(claims.planType || record?.planType || '').toLowerCase();
const expiresAt = Date.parse(claims.expiresAt || '');
const identityKnown = Boolean(claims.userId && child?.chatgptUserId === claims.userId)
|| Boolean(claims.email && workspaceIdKey(claims.email) === workspaceIdKey(child?.email));
return record?.refreshToken && workspaceId && claims.accountId === workspaceId && ['team', 'business', 'self_serve_business_prolite'].includes(plan)
&& identityKnown && Number.isFinite(expiresAt) && (allowExpired || expiresAt > Date.now() + 30_000)
&& childTokenIdentityMatches(child, record.accessToken) ? record : null;
}
function publicChild(child, teamId = null) {
const { accessToken, refreshToken, idToken, password, totp, secret, cookies, sessionJson, credentials, authSession, teamAuthSessions, teamLoginStates, teamLoginStatus, teamLoginMessage, teamLoginAttemptedAt, verificationCode, loginUrl, loginBrowserRequired, workspaceTokens, teamInvitations, pendingInviteId, ...safe } = child;
const membership = teamId ? membershipFor(child, teamId) : null;
const scopedMother = teamId ? state.mothers.find((mother) => [mother.id, mother.team, mother.accountId].some((id) => workspaceIdKey(id) === workspaceIdKey(teamId))) : null;
const teamLogin = teamId ? teamLoginFor(child, String(scopedMother?.accountId || teamId).trim()) : null;
const quotaRecord = membership || (!teamId || workspaceIdKey(child.team) === workspaceIdKey(teamId) ? child : {});
const safeToken = accessToken ? preview(accessToken) : child.token?.startsWith('待') ? child.token : preview(child.token);
const history = Array.isArray(child.workspaceHistory) ? child.workspaceHistory : [];
const joinedTeams = history.map((entry) => ({
team: entry.team || entry.workspaceId || '',
status: entry.status || 'unknown',
joinedAt: entry.joinedAt || null,
removedAt: entry.removedAt || null,
cooldownAt: entry.cooldownAt || null,
retryAfter: entry.retryAfter || null,
rejoinEligible: entry.rejoinEligible ?? null,
reason: entry.reason || null,
seatType: entry.seatType || null,
quota5h: entry.quota5h ?? null,
quota7d: entry.quota7d ?? null,
quotaSnapshot: entry.quotaSnapshot || null,
quota5hResetAfterSeconds: entry.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: entry.quota7dResetAfterSeconds ?? null,
quota5hResetAt: entry.quota5hResetAt || null,
quota7dResetAt: entry.quota7dResetAt || null,
quotaUpdatedAt: entry.quotaUpdatedAt || null,
lastProbe: entry.lastProbe || null,
quotaKickExhaustedAt: entry.quotaKickExhaustedAt || null,
quotaKickPendingUntil: entry.quotaKickPendingUntil || null,
quotaKickWindow: entry.quotaKickWindow || null,
quotaKickVerifiedAt: entry.quotaKickVerifiedAt || null,
quotaStatus: entry.quotaStatus || 'unknown',
quotaStatusWindow: entry.quotaStatusWindow || null,
...publicManualKickTimerFields(entry),
})).filter((entry) => entry.team);
if (child.team && !joinedTeams.some((entry) => entry.team === child.team && entry.status === 'active')
&& !joinedTeams.some((entry) => entry.team === child.team && ['kicked', 'cooldown'].includes(entry.status))) {
joinedTeams.push({ team: child.team, status: 'active', joinedAt: child.joinedAt || null, removedAt: null, retryAfter: null, reason: null, seatType: child.memberSnapshot?.seatType || null, quota5h: child.quota5h ?? null, quota7d: child.quota7d ?? null, quotaSnapshot: child.quotaSnapshot || null, quota5hResetAfterSeconds: child.quota5hResetAfterSeconds ?? null, quota7dResetAfterSeconds: child.quota7dResetAfterSeconds ?? null, quota5hResetAt: child.quota5hResetAt || null, quota7dResetAt: child.quota7dResetAt || null, quotaUpdatedAt: child.quotaUpdatedAt || child.lastQuotaCheckAt || null, ...publicManualKickTimerFields(null) });
}
return {
...safe,
localDeletion: childLocalDeletionPolicy(child),
teamInvitations: state.mothers.flatMap((mother) => {
const invitation = invitationForTeam(child, mother);
const joined = isChildMemberOfTeam(child, mother.team);
if (!joined && invitation?.status !== 'invited') return [];
return [{ motherId: mother.id, workspaceId: configuredTeamId(mother), teamName: teamDisplayName(mother), status: joined ? 'joined' : 'invited', invitedAt: invitation?.invitedAt || null }];
}),
teamAuthorizations: state.mothers.flatMap((mother) => {
const workspaceId = configuredTeamId(mother);
const record = child.workspaceTokens?.[workspaceId];
if (!invitationForTeam(child, mother) && !isChildMemberOfTeam(child, mother.team) && !record && !child.teamAuthSessions?.[workspaceId]) return [];
return [{ motherId: mother.id, workspaceId, hasAccessToken: Boolean(record?.accessToken), hasRefreshToken: Boolean(record?.refreshToken), expiresAt: accessTokenClaims(record?.accessToken).expiresAt || null, ready: Boolean(completeTeamJsonFor(child, mother)), login: teamLoginFor(child, workspaceId) }];
}),
quota5h: quotaRecord.quota5h ?? null,
quota7d: quotaRecord.quota7d ?? null,
quotaSnapshot: quotaRecord.quotaSnapshot || null,
quotaUpdatedAt: quotaRecord.quotaUpdatedAt || null,
lastProbe: quotaRecord.lastProbe || null,
quotaStatus: quotaRecord.quotaStatus || 'unknown',
quotaStatusWindow: quotaRecord.quotaStatusWindow || null,
quota5hResetAfterSeconds: quotaRecord.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: quotaRecord.quota7dResetAfterSeconds ?? null,
quota5hResetAt: quotaRecord.quota5hResetAt || null,
quota7dResetAt: quotaRecord.quota7dResetAt || null,
quotaKickExhaustedAt: membership?.quotaKickExhaustedAt || null,
quotaKickPendingUntil: membership?.quotaKickPendingUntil || null,
quotaKickWindow: membership?.quotaKickWindow || null,
seatType: membership?.seatType || child.memberSnapshot?.seatType || child.seatType || null,
...publicManualKickTimerFields(membership),
token: safeToken,
hasAccessToken: Boolean(accessToken || (child.token && !child.token.startsWith('待'))),
// A Free account keeps its pool identity after joining one or more Teams.
// Team membership is represented by `team` and `joinedTeams`, not by the
// account type itself; imported Team owner records live on `mothers`.
accountType: String(child.plan || '').toLowerCase() === 'team' ? 'team' : 'free',
credentialsStatus: {
hasPassword: Boolean(password),
hasTotp: Boolean(totp || child.secret),
hasAccessToken: Boolean(accessToken || (child.token && !child.token.startsWith('待'))),
hasRefreshToken: Boolean(refreshToken),
canAcquire: Boolean(accessToken || refreshToken || (child.email && password)),
},
login: {
status: child.loginStatus || (accessToken ? 'ready' : child.password ? 'login_required' : 'credentials_required'),
code: child.loginErrorCode || null,
stage: child.loginFailureStage || null,
httpStatus: child.loginHttpStatus || null,
message: child.loginMessage || '',
browserRequired: Boolean(loginBrowserRequired),
authUrl: loginBrowserRequired ? loginUrl || null : null,
lastAttemptAt: child.loginAttemptedAt || null,
},
teamLogin,
sub2apiStatus: {
imported: Boolean(child.sub2apiImported || child.sub2api?.imported || child.importSource === 'sub2api' || accessToken),
exportable: Boolean(accessToken),
importedAt: child.importedAt || null,
synced: child.sub2api?.synced === true,
integrationId: child.sub2api?.integrationId || null,
integrationName: child.sub2api?.integrationName || '',
accountId: child.sub2api?.accountId || null,
groupId: child.sub2api?.groupId || null,
groupName: child.sub2api?.groupName || '',
message: child.sub2api?.message || '',
syncedAt: child.sub2api?.syncedAt || null,
},
tokenScope: 'free',
banStatus: child.banStatus === 'banned' || child.status === 'banned' ? 'banned' : 'clear',
bannedAt: child.bannedAt || null,
banReason: child.banReason || '',
banEvidence: child.banEvidence || null,
joinedTeams,
};
}
function hasQuotaObservation(record) {
if (!record) return false;
const snapshot = record.quotaSnapshot;
const measured = (value) => ['number', 'string'].includes(typeof value)
&& String(value).trim() !== '' && Number.isFinite(Number(value));
return measured(record.quota5h) || measured(record.quota7d)
|| measured(snapshot?.primary?.usedPercent) || measured(snapshot?.secondary?.usedPercent)
|| snapshot?.quotaWindowMapping === 'duration-v1'
|| record.lastProbe?.ok === true;
}
function quotaRecordFields(record = {}) {
return {
quota5h: record.quota5h ?? null,
quota7d: record.quota7d ?? null,
quotaSnapshot: record.quotaSnapshot || null,
quotaUpdatedAt: record.quotaUpdatedAt || null,
quota5hResetAfterSeconds: record.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: record.quota7dResetAfterSeconds ?? null,
quota5hResetAt: record.quota5hResetAt || null,
quota7dResetAt: record.quota7dResetAt || null,
lastProbe: record.lastProbe || null,
lastQuotaCheckAt: record.lastQuotaCheckAt || null,
quotaStatus: record.quotaStatus || 'unknown',
quotaStatusWindow: record.quotaStatusWindow || null,
};
}
function publicMother(mother) {
const { accessToken, refreshToken, idToken, password, totp, secret, cookies, sessionJson, credentials, authSession, teamAuthSessions, workspaceTokens, workspaceHistory, verificationCode, loginUrl, token, ownerAccounts, seatClaims, inviteSeatType, ...safe } = mother;
const sub2apiConfig = sub2ApiConfigForMother(mother);
const seatReservations = seatClaimReservations(mother);
const seatAvailability = totalSeatAvailability(seatAdmissionSnapshot(mother), seatReservations);
const safeOwners = Array.isArray(ownerAccounts) ? ownerAccounts.map((owner) => ({
...publicTeamOwnerRecord(mother, owner.email),
email: owner.email || '',
name: owner.name || '',
userId: owner.chatgptUserId || null,
token: owner.accessToken ? preview(owner.accessToken) : '',
hasAccessToken: Boolean(owner.accessToken),
tokenScope: 'team',
credentialsStatus: { hasPassword: Boolean(owner.password), hasTotp: Boolean(owner.totp), hasAccessToken: Boolean(owner.accessToken), hasRefreshToken: Boolean(owner.refreshToken) },
})) : [];
return {
...safe,
rotationEnabled: normalizeTeamRotationEnabled(mother.rotationEnabled),
defaultSeatType: normalizeDefaultSeatType(mother.defaultSeatType, inviteSeatType),
seatClaimsCount: seatReservations.default + seatReservations.prolite,
seatsInUse: seatAvailability?.inUse ?? null,
seatsHeld: seatAvailability?.held ?? null,
seatsOpen: seatAvailability?.remaining ?? null,
dailyRotationLimit: normalizeDailyRotationLimit(mother.dailyRotationLimit),
syncOwnerToSub2api: mother.syncOwnerToSub2api !== false,
dailyRotationUsage: dailyRotationBudget(mother),
sub2apiIntegrationId: mother.sub2apiIntegrationId || sub2apiConfig?.id || null,
sub2apiIntegrationName: sub2apiConfig?.name || '',
ownerAccounts: safeOwners,
token: accessToken ? preview(accessToken) : token?.startsWith('待') ? token : preview(token),
hasAccessToken: Boolean(accessToken || (token && !token.startsWith('待'))),
credentialsStatus: {
hasPassword: Boolean(password),
hasTotp: Boolean(totp || mother.secret),
hasAccessToken: Boolean(accessToken || (token && !token.startsWith('待'))),
},
tokenScope: 'team',
};
}
function publicTeamOwnerRecord(mother, email) {
const target = String(email || '').trim().toLowerCase();
if (!target) return null;
const owner = [mother, ...(Array.isArray(mother?.ownerAccounts) ? mother.ownerAccounts : [])]
.find((item) => String(item?.email || '').trim().toLowerCase() === target);
if (!owner) return null;
const storedSnapshot = owner.quotaSnapshot && typeof owner.quotaSnapshot === 'object' ? owner.quotaSnapshot : {};
const probe = owner.lastProbe?.ok === true ? owner.lastProbe : null;
const sourceSnapshot = probe ? { ...probe, updatedAt: owner.quotaUpdatedAt || owner.lastQuotaCheckAt || null } : storedSnapshot;
const primary = sourceSnapshot.primary || {};
const secondary = sourceSnapshot.secondary || {};
const numeric = (value) => !['number', 'string'].includes(typeof value) || String(value).trim() === '' || !Number.isFinite(Number(value)) ? null : Number(value);
const remaining = (value) => {
const used = numeric(value?.usedPercent);
return used == null ? null : Math.max(0, Math.min(100, Math.round(100 - used)));
};
const complete = sourceSnapshot.quotaWindowMapping === 'duration-v1';
const quota5h = complete ? remaining(primary) : (probe ? remaining(primary) ?? numeric(owner.quota5h) : numeric(owner.quota5h) ?? remaining(primary));
const quota7d = complete ? remaining(secondary) : (probe ? remaining(secondary) ?? numeric(owner.quota7d) : numeric(owner.quota7d) ?? remaining(secondary));
return {
name: owner.name || owner.email || email,
token: owner.accessToken ? preview(owner.accessToken) : '',
hasAccessToken: Boolean(owner.accessToken),
tokenScope: 'team',
quota5h,
quota7d,
quotaSnapshot: complete || sourceSnapshot.primary || sourceSnapshot.secondary
? sourceSnapshot
: (primary.usedPercent != null || secondary.usedPercent != null ? {
primary,
secondary,
source: probe?.source || 'wham/usage',
quotaWindowMapping: probe?.quotaWindowMapping || undefined,
updatedAt: owner.quotaUpdatedAt || null,
} : null),
quota5hResetAfterSeconds: complete && quota5h == null ? null : owner.quota5hResetAfterSeconds ?? primary.resetAfterSeconds ?? null,
quota7dResetAfterSeconds: complete && quota7d == null ? null : owner.quota7dResetAfterSeconds ?? secondary.resetAfterSeconds ?? null,
quota5hResetAt: complete && quota5h == null ? null : owner.quota5hResetAt || primary.resetAt || null,
quota7dResetAt: complete && quota7d == null ? null : owner.quota7dResetAt || secondary.resetAt || null,
quotaUpdatedAt: owner.quotaUpdatedAt || null,
lastProbe: owner.lastProbe || null,
credentialsStatus: { hasPassword: Boolean(owner.password), hasTotp: Boolean(owner.totp || owner.secret), hasAccessToken: Boolean(owner.accessToken), hasRefreshToken: Boolean(owner.refreshToken) },
sub2apiStatus: { imported: Boolean(owner.accessToken), exportable: Boolean(owner.accessToken), importedAt: owner.importedAt || null },
};
}
function publicTeam(mother) {
const teamId = canonicalTeamId(mother);
const displayName = mother.teamName || mother.displayName || '未命名 Team';
const primaryOwnerEmail = String(mother.primaryOwnerEmail || mother.email || '');
const children = state.children.filter((child) => isChildMemberOfTeam(child, teamId));
const seatReservations = seatClaimReservations(mother);
const admission = seatAdmissionSnapshot(mother);
const seatAvailability = totalSeatAvailability(admission, seatReservations);
const seatsEntitled = admission.seatsEntitled !== null && admission.seatsEntitled !== undefined && Number.isFinite(Number(admission.seatsEntitled)) ? Number(admission.seatsEntitled) : null;
const seatsInUse = admission.seatsInUse !== null && admission.seatsInUse !== undefined && Number.isFinite(Number(admission.seatsInUse)) ? Number(admission.seatsInUse) : null;
const reservedSeats = seatReservations.default + seatReservations.prolite;
const currentAccounts = [];
const ownerEmails = new Set([mother.email, ...(mother.ownerAccounts || []).map((owner) => owner.email)].filter(Boolean).map((email) => String(email).toLowerCase()));
const authoritativeMembers = mother.lastMembersProbe?.ok === true;
const memberGroups = groupTeamAccounts((mother.members || []).filter(memberIsActive), children, { authoritativeMembers });
for (const group of memberGroups) {
const { member, child } = group;
if (child) {
const memberSnapshot = member || child.memberSnapshot || {};
const owner = memberIsOwner(memberSnapshot, mother) || ownerEmails.has(String(child.email || '').toLowerCase());
const childProjection = publicChild(child, teamId);
const ownerProjection = owner ? publicTeamOwnerRecord(mother, child.email) : null;
const childQuotaRecord = membershipFor(child, teamId)
|| (workspaceIdKey(child.team) === workspaceIdKey(teamId) ? child : {});
const quotaProjection = ownerProjection && !hasQuotaObservation(childQuotaRecord)
? ownerProjection : childProjection;
currentAccounts.push({
...childProjection,
...(ownerProjection || {}),
id: childProjection.id,
email: childProjection.email || memberSnapshot.email || '',
name: childProjection.name || memberSnapshot.name || ownerProjection?.name || '',
memberId: childProjection.memberId || memberSnapshot.id || null,
accountUserId: childProjection.accountUserId || memberSnapshot.accountUserId || memberSnapshot.account_user_id || null,
...quotaRecordFields(quotaProjection),
lastProbe: childProjection.lastProbe || quotaProjection.lastProbe || null,
credentialsStatus: childProjection.credentialsStatus || ownerProjection?.credentialsStatus || null,
accountType: owner ? 'team-owner' : 'team-member',
role: owner ? (memberSnapshot.role || 'account-owner') : memberSnapshot.role || null,
seatType: memberSnapshot.seatType || memberSnapshot.seat_type || childProjection.seatType || null,
joinedAt: childProjection.joinedAt || memberSnapshot.createdTime || memberSnapshot.created_time || null,
});
} else if (member) {
const memberOwner = memberIsOwner(member, mother) ? publicTeamOwnerRecord(mother, member.email) : null;
currentAccounts.push({
id: member.id || member.accountUserId || `member_${currentAccounts.length}`,
memberId: member.id || null,
accountUserId: member.accountUserId || member.account_user_id || null,
email: member.email || '',
name: member.name || '',
...(memberOwner || {}),
accountType: memberIsOwner(member, mother) ? 'team-owner' : 'team-member',
role: member.role || null,
seatType: member.seatType || null,
status: member.deactivatedTime ? 'inactive' : 'active',
quota5h: memberOwner?.quota5h ?? null,
quota7d: memberOwner?.quota7d ?? null,
quotaSnapshot: memberOwner?.quotaSnapshot || null,
quota5hResetAfterSeconds: memberOwner?.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: memberOwner?.quota7dResetAfterSeconds ?? null,
quota5hResetAt: memberOwner?.quota5hResetAt || null,
quota7dResetAt: memberOwner?.quota7dResetAt || null,
quotaUpdatedAt: memberOwner?.quotaUpdatedAt || null,
credentialsStatus: member.email?.toLowerCase() === mother.email?.toLowerCase() ? { hasPassword: Boolean(mother.password), hasTotp: Boolean(mother.totp || mother.secret), hasAccessToken: Boolean(mother.accessToken) } : null,
joinedTeams: [{ team: teamId, status: 'active', joinedAt: member.createdTime || null }],
});
}
}
for (const owner of authoritativeMembers ? [] : (mother.ownerAccounts || [])) {
if (!owner.email || currentAccounts.some((account) => account.email?.toLowerCase() === owner.email.toLowerCase())) continue;
const ownerProjection = publicTeamOwnerRecord(mother, owner.email);
currentAccounts.push({
id: owner.chatgptUserId || `owner_${currentAccounts.length}`,
email: owner.email,
name: owner.name || '',
accountType: 'team-owner',
role: 'account-owner',
status: 'active',
token: owner.accessToken ? preview(owner.accessToken) : '',
hasAccessToken: Boolean(owner.accessToken),
tokenScope: 'team',
quota5h: ownerProjection?.quota5h ?? null,
quota7d: ownerProjection?.quota7d ?? null,
quotaSnapshot: ownerProjection?.quotaSnapshot || null,
quota5hResetAfterSeconds: ownerProjection?.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: ownerProjection?.quota7dResetAfterSeconds ?? null,
quota5hResetAt: ownerProjection?.quota5hResetAt || null,
quota7dResetAt: ownerProjection?.quota7dResetAt || null,
quotaUpdatedAt: ownerProjection?.quotaUpdatedAt || null,
lastProbe: ownerProjection?.lastProbe || null,
credentialsStatus: { hasPassword: Boolean(owner.password), hasTotp: Boolean(owner.totp), hasAccessToken: Boolean(owner.accessToken), hasRefreshToken: Boolean(owner.refreshToken) },
sub2apiStatus: { imported: Boolean(owner.accessToken), exportable: Boolean(owner.accessToken), importedAt: null },
joinedTeams: [{ team: teamId, status: 'active', joinedAt: null }],
});
}
if (!authoritativeMembers && !currentAccounts.some((account) => account.email?.toLowerCase() === mother.email?.toLowerCase()) && mother.email) {
const ownerProjection = publicTeamOwnerRecord(mother, mother.email);
currentAccounts.unshift({ id: mother.chatgptUserId || `owner_${mother.id}`, email: mother.email, ...ownerProjection, accountType: 'team-owner', status: 'active', quota5h: ownerProjection?.quota5h ?? null, quota7d: ownerProjection?.quota7d ?? null, quotaSnapshot: ownerProjection?.quotaSnapshot || null, joinedTeams: [{ team: teamId, status: 'active', joinedAt: mother.createdAt || null }] });
}
const primaryOwner = currentAccounts.find((account) => String(account.email || '').toLowerCase() === primaryOwnerEmail.toLowerCase()) || currentAccounts.find((account) => account.accountType === 'team-owner');
const sub2apiConfig = sub2ApiConfigForMother(mother);
return {
id: mother.id,
teamId,
name: displayName,
displayName,
rotationMode: mother.rotationMode === 'rotating' ? 'rotating' : 'fixed',
rotationEnabled: normalizeTeamRotationEnabled(mother.rotationEnabled),
defaultSeatType: normalizeDefaultSeatType(mother.defaultSeatType, mother.inviteSeatType),
dailyRotationLimit: normalizeDailyRotationLimit(mother.dailyRotationLimit),
dailyRotationUsage: dailyRotationBudget(mother),
owner: { email: primaryOwner?.email || mother.email || '', name: primaryOwner?.name || mother.name || '', userId: primaryOwner?.id || mother.chatgptUserId || null },
primaryOwnerEmail,
owners: currentAccounts.filter((account) => account.accountType === 'team-owner').map((account) => ({ email: account.email || '', name: account.name || '', userId: account.id || null })),
seats: {
used: Number.isFinite(seatsInUse) ? seatsInUse : null,
entitled: Number.isFinite(seatsEntitled) ? seatsEntitled : null,
held: seatAvailability?.held ?? null,
open: seatAvailability?.remaining ?? null,
reserved: reservedSeats,
},
status: mother.status || 'unconfigured',
lastCheck: mother.lastCheck || null,
lastSync: mother.lastWorkspaceSyncAt || null,
sub2apiIntegrationId: mother.sub2apiIntegrationId || sub2apiConfig?.id || null,
sub2apiIntegrationName: sub2apiConfig?.name || '',
rotationProgress: mother.rotationProgress || null,
currentAccounts,
};
}
function publicHistory() {
return (Array.isArray(state.history) ? state.history : []).map((item) => {
let detail = String(item.detail || '');
for (const mother of state.mothers) {
const teamId = canonicalTeamId(mother);
if (!teamId) continue;
detail = detail.split(teamId).join(teamDisplayName(mother));
}
return { ...item, detail };
});
}
function publicState({ includeHistory = true } = {}) {
const integrations = state.settings?.integrations || {};
const safeSub2Apis = sub2ApiConfigs().map(publicSub2ApiConfig);
const safeSettings = {
...state.settings,
integrations: {
sub2api: safeSub2Apis[0] || publicSub2ApiConfig(integrations.sub2api),
sub2apis: safeSub2Apis,
mailbox: { serviceType: integrations.mailbox?.serviceType || 'manual', endpoint: integrations.mailbox?.endpoint || '', enabled: integrations.mailbox?.enabled === true, apiKeySet: Boolean(integrations.mailbox?.apiKey) },
roxy: publicRoxyConfig(integrations.roxy),
},
proxy: publicProxySettings(state.settings),
};
const snapshot = { ...state, settings: safeSettings, teams: state.mothers.map(publicTeam), mothers: state.mothers.map(publicMother), children: state.children.map((child) => publicChild(child)) };
// Short-lived OAuth sessions contain PKCE verifiers and cookies and are
// encrypted at rest only; they must never be included in dashboard state.
delete snapshot.quickJsonAuthSessions;
// Vault credentials are only available through explicit detail/export requests.
delete snapshot.motherVault;
delete snapshot.mailboxVault;
if (includeHistory) snapshot.history = publicHistory();
else delete snapshot.history;
return snapshot;
}
const configuredCorsOrigins = new Set(String(process.env.TEAM_ROTATION_ALLOWED_ORIGINS || '')
.split(',').map((value) => value.trim()).filter(Boolean));
if (isLoopbackHost(host)) {
configuredCorsOrigins.add('http://127.0.0.1:5173');
configuredCorsOrigins.add('http://localhost:5173');
}
function requestOriginAllowed(req) {
const origin = String(req.headers.origin || '').trim();
if (!origin) return true;
if (configuredCorsOrigins.has(origin)) return true;
try { return new URL(origin).host === String(req.headers.host || ''); } catch { return false; }
}
function requestHostAllowed(req) {
if (!isLoopbackHost(host)) return true;
let requestedHostname;
try { requestedHostname = new URL(`http://${String(req.headers.host || '')}`).hostname; }
catch { return false; }
if (isLoopbackHost(requestedHostname)) return true;
for (const origin of configuredCorsOrigins) {
try {
if (new URL(origin).hostname.toLowerCase() === requestedHostname.toLowerCase()) return true;
} catch { /* invalid configured origins are ignored */ }
}
return false;
}
function corsHeaders(req) {
const origin = String(req.headers.origin || '').trim();
if (!origin || !requestOriginAllowed(req)) return {};
return { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', vary: 'Origin' };
}
function apiAuthOk(req) {
if (!apiAuthToken) return true;
const authorization = String(req.headers.authorization || '');
const supplied = authorization.toLowerCase().startsWith('bearer ')
? authorization.slice(7).trim()
: String(req.headers['x-api-token'] || '').trim();
return secureTokenEqual(supplied, apiAuthToken);
}
function responseHeaders(res) {
return {
...(res.securityHeaders || {}),
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
};
}
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, { ...responseHeaders(res), 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(body);
}
function sendText(res, status, body, type = 'text/plain; charset=utf-8') { res.writeHead(status, { ...responseHeaders(res), 'content-type': type }); res.end(body); }
async function bodyOf(req) {
let text = '';
for await (const chunk of req) {
text += chunk;
if (text.length > 2_000_000) throw new Error('request too large');
}
if (!text) return {};
try { return JSON.parse(text); } catch { throw new Error('invalid JSON'); }
}
function findMother(id) { return state.mothers.find((mother) => mother.id === id); }
function findChild(id) { return state.children.find((child) => child.id === id); }
function configuredTeamId(mother) {
const accountId = String(mother?.accountId || '').trim();
if (accountId) return accountId;
const team = String(mother?.team || '').trim();
return team && team !== String(mother?.id || '').trim() ? team : '';
}
function canonicalTeamId(mother) { return configuredTeamId(mother) || mother?.id || ''; }
function teamDisplayName(mother) { return mother?.teamName || mother?.displayName || '未命名 Team'; }
function primaryOwnerRecord(mother) {
const primaryEmail = String(mother?.primaryOwnerEmail || mother?.email || '').toLowerCase();
return [mother, ...(mother?.ownerAccounts || [])].find((owner) => String(owner?.email || '').toLowerCase() === primaryEmail) || mother;
}
function promotePrimaryOwner(mother) {
const owner = primaryOwnerRecord(mother);
if (!owner || owner === mother) return;
const previousOwner = (mother.ownerAccounts || []).find((candidate) => workspaceIdKey(candidate.email) === workspaceIdKey(mother.email));
if (previousOwner) Object.assign(previousOwner, quotaRecordFields(mother));
// Changing the primary operator must never change the Team workspace itself.
for (const key of ['email', 'password', 'totp', 'mailboxUrl', 'accessToken', 'refreshToken', 'chatgptUserId', 'clientId', 'idToken', 'organizationId', 'modelMapping', 'expiresAt', 'subscriptionExpiresAt']) {
if (owner[key] !== undefined && owner[key] !== null && owner[key] !== '') mother[key] = owner[key];
}
Object.assign(mother, quotaRecordFields(owner));
}
function isTeamAccount(item) {
const fields = credentialFields(item);
return String(fields.planType || item.plan || item.accountType || '').toLowerCase() === 'team';
}
function mergeImportedMother(mother, item) {
const imported = motherFromImportedAccount(item);
const owner = imported.ownerAccounts?.[0];
if (!Array.isArray(mother.ownerAccounts)) mother.ownerAccounts = [];
if (owner?.email) {
const existing = mother.ownerAccounts.find((entry) => entry.email?.toLowerCase() === owner.email.toLowerCase());
if (existing) Object.assign(existing, Object.fromEntries(Object.entries(owner).filter(([, value]) => value !== '' && value !== null && value !== undefined)));
else mother.ownerAccounts.push(owner);
}
const primaryFields = ['teamName', 'seats', 'used', 'accountId', 'team', 'subscription', 'seatSnapshot', 'dailyRotationLimit', 'defaultSeatType', 'rotationEnabled'];
for (const key of primaryFields) {
if ((mother[key] === '' || mother[key] === null || mother[key] === undefined) && imported[key] !== '' && imported[key] !== null && imported[key] !== undefined) mother[key] = imported[key];
}
if (mother.accountId) mother.team = String(mother.accountId).trim();
if (!mother.accessToken && imported.accessToken) mother.accessToken = imported.accessToken;
if (!mother.refreshToken && imported.refreshToken) mother.refreshToken = imported.refreshToken;
if (!mother.email && imported.email) mother.email = imported.email;
if (!mother.name && imported.name) mother.name = imported.name;
return mother;
}
function linkFreeAccountsToImportedTeams() {
const teamsByOwnerEmail = new Map();
const ownerCredentialsByEmail = new Map();
const teamQuotaByEmail = new Map();
for (const mother of state.mothers) {
const teamId = canonicalTeamId(mother);
if (!teamId) continue;
for (const owner of [{ email: mother.email, password: mother.password, totp: mother.totp, mailboxUrl: mother.mailboxUrl }, ...(mother.ownerAccounts || [])]) {
if (owner.email) {
const key = String(owner.email).toLowerCase();
const existing = ownerCredentialsByEmail.get(key) || {};
ownerCredentialsByEmail.set(key, {
...existing,
password: existing.password || owner.password || '',
totp: existing.totp || owner.totp || '',
mailboxUrl: existing.mailboxUrl || owner.mailboxUrl || '',
});
const teamKey = `${teamId}:${key}`;
const existingQuota = teamQuotaByEmail.get(teamKey) || {};
teamQuotaByEmail.set(teamKey, {
quota5h: existingQuota.quota5h ?? owner.quota5h ?? null,
quota7d: existingQuota.quota7d ?? owner.quota7d ?? null,
quotaSnapshot: existingQuota.quotaSnapshot || owner.quotaSnapshot || null,
quota5hResetAfterSeconds: existingQuota.quota5hResetAfterSeconds ?? owner.quota5hResetAfterSeconds ?? null,
quota7dResetAfterSeconds: existingQuota.quota7dResetAfterSeconds ?? owner.quota7dResetAfterSeconds ?? null,
quota5hResetAt: existingQuota.quota5hResetAt || owner.quota5hResetAt || null,
quota7dResetAt: existingQuota.quota7dResetAt || owner.quota7dResetAt || null,
quotaUpdatedAt: existingQuota.quotaUpdatedAt || owner.quotaUpdatedAt || null,
});
}
}
const memberEmails = (mother.members || [])
.filter((member) => memberIsActive(member))
.map((member) => member.email)
.filter(Boolean);
const accountEmails = [mother.email, ...(mother.ownerAccounts || []).map((owner) => owner.email), ...memberEmails].filter(Boolean);
for (const email of accountEmails) {
const key = String(email).toLowerCase();
if (!teamsByOwnerEmail.has(key)) teamsByOwnerEmail.set(key, []);
const values = teamsByOwnerEmail.get(key);
if (!values.includes(teamId)) values.push(teamId);
}
}
for (const child of state.children) {
if (String(child.plan || '').toLowerCase() !== 'free' || !child.email) continue;
const teamIds = teamsByOwnerEmail.get(child.email.toLowerCase()) || [];
const ownerCredentials = ownerCredentialsByEmail.get(child.email.toLowerCase());
if (ownerCredentials) {
if (!child.password && ownerCredentials.password) child.password = ownerCredentials.password;
if (!child.totp && ownerCredentials.totp) child.totp = ownerCredentials.totp;
if (!child.mailboxUrl && ownerCredentials.mailboxUrl) child.mailboxUrl = ownerCredentials.mailboxUrl;
}
for (const teamId of teamIds) {
const membership = membershipFor(child, teamId, true);
membership.source = membership.source || 'team_owner_email_match';
membership.status = 'active';
membership.joinedAt = membership.joinedAt || child.joinedAt || now();
const importedQuota = teamQuotaByEmail.get(`${teamId}:${child.email.toLowerCase()}`);
const measuredQuota = membership.quotaSnapshot?.primary?.usedPercent != null || membership.quotaSnapshot?.secondary?.usedPercent != null;
if (importedQuota && !measuredQuota) {
if (importedQuota.quota5h != null) membership.quota5h = importedQuota.quota5h;
if (importedQuota.quota7d != null) membership.quota7d = importedQuota.quota7d;
if (importedQuota.quotaSnapshot) membership.quotaSnapshot = importedQuota.quotaSnapshot;
if (importedQuota.quota5hResetAfterSeconds != null) membership.quota5hResetAfterSeconds = importedQuota.quota5hResetAfterSeconds;
if (importedQuota.quota7dResetAfterSeconds != null) membership.quota7dResetAfterSeconds = importedQuota.quota7dResetAfterSeconds;
if (importedQuota.quota5hResetAt) membership.quota5hResetAt = importedQuota.quota5hResetAt;
if (importedQuota.quota7dResetAt) membership.quota7dResetAt = importedQuota.quota7dResetAt;
if (importedQuota.quotaUpdatedAt) membership.quotaUpdatedAt = importedQuota.quotaUpdatedAt;
membership.quotaSource = 'sub2api-team-import';
}
}
if (teamIds.length && !child.team) {
child.team = teamIds[0];
child.joinedAt = child.joinedAt || now();
if (!childIsBanned(child)) child.status = child.accessToken ? 'active' : 'login_required';
}
}
}
function migrateTeamMemberships(oldTeam, newTeam) {
if (!oldTeam || !newTeam || oldTeam === newTeam) return;
for (const child of state.children) {
if (child.team === oldTeam) child.team = newTeam;
if (Array.isArray(child.workspaceHistory)) {
for (const entry of child.workspaceHistory) {
if (entry.team === oldTeam) entry.team = newTeam;
}
}
if (child.memberSnapshot?.team === oldTeam) child.memberSnapshot.team = newTeam;
if (child.workspaceTokens?.[oldTeam]) {
child.workspaceTokens[newTeam] = child.workspaceTokens[newTeam] || child.workspaceTokens[oldTeam];
delete child.workspaceTokens[oldTeam];
}
}
}
function bearer(token) { return token ? { authorization: `Bearer ${token}` } : {}; }
function chatGptHeaders(accessToken, accountId, targetPath, targetRoute, extra = {}) {
return {
accept: 'application/json, */*',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
...bearer(accessToken),
...(accountId ? { 'chatgpt-account-id': accountId } : {}),
origin: CHATGPT_BASE_URL,
referer: `${CHATGPT_BASE_URL}/admin/members`,
'oai-device-id': extra['oai-device-id'] || randomUUID(),
'oai-language': 'zh-CN',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/149 Safari/537.36',
'x-openai-target-path': targetPath,
'x-openai-target-route': targetRoute,
...(extra.cookie ? { cookie: extra.cookie } : {}),
...extra,
};
}
async function fetchChatGptJson(accessToken, requestPath, options = {}) {
if (!accessToken) return { ok: false, status: 0, message: 'missing_token', payload: {} };
const targetPath = options.targetPath || requestPath.split('?')[0];
const targetRoute = options.targetRoute || targetPath.replace(/\/[^/]+(?=\/users|\/subscriptions)/, '/{account_id}');
const url = requestPath.startsWith('http') ? requestPath : `${CHATGPT_BASE_URL}${requestPath}`;
const started = Date.now();
try {
const response = await proxyFetch(url, {
method: options.method || 'GET',
headers: chatGptHeaders(accessToken, options.accountId, targetPath, targetRoute, options.headers || {}),
body: options.body === undefined ? undefined : JSON.stringify(options.body),
redirect: 'manual',
signal: AbortSignal.timeout(options.timeoutMs || OPENAI_REQUEST_TIMEOUT_MS),
});
const text = await response.text().catch(() => '');
let payload = {};
try { payload = text ? JSON.parse(text) : {}; } catch { payload = { raw: text.slice(0, 500) }; }
const detail = payload && typeof payload === 'object' ? payload.detail || payload.error_description || payload.error?.message || payload.error?.code || payload.error || payload.message : '';
const message = typeof detail === 'string' ? detail : detail ? JSON.stringify(detail) : `http_${response.status}`;
return { ok: response.ok, status: response.status, payload, latencyMs: Date.now() - started, message: response.ok ? 'ok' : message, errorCode: payload?.code || payload?.error?.code || null, location: response.headers.get('location') };
} catch (error) {
return { ok: false, status: 0, payload: {}, latencyMs: Date.now() - started, message: error?.name === 'TimeoutError' ? 'timeout' : 'network_error' };
}
}
async function setWorkspaceDefaultSeatType(mother, workspaceId, value) {
const accountId = String(workspaceId || '').trim();
if (!mother || !accountId) return { ok: false, status: 400, message: 'workspace_id_required' };
const defaultSeatType = normalizeDefaultSeatType(value);
const requestPath = `/backend-api/accounts/${encodeURIComponent(accountId)}/settings/default_seat_type`;
const request = () => withTeamManager(mother, (manager) => fetchChatGptJson(manager.accessToken, requestPath, {
method: 'POST',
accountId,
targetPath: requestPath,
targetRoute: '/backend-api/accounts/{account_id}/settings/default_seat_type',
body: { value: defaultSeatType },
headers: {
'content-type': 'application/json',
...(manager.deviceId ? { 'oai-device-id': manager.deviceId } : {}),
...(manager.cookie ? { cookie: manager.cookie } : {}),
},
}));
let attempted = await request();
if (!attempted?.result || [401, 403].includes(Number(attempted.result.status))) {
const recovered = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin: true });
if (recovered.ok) attempted = await request();
}
if (!attempted?.result) return { ok: false, status: 401, message: 'workspace_owner_token_required', defaultSeatType };
const result = attempted.result;
const ok = result.ok && result.payload?.success !== false;
return {
...result,
ok,
status: ok ? result.status : (Number(result.status) >= 400 ? result.status : 502),
message: ok ? 'default_seat_type_updated' : result.message || 'default_seat_type_update_failed',
defaultSeatType,
};
}
function readWindow(source) {
if (!source || typeof source !== 'object') return { usedPercent: null, resetAfterSeconds: null };
const numeric = (value) => !['number', 'string'].includes(typeof value) || String(value).trim() === '' ? null : Number(value);
const used = numeric(source.used_percent ?? source.usedPercent ?? source.used_percentage);
const reset = numeric(source.reset_after_seconds ?? source.resetAfterSeconds ?? source.reset_seconds);
const resetAt = source.reset_at ?? source.resetAt ?? source.reset_time;
const seconds = numeric(source.limit_window_seconds ?? source.limitWindowSeconds);
const minutes = numeric(source.window_minutes ?? source.windowMinutes);
const resetDate = typeof resetAt === 'number' && Number.isFinite(resetAt)
? new Date(resetAt * 1000)
: null;
return {
usedPercent: Number.isFinite(used) ? Math.max(0, Math.min(100, used)) : null,
resetAfterSeconds: Number.isFinite(reset) ? reset : null,
resetAt: resetDate && Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : typeof resetAt === 'string' ? resetAt : null,
windowMinutes: Number.isFinite(seconds) && seconds > 0 ? seconds / 60 : Number.isFinite(minutes) ? minutes : null,
};
}
function readUsageWindows(limit = {}) {
const entries = [
readWindow(limit.primary_window || limit.primaryWindow),
readWindow(limit.secondary_window || limit.secondaryWindow),
];
const windows = { primary: readWindow(), secondary: readWindow() };
const slots = ['primary', 'secondary'];
for (const entry of entries) {
if (entry.windowMinutes === 300) windows.primary = entry;
if (entry.windowMinutes === 10080) windows.secondary = entry;
}
if (entries.every((entry) => entry.windowMinutes == null)) {
entries.forEach((entry, index) => { windows[slots[index]] = entry; });
}
return { ...windows, quotaWindowMapping: 'duration-v1' };
}
function quotaFromExtra(extra = {}) {
const primary = readWindow({
used_percent: extra.codex_5h_used_percent ?? extra.codex_primary_used_percent,
reset_after_seconds: extra.codex_5h_reset_after_seconds ?? extra.codex_primary_reset_after_seconds,
reset_at: extra.codex_5h_reset_at,
window_minutes: extra.codex_5h_window_minutes ?? extra.codex_primary_window_minutes,
});
const secondary = readWindow({
used_percent: extra.codex_7d_used_percent ?? extra.codex_secondary_used_percent,
reset_after_seconds: extra.codex_7d_reset_after_seconds ?? extra.codex_secondary_reset_after_seconds,
reset_at: extra.codex_7d_reset_at,
window_minutes: extra.codex_7d_window_minutes ?? extra.codex_secondary_window_minutes,
});
return {
quota5h: primary.usedPercent == null ? null : Math.max(0, Math.round(100 - primary.usedPercent)),
quota7d: secondary.usedPercent == null ? null : Math.max(0, Math.round(100 - secondary.usedPercent)),
quotaSnapshot: { primary, secondary, source: 'sub2api-extra', quotaWindowMapping: 'duration-v1', updatedAt: extra.codex_usage_updated_at || null },
};
}
function quotaSnapshotFromInput(input, extra, derived) {
if (input.quotaSnapshot && typeof input.quotaSnapshot === 'object') return input.quotaSnapshot;
const primary = { ...(derived.quotaSnapshot?.primary || {}) };
const secondary = { ...(derived.quotaSnapshot?.secondary || {}) };
if (primary.usedPercent == null && input.quota5h != null && Number.isFinite(Number(input.quota5h))) primary.usedPercent = Math.max(0, Math.min(100, 100 - Number(input.quota5h)));
if (secondary.usedPercent == null && input.quota7d != null && Number.isFinite(Number(input.quota7d))) secondary.usedPercent = Math.max(0, Math.min(100, 100 - Number(input.quota7d)));
if (primary.resetAfterSeconds == null && Number.isFinite(Number(input.quota5hResetAfterSeconds))) primary.resetAfterSeconds = Number(input.quota5hResetAfterSeconds);
if (secondary.resetAfterSeconds == null && Number.isFinite(Number(input.quota7dResetAfterSeconds))) secondary.resetAfterSeconds = Number(input.quota7dResetAfterSeconds);
if (primary.resetAt == null && input.quota5hResetAt) primary.resetAt = input.quota5hResetAt;
if (secondary.resetAt == null && input.quota7dResetAt) secondary.resetAt = input.quota7dResetAt;
return {
primary,
secondary,
source: Object.keys(extra || {}).length ? 'sub2api-extra' : 'import',
quotaWindowMapping: Object.keys(extra || {}).some((key) => /^codex_(5h|7d|primary|secondary)/.test(key)) ? 'duration-v1' : undefined,
updatedAt: input.quotaUpdatedAt || extra?.codex_usage_updated_at || derived.quotaSnapshot?.updatedAt || null,
};
}
function credentialFields(input = {}) {
const credentials = input.credentials && typeof input.credentials === 'object' ? input.credentials : input;
const extra = input.extra && typeof input.extra === 'object'
? input.extra
: credentials.extra && typeof credentials.extra === 'object' ? credentials.extra : {};
const accessToken = credentials.access_token || credentials.accessToken || input.accessToken || input.token || '';
const refreshToken = credentials.refresh_token || credentials.refreshToken || input.refreshToken || '';
const accountId = credentials.chatgpt_account_id || credentials.chatgptAccountId || input.accountId || input.workspaceId || '';
const email = input.email || credentials.email || extra.email || '';
const planType = credentials.plan_type || credentials.planType || input.planType || input.plan || '';
const derivedSnapshot = quotaFromExtra(extra);
const snapshot = quotaSnapshotFromInput(input, extra, derivedSnapshot);
return {
accessToken,
refreshToken,
accountId,
email: String(email),
planType,
chatgptUserId: credentials.chatgpt_user_id || credentials.chatgptUserId || '',
clientId: credentials.client_id || credentials.clientId || '',
idToken: credentials.id_token || credentials.idToken || '',
organizationId: credentials.organization_id || credentials.organizationId || '',
modelMapping: credentials.model_mapping || credentials.modelMapping || null,
concurrency: input.concurrency ?? credentials.concurrency ?? null,
priority: input.priority ?? credentials.priority ?? null,
rateMultiplier: input.rate_multiplier ?? input.rateMultiplier ?? credentials.rate_multiplier ?? credentials.rateMultiplier ?? null,
autoPauseOnExpired: input.auto_pause_on_expired ?? input.autoPauseOnExpired ?? credentials.auto_pause_on_expired ?? credentials.autoPauseOnExpired ?? null,
password: credentials.password || credentials.pass || input.password || '',
totp: credentials.totp || credentials.two_factor_secret || credentials.twoFactorSecret || input.totp || input.twoFactorSecret || '',
mailboxUrl: credentials.mailbox_url || credentials.mailboxUrl || input.mailboxUrl || input.mailbox_url || '',
expiresAt: credentials.expires_at || credentials.expiresAt || null,
subscriptionExpiresAt: credentials.subscription_expires_at || credentials.subscriptionExpiresAt || null,
extra,
...derivedSnapshot,
quotaSnapshot: snapshot,
quota5h: input.quota5h ?? derivedSnapshot.quota5h,
quota7d: input.quota7d ?? derivedSnapshot.quota7d,
quota5hResetAfterSeconds: input.quota5hResetAfterSeconds ?? extra.codex_5h_reset_after_seconds ?? null,
quota7dResetAfterSeconds: input.quota7dResetAfterSeconds ?? extra.codex_7d_reset_after_seconds ?? null,
quota5hResetAt: input.quota5hResetAt ?? extra.codex_5h_reset_at ?? null,
quota7dResetAt: input.quota7dResetAt ?? extra.codex_7d_reset_at ?? null,
quotaUpdatedAt: input.quotaUpdatedAt ?? extra.codex_usage_updated_at ?? snapshot.updatedAt ?? null,
};
}
function splitCredentialLine(value) {
const line = String(value || '').trim();
if (!line) return [];
const delimiter = line.includes('----') ? '----' : line.includes('|') ? '|' : line.includes(',') ? ',' : null;
return delimiter ? line.split(delimiter).map((part) => part.trim()) : [line];
}
function childFromImportedAccount(item) {
const fields = credentialFields(item);
const history = Array.isArray(item.workspaceHistory) ? item.workspaceHistory : [];
const token = fields.accessToken ? preview(fields.accessToken) : '待登录获取 AT';
const initialStatus = item.status || (fields.accessToken ? 'ready' : fields.password ? 'login_required' : 'unconfigured');
return {
id: item.id || `child_${randomUUID().slice(0, 8)}`,
email: fields.email,
password: fields.password,
totp: fields.totp,
mailboxUrl: fields.mailboxUrl,
accessToken: fields.accessToken,
refreshToken: fields.refreshToken,
accountId: fields.accountId,
chatgptUserId: fields.chatgptUserId,
clientId: fields.clientId,
idToken: fields.idToken,
organizationId: fields.organizationId,
modelMapping: fields.modelMapping,
concurrency: fields.concurrency,
priority: fields.priority,
rateMultiplier: fields.rateMultiplier,
autoPauseOnExpired: fields.autoPauseOnExpired,
expiresAt: fields.expiresAt,
subscriptionExpiresAt: fields.subscriptionExpiresAt,
plan: fields.planType || '待检测',
tokenScope: 'free',
token,
status: initialStatus,
quota5h: fields.quota5h,
quota7d: fields.quota7d,
quotaSnapshot: fields.quotaSnapshot,
quota5hResetAfterSeconds: fields.quota5hResetAfterSeconds,
quota7dResetAfterSeconds: fields.quota7dResetAfterSeconds,
quota5hResetAt: fields.quota5hResetAt,
quota7dResetAt: fields.quota7dResetAt,
quotaUpdatedAt: fields.quotaUpdatedAt,
extra: fields.extra,
team: item.team || null,
joinedAt: item.joinedAt || null,
nextRetry: item.nextRetry || null,
workspaceHistory: history,
importSource: item.source || item.importSource || null,
sub2apiImported: Boolean(item.sub2apiImported || item.source === 'sub2api' || item.importSource === 'sub2api'),
createdAt: item.createdAt || now(),
importedAt: now(),
};
}
function singleSub2ApiAccount(input = {}) {
let candidate = input?.json ?? input?.account ?? input;
if (typeof candidate === 'string') {
try {
candidate = JSON.parse(candidate);
} catch {
return { ok: false, message: 'invalid_sub2api_json' };
}
}
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return { ok: false, message: 'invalid_sub2api_account' };
if (candidate.account !== undefined) candidate = candidate.account;
if (Array.isArray(candidate?.accounts)) {
if (candidate.accounts.length !== 1) return { ok: false, message: 'single_sub2api_account_required' };
candidate = candidate.accounts[0];
}
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return { ok: false, message: 'invalid_sub2api_account' };
return { ok: true, account: candidate };
}
function childDeletionBusy(child) {
return childAuthQueues.has(child.id) || childTeamAuthOperations.has(child.id)
|| autoJsonAccountQueues.has(child.id) || refillCandidateReservations.has(child.id);
}
function childHasAuthorizationHistory(child) {
const hasText = (value) => typeof value === 'string' && Boolean(value.trim());
const hasTokens = (record) => record && typeof record === 'object'
&& ['accessToken', 'access_token', 'refreshToken', 'refresh_token', 'idToken', 'id_token'].some((key) => hasText(record[key]));
return Boolean(hasTokens(child) || hasTokens(child.credentials) || hasTokens(child.sub2api?.credentials)
|| hasText(child.token) && !child.token.startsWith('待')
|| hasText(child.authAt) || child.sub2apiImported === true || child.sub2api?.imported === true
|| Object.values(child.workspaceTokens || {}).some((record) => hasTokens(record) || hasText(record?.acquiredAt))
|| (child.workspaceHistory || []).some((entry) => hasText(entry?.teamAccessToken) || hasText(entry?.workspaceTokenUpdatedAt)));
}
// This is a local eligibility hint, not remote proof of departure. Even banned
// accounts must leave every Team before deletion can verify and remove them.
function childHasLocalTeamMembership(child) {
return Boolean(workspaceIdKey(child.team)
|| (child.workspaceHistory || []).some((entry) => entry?.status === 'active' && (entry.team || entry.workspaceId))
|| state.mothers.some((mother) => (mother.members || []).some((member) => deletionMemberMatchesChild(member, child, configuredTeamId(mother)))));
}
function childLocalDeletionPolicy(child, { verifiedAbsent = false } = {}) {
const result = (eligible, reason, message) => ({ eligible, reason, message });
const history = Array.isArray(child.workspaceHistory) ? child.workspaceHistory : [];
const localMember = childHasLocalTeamMembership(child);
if (localMember && !verifiedAbsent) {
return result(false, 'child_has_active_team_memberships', '仍在 Team 中,请先移出所有当前 Team');
}
if (childDeletionBusy(child)) return result(false, 'account_operation_in_progress', '账号操作进行中,请稍后再删除');
if (childIsBanned(child)) return result(true, 'banned_account', '已封禁,可核验后删除本地记录');
const departed = new Set(['kicked', 'cooldown', 'removed', 'left']);
if (verifiedAbsent && localMember || departed.has(child.status) || history.some((entry) => departed.has(entry?.status))) {
return result(true, 'departed_account', '已退出 Team,可核验后删除本地记录');
}
if (!childHasAuthorizationHistory(child)) return result(true, 'never_authorized', '未获取过授权,可核验后删除本地记录');
return result(false, 'authorized_free_account', '已有授权且没有退出记录,不能删除此 Free 账号');
}
function childDeletionGuard(child) {
// A completed login can replace credentials while a remote membership query
// is pending, even if its queue has already cleared by the time it returns.
const fingerprint = () => createHash('sha256').update(JSON.stringify([
child.id, child.email, child.chatgptUserId, child.team, child.status, child.banStatus,
child.accessToken, child.refreshToken, child.idToken, child.token, child.authAt,
child.sub2apiImported, child.sub2api, child.credentials, child.workspaceTokens, child.workspaceHistory,
state.mothers.map((mother) => [mother.id, configuredTeamId(mother),
(mother.members || []).filter((member) => deletionMemberMatchesChild(member, child, configuredTeamId(mother)))]),
])).digest('hex');
const initial = fingerprint();
return (verifiedAbsent = false) => {
if (findChild(child.id) !== child || fingerprint() !== initial) return { ok: false, message: 'account_changed_during_deletion' };
if (childDeletionBusy(child)) return { ok: false, message: 'account_operation_in_progress' };
const policy = childLocalDeletionPolicy(child, { verifiedAbsent });
// Local active/cache records can be stale. Only a complete official absence
// check below may override them; the public UI remains conservative.
return policy.eligible || !verifiedAbsent && policy.reason === 'child_has_active_team_memberships'
? null : { ok: false, message: policy.reason };
};
}
function listPayloadFailed(payload) {
return payload?.success === false || payload?.ok === false
|| [payload?.error, payload?.errors].some((value) => value && (!Array.isArray(value) || value.length));
}
async function withChildDeletionTeamLocks(operation) {
const ids = state.mothers.map((mother) => mother.id).sort();
const lock = (index) => index === ids.length ? operation()
: withTeamMaintenanceLock(ids[index], 'delete_local_child', () => lock(index + 1));
return lock(0);
}
// Deletion needs a complete current list, not historical membership or a
// pending invitation. A malformed or incomplete success response is unknown.
async function queryDeletionMembers(mother) {
const items = [];
const seen = new Set();
let offset = 0;
let expectedTotal = null;
for (let page = 0; page < 200; page += 1) {
const result = await queryWorkspaceMembersPage(mother, { offset, limit: 100 })
.catch(() => ({ ok: false, status: 502 }));
if (!result.ok) return { ok: false, status: result.status || 502, authFailure: isOpenAiAuthFailure(result),
reason: offset ? 'members_list_incomplete' : 'members_query_failed' };
const payload = result.payload;
const rows = payload?.items;
const invalid = () => ({ ok: false, status: 502, reason: 'members_list_incomplete' });
if (listPayloadFailed(payload) || !Array.isArray(rows) || rows.length !== result.items.length) return invalid();
if (payload.offset != null && Number(payload.offset) !== offset) return invalid();
if (payload.total != null) {
const total = Number(payload.total);
if (!Number.isInteger(total) || total < offset + rows.length
|| (expectedTotal != null && total !== expectedTotal)) return invalid();
expectedTotal = total;
}
for (const member of result.items) {
const key = String(member.id || member.accountUserId || workspaceIdKey(member.email));
if (seen.has(key)) return invalid();
seen.add(key);
items.push(member);
}
offset += rows.length;
if (!rows.length) return expectedTotal != null && offset < expectedTotal ? invalid() : { ok: true, status: 200, items, total: items.length };
if (expectedTotal != null && offset >= expectedTotal) return { ok: true, status: 200, items, total: items.length };
}
return { ok: false, status: 502, reason: 'members_list_incomplete' };
}
function deletionMemberMatchesChild(member, child, workspaceId) {
if (!memberIsActive(member)) return false;
const email = workspaceIdKey(child.email);
if (email && [member.email, member.verifiedEmail].some((value) => workspaceIdKey(value) === email)) return true;
const scoped = membershipHistoryFor(child, workspaceId);
const ids = new Set([child.chatgptUserId, ...scoped.flatMap((entry) => [entry.memberId, entry.accountUserId]),
...(workspaceIdKey(child.team) === workspaceIdKey(workspaceId) ? [child.memberId, child.accountUserId, child.memberSnapshot?.id] : []),
].filter(Boolean).map(String));
return [member.id, member.accountUserId].some((id) => id && ids.has(String(id)));
}
function hasConfirmedLocalDeparture(child, workspaceId, mothers) {
if (workspaceIdKey(child.team) === workspaceIdKey(workspaceId)) return false;
const history = membershipHistoryFor(child, workspaceId);
if (history.some((entry) => entry.status === 'active')) return false;
const latest = history.at(-1);
// These fields are persisted only after a successful member removal. A
// local Team deletion or an unverified "kicked" label is not sufficient.
const removedAt = Date.parse(latest?.removedAt || '');
const joinedAt = Date.parse(latest?.joinedAt || '');
if (latest?.status !== 'kicked' || !latest.reason
|| !Number.isFinite(removedAt) || removedAt > Date.now()
|| latest.joinedAt && (!Number.isFinite(joinedAt) || joinedAt > removedAt)) return false;
// Keep conflicting cached memberships for verification. Pending invites
// and previously saved Team credentials are not current memberships.
return mothers.every((mother) => Array.isArray(mother.members)
&& !mother.members.some((member) => deletionMemberMatchesChild(member, child, workspaceId)));
}
async function verifyChildLocalDeletion(child, cache = new Map()) {
const checkCurrent = childDeletionGuard(child);
const initialFailure = checkCurrent();
if (initialFailure) return initialFailure;
const groups = new Map();
for (const mother of state.mothers) {
const workspaceId = configuredTeamId(mother);
if (!workspaceId) continue;
const key = workspaceIdKey(workspaceId);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ ...mother, accountId: workspaceId });
}
const history = Array.isArray(child.workspaceHistory) ? child.workspaceHistory : [];
const unknownActive = history.some((entry) => entry?.status === 'active'
&& !groups.has(workspaceIdKey(entry.team || entry.workspaceId)));
const currentKey = workspaceIdKey(child.team);
const currentRemoved = history.some((entry) => workspaceIdKey(entry.team || entry.workspaceId) === currentKey
&& ['kicked', 'cooldown', 'removed', 'left', 'team_removed'].includes(entry.status));
if (unknownActive || currentKey && !groups.has(currentKey) && !currentRemoved) {
return { ok: false, message: 'membership_verification_failed', verificationTeams: [{ status: 409, reason: 'workspace_not_configured' }] };
}
const verificationTeams = [];
for (const [key, mothers] of groups) {
if (!cache.has(key)) cache.set(key, (async () => {
let result;
let incomplete;
for (const mother of mothers) {
result = await queryDeletionMembers(mother).catch(() => ({ ok: false, status: 502, reason: 'members_list_incomplete' }));
if (result.ok) return result;
if (result.reason === 'members_list_incomplete') incomplete = result;
}
return incomplete || result;
})());
const result = await cache.get(key);
if (!result.ok) {
if (result.reason === 'members_query_failed' && hasConfirmedLocalDeparture(child, key, mothers)) continue;
verificationTeams.push({ motherId: mothers[0].id, workspaceId: mothers[0].accountId, status: result.status, reason: result.reason });
} else if (result.items.some((member) => deletionMemberMatchesChild(member, child, key))) {
return { ok: false, message: 'child_has_active_team_memberships' };
} else if (!workspaceIdKey(child.email) && !child.chatgptUserId && result.items.some(memberIsActive)) {
verificationTeams.push({ motherId: mothers[0].id, workspaceId: mothers[0].accountId, status: 409, reason: 'child_identity_unknown' });
}
}
if (verificationTeams.length) return { ok: false, message: 'membership_verification_failed', verificationTeams };
const currentFailure = checkCurrent(true);
if (currentFailure) return currentFailure;
return { ok: true, checkCurrent: () => checkCurrent(true) };
}
function detachDeletedChildLocalWork(child) {
const roxy = state.settings.integrations?.roxy;
if (roxy) roxy.profiles = (roxy.profiles || []).filter((entry) => entry.childId !== child.id);
for (const session of [child.authSession, ...Object.values(child.teamAuthSessions || {})]) {
if (session?.state) clearOAuthCallback(session.state);
}
for (const mother of state.mothers) {
for (const claim of seatClaimsForMother(mother)) {
if (!seatClaimMatchesChild(claim, child)) continue;
// The external invitation is unchanged and may still consume capacity.
Object.assign(claim, { childId: null, email: claim.email || child.email || '', phase: 'detached_invitation', updatedAt: now() });
}
}
for (const job of autoJsonJobs.values()) {
if (job.status !== 'queued') continue;
job.ids = job.ids.filter((id) => id !== child.id);
}
}
function applyFreeSub2ApiAccount(child, account, fields = credentialFields(account)) {
const imported = childFromImportedAccount({
...account,
id: child.id,
planType: fields.planType || 'free',
source: 'sub2api',
sub2apiImported: true,
team: child.team || null,
workspaceHistory: child.workspaceHistory || [],
});
const credentialKeys = [
'password', 'totp', 'mailboxUrl', 'accessToken', 'refreshToken', 'accountId', 'chatgptUserId',
'clientId', 'idToken', 'organizationId', 'modelMapping', 'concurrency', 'priority', 'rateMultiplier',
'autoPauseOnExpired', 'expiresAt', 'subscriptionExpiresAt', 'quota5h', 'quota7d', 'quotaSnapshot',
'quota5hResetAfterSeconds', 'quota7dResetAfterSeconds', 'quota5hResetAt', 'quota7dResetAt', 'quotaUpdatedAt',
];
for (const key of credentialKeys) {
const value = imported[key];
if (value !== '' && value !== null && value !== undefined) child[key] = value;
}
if (fields.email) child.email = fields.email;
if (fields.extra && Object.keys(fields.extra).length) child.extra = { ...(child.extra || {}), ...fields.extra };
child.plan = fields.planType || (child.plan && child.plan !== '待检测' ? child.plan : 'free');
child.tokenScope = 'free';
child.token = child.accessToken ? preview(child.accessToken) : child.token || '待登录获取 AT';
child.importSource = 'sub2api';
child.sub2apiImported = true;
child.importedAt = now();
child.authAt = now();
if (!childIsBanned(child)) child.status = child.team ? 'active' : 'ready';
setChildLoginState(child, 'ready', '已录入 Sub2API Free JSON');
}
function ownerAccountFromImportedAccount(item) {
const fields = credentialFields(item);
return {
email: fields.email,
name: item.name || fields.email || '',
password: fields.password,
totp: fields.totp,
mailboxUrl: fields.mailboxUrl,
accessToken: fields.accessToken,
refreshToken: fields.refreshToken,
accountId: fields.accountId,
chatgptUserId: fields.chatgptUserId,
clientId: fields.clientId,
idToken: fields.idToken,
organizationId: fields.organizationId,
modelMapping: fields.modelMapping,
expiresAt: fields.expiresAt,
subscriptionExpiresAt: fields.subscriptionExpiresAt,
planType: fields.planType || 'team',
tokenScope: 'team',
quota5h: fields.quota5h,
quota7d: fields.quota7d,
quotaSnapshot: fields.quotaSnapshot,
quota5hResetAfterSeconds: fields.quota5hResetAfterSeconds,
quota7dResetAfterSeconds: fields.quota7dResetAfterSeconds,
quota5hResetAt: fields.quota5hResetAt,
quota7dResetAt: fields.quota7dResetAt,
quotaUpdatedAt: fields.quotaUpdatedAt,
extra: fields.extra,
};
}
function motherFromImportedAccount(item) {
const fields = credentialFields(item);
return {
id: item.id || `mother_${randomUUID().slice(0, 8)}`,
email: fields.email,
name: item.name || fields.email || '未命名母号',
password: fields.password,
totp: fields.totp,
mailboxUrl: fields.mailboxUrl,
team: fields.accountId || item.team || item.workspaceName || '',
teamName: item.teamName || item.displayName || item.workspaceName || item.workspace_name || '',
rotationMode: item.rotationMode === 'rotating' ? 'rotating' : 'fixed',
rotationEnabled: normalizeTeamRotationEnabled(item.rotationEnabled ?? item.rotation_enabled),
defaultSeatType: normalizeDefaultSeatType(item.defaultSeatType ?? item.default_seat_type, item.inviteSeatType ?? item.invite_seat_type),
dailyRotationLimit: normalizeDailyRotationLimit(item.dailyRotationLimit ?? item.daily_rotation_limit),
primaryOwnerEmail: item.primaryOwnerEmail || item.primary_owner_email || fields.email || '',
sub2apiIntegrationId: sub2ApiConfigById(item.sub2apiIntegrationId)?.id || sub2ApiConfigs()[0]?.id || DEFAULT_SUB2API_ID,
seats: item.seats == null ? null : Number(item.seats),
used: item.used == null ? null : Number(item.used),
accessToken: fields.accessToken,
refreshToken: fields.refreshToken,
accountId: fields.accountId,
chatgptUserId: fields.chatgptUserId,
clientId: fields.clientId,
idToken: fields.idToken,
organizationId: fields.organizationId,
modelMapping: fields.modelMapping,
subscriptionExpiresAt: fields.subscriptionExpiresAt,
expiresAt: fields.expiresAt,
planType: fields.planType || null,
tokenScope: 'team',
quotaSnapshot: fields.quotaSnapshot,
extra: fields.extra,
ownerAccounts: [ownerAccountFromImportedAccount(item)],
status: fields.accessToken ? 'unconfigured' : 'unconfigured',
lastCheck: null,
createdAt: item.createdAt || now(),
};
}
async function probeUsage(accessToken, accountId) {
if (!accessToken) return { ok: false, status: 0, message: 'missing_token', primary: readWindow(), secondary: readWindow(), quotaWindowMapping: 'duration-v1' };
const started = Date.now();
try {
const response = await proxyFetch(`${CHATGPT_BASE_URL}/backend-api/wham/usage`, { headers: chatGptHeaders(accessToken, accountId, '/backend-api/wham/usage', '/backend-api/wham/usage'), signal: AbortSignal.timeout(OPENAI_REQUEST_TIMEOUT_MS) });
const payload = await response.json().catch(() => ({}));
const limit = payload.rate_limit || payload.rateLimit || payload;
const errorValue = payload?.detail || payload?.error_description || payload?.error?.message || payload?.error?.code || payload?.error;
const errorMessage = typeof errorValue === 'string' ? errorValue : errorValue ? JSON.stringify(errorValue) : `http_${response.status}`;
const windows = readUsageWindows(limit);
return { ok: response.ok, status: response.status, message: response.ok ? 'ok' : errorMessage, errorCode: payload?.code || payload?.error?.code || null, latencyMs: Date.now() - started, planType: payload.plan_type || payload.planType, limitReached: Boolean(limit.limit_reached ?? limit.limitReached), ...windows, source: 'wham/usage' };
} catch (error) {
return { ok: false, status: 0, message: error?.name === 'TimeoutError' ? 'timeout' : 'network_error', latencyMs: Date.now() - started, primary: readWindow(), secondary: readWindow(), quotaWindowMapping: 'duration-v1' };
}
}
// K12's liveness flow distinguishes an explicit OpenAI account suspension from
// ordinary expired-token/network failures. Only run it after the quota probe
// fails, so healthy accounts do not spend an extra Responses request.
async function probeAccountLiveness(accessToken, accountId) {
if (!accessToken) return { ok: false, status: 0, message: 'missing_token', banned: false };
const started = Date.now();
try {
const response = await proxyFetch(`${CHATGPT_BASE_URL}/backend-api/codex/responses`, {
method: 'POST',
headers: {
accept: 'text/event-stream, application/json',
'content-type': 'application/json',
authorization: `Bearer ${accessToken}`,
'openai-beta': 'responses=experimental',
originator: 'opencode',
...(accountId ? { 'chatgpt-account-id': accountId } : {}),
},
body: JSON.stringify({ model: 'gpt-5', input: [{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], instructions: 'You are a helpful assistant.', stream: true, store: false }),
signal: AbortSignal.timeout(Math.max(10000, Math.min(45000, OPENAI_REQUEST_TIMEOUT_MS * 3))),
});
const raw = await response.text().catch(() => '');
let payload = {};
try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; }
const value = payload?.error?.message || payload?.error?.code || payload?.detail || payload?.message || raw.slice(0, 500) || `http_${response.status}`;
const message = typeof value === 'string' ? value : JSON.stringify(value);
return { ok: response.ok, status: response.status, message: response.ok ? 'ok' : message, banned: !response.ok && Boolean(explicitAccountBanMessage(message)), latencyMs: Date.now() - started, source: 'codex/responses' };
} catch (error) {
return { ok: false, status: 0, message: error?.name === 'TimeoutError' ? 'timeout' : 'network_error', banned: false, latencyMs: Date.now() - started, source: 'codex/responses' };
}
}
function applyMotherQuotaResult(mother, result) {
if (!mother) return;
mother.lastQuotaCheckAt = now();
if (result?.ok !== true) {
mother.lastProbe = result;
return;
}
const hasPrimary = result.primary?.usedPercent != null;
const hasSecondary = result.secondary?.usedPercent != null;
const durationMapped = result.quotaWindowMapping === 'duration-v1';
if (!(hasPrimary || hasSecondary || durationMapped)) {
mother.lastProbe = result;
return;
}
const remaining = (source) => source?.usedPercent == null ? null : Math.max(0, Math.round(100 - Number(source.usedPercent)));
mother.quota5h = hasPrimary ? remaining(result.primary) : (durationMapped ? null : (mother.quota5h ?? null));
mother.quota7d = hasSecondary ? remaining(result.secondary) : (durationMapped ? null : (mother.quota7d ?? null));
mother.quota5hResetAfterSeconds = hasPrimary ? (result.primary.resetAfterSeconds ?? null) : (durationMapped ? null : (mother.quota5hResetAfterSeconds ?? null));
mother.quota7dResetAfterSeconds = hasSecondary ? (result.secondary.resetAfterSeconds ?? null) : (durationMapped ? null : (mother.quota7dResetAfterSeconds ?? null));
mother.quota5hResetAt = hasPrimary ? (result.primary.resetAt || (result.primary.resetAfterSeconds != null ? new Date(Date.now() + result.primary.resetAfterSeconds * 1000).toISOString() : null)) : (durationMapped ? null : (mother.quota5hResetAt || null));
mother.quota7dResetAt = hasSecondary ? (result.secondary.resetAt || (result.secondary.resetAfterSeconds != null ? new Date(Date.now() + result.secondary.resetAfterSeconds * 1000).toISOString() : null)) : (durationMapped ? null : (mother.quota7dResetAt || null));
mother.quotaSnapshot = {
primary: hasPrimary ? result.primary : readWindow(),
secondary: hasSecondary ? result.secondary : readWindow(),
source: result.source || 'wham/usage',
quotaWindowMapping: result.quotaWindowMapping || undefined,
updatedAt: mother.lastQuotaCheckAt,
};
mother.quotaUpdatedAt = mother.lastQuotaCheckAt;
mother.lastProbe = result;
}
function explicitAccountBanMessage(...values) {
for (const value of values) {
const message = value instanceof Error
? value.message
: typeof value === 'string'
? value
: value && typeof value === 'object'
? [value.message, value.errorCode, value.code].filter(Boolean).join(' ')
: '';
if (/account_deactivated|account disabled|account has been (?:deleted|deactivated|disabled|suspended|banned|terminated)|account.*(?:suspended|banned|terminated|deactivated|disabled)|user.*(?:suspended|banned|terminated|deactivated|disabled)|账号已停用|账户已停用|账号已被删除|账户已被删除|账号已封|账号被封|封号|被封禁|账户被封|停用/i.test(message)) return message;
}
return '';
}
function childIsBanned(child) {
return child?.banStatus === 'banned' || child?.status === 'banned';
}
function markChildBanned(child, mother, evidence = {}) {
if (!child) return false;
const detectedAt = now();
const reason = explicitAccountBanMessage(evidence) || 'OpenAI 账号已被停用或封禁';
const wasBanned = childIsBanned(child);
child.banStatus = 'banned';
child.status = 'banned';
child.bannedAt = child.bannedAt || detectedAt;
child.banReason = child.banReason || reason;
child.banEvidence = {
teamId: canonicalTeamId(mother) || null,
source: evidence.source || 'team_quota_probe',
status: Number.isFinite(Number(evidence.status)) ? Number(evidence.status) : null,
code: evidence.errorCode || evidence.code || null,
message: reason,
detectedAt,
};
if (!wasBanned) addHistory('检测到封号', `${child.email} 已确认封禁,永久移出 Free 补位池`, 'partial');
return !wasBanned;
}
function normalizeSubscription(payload, accountId) {
const record = payload && typeof payload === 'object' ? payload : {};
const capacities = Array.isArray(record.seat_capacity) ? record.seat_capacity : Array.isArray(record.seatCapacity) ? record.seatCapacity : [];
const rawSeatsInUse = record.seats_in_use ?? record.seatsInUse;
const rawSeatsEntitled = record.seats_entitled ?? record.seatsEntitled;
const seatsInUse = rawSeatsInUse === null || rawSeatsInUse === undefined || rawSeatsInUse === '' ? null : Number(rawSeatsInUse);
const seatsEntitled = rawSeatsEntitled === null || rawSeatsEntitled === undefined || rawSeatsEntitled === '' ? null : Number(rawSeatsEntitled);
return {
id: record.id || accountId || null,
planType: record.plan_type || record.planType || null,
seatsInUse: Number.isFinite(seatsInUse) ? seatsInUse : null,
seatsEntitled: Number.isFinite(seatsEntitled) ? seatsEntitled : null,
seatCapacity: capacities,
assigned: record.assigned && typeof record.assigned === 'object' ? record.assigned : {},
activeStart: record.active_start || record.activeStart || null,
activeUntil: record.active_until || record.activeUntil || null,
billingPeriod: record.billing_period || record.billingPeriod || null,
willRenew: typeof record.will_renew === 'boolean' ? record.will_renew : record.willRenew ?? null,
isDelinquent: typeof record.is_delinquent === 'boolean' ? record.is_delinquent : record.isDelinquent ?? null,
fetchedAt: now(),
};
}
function normalizeMember(value) {
const record = value && typeof value === 'object' ? value : {};
return {
id: record.id || record.user_id || record.account_user_id || null,
accountUserId: record.account_user_id || record.accountUserId || null,
email: record.email || record.email_address || null,
verifiedEmail: record.verified_email || record.verifiedEmail || null,
role: record.role || null,
seatType: record.seat_type || record.seatType || null,
creditLimits: record.credit_limits || record.creditLimits || null,
name: record.name || record.full_name || null,
createdTime: record.created_time || record.createdTime || null,
isScimManaged: record.is_scim_managed ?? record.isScimManaged ?? null,
creationSource: record.creation_source || record.creationSource || null,
deactivatedTime: record.deactivated_time || record.deactivatedTime || null,
pendingSeatType: record.pending_seat_type || record.pendingSeatType || null,
reclaimableSeatType: record.reclaimable_seat_type || record.reclaimableSeatType || null,
};
}
function memberIsActive(member) {
return Boolean((member?.email || member?.id || member?.accountUserId) && !member.deactivatedTime);
}
function memberIsOwner(member, mother) {
const role = String(member?.role || '').toLowerCase().replace(/[\s_]+/g, '-');
const email = String(member?.email || '').toLowerCase();
const motherEmail = String(mother?.email || '').toLowerCase();
return (Boolean(email && motherEmail) && email === motherEmail) || role === 'owner' || role === 'account-owner';
}
function workspaceMemberCount(mother, fallback = 1) {
const count = Array.isArray(mother?.members) ? mother.members.filter(memberIsActive).length : 0;
return count || Math.max(1, Number(fallback) || 1);
}
function memberIsProtected(member, mother) {
const role = String(member?.role || '').toLowerCase().replace(/[\s_]+/g, '-');
const email = String(member?.email || '').toLowerCase();
if (mother?.rotationMode === 'fixed') {
const primaryEmail = String(mother.primaryOwnerEmail || mother.email || '').toLowerCase();
const primaryOwner = primaryOwnerRecord(mother);
const primaryUserId = String(primaryOwner?.chatgptUserId || mother.chatgptUserId || '');
if (primaryEmail && email === primaryEmail) return true;
if (primaryUserId && String(member?.id || '') === primaryUserId) return true;
}
// Some Team responses label every seat as account-owner; only protect an
// unidentified owner when there is no usable identity to match.
return !email && (role === 'account-owner' || role === 'owner');
}
async function queryWorkspaceMembersPage(mother, { offset = 0, limit = 25, query = '' } = {}) {
if (!mother?.accountId) return { ok: false, status: 400, message: 'workspace_id_required', items: [], total: 0, offset, limit };
const safeOffset = Math.max(0, Number(offset) || 0);
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 25));
const params = new URLSearchParams({ offset: String(safeOffset), limit: String(safeLimit), query: String(query || '') });
const accountId = encodeURIComponent(mother.accountId);
const requestPath = `/backend-api/accounts/${accountId}/users?${params}`;
const attempted = await withTeamManager(mother, (manager) => fetchChatGptJson(manager.accessToken, requestPath, {
accountId: mother.accountId,
targetPath: `/backend-api/accounts/${mother.accountId}/users`,
targetRoute: '/backend-api/accounts/{account_id}/users',
headers: { ...(manager.deviceId ? { 'oai-device-id': manager.deviceId } : {}), ...(manager.cookie ? { cookie: manager.cookie } : {}) },
}));
if (!attempted?.result) return { ok: false, status: 401, message: 'workspace_owner_token_required', items: [], total: 0, offset: safeOffset, limit: safeLimit };
const result = attempted.result;
const record = result.payload && typeof result.payload === 'object' ? result.payload : {};
const items = Array.isArray(record.items) ? record.items.map(normalizeMember).filter((item) => item.id || item.email) : [];
return { ...result, accountId: mother.accountId, items, total: Number.isFinite(Number(record.total)) ? Number(record.total) : items.length, offset: Number(record.offset ?? safeOffset), limit: Number(record.limit ?? safeLimit) };
}
async function queryAllWorkspaceMembers(mother, query = '') {
const members = [];
const seen = new Set();
let offset = 0;
const pageLimit = 100;
for (let page = 0; page < 200; page += 1) {
const result = await queryWorkspaceMembersPage(mother, { offset, limit: pageLimit, query });
if (!result.ok) return { ...result, items: members, total: members.length };
for (const member of result.items) {
const key = member.id || `${member.email || ''}:${member.createdTime || ''}`;
if (!seen.has(key)) { seen.add(key); members.push(member); }
}
if (!result.items.length || (result.total > 0 && offset + result.items.length >= result.total)) break;
offset += Math.max(1, result.items.length);
}
return { ok: true, status: 200, message: 'ok', accountId: mother.accountId, items: members, total: members.length, offset: 0, limit: pageLimit };
}
async function queryWorkspaceSubscription(mother) {
if (!mother?.accountId) return { ok: false, status: 400, message: 'workspace_id_required', subscription: null };
const accountId = encodeURIComponent(mother.accountId);
const requestPath = `/backend-api/subscriptions?account_id=${accountId}`;
// Subscription/seat data is scoped to the primary Team account. Do not use
// a rotating member or secondary owner here: those tokens may authenticate
// successfully but are not guaranteed to have billing/seat permissions.
const attempted = await fetchChatGptJson(mother.accessToken, requestPath, {
accountId: mother.accountId,
targetPath: '/backend-api/subscriptions',
targetRoute: '/backend-api/subscriptions',
headers: { ...(mother.deviceId ? { 'oai-device-id': mother.deviceId } : {}), ...(mother.cookie ? { cookie: mother.cookie } : {}) },
});
if (!attempted) return { ok: false, status: 401, message: 'workspace_owner_token_required', subscription: null };
const result = attempted;
return { ...result, accountId: mother.accountId, subscription: result.ok ? normalizeSubscription(result.payload, mother.accountId) : null };
}
function applyMotherSubscription(mother, subscription) {
if (!mother || !subscription) return;
mother.subscription = subscription;
mother.seatSnapshot = subscription;
mother.seats = subscription.seatsEntitled;
const remoteUsed = subscription.seatsInUse !== null && subscription.seatsInUse !== '' && Number.isFinite(Number(subscription.seatsInUse))
? Math.max(0, Number(subscription.seatsInUse))
: 0;
const visibleMembers = workspaceVisibleMemberCount(mother);
const reservations = seatClaimReservations(mother);
const claimed = reservations.default + reservations.prolite;
const effectiveUsed = Math.max(remoteUsed, visibleMembers) + claimed;
const entitled = subscription.seatsEntitled !== null && subscription.seatsEntitled !== '' && Number.isFinite(Number(subscription.seatsEntitled))
? Math.max(0, Number(subscription.seatsEntitled))
: null;
mother.used = entitled == null ? effectiveUsed : Math.min(entitled, effectiveUsed);
}
function seatAdmissionSnapshot(mother) {
const snapshot = mother?.seatSnapshot || mother?.subscription || {};
const remoteUsed = snapshot.seatsInUse !== null && snapshot.seatsInUse !== '' && Number.isFinite(Number(snapshot.seatsInUse))
? Math.max(0, Number(snapshot.seatsInUse))
: null;
const visibleMembers = workspaceVisibleMemberCount(mother);
return {
...snapshot,
seatsEntitled: snapshot.seatsEntitled ?? mother?.seats ?? null,
seatsInUse: remoteUsed == null ? (visibleMembers || null) : Math.max(remoteUsed, visibleMembers),
};
}
async function refreshMotherSubscription(mother, { allowCredentialLogin = true, unauthorizedOnly = false } = {}) {
if (!teamTokenDetails(mother?.accessToken, mother?.accountId)) {
await recoverTeamManagerToken(mother, { allowCredentialLogin, primaryOnly: true });
}
let result = await queryWorkspaceSubscription(mother);
const shouldRecover = unauthorizedOnly
? Number(result?.status) === 401 || result?.message === 'missing_token'
: isOpenAiAuthFailure(result);
if (shouldRecover) {
const recovered = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin, primaryOnly: true });
if (recovered.ok) result = await queryWorkspaceSubscription(mother);
}
if (result.ok && result.subscription) applyMotherSubscription(mother, result.subscription);
return result;
}
function normalizeExpiryThresholdDays(value, fallback = 7) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.min(365, Math.max(1, Math.floor(numeric))) : fallback;
}
async function requestWorkspaceBillingPreview(mother, updatedSeats) {
const params = new URLSearchParams({ account_id: mother.accountId, updated_seats: String(updatedSeats) });
return fetchChatGptJson(mother.accessToken, `/backend-api/subscriptions/update/preview?${params}`, {
accountId: mother.accountId,
targetPath: '/backend-api/subscriptions/update/preview',
targetRoute: '/backend-api/subscriptions/update/preview',
headers: { ...(mother.deviceId ? { 'oai-device-id': mother.deviceId } : {}), ...(mother.cookie ? { cookie: mother.cookie } : {}) },
});
}
async function requestSingleSeatBillingPreview(mother, initialSeats) {
let updatedSeats = Number.isInteger(Number(initialSeats)) && Number(initialSeats) >= 2 ? Number(initialSeats) : 3;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const result = await requestWorkspaceBillingPreview(mother, updatedSeats);
if (!result.ok) return { ...result, updatedSeats, attempt };
const currentSeats = currentSeatQuantity(result.payload);
if (currentSeats == null) return { ok: false, status: 502, message: 'billing_preview_current_seats_missing', updatedSeats, attempt };
const expectedUpdatedSeats = currentSeats + 1;
if (updatedSeats === expectedUpdatedSeats) return { ...result, currentSeats, updatedSeats, attempt };
updatedSeats = expectedUpdatedSeats;
}
return { ok: false, status: 409, message: 'billing_preview_seats_changed_repeatedly', updatedSeats, attempt: 3 };
}
async function queryWorkspaceBillingPreview(mother, { thresholdDays = 7 } = {}) {
if (!mother) return { ok: false, status: 404, message: 'mother_not_found' };
const identity = {
motherId: mother.id,
teamId: canonicalTeamId(mother),
teamName: teamDisplayName(mother),
ownerEmail: mother.email || '',
};
if (!mother.accountId) return { ...identity, ok: false, status: 400, message: 'workspace_id_required' };
const normalizedThreshold = normalizeExpiryThresholdDays(thresholdDays);
const subscriptionResult = await refreshMotherSubscription(mother, { allowCredentialLogin: true, unauthorizedOnly: true });
const subscription = subscriptionResult.subscription || mother.subscription || mother.seatSnapshot || {};
const currentSeats = Number(subscription.seatsEntitled ?? mother.seats);
const initialSeats = Number.isInteger(currentSeats) && currentSeats >= 1 ? currentSeats + 1 : 3;
let previewResult = await requestSingleSeatBillingPreview(mother, initialSeats);
let managerRecovery = null;
if (Number(previewResult?.status) === 401 || previewResult?.message === 'missing_token') {
managerRecovery = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin: true, primaryOnly: true });
if (managerRecovery.ok) previewResult = await requestSingleSeatBillingPreview(mother, initialSeats);
}
if (!previewResult.ok) {
mother.lastBillingPreviewProbe = {
ok: false,
previewOk: false,
subscriptionOk: subscriptionResult.ok === true,
status: previewResult.status,
message: previewResult.message,
checkedAt: now(),
};
return {
...identity,
ok: false,
status: previewResult.status || 502,
code: previewResult.errorCode || null,
message: previewResult.message || 'billing_preview_failed',
cached: mother.billingPreview || null,
managerRecovery: managerRecovery ? { ok: managerRecovery.ok, code: managerRecovery.code || null, message: managerRecovery.message || '' } : null,
};
}
const normalized = normalizeBillingPreview(previewResult.payload, {
updatedSeats: previewResult.updatedSeats,
activeUntil: subscription.activeUntil,
willRenew: subscription.willRenew,
isDelinquent: subscription.isDelinquent,
billingPeriod: subscription.billingPeriod,
thresholdDays: normalizedThreshold,
fetchedAt: now(),
});
if (!normalized.ok) {
mother.lastBillingPreviewProbe = { ok: false, status: 502, message: normalized.message, checkedAt: now() };
return { ...identity, ...normalized, status: 502, cached: mother.billingPreview || null };
}
const subscriptionFresh = subscriptionResult.ok === true;
const billingPreview = {
...normalized,
ok: subscriptionFresh,
partial: !subscriptionFresh,
subscriptionFresh,
subscriptionStatus: subscriptionResult.status || null,
subscriptionMessage: subscriptionResult.message || null,
activeUntilSource: subscriptionFresh ? 'live' : (normalized.activeUntil ? 'cached' : 'unavailable'),
};
mother.billingPreview = billingPreview;
if (!subscriptionResult.ok) {
const subscriptionStatus = Number(subscriptionResult.status);
const status = Number.isInteger(subscriptionStatus) && subscriptionStatus >= 400 && subscriptionStatus <= 599
? subscriptionStatus
: 207;
const message = subscriptionResult.message || 'workspace_subscription_failed';
mother.lastBillingPreviewProbe = {
ok: false,
previewOk: true,
subscriptionOk: false,
status,
message,
checkedAt: now(),
};
return {
...identity,
...billingPreview,
ok: false,
partial: true,
status,
code: subscriptionResult.errorCode || null,
message,
};
}
mother.lastBillingPreviewProbe = {
ok: true,
previewOk: true,
subscriptionOk: true,
status: 200,
message: 'ok',
checkedAt: now(),
};
return { ...identity, status: 200, ...billingPreview };
}
async function scanWorkspaceBillingPreviews({ motherIds = [], thresholdDays = 7 } = {}) {
const ids = new Set((Array.isArray(motherIds) ? motherIds : []).map(String).filter(Boolean));
const mothers = state.mothers.filter((mother) => !ids.size || ids.has(String(mother.id)) || ids.has(String(canonicalTeamId(mother))));
const normalizedThreshold = normalizeExpiryThresholdDays(thresholdDays);
const items = await mapWithConcurrency(mothers, async (mother) => {
try {
return await queryWorkspaceBillingPreview(mother, { thresholdDays: normalizedThreshold });
} catch (error) {
const message = error?.message || 'billing_preview_unexpected_error';
mother.lastBillingPreviewProbe = { ok: false, status: 500, message, checkedAt: now() };
return {
motherId: mother.id,
teamId: canonicalTeamId(mother),
teamName: teamDisplayName(mother),
ownerEmail: mother.email || '',
ok: false,
status: 500,
message,
cached: mother.billingPreview || null,
};
}
});
items.sort((left, right) => {
if (left.ok !== right.ok) return left.ok ? -1 : 1;
const leftTime = Date.parse(left.activeUntil || '');
const rightTime = Date.parse(right.activeUntil || '');
if (!Number.isFinite(leftTime)) return Number.isFinite(rightTime) ? 1 : 0;
if (!Number.isFinite(rightTime)) return -1;
return leftTime - rightTime;
});
const succeeded = items.filter((item) => item.ok).length;
const failed = items.length - succeeded;
const expiring = items.filter((item) => ['due_soon', 'expired', 'cancelling', 'delinquent'].includes(item.expiryStatus)).length;
addHistory('查询临期 Team', `查询 ${items.length} 个 Team,临期或异常 ${expiring} 个,失败 ${failed} 个`, failed ? 'partial' : 'success');
await persist();
return {
ok: items.length > 0 && failed === 0,
status: failed ? 207 : 200,
thresholdDays: normalizedThreshold,
total: items.length,
succeeded,
failed,
expiring,
checkedAt: now(),
items,
};
}
function subscriptionCapacityIsValid(result) {
if (!result?.ok || !result.subscription || listPayloadFailed(result.payload)) return false;
if (!totalSeatAvailability(result.subscription)) return false;
const types = seatCapacityByType(result.subscription);
return types.size > 0 && [...types.values()].every((entry) => entry.remaining !== null);
}
function invitationCapacityCheckCode(result, fallback) {
const code = result?.reason || result?.message;
if (['members_query_failed', 'members_list_incomplete', 'subscription_query_failed', 'invalid_seat_capacity',
'invalid_invitation_list', 'invitation_list_incomplete', 'pending_invitation_id_missing', 'invitation_email_required',
'invitation_query_failed', 'workspace_owner_token_required', 'workspace_id_required', 'missing_token'].includes(code)) return code;
return Number.isInteger(result?.status) && result.status > 0 && result.status !== 200 ? `http_${result.status}` : fallback;
}
function expireConfirmedAbsentInvitation(mother, claim) {
const workspaceKey = configuredTeamKey(mother);
// A deleted pool account can be re-imported with a new local ID. Clear only
// matching email/workspace/invitation metadata, including that new record.
for (const child of state.children.filter((entry) => claim.email && workspaceIdKey(entry.email) === workspaceIdKey(claim.email))) {
for (const entry of Array.isArray(child.teamInvitations) ? child.teamInvitations : []) {
if (workspaceIdKey(entry.workspaceId) !== workspaceKey || entry.status !== 'invited') continue;
if (entry.inviteId && entry.inviteId !== claim.inviteId) continue;
Object.assign(entry, { status: 'expired', expiredAt: now() });
}
if (workspaceIdKey(child.pendingWorkspaceId) === workspaceKey
&& claim.inviteId && child.pendingInviteId === claim.inviteId) {
child.pendingWorkspaceId = null;
child.pendingInviteId = null;
}
}
}
// An absent invitation may have been accepted, so both complete member and
// pending-invitation lists are required before a stale local hold is released.
// This only reads existing manager credentials and never starts an account login.
async function reconcileInvitationCapacity(mother, membersResult) {
if (membersResult.ok) {
mother.members = membersResult.items;
reconcileSeatClaimsWithMembers(mother, membersResult.items);
}
let released = 0;
const checked = new Map();
let invitationCheck = { ok: true, status: 200, code: null };
for (const { claim } of seatClaimEntriesForWorkspace(mother)) {
if (!['invited', 'detached_invitation'].includes(claim.phase)) continue;
const child = claim.childId ? findChild(claim.childId) : null;
if (membersResult.ok && membersResult.items.some((member) => deletionMemberMatchesChild(member,
child || { email: claim.email }, configuredTeamId(mother)))) {
releaseSeatClaim(mother, claim);
continue;
}
const pending = claim.email
? await pendingWorkspaceInvitation(mother, configuredTeamId(mother), claim.email).catch(() => ({ ok: false, status: 502, message: 'invitation_query_failed' }))
: { ok: false, status: 502, message: 'invitation_email_required' };
checked.set(claim.id, pending);
if (!pending.ok) {
if (invitationCheck.ok) invitationCheck = { ok: false, status: pending.status || 502, code: invitationCapacityCheckCode(pending, 'invitation_query_failed') };
continue;
}
if (pending.inviteId) {
claim.updatedAt = now();
continue;
}
if (!membersResult.ok) continue;
expireConfirmedAbsentInvitation(mother, claim);
releaseSeatClaim(mother, claim);
released += 1;
}
const reservations = seatClaimEntriesForWorkspace(mother).map(({ claim }) => ({
id: claim.id,
email: String(claim.email || ''),
seatType: claim.seatType === 'prolite' ? 'prolite' : 'default',
phase: claim.phase || 'approval_pending',
cancellable: ['invited', 'detached_invitation'].includes(claim.phase) && Boolean(claim.email),
status: !['invited', 'detached_invitation'].includes(claim.phase) ? 'approval_pending'
: checked.get(claim.id)?.ok && checked.get(claim.id)?.inviteId ? 'invited' : 'unverified',
}));
return { released, reservations, invitationCheck };
}
async function refreshInvitationCapacity(mother) {
const [membersResult, subscriptionResult] = await Promise.all([
queryDeletionMembers(mother).catch(() => ({ ok: false, status: 502, reason: 'members_query_failed' })),
queryWorkspaceSubscription(mother).catch(() => ({ ok: false, status: 502, message: 'subscription_query_failed' })),
]);
const reconciled = await reconcileInvitationCapacity(mother, membersResult);
const subscriptionValid = subscriptionCapacityIsValid(subscriptionResult);
if (subscriptionValid) applyMotherSubscription(mother, subscriptionResult.subscription);
else if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
mother.lastWorkspaceSyncAt = now();
mother.lastMembersProbe = { ok: membersResult.ok, status: membersResult.status || (membersResult.ok ? 200 : 502), message: membersResult.reason || null, total: membersResult.ok ? membersResult.items.length : null };
mother.lastSubscriptionProbe = { ok: subscriptionValid, status: subscriptionValid ? 200 : subscriptionResult.ok ? 502 : subscriptionResult.status || 502,
message: subscriptionValid ? null : subscriptionResult.ok ? 'invalid_seat_capacity' : invitationCapacityCheckCode(subscriptionResult, 'subscription_query_failed') };
const checks = {
members: { ok: membersResult.ok === true, status: membersResult.status || (membersResult.ok ? 200 : 502), code: membersResult.ok ? null : membersResult.reason || 'members_query_failed' },
subscription: { ok: subscriptionValid, status: mother.lastSubscriptionProbe.status, code: subscriptionValid ? null : mother.lastSubscriptionProbe.message || 'subscription_query_failed' },
invitations: reconciled.invitationCheck,
};
const fresh = Object.values(checks).every((check) => check.ok);
await persist();
return { ok: fresh, fresh, checks, released: reconciled.released, reservations: reconciled.reservations, membersResult,
subscriptionResult: subscriptionValid ? subscriptionResult : { ...subscriptionResult, ok: false, subscription: null } };
}
async function cancelTeamInvitation(motherId, reservationId) {
const mother = findMother(motherId);
const failure = (status, code, message) => ({ status, payload: { ok: false, code, message, state: publicState({ includeHistory: false }) } });
if (!mother) return failure(404, 'mother_not_found', 'Team 不存在');
const claim = seatClaimEntriesForWorkspace(mother).find(({ claim }) => claim.id === reservationId)?.claim;
if (!claim) return failure(404, 'invalid_invitation_reservation', '邀请记录已变化,请刷新后重试');
if (!['invited', 'detached_invitation'].includes(claim.phase) || !claim.email) {
return failure(409, 'invitation_not_cancellable', '此记录是待确认的入团占位,不能作为邀请撤销,请先刷新成员');
}
const workspaceId = configuredTeamId(mother);
const verificationFailure = (result) => failure(Number(result?.status) >= 400 && Number(result.status) <= 599 ? Number(result.status) : 502,
'invitation_verification_failed', '无法核验官方成员或邀请状态,已保留原记录,请检查授权和代理后重试');
const members = await queryDeletionMembers(mother).catch(() => ({ ok: false, status: 502 }));
if (!members.ok) return verificationFailure(members);
if (members.items.some((member) => deletionMemberMatchesChild(member, { email: claim.email }, workspaceId))) {
return failure(409, 'already_workspace_member', '该邮箱已经加入 Team,不能撤销为邀请;请刷新成员后按成员操作');
}
const pending = await pendingWorkspaceInvitation(mother, workspaceId, claim.email).catch(() => ({ ok: false, status: 502 }));
if (!pending.ok) return verificationFailure(pending);
if (pending.inviteId) {
const requestPath = `/backend-api/accounts/${encodeURIComponent(workspaceId)}/invites`;
const attempted = await withTeamManager(mother, (manager) => fetchChatGptJson(manager.accessToken, requestPath, {
method: 'DELETE', accountId: workspaceId, targetPath: requestPath,
targetRoute: '/backend-api/accounts/{account_id}/invites',
headers: { 'content-type': 'application/json', ...(manager.deviceId ? { 'oai-device-id': manager.deviceId } : {}), ...(manager.cookie ? { cookie: manager.cookie } : {}) },
body: { email_address: String(claim.email).trim().toLowerCase() },
})).catch(() => null);
const response = attempted?.result;
if (!response?.ok || listPayloadFailed(response.payload)) {
const status = Number(response?.status) >= 400 && Number(response.status) <= 599 ? Number(response.status) : 502;
return failure(status, 'invitation_cancel_failed', `官方未确认撤销邀请(HTTP ${status}),原邀请占位已保留`);
}
}
// DELETE success alone is not proof that capacity is available. Re-read both
// lists; a concurrent acceptance must never be treated as an empty seat.
const refreshed = await refreshInvitationCapacity(mother);
const { membersResult, subscriptionResult, ...capacity } = refreshed;
const stillReserved = seatClaimEntriesForWorkspace(mother).some((entry) => entry.claim.id === reservationId);
if (!membersResult.ok || stillReserved) {
return { status: 409, payload: { ...capacity, ok: false, code: 'invitation_cancellation_unconfirmed',
message: '已尝试撤销,但官方状态尚未确认;保留占位,请刷新席位后重试', state: publicState({ includeHistory: false }) } };
}
const accepted = membersResult.items.some((member) => deletionMemberMatchesChild(member, { email: claim.email }, workspaceId));
addHistory('撤销 Team 邀请', `${teamDisplayName(mother)} · ${claim.email} · ${accepted ? '账号已加入,按成员计算席位' : pending.inviteId ? '官方邀请已撤销' : '失效邀请记录已清理'}`, 'success');
await persist();
return { status: 200, payload: { ...capacity, ok: true, code: accepted ? 'already_workspace_member' : 'invitation_cancelled',
message: accepted ? '该邮箱已在操作期间加入 Team,席位按成员状态计算' : capacity.fresh ? '旧邀请已清理,席位已重新计算' : '旧邀请已清理,席位数量仍待官方接口核验',
state: publicState({ includeHistory: false }) } };
}
async function syncMotherWorkspace(mother, { query = '', force = false, allowCredentialLogin = true, excludedMemberIds = [], excludedMemberEmails = [] } = {}) {
if (!mother) return { ok: false, status: 404, message: 'mother_not_found' };
if (!teamManagerContext(mother)) await recoverTeamManagerToken(mother, { allowCredentialLogin });
if (!teamTokenDetails(mother.accessToken, mother.accountId)) {
await recoverTeamManagerToken(mother, { allowCredentialLogin, primaryOnly: true });
}
let [subscriptionResult, membersResult] = await Promise.all([
queryWorkspaceSubscription(mother),
queryDeletionMembers(mother),
]);
if (isOpenAiAuthFailure(subscriptionResult)) {
const recovered = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin, primaryOnly: true });
if (recovered.ok) subscriptionResult = await queryWorkspaceSubscription(mother);
}
if (membersResult.authFailure || isOpenAiAuthFailure(membersResult)) {
const recovered = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin });
if (recovered.ok) membersResult = await queryDeletionMembers(mother);
}
const subscription = subscriptionResult.subscription;
const members = excludePreviouslyRemovedMembers(membersResult.items, excludedMemberIds, excludedMemberEmails);
mother.lastWorkspaceSyncAt = now();
mother.lastSubscriptionProbe = { ok: subscriptionResult.ok, status: subscriptionResult.status, message: subscriptionResult.message, latencyMs: subscriptionResult.latencyMs };
mother.lastMembersProbe = { ok: membersResult.ok, status: membersResult.status, message: membersResult.message, latencyMs: membersResult.latencyMs, total: members.length };
await reconcileInvitationCapacity(mother, { ...membersResult, items: members });
if (subscription) applyMotherSubscription(mother, subscription);
else if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
const activeMembers = members.filter(memberIsActive);
for (const child of state.children) {
const member = activeMembers.find((entry) => entry.email && child.email && entry.email.toLowerCase() === child.email.toLowerCase());
if (member && !memberIsProtected(member, mother)) {
child.accountUserId = member.accountUserId || child.accountUserId;
child.memberId = member.id || child.memberId;
child.memberSnapshot = member;
const latestMembership = latestMembershipHistoryFor(child, mother.team);
const retryAt = Date.parse(latestMembership?.retryAfter || '');
const locallyCooling = ['kicked', 'cooldown'].includes(latestMembership?.status)
&& ((Number.isFinite(retryAt) && retryAt > Date.now()) || latestMembership?.rejoinEligible === false);
if (child.status !== 'kicked' && !locallyCooling) {
const existingMembership = membershipFor(child, mother.team);
const membership = existingMembership || membershipFor(child, mother.team, true);
if (!existingMembership && member.createdTime) membership.joinedAt = member.createdTime;
membership.source = membership.source || 'member_sync';
membership.role = member.role || membership.role || null;
membership.seatType = member.seatType || membership.seatType || null;
if (membership.joinStatus === 'seat_type_mismatch') {
const pendingStatus = state.settings.promoteJoinedAccounts !== false ? 'approved_pending_owner' : 'member_confirmed_token_pending';
membership.joinStatus = pendingStatus;
child.joinStatus = pendingStatus;
membership.ownerRoleStatus = state.settings.promoteJoinedAccounts !== false ? 'pending' : 'skipped';
delete membership.requestedSeatType;
}
// `team` remains a current-space convenience field; history holds all active memberships.
child.team = child.team || mother.team;
child.joinedAt = child.joinedAt || membership.joinedAt;
if (!childIsBanned(child)) child.status = child.accessToken ? 'active' : 'ready';
}
}
}
addHistory('同步空间', `${mother.team || mother.accountId} 席位 ${mother.used ?? '-'} / ${mother.seats ?? '-'},成员 ${members.length}`);
await persist();
const normalizedQuery = String(query || '').trim().toLowerCase();
const returnedMembers = normalizedQuery
? members.filter((member) => [member.email, member.name, member.id, member.accountUserId].some((value) => String(value || '').toLowerCase().includes(normalizedQuery)))
: members;
return { ok: subscriptionResult.ok && membersResult.ok, status: subscriptionResult.ok && membersResult.ok ? 200 : (subscriptionResult.status || membersResult.status || 502), motherId: mother.id, accountId: mother.accountId, subscription: subscription || null, seatSnapshot: mother.seatSnapshot || null, members: returnedMembers, subscriptionResult: { ok: subscriptionResult.ok, status: subscriptionResult.status, message: subscriptionResult.message }, membersResult: { ok: membersResult.ok, status: membersResult.status, message: membersResult.message } };
}
async function removeWorkspaceMember(mother, member) {
if (!mother?.accountId || !member?.id) return { ok: false, status: 400, message: 'workspace_member_credentials_required' };
const accountId = encodeURIComponent(mother.accountId);
const memberId = encodeURIComponent(member.id);
const attempted = await withTeamManager(mother, (manager) => fetchChatGptJson(manager.accessToken, `/backend-api/accounts/${accountId}/users/${memberId}`, {
method: 'DELETE',
accountId: mother.accountId,
targetPath: `/backend-api/accounts/${mother.accountId}/users/${member.id}`,
targetRoute: '/backend-api/accounts/{account_id}/users/{user_id}',
headers: { ...(manager.deviceId ? { 'oai-device-id': manager.deviceId } : {}), ...(manager.cookie ? { cookie: manager.cookie } : {}) },
}));
if (!attempted?.result) return { ok: false, status: 401, message: 'workspace_owner_token_required' };
const result = attempted.result;
return { ok: result.ok && result.payload?.success !== false, status: result.status, message: result.message, payload: result.payload };
}
function childKickTarget(childId, body = {}) {
const fail = (status, message) => ({ status, payload: { ok: false, message, code: message } });
if (!body || typeof body !== 'object' || Array.isArray(body)) return { failure: fail(400, 'team_kick_scope_required') };
const child = findChild(childId);
if (!child) return { failure: fail(404, 'child_not_found') };
if (Object.hasOwn(body, 'expectedEmail')) {
if (typeof body.expectedEmail !== 'string' || !body.expectedEmail.trim()) {
return { failure: fail(400, 'team_kick_identity_required') };
}
if (workspaceIdKey(body.expectedEmail) !== workspaceIdKey(child.email)) {
return { failure: fail(409, 'child_identity_changed_before_kick') };
}
}
const explicit = Object.hasOwn(body || {}, 'motherId') || Object.hasOwn(body || {}, 'workspaceId');
if (explicit && (typeof body.motherId !== 'string' || !body.motherId.trim()
|| typeof body.workspaceId !== 'string' || !body.workspaceId.trim())) {
return { failure: fail(400, 'team_kick_scope_required') };
}
const workspaceId = explicit ? body.workspaceId.trim() : String(child.team || '').trim();
const mother = explicit ? findMother(body.motherId.trim())
: state.mothers.find((item) => configuredTeamKey(item) === workspaceIdKey(workspaceId));
if (!mother || !workspaceId) return { failure: fail(explicit ? 404 : 409, explicit ? 'mother_not_found' : 'child_workspace_not_configured') };
if (configuredTeamId(mother) !== workspaceId) return { failure: fail(409, 'team_workspace_mismatch') };
const email = workspaceIdKey(child.email);
if (!email) return { failure: fail(409, 'member_email_required') };
const membership = membershipFor(child, workspaceId);
const currentTeam = child.team;
const userId = child.chatgptUserId;
const motherTeam = mother.team;
const knownMember = () => isChildMemberOfTeam(child, workspaceId)
|| (mother.members || []).some((member) => memberIsActive(member)
&& workspaceIdKey(member.email || member.verifiedEmail) === email);
const currentFailure = ({ checkBusy = true } = {}) => {
if (findChild(childId) !== child || workspaceIdKey(child.email) !== email || child.chatgptUserId !== userId) return fail(409, 'child_identity_changed_during_kick');
if (findMother(mother.id) !== mother || configuredTeamId(mother) !== workspaceId || mother.team !== motherTeam) return fail(409, 'child_workspace_changed_during_kick');
if (membershipFor(child, workspaceId) !== membership || !knownMember()
|| !explicit && child.team !== currentTeam) return fail(409, 'team_membership_changed_during_kick');
// Preserve legacy callers that deliberately remove an account during an
// authorization. The scoped UI action does not interrupt ongoing work.
if (explicit && checkBusy && childDeletionBusy(child)) return fail(409, 'account_operation_in_progress');
if (explicit && checkBusy && anySeatClaimForChild(child)) return fail(409, 'child_has_pending_seat_claim');
return null;
};
return { child, mother, workspaceId, email, membership, currentFailure, failure: currentFailure() };
}
async function kickChildFromWorkspace(childId, body = {}, target = childKickTarget(childId, body)) {
if (target.failure) return target.failure;
const { child, mother, workspaceId: oldTeam, email, currentFailure } = target;
const fail = (status, message, extra = {}) => ({ status, payload: { ok: false, code: message, message, ...extra } });
const upstreamFailure = (status, message) => fail(502, message, { upstreamStatus: Number(status) || 0 });
let stale = currentFailure();
if (stale) return stale;
if (!teamManagerContext(mother)) await recoverTeamManagerToken(mother);
stale = currentFailure();
if (stale) return stale;
if (!teamManagerContext(mother) || !mother.accountId) return fail(409, 'workspace_credentials_required');
const listed = await queryDeletionMembers(mother);
stale = currentFailure();
if (stale) return stale;
if (!listed.ok) return upstreamFailure(listed.status, 'workspace_members_refresh_failed');
const members = (listed.items || []).filter(memberIsActive);
// Global child.memberId can belong to another Team. Only the freshly listed
// account email may select a destructive request in this exact workspace.
const matches = members.filter((item) => workspaceIdKey(item.email || item.verifiedEmail) === email);
if (matches.length !== 1 || !matches[0].id) return fail(409, matches.length > 1 ? 'workspace_member_ambiguous' : 'workspace_member_not_found');
const member = { ...matches[0], email: matches[0].email || matches[0].verifiedEmail };
if (memberIsProtected(member, mother)) return fail(403, 'protected_workspace_member');
if (members.length <= 1) return fail(409, 'minimum_workspace_member_required');
const remote = await removeWorkspaceMember(mother, member);
stale = currentFailure({ checkBusy: false });
if (stale) return { ...stale, payload: { ...stale.payload, remoteRemoved: remote.ok === true } };
if (!remote.ok) return upstreamFailure(remote.status, 'workspace_member_remove_failed');
releaseSeatClaim(mother, seatClaimForChild(mother, child));
const banned = childIsBanned(child);
const removedAt = now();
const membership = membershipFor(child, oldTeam, true);
clearManualKickTimer(membership, banned ? 'account_banned' : 'manual_removal', removedAt);
clearQuotaKickDelay(membership);
Object.assign(membership, {
status: 'kicked',
removedAt,
reason: typeof body.reason === 'string' && body.reason ? body.reason : 'manual',
retryAfter: body.retryAfter || null,
rejoinEligible: banned ? false : true,
});
if (workspaceIdKey(child.team) === workspaceIdKey(oldTeam)) {
const replacement = (child.workspaceHistory || []).find((entry) => entry.status === 'active' && entry.team);
child.team = replacement?.team || null;
child.status = banned ? 'banned' : child.team ? 'active' : 'kicked';
child.retryReason = membership.reason;
}
removeTeamOwnerForChild(mother, child);
mother.members = members.filter((item) => item.id !== member.id);
mother.used = mother.members.length;
addHistory('移出 Team', `${child.email} 已从 ${oldTeam} 移除`);
await persist();
return { status: 200, payload: { ...publicChild(child), ok: true, motherId: mother.id, workspaceId: oldTeam } };
}
async function setWorkspaceMemberRole(mother, workspaceId, memberId, role = 'account-owner') {
if (!workspaceId || !memberId) return { ok: false, status: 400, message: 'workspace_member_credentials_required' };
const accountId = encodeURIComponent(workspaceId);
const userId = encodeURIComponent(memberId);
const attempted = await withTeamManager(mother, (manager) => fetchChatGptJson(manager.accessToken, `/backend-api/accounts/${accountId}/users/${userId}`, {
method: 'PATCH',
accountId: workspaceId,
targetPath: `/backend-api/accounts/${workspaceId}/users/${memberId}`,
targetRoute: '/backend-api/accounts/{account_id}/users/{user_id}',
body: { role },
headers: {
'content-type': 'application/json',
'x-openai-account-user-update-source': 'web_members_table',
...(manager.deviceId ? { 'oai-device-id': manager.deviceId } : {}),
...(manager.cookie ? { cookie: manager.cookie } : {}),
},
}));
if (!attempted?.result) return { ok: false, status: 401, message: 'workspace_owner_token_required' };
const result = attempted.result;
return { ok: result.ok && result.payload?.success !== false, status: result.status, message: result.message, payload: result.payload };
}
async function promoteJoinedMemberToOwner(mother, child, workspaceId) {
const email = String(child?.email || '').trim().toLowerCase();
if (!email) return { ok: false, status: 400, message: 'member_email_required' };
let member = null;
let membersResult = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
membersResult = await queryAllWorkspaceMembers(mother, child.email);
member = (membersResult.items || []).filter(memberIsActive).find((item) => String(item.email || '').trim().toLowerCase() === email);
if (member?.id || !membersResult.ok) break;
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!membersResult?.ok) return { ok: false, status: membersResult?.status || 502, message: membersResult?.message || 'member_lookup_failed' };
if (!member?.id) return { ok: false, status: 202, message: 'membership_not_visible_after_approval' };
child.accountUserId = member.accountUserId || child.accountUserId;
child.memberId = member.id;
child.memberSnapshot = member;
if (!childIsBanned(child)) child.status = 'active';
child.team = mother.team;
child.joinedAt = child.joinedAt || now();
const membership = membershipFor(child, mother.team, true);
membership.role = member.role || membership.role || null;
membership.memberId = member.id;
membership.seatType = member.seatType || membership.seatType || null;
const updated = await setWorkspaceMemberRole(mother, workspaceId, member.id, 'account-owner');
if (!updated.ok) return { ...updated, memberId: member.id };
const ownerMember = { ...member, role: 'account-owner' };
const memberIndex = (mother.members || []).findIndex((item) => item.id === member.id || (item.email && item.email.toLowerCase() === email));
if (memberIndex >= 0) mother.members[memberIndex] = ownerMember;
else mother.members = [...(mother.members || []), ownerMember];
child.accountUserId = ownerMember.accountUserId || child.accountUserId;
child.memberId = ownerMember.id;
child.memberSnapshot = ownerMember;
membership.role = 'account-owner';
membership.memberId = ownerMember.id;
return { ok: true, status: updated.status, message: 'owner_role_confirmed', member: ownerMember, payload: updated.payload };
}
function accessTokenFromSessionPayload(payload) {
const candidates = [
payload,
payload?.session,
payload?.data,
payload?.data?.session,
payload?.user,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
for (const key of ['access_token', 'accessToken', 'token']) {
const token = String(candidate[key] || '').trim();
if (token) return token;
}
}
return '';
}
function childIsWorkspaceOwner(child, mother) {
const membership = membershipFor(child, mother?.team);
if (membership?.role) return memberIsOwner({ email: child?.email, role: membership.role }, mother);
if (membership?.ownerRoleStatus) return membership.ownerRoleStatus === 'applied';
const email = String(child?.email || '').trim().toLowerCase();
const currentMember = (mother?.members || []).find((member) => String(member?.email || '').trim().toLowerCase() === email);
if (currentMember) return memberIsOwner(currentMember, mother);
return child?.team === mother?.team
&& (child?.ownerRoleStatus === 'applied' || memberIsOwner(child?.memberSnapshot, mother));
}
function childMatchesKnownTeamOwner(child, mother) {
const email = String(child?.email || '').trim().toLowerCase();
if (!email || !mother) return false;
const membership = membershipFor(child, mother.team);
if (membership?.role && !memberIsOwner({ email: child.email, role: membership.role }, mother)) return false;
if (membership?.ownerRoleStatus === 'skipped') return false;
return [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])]
.some((owner) => String(owner?.email || '').trim().toLowerCase() === email);
}
function upsertTeamOwnerFromChild(mother, child, workspaceId, workspaceToken) {
if (!mother || !child?.email || !workspaceToken?.accessToken) return null;
if (!Array.isArray(mother.ownerAccounts)) mother.ownerAccounts = [];
const target = child.email.toLowerCase();
const index = mother.ownerAccounts.findIndex((owner) => String(owner?.email || '').toLowerCase() === target);
const existing = index >= 0 ? mother.ownerAccounts[index] : {};
const membership = membershipFor(child, mother.team);
const quotaSource = hasQuotaObservation(membership) ? membership : existing;
const next = {
...existing,
email: child.email,
name: child.name || existing.name || child.email,
accessToken: workspaceToken.accessToken,
refreshToken: workspaceToken.refreshToken || existing.refreshToken || '',
idToken: workspaceToken.idToken || existing.idToken || '',
clientId: workspaceToken.clientId || child.clientId || existing.clientId || '',
chatgptUserId: workspaceToken.userId || child.chatgptUserId || existing.chatgptUserId || '',
accountId: workspaceId,
team: mother.team || workspaceId,
plan: 'team',
planType: 'team',
tokenScope: 'team',
expiresAt: workspaceToken.expiresAt || existing.expiresAt || null,
...quotaRecordFields(quotaSource),
lastProbe: membership?.lastProbe || quotaSource.lastProbe || null,
linkedFreeAccountId: child.id,
workspaceTokenUpdatedAt: workspaceToken.acquiredAt || now(),
};
if (index >= 0) mother.ownerAccounts[index] = next;
else mother.ownerAccounts.push(next);
const primaryEmail = String(mother.primaryOwnerEmail || mother.email || '').trim().toLowerCase();
if (target === primaryEmail) {
mother.accessToken = workspaceToken.accessToken;
mother.refreshToken = workspaceToken.refreshToken || mother.refreshToken || '';
mother.idToken = workspaceToken.idToken || mother.idToken || '';
mother.clientId = workspaceToken.clientId || child.clientId || mother.clientId || '';
mother.chatgptUserId = workspaceToken.userId || child.chatgptUserId || mother.chatgptUserId || '';
mother.expiresAt = workspaceToken.expiresAt || mother.expiresAt || null;
mother.token = preview(workspaceToken.accessToken);
}
return next;
}
function removeTeamOwnerForChild(mother, child) {
if (!mother || !child?.email || !Array.isArray(mother.ownerAccounts)) return;
const email = child.email.toLowerCase();
const primaryOwner = String(mother.email || '').toLowerCase();
mother.ownerAccounts = mother.ownerAccounts.filter((owner) => {
const ownerEmail = String(owner?.email || '').toLowerCase();
if (ownerEmail !== email) return true;
return ownerEmail === primaryOwner;
});
}
async function probeMother(mother) {
let attempted = await withTeamManager(mother, (manager) => probeUsage(manager.accessToken, mother.accountId), { email: mother.email });
let result = attempted?.result || await probeUsage('', mother.accountId);
const workspace = await syncMotherWorkspace(mother, { force: true });
if (isOpenAiAuthFailure(result)) {
attempted = await withTeamManager(mother, (manager) => probeUsage(manager.accessToken, mother.accountId), { email: mother.email });
result = attempted?.result || result;
}
mother.lastCheck = now();
applyMotherQuotaResult(mother, result);
const banReason = explicitAccountBanMessage(result);
Object.assign(mother, quotaHealth(result, banReason));
mother.banReason = banReason || '';
mother.bannedAt = banReason ? (mother.bannedAt || now()) : null;
mother.managementStatus = managementHealth(workspace.membersResult, workspace.subscriptionResult);
result.seatSnapshot = workspace.seatSnapshot;
result.members = workspace.members;
result.subscriptionOk = workspace.subscriptionResult?.ok || false;
result.membersOk = workspace.membersResult?.ok || false;
result.membersResult = workspace.membersResult;
result.subscriptionResult = workspace.subscriptionResult;
result.accountStatus = mother.accountStatus;
result.managementStatus = mother.managementStatus;
result.banReason = mother.banReason || null;
return result;
}
function selectedKickWindow(mother = null) {
const configured = normalizeKickWindow(state.settings?.kickWindow);
// A time-based policy is explicit and applies to every Team rotation mode.
if (configured === 'time') return 'time';
// Rotating spaces use the long quota window so a seat is only replaced after
// its weekly allowance is exhausted. Fixed spaces follow the global setting.
if (mother?.rotationMode === 'rotating') return '7d';
return configured;
}
function quotaIsExhausted(child, result, window = selectedKickWindow()) {
if (window === 'time') return false;
const selected = window === '7d' ? result?.secondary : result?.primary;
return Boolean(result?.ok && selected?.usedPercent != null && selected.usedPercent >= 99.99);
}
function quotaRetryAfter(child, window = selectedKickWindow(), teamId = null) {
if (window === 'time') return new Date(Date.now() + normalizeKickAfterHours(state.settings?.kickAfterHours) * 60 * 60 * 1000).toISOString();
const membership = teamId ? latestMembershipHistoryFor(child, teamId) : null;
const resetAt = window === '7d'
? (membership?.quota7dResetAt || (child.team === teamId ? child.quota7dResetAt : null))
: (membership?.quota5hResetAt || (child.team === teamId ? child.quota5hResetAt : null));
const resetTimes = [resetAt]
.map((value) => Date.parse(value || ''))
.filter((value) => Number.isFinite(value) && value > Date.now());
if (!resetTimes.length) return null;
return new Date(resetTimes[0]).toISOString();
}
function quotaKickReason(child, window = selectedKickWindow()) {
if (window === 'time') return 'time_elapsed';
return window === '7d' ? 'quota_7d' : 'quota_5h';
}
function membershipQuotaIsExhausted(child, teamId, window = selectedKickWindow()) {
if (window === 'time') return false;
const membership = latestMembershipHistoryFor(child, teamId);
if (!membership) return false;
if (membership.quotaStatus === 'exhausted' && membership.quotaStatusWindow === window) return true;
return quotaIsExhausted(child, membership.lastProbe, window);
}
function quotaKickEligible(child, teamId, window) {
if (state.settings.delayedKickEnabled && ['5h', '7d'].includes(window)) {
return quotaKickDelayDue(membershipFor(child, teamId), window);
}
return membershipQuotaIsExhausted(child, teamId, window)
|| (child.team === teamId && child.lastProbe?.ok === true && quotaIsExhausted(child, child.lastProbe, window));
}
function clearAllQuotaKickDelays() {
for (const child of state.children) {
for (const membership of child.workspaceHistory || []) {
if (membership.status === 'active') clearQuotaKickDelay(membership);
}
}
}
function reconcileQuotaKickDelaySettings(previousWindow, previousEnabled, previousMinutes) {
if (previousWindow !== state.settings.kickWindow || previousEnabled !== state.settings.delayedKickEnabled) {
clearAllQuotaKickDelays();
return;
}
if (previousMinutes === state.settings.delayedKickMinutes) return;
for (const child of state.children) {
for (const membership of child.workspaceHistory || []) {
if (membership.status !== 'active' || !membership.quotaKickWindow) continue;
const startedAt = Date.parse(membership.quotaKickExhaustedAt || '');
if (!Number.isFinite(startedAt)) {
clearQuotaKickDelay(membership);
continue;
}
membership.quotaKickPendingUntil = new Date(startedAt + state.settings.delayedKickMinutes * 60_000).toISOString();
membership.quotaKickVerifiedAt = null;
}
}
}
function childManualKickTimerExpired(child, teamId, currentTime = Date.now()) {
return manualKickTimerExpired(membershipFor(child, teamId), currentTime);
}
function teamHasExpiredManualKickTimers(mother, currentTime = Date.now()) {
const teamId = canonicalTeamId(mother);
return state.children.some((child) => (
isChildMemberOfTeam(child, teamId)
&& childManualKickTimerExpired(child, teamId, currentTime)
));
}
function teamHasActiveManualKickTimers(mother) {
const teamId = canonicalTeamId(mother);
return state.children.some((child) => (
isChildMemberOfTeam(child, teamId)
&& membershipFor(child, teamId)?.manualKickEnabled === true
));
}
function timeKickExpired(child, teamId, afterHours = state.settings?.kickAfterHours) {
const membership = membershipFor(child, teamId);
// An explicitly started member timer replaces the joinedAt-based deadline.
// A malformed explicit timer fails closed instead of causing an early kick.
if (membership?.manualKickEnabled === true) return manualKickTimerExpired(membership);
const joinedAt = membership?.joinedAt || (child?.team === teamId ? child.joinedAt : null);
const joinedTimestamp = Date.parse(joinedAt || '');
if (!Number.isFinite(joinedTimestamp)) return false;
const hours = normalizeKickAfterHours(afterHours);
return Date.now() >= joinedTimestamp + hours * 60 * 60 * 1000;
}
function childIsProtectedForManualKick(child, mother) {
if (mother?.rotationMode !== 'fixed') return false;
const email = String(child?.email || '').trim().toLowerCase();
const primaryEmail = String(mother.primaryOwnerEmail || mother.email || '').trim().toLowerCase();
if (email && primaryEmail && email === primaryEmail) return true;
const member = (mother.members || []).find((item) => (
(child?.memberId && item.id === child.memberId)
|| (item.email && email && String(item.email).trim().toLowerCase() === email)
));
return Boolean(member && memberIsProtected(member, mother));
}
async function updateManualKickTimers(motherId, body = {}) {
const mother = findMother(motherId);
if (!mother) return { ok: false, status: 404, message: 'mother_not_found' };
const rawIds = Array.isArray(body.childIds)
? body.childIds
: Array.isArray(body.ids)
? body.ids
: body.childId !== undefined
? [body.childId]
: [];
const childIds = [...new Set(rawIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!childIds.length) return { ok: false, status: 400, message: 'child_ids_required' };
if (childIds.length > 500) return { ok: false, status: 400, message: 'too_many_child_ids', maximum: 500 };
const timerInput = parseManualKickTimerInput(body);
if (!timerInput.ok) return { ok: false, status: 400, message: timerInput.code, ...timerInput };
const teamId = canonicalTeamId(mother);
const updated = [];
const skipped = [];
const changedAt = now();
for (const childId of childIds) {
const child = findChild(childId);
if (!child) {
skipped.push({ id: childId, email: '', reason: 'child_not_found' });
continue;
}
if (!isChildMemberOfTeam(child, teamId)) {
skipped.push({ id: child.id, email: child.email || '', reason: 'child_not_active_in_team' });
continue;
}
if (timerInput.enabled && childIsBanned(child)) {
skipped.push({ id: child.id, email: child.email || '', reason: 'account_banned' });
continue;
}
if (timerInput.enabled && childIsProtectedForManualKick(child, mother)) {
skipped.push({ id: child.id, email: child.email || '', reason: 'protected_workspace_member' });
continue;
}
let membership = membershipFor(child, teamId);
if (!membership && !timerInput.enabled) {
skipped.push({ id: child.id, email: child.email || '', reason: 'manual_kick_timer_not_active' });
continue;
}
// Legacy records may only have child.team. The membership assertion above
// makes it safe to materialize their active history when starting a timer.
if (!membership) membership = membershipFor(child, teamId, true);
if (!timerInput.enabled) {
if (membership.manualKickEnabled !== true) {
skipped.push({ id: child.id, email: child.email || '', reason: 'manual_kick_timer_not_active' });
continue;
}
clearManualKickTimer(membership, 'cancelled', changedAt);
} else {
if (membership.manualKickEnabled === true) clearManualKickTimer(membership, 'restarted', changedAt);
Object.assign(membership, {
manualKickEnabled: true,
manualKickStartedAt: timerInput.startedAt,
manualKickAt: timerInput.kickAt,
manualKickDurationMinutes: timerInput.durationMinutes,
});
}
updated.push({
id: child.id,
email: child.email || '',
team: teamId,
...publicManualKickTimerFields(membership),
});
}
const action = timerInput.enabled ? '启动成员倒计时' : '取消成员倒计时';
const durationDetail = timerInput.enabled ? `,时长 ${timerInput.durationMinutes} 分钟` : '';
addHistory(action, `${teamDisplayName(mother)} 更新 ${updated.length} 个账号${durationDetail}${skipped.length ? `,跳过 ${skipped.length} 个` : ''}`, skipped.length ? 'partial' : 'success', {
summary: {
team: teamId,
enabled: timerInput.enabled,
durationMinutes: timerInput.durationMinutes || null,
kickAt: timerInput.kickAt || null,
updated: updated.map((item) => ({ id: item.id, email: item.email })),
skipped,
},
});
await persist();
return {
ok: updated.length > 0,
status: updated.length ? (skipped.length ? 207 : 200) : 409,
message: updated.length
? (skipped.length ? 'manual_kick_timer_partially_updated' : 'manual_kick_timer_updated')
: skipped[0]?.reason || 'manual_kick_timer_no_accounts_updated',
enabled: timerInput.enabled,
updated,
skipped,
state: publicState({ includeHistory: false }),
};
}
function applyQuotaResult(child, result, teamId = null, window = selectedKickWindow(), { createMembership = false } = {}) {
child.lastQuotaCheckAt = now();
const sameTeam = !teamId || workspaceIdKey(child.team) === workspaceIdKey(teamId);
const shouldUpdateAccount = sameTeam;
if (shouldUpdateAccount) child.lastProbe = result;
const success = result?.ok === true;
const hasPrimaryQuota = result?.primary?.usedPercent != null;
const hasSecondaryQuota = result?.secondary?.usedPercent != null;
const hasMeasuredQuota = hasPrimaryQuota || hasSecondaryQuota;
const durationMapped = result?.quotaWindowMapping === 'duration-v1';
// A Team probe must always have an account+Team record. Never fall back to
// a different Team's top-level compatibility fields when that record exists.
const membership = teamId
? membershipFor(child, teamId, createMembership || success)
: null;
const previousSnapshot = membership
? (membership.quotaSnapshot && typeof membership.quotaSnapshot === 'object' ? membership.quotaSnapshot : {})
: (shouldUpdateAccount && !teamId && child.quotaSnapshot && typeof child.quotaSnapshot === 'object' ? child.quotaSnapshot : {});
const previousQuota5h = membership ? (membership.quota5h ?? null) : (!teamId && shouldUpdateAccount ? (child.quota5h ?? null) : null);
const previousQuota7d = membership ? (membership.quota7d ?? null) : (!teamId && shouldUpdateAccount ? (child.quota7d ?? null) : null);
const previousReset5h = membership ? (membership.quota5hResetAfterSeconds ?? null) : (!teamId && shouldUpdateAccount ? (child.quota5hResetAfterSeconds ?? null) : null);
const previousReset7d = membership ? (membership.quota7dResetAfterSeconds ?? null) : (!teamId && shouldUpdateAccount ? (child.quota7dResetAfterSeconds ?? null) : null);
const previousResetAt5h = membership ? (membership.quota5hResetAt || null) : (!teamId && shouldUpdateAccount ? (child.quota5hResetAt || null) : null);
const previousResetAt7d = membership ? (membership.quota7dResetAt || null) : (!teamId && shouldUpdateAccount ? (child.quota7dResetAt || null) : null);
const remainingFromWindow = (source) => source?.usedPercent == null ? null : Math.max(0, Math.round(100 - source.usedPercent));
const nextQuota5h = success && hasPrimaryQuota ? remainingFromWindow(result.primary) : (success && durationMapped ? null : previousQuota5h);
const nextQuota7d = success && hasSecondaryQuota ? remainingFromWindow(result.secondary) : (success && durationMapped ? null : previousQuota7d);
const nextReset5h = success && (hasPrimaryQuota || durationMapped) ? (result.primary?.resetAfterSeconds ?? null) : previousReset5h;
const nextReset7d = success && (hasSecondaryQuota || durationMapped) ? (result.secondary?.resetAfterSeconds ?? null) : previousReset7d;
const nextResetAt5h = success && (hasPrimaryQuota || durationMapped)
? (result.primary?.resetAt || (result.primary?.resetAfterSeconds != null ? new Date(Date.now() + result.primary.resetAfterSeconds * 1000).toISOString() : null))
: previousResetAt5h;
const nextResetAt7d = success && (hasSecondaryQuota || durationMapped)
? (result.secondary?.resetAt || (result.secondary?.resetAfterSeconds != null ? new Date(Date.now() + result.secondary.resetAfterSeconds * 1000).toISOString() : null))
: previousResetAt7d;
const nextSnapshot = success && (hasMeasuredQuota || durationMapped) ? {
primary: hasPrimaryQuota ? result.primary : readWindow(),
secondary: hasSecondaryQuota ? result.secondary : readWindow(),
source: result.source || 'wham/usage',
quotaWindowMapping: result.quotaWindowMapping || undefined,
updatedAt: now(),
} : previousSnapshot;
// Keep account-level compatibility values only for a direct probe or the
// child's current Team. A probe for another Team must remain isolated.
if (shouldUpdateAccount && (!teamId || sameTeam)) {
if (success && (hasPrimaryQuota || durationMapped)) child.quota5h = nextQuota5h;
if (success && (hasSecondaryQuota || durationMapped)) child.quota7d = nextQuota7d;
if (success && (hasPrimaryQuota || durationMapped)) {
child.quota5hResetAfterSeconds = nextReset5h;
child.quota5hResetAt = nextResetAt5h;
}
if (success && (hasSecondaryQuota || durationMapped)) {
child.quota7dResetAfterSeconds = nextReset7d;
child.quota7dResetAt = nextResetAt7d;
}
if (success && (hasMeasuredQuota || durationMapped)) {
child.quotaSnapshot = nextSnapshot;
child.quotaUpdatedAt = nextSnapshot.updatedAt;
}
}
if (membership) {
membership.lastProbe = result;
if (success && (hasMeasuredQuota || durationMapped)) {
membership.quota5h = nextQuota5h;
membership.quota7d = nextQuota7d;
membership.quotaSnapshot = nextSnapshot;
membership.quotaUpdatedAt = nextSnapshot.updatedAt;
membership.quota5hResetAfterSeconds = nextReset5h;
membership.quota7dResetAfterSeconds = nextReset7d;
membership.quota5hResetAt = nextResetAt5h;
membership.quota7dResetAt = nextResetAt7d;
membership.quotaSource = result.source || 'wham/usage';
}
membership.quotaStatus = success && hasMeasuredQuota
? (quotaIsExhausted(child, result, window) ? 'exhausted' : 'available')
: 'unknown';
membership.quotaStatusWindow = window;
}
if (membership) {
updateQuotaKickDelay(membership, {
enabled: state.settings.delayedKickEnabled,
window,
minutes: state.settings.delayedKickMinutes,
probe: result,
});
const selected = window === '7d' ? result.secondary : result.primary;
if (state.settings.delayedKickEnabled && ['5h', '7d'].includes(window)
&& (!result.ok || selected?.usedPercent == null || selected.usedPercent === '' || !Number.isFinite(Number(selected.usedPercent)))) {
membership.quotaStatus = 'unknown';
membership.quotaStatusWindow = window;
}
}
if (!childIsBanned(child) && shouldUpdateAccount && quotaIsExhausted(child, result, window)) child.status = 'exhausted';
else if (!childIsBanned(child) && shouldUpdateAccount && (result.primary?.usedPercent != null || result.secondary?.usedPercent != null)) {
const remaining = [membership?.quota5h ?? child.quota5h, membership?.quota7d ?? child.quota7d].filter((value) => value != null);
child.status = remaining.length && Math.min(...remaining) <= state.settings.threshold ? 'warning' : result.ok ? 'active' : child.status;
}
}
async function probeChild(child) {
if (!child) return { ok: false, status: 404, message: 'child_not_found' };
// Free accounts are only credentials for entering a Team. Their usable quota
// starts from the Team workspace and must never be probed as a Free account.
return { ok: false, status: 409, code: 'free_quota_not_tracked', message: 'Free 账号不单独检测额度;请在加入 Team 后检测对应空间额度', id: child.id, email: child.email };
}
const teamQuotaRefreshes = new Map();
async function refreshChildTeamQuota(childId, body = {}) {
const motherId = typeof body?.motherId === 'string' ? body.motherId.trim() : '';
const workspaceId = typeof body?.workspaceId === 'string' ? body.workspaceId.trim() : '';
const base = { childId, motherId, workspaceId };
const failure = (status, code, message, extra = {}) => ({ ok: false, status, ...base,
quota5h: null, quota7d: null, quotaUpdatedAt: null, code, message, ...extra });
if (!motherId || !workspaceId) return failure(400, 'team_quota_scope_required', '请指定母号和目标 Team 空间');
const child = findChild(childId);
const mother = findMother(motherId);
if (!child) return failure(404, 'child_not_found', '账号不存在,请刷新列表');
if (!mother) return failure(404, 'mother_not_found', '目标母号不存在,请刷新列表');
if (configuredTeamId(mother) !== workspaceId) return failure(409, 'team_workspace_mismatch', '母号与目标 Team 不匹配,请刷新列表');
const membership = membershipFor(child, workspaceId);
if (!membership) return failure(409, 'team_membership_required', '账号未在此 Team 中,不能刷新该空间额度');
const email = child.email;
const userId = child.chatgptUserId;
const team = mother.team;
const currentFailure = () => {
if (findChild(childId) !== child || child.email !== email || child.chatgptUserId !== userId) {
return failure(409, 'token_identity_mismatch', '账号已变更,本次额度结果未保存');
}
if (findMother(motherId) !== mother || configuredTeamId(mother) !== workspaceId || mother.team !== team) {
return failure(409, 'team_workspace_changed', '目标 Team 已变更,本次额度结果未保存');
}
if (membershipFor(child, workspaceId) !== membership) {
return failure(409, 'team_membership_changed', '账号成员状态已变更,本次额度结果未保存');
}
if (childIsBanned(child)) return failure(409, 'account_banned', '账号已停用,不能刷新额度');
return null;
};
const snapshot = () => ({ quota5h: membership.quota5h ?? null, quota7d: membership.quota7d ?? null,
quotaUpdatedAt: membership.quotaUpdatedAt || null });
const recordFailure = async (code, message, upstreamStatus = 0, status = 502) => {
const stale = currentFailure();
if (stale) return stale;
// Error text is intentionally local: an upstream body may contain tokens
// or proxy credentials. Preserve the last successful observation intact.
applyQuotaResult(child, { ok: false, status: upstreamStatus, errorCode: code, message,
primary: readWindow(), secondary: readWindow(), quotaWindowMapping: 'duration-v1', source: 'wham/usage' }, workspaceId, selectedKickWindow(mother));
const result = failure(status, code, message, { ...snapshot(), upstreamStatus });
await persist();
return currentFailure() || result;
};
const matchesToken = (accessToken) => {
const claims = accessTokenClaims(accessToken);
const knownIdentity = Boolean(claims.email && workspaceIdKey(claims.email) === workspaceIdKey(email)
|| userId && claims.userId === String(userId).trim());
return Boolean(accessToken && knownIdentity && childTokenIdentityMatches(child, accessToken)
&& claims.accountId === workspaceId
&& ['team', 'business', 'self_serve_business_prolite'].includes(claims.planType.toLowerCase()));
};
const invalid = currentFailure();
if (invalid) return invalid;
const refreshKey = JSON.stringify([childId, motherId, workspaceId]);
if (teamQuotaRefreshes.has(refreshKey)) return teamQuotaRefreshes.get(refreshKey);
const queueKey = String(child.id);
const previous = childAuthQueues.get(queueKey) || Promise.resolve();
const current = previous.catch(() => {}).then(async () => {
const stale = currentFailure();
if (stale) return stale;
try {
// Use the account's existing proxy binding without creating a browser,
// login session, invitation or a request for any other account's quota.
const result = await withChildAuthorizationNetwork(child, true, async () => {
let stale = currentFailure();
if (stale) return stale;
let token = workspaceTokenFor(child, workspaceId) || accountTeamTokenFor(child, workspaceId, { allowExpired: true });
if (!matchesToken(token?.accessToken)) {
return recordFailure('team_token_required', '此账号尚无匹配的 Team 授权,请先获取 Team JSON', 0, 409);
}
let refreshedOnce = false;
const refreshToken = async () => {
if (!token.refreshToken) return { ok: false, status: 401 };
const beforeAccessToken = token.accessToken;
const beforeRefreshToken = token.refreshToken;
const refreshed = await refreshOpenAiAccessToken(beforeRefreshToken, token.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
const stale = currentFailure();
if (stale) return { stale };
const latest = workspaceTokenFor(child, workspaceId) || accountTeamTokenFor(child, workspaceId, { allowExpired: true });
if (latest?.accessToken !== beforeAccessToken || latest?.refreshToken !== beforeRefreshToken) {
return { stale: failure(409, 'team_token_changed', 'Team 授权已更新,请重新刷新额度') };
}
if (!refreshed.ok || !matchesToken(refreshed.accessToken) || !teamTokenDetails(refreshed.accessToken, workspaceId)) {
return { ok: false, status: refreshed.status || 0 };
}
token = saveWorkspaceToken(child, workspaceId, refreshed.accessToken, refreshed.claims, {
refreshToken: refreshed.refreshToken || beforeRefreshToken, idToken: refreshed.idToken || token.idToken || '',
clientId: token.clientId || '', source: 'team_refresh_token',
});
membership.workspaceTokenStatus = 'ready';
membership.workspaceTokenUpdatedAt = token.acquiredAt;
await persist();
refreshedOnce = true;
return currentFailure() ? { stale: currentFailure() } : { ok: true };
};
const refreshFailure = (result) => result.stale || recordFailure('team_token_refresh_failed',
'Team 授权已失效,刷新失败;请重新获取此空间的 Team JSON', result.status || 0);
if (!teamTokenDetails(token.accessToken, workspaceId)) {
const refreshed = await refreshToken();
if (!refreshed.ok) return refreshFailure(refreshed);
}
let probe = await probeUsage(token.accessToken, workspaceId);
stale = currentFailure();
if (stale) return stale;
if (!refreshedOnce && isOpenAiAuthFailure(probe) && token.refreshToken) {
const refreshed = await refreshToken();
if (!refreshed.ok) return refreshFailure(refreshed);
probe = await probeUsage(token.accessToken, workspaceId);
stale = currentFailure();
if (stale) return stale;
}
if (!probe.ok) {
const status = Number(probe.status) || 0;
const code = status === 401 ? 'team_quota_auth_failed' : status === 403 ? 'team_quota_forbidden' : 'team_quota_request_failed';
const message = status === 401 ? 'Team 授权已失效,请重新获取此空间的 Team JSON'
: status === 403 ? '额度接口拒绝访问(403),请检查该空间授权与代理网络'
: status ? `额度接口返回 ${status},请稍后重试` : '额度请求超时或网络不可用,请检查代理后重试';
return recordFailure(code, message, status);
}
if (probe.primary?.usedPercent == null && probe.secondary?.usedPercent == null) {
return recordFailure('team_quota_unavailable', '额度接口未返回 5h 或 7d 的真实数据,请稍后重试', probe.status);
}
applyQuotaResult(child, probe, workspaceId, selectedKickWindow(mother));
const saved = { ok: true, status: 200, ...base, ...snapshot(), code: 'team_quota_refreshed', message: 'Team 额度已刷新' };
await persist();
return currentFailure() || saved;
});
if (result?.childId === childId) return result;
return recordFailure('team_quota_proxy_unavailable', '此账号的代理配置不可用,请检查代理池后重试');
} catch {
return recordFailure('team_quota_refresh_failed', '额度刷新失败,请稍后重试');
}
});
childAuthQueues.set(queueKey, current);
teamQuotaRefreshes.set(refreshKey, current);
try {
return await current;
} finally {
if (childAuthQueues.get(queueKey) === current) childAuthQueues.delete(queueKey);
if (teamQuotaRefreshes.get(refreshKey) === current) teamQuotaRefreshes.delete(refreshKey);
}
}
function setChildLoginState(child, status, message = '') {
child.loginStatus = status;
child.loginMessage = message;
child.loginAttemptedAt = now();
if (status === 'ready') {
child.loginErrorCode = null;
child.loginFailureStage = null;
child.loginHttpStatus = null;
}
}
function freeTokenNeedsRefresh(child, skewMs = 30_000) {
if (!child?.accessToken) return true;
const claims = accessTokenClaims(child.accessToken);
const expiresAt = Date.parse(claims.expiresAt || child.expiresAt || '');
return Number.isFinite(expiresAt) && expiresAt <= Date.now() + skewMs;
}
function freeAuthRetryBackoffActive(child) {
if (pendingOAuthCallback(child?.authSession)) return false;
const retryBackoffMs = Math.max(60_000, Number(process.env.FREE_AUTH_RETRY_BACKOFF_MS) || 300_000);
const lastAttempt = Date.parse(child?.loginAttemptedAt || '');
return Number.isFinite(lastAttempt)
&& Date.now() - lastAttempt < retryBackoffMs
&& ['login_required', 'credentials_required'].includes(child?.loginStatus);
}
function freeAuthRequiresManualInput(child) {
if (pendingOAuthCallback(child?.authSession)) return false;
return child?.status === 'login_pending'
|| child?.loginErrorCode === 'browser_oauth_credentials_rejected'
|| child?.status === 'phone_verification_required'
|| child?.loginStatus === 'phone_verification_required';
}
function explicitOAuthCallbackFailure(session, callbackUrl) {
if (!callbackUrl) return null;
try {
parseValidOAuthCallback(callbackUrl);
if (!session?.state || (!session.codeVerifier && !session.authorization?.sessionId)) {
return { ok: false, status: 409, code: 'callback_session_missing', message: '原 OAuth 会话已失效,请先打开当前账号的授权链接再粘贴回调地址' };
}
parseValidOAuthCallback(callbackUrl, session.state);
return null;
} catch (error) {
return { ok: false, status: error.status || 400, code: error.code || 'callback_invalid', message: error.message || 'OAuth 回调地址无效' };
}
}
function pendingOAuthCallback(session, explicitUrl = '') {
if (explicitUrl) return parseValidOAuthCallback(explicitUrl, session?.state).toString();
if (!session?.state) return '';
const persisted = String(session.callbackUrl || '').trim();
if (persisted) {
const receivedAt = Date.parse(session.callbackReceivedAt || '');
if (!Number.isFinite(receivedAt)) {
session.callbackReceivedAt = now();
return persisted;
}
if (Date.now() - receivedAt < 10 * 60 * 1000) return persisted;
clearOAuthCallback(session.state, persisted);
delete session.callbackUrl;
delete session.callbackReceivedAt;
if (session.currentUrl === persisted) session.currentUrl = '';
return '';
}
const received = consumeOAuthCallback(session.state);
if (received) return received;
try {
const current = new URL(session.currentUrl);
if (current.pathname === '/auth/callback' && current.searchParams.get('code') && current.searchParams.get('state') === session.state) return current.toString();
} catch { /* The login page is not necessarily an absolute URL. */ }
return '';
}
async function ensureChildFreeAuth(child, { forceRefresh = false, verificationCode = '', callbackUrl = '', browserLogin = true, onProgress = () => {} } = {}) {
if (!child) return { ok: false, status: 404, code: 'child_not_found', message: 'child_not_found' };
const callbackFailure = explicitOAuthCallbackFailure(child.authSession, callbackUrl);
if (callbackFailure) return { ...callbackFailure, child: publicChild(child) };
if (pendingOAuthCallback(child.authSession, callbackUrl)) {
return acquireChildAuth(child, { refresh: true, allowCredentialLogin: true, verificationCode, callbackUrl, browserLogin, onProgress });
}
if (!forceRefresh && !freeTokenNeedsRefresh(child)) {
return { ok: true, status: 200, source: 'access_token', child: publicChild(child) };
}
return acquireChildAuth(child, { refresh: true, allowCredentialLogin: true, verificationCode, callbackUrl, browserLogin, onProgress });
}
function applyRefreshedChildToken(child, result) {
if (!childTokenIdentityMatches(child, result.accessToken)) return false;
const claims = result.claims || accessTokenClaims(result.accessToken);
// Compare against the previous identity before updating the account record.
const teamToken = accountTeamTokenFor({ ...child, accessToken: result.accessToken }, claims.accountId);
child.accessToken = result.accessToken;
child.refreshToken = result.refreshToken || child.refreshToken;
child.idToken = result.idToken || child.idToken;
child.clientId = result.clientId || child.clientId;
child.organizationId = result.organizationId || child.organizationId;
child.subscriptionExpiresAt = result.subscriptionExpiresAt || child.subscriptionExpiresAt;
child.accountId = claims.accountId || child.accountId;
child.chatgptUserId = claims.userId || child.chatgptUserId;
child.expiresAt = claims.expiresAt || (result.expiresIn ? new Date(Date.now() + result.expiresIn * 1000).toISOString() : child.expiresAt);
child.plan = claims.planType || child.plan || '待检测';
child.token = preview(child.accessToken);
child.authAt = now();
child.sub2apiImported = true;
child.authSession = null;
child.loginUrl = null;
child.loginBrowserRequired = false;
if (!childIsBanned(child)) child.status = child.team ? 'active' : 'ready';
setChildLoginState(child, 'ready', '已通过 refresh token 获取新的 AT');
if (teamToken) {
const mother = state.mothers.find((item) => item.accountId === claims.accountId && isChildMemberOfTeam(child, item.team));
adoptAccountTeamToken(mother, child, { ...teamToken, refreshToken: child.refreshToken, idToken: child.idToken, clientId: child.clientId });
}
return true;
}
function applyLoggedInChildToken(child, result) {
if (!childTokenIdentityMatches(child, result.accessToken)) return false;
const claims = result.claims || accessTokenClaims(result.accessToken);
const teamToken = accountTeamTokenFor({ ...child, accessToken: result.accessToken }, claims.accountId);
child.accessToken = result.accessToken;
child.refreshToken = result.refreshToken || child.refreshToken;
child.idToken = result.idToken || child.idToken;
child.clientId = result.clientId || child.clientId;
child.organizationId = result.organizationId || child.organizationId;
child.subscriptionExpiresAt = result.subscriptionExpiresAt || child.subscriptionExpiresAt;
child.accountId = claims.accountId || child.accountId;
child.chatgptUserId = claims.userId || child.chatgptUserId;
child.expiresAt = claims.expiresAt || child.expiresAt;
child.plan = claims.planType || child.plan || 'free';
child.token = preview(child.accessToken);
child.authAt = now();
child.sub2apiImported = true;
child.authSession = null;
child.loginUrl = null;
child.loginBrowserRequired = false;
if (result.sub2api) {
child.sub2api = {
...(child.sub2api || {}),
imported: true,
integrationId: result.sub2api.integrationId || null,
integrationName: result.sub2api.integrationName || '',
accountId: result.sub2api.accountId || null,
groupId: result.sub2api.groupId || null,
groupName: result.sub2api.groupName || '',
action: result.sub2api.action || '',
synced: result.sub2api.synced === true,
message: result.sub2api.message || '',
syncedAt: result.sub2api.synced ? now() : null,
};
}
if (!childIsBanned(child)) child.status = child.team ? 'active' : 'ready';
setChildLoginState(child, 'ready', result.sub2api?.synced
? '已完成 Codex OAuth,Free JSON 已生成并同步 Sub2API'
: result.sub2api
? `已生成 Free JSON,Sub2API 同步未完成:${result.sub2api.message || 'unknown_error'}`
: '已完成登录,Free JSON 已生成');
if (teamToken) {
const mother = state.mothers.find((item) => item.accountId === claims.accountId && isChildMemberOfTeam(child, item.team));
adoptAccountTeamToken(mother, child, { ...teamToken, refreshToken: child.refreshToken, idToken: child.idToken, clientId: child.clientId });
}
return true;
}
async function rejectChildTokenIdentity(child) {
setChildLoginState(child, 'login_required', '新授权与现有账号身份不一致,已保留原凭据');
await persist();
return { ok: false, status: 502, code: 'token_identity_mismatch', message: child.loginMessage, child: publicChild(child) };
}
const childAuthQueues = new Map();
const childTeamAuthOperations = new Map();
let roxyProfileProvisioning = Promise.resolve();
const autoJsonExecution = new AsyncLocalStorage();
const roxyExecution = new AsyncLocalStorage();
// Only locally produced cleanup metadata may survive an unexpected exception;
// exception messages and arbitrary provider payloads remain private.
const roxyAuthorizationFailureCleanup = new WeakMap();
function roxyConcurrency(config = state.settings.integrations?.roxy) {
return Math.min(config?.concurrency || 1, config?.profileMode === 'pooled' ? config.poolSize || 3 : 3);
}
const roxyBrowserQueue = createConcurrencyQueue(() => roxyConcurrency());
const activeRoxyPoolSlots = new Set();
const roxyCleanupBlocks = new Map();
let roxyProfileCleanupJobs = 0;
let roxyProfileCleanupFinished = Promise.resolve();
function runRoxyQueued(operation) {
return roxyExecution.getStore() ? operation() : roxyBrowserQueue.run(operation);
}
async function verifyRoxyCleanup(target = null) {
for (const [key, { config, dirId }] of roxyCleanupBlocks) {
if (target && (target.dirId !== dirId || target.baseUrl !== config.baseUrl)) continue;
try {
const current = state.settings.integrations?.roxy;
const connectionConfig = current?.baseUrl === config.baseUrl ? { ...config, apiKey: current.apiKey } : config;
const connections = await createRoxyApiClient(connectionConfig).connectionInfo(dirId);
if (connections.some((entry) => entry.dirId === dirId)) throw new Error('still_open');
} catch { throw new RoxyBrowserError('roxy_cleanup_failed', { status: 503 }); }
roxyCleanupBlocks.delete(key);
}
}
async function ensureChildRoxyProfile(child, execution, onProgress) {
// Share the provision/persist lock across Free and all Team authorization
// paths. Different accounts must not overwrite each other's mappings.
const pending = roxyProfileProvisioning.catch(() => {}).then(async () => {
const current = state.settings.integrations?.roxy;
if (!current?.enabled) return;
if (findChild(child.id) !== child) throw new RoxyBrowserError('roxy_profile_missing');
const bound = current.profiles.find((profile) => profile.childId === child.id);
// Validate the proxy pool before consuming a Roxy environment slot.
bindRoxyProxyProfiles({ ...structuredClone(current), profiles: [
...structuredClone(current.profiles), ...(!bound ? [{ childId: child.id, dirId: 'pending_profile' }] : []),
] });
onProgress('roxy_profile_preparing', '正在核验账号绑定的 Roxy 环境,失效时重新分配');
const { source, ...profile } = await resolveRoxyAccountProfile(current, child.id, { email: child.email });
if (source === 'created') execution.preparedProfile = { config: current, dirId: profile.dirId };
if (findChild(child.id) !== child || state.settings.integrations.roxy !== current) {
throw new RoxyBrowserError('roxy_profile_conflict');
}
if (source === 'bound') return;
current.profiles = [...current.profiles.filter((entry) => entry.childId !== child.id), profile];
// Preserve this binding even if proxy selection/login later fails. The
// next attempt resumes the same environment instead of creating another.
await persist();
onProgress('roxy_profile_bound', source === 'created'
? '已创建并保存该账号的独立 Roxy 窗口' : '已恢复并保存该账号的独立 Roxy 窗口');
});
roxyProfileProvisioning = pending;
return pending;
}
async function prepareRoxyPoolAccount(child, execution, onProgress) {
const pending = roxyProfileProvisioning.catch(() => {}).then(async () => {
const current = state.settings.integrations?.roxy;
if (findChild(child.id) !== child || current?.profileMode !== 'pooled') throw new RoxyBrowserError('roxy_profile_missing');
bindRoxyProxyProfiles(structuredClone(current));
if (current.useProxyPool) {
const entries = state.settings.proxy.entries.filter((entry) => entry?.url && entry.enabled !== false);
const existing = child.roxyProxyId || current.profiles.find((profile) => profile.childId === child.id)?.proxyId;
const selected = entries.find((entry) => entry.id === existing) || entries.reduce((best, entry) => {
const count = (id) => state.children.filter((item) => item.roxyProxyId === id).length;
return count(entry.id) < count(best.id) ? entry : best;
}, entries[0]);
if (!selected) throw new RoxyBrowserError('roxy_proxy_missing');
roxyProxyInfo(selected);
child.roxyProxyId = selected.id;
}
const slots = Array.from({ length: current.poolSize || 3 }, (_, index) => index)
.filter((index) => !activeRoxyPoolSlots.has(index));
onProgress('roxy_pool_preparing', '正在分配空闲 Roxy 环境');
let resolved;
for (const slot of slots) {
activeRoxyPoolSlots.add(slot);
execution.slot = slot;
try {
resolved = await resolveRoxyPoolProfile(current, slot);
execution.preparedProfile = { config: current, dirId: resolved.dirId };
break;
} catch (error) {
activeRoxyPoolSlots.delete(slot);
delete execution.slot;
// A user-opened environment must not block another idle pool slot,
// or cause the tool to create an extra environment beyond its limit.
if (error?.code !== 'roxy_profile_busy') throw error;
}
}
if (!resolved) throw new RoxyBrowserError('roxy_profile_busy');
if (findChild(child.id) !== child || state.settings.integrations.roxy !== current) throw new RoxyBrowserError('roxy_profile_conflict');
const slot = execution.slot;
current.poolProfiles = [...(current.poolProfiles || []).filter((entry) => entry.slot !== slot && entry.dirId !== resolved.dirId), { slot, dirId: resolved.dirId }];
execution.profile = { dirId: resolved.dirId, ...(child.roxyProxyId ? { proxyId: child.roxyProxyId } : {}) };
await persist();
onProgress('roxy_pool_acquired', '已分配空闲环境,本次账号使用隔离登录会话');
});
roxyProfileProvisioning = pending;
return pending;
}
async function releaseSavedRoxyProfile(child, config, dirId, onProgress = () => {}, { failed = false } = {}) {
const pending = roxyProfileProvisioning.catch(() => {}).then(async () => {
const current = state.settings.integrations?.roxy;
if (current !== config || findChild(child.id) !== child) throw new RoxyBrowserError('roxy_profile_conflict');
// One failed close still blocks new browser starts, but must not prevent
// another independently closed account from releasing its saved slot.
await verifyRoxyCleanup({ baseUrl: config.baseUrl, dirId });
onProgress(failed ? 'roxy_failed_profile_deleting' : 'roxy_profile_deleting', failed
? '本次授权失败,正在确认窗口关闭并释放 Roxy 环境名额' : 'JSON 已保存,正在关闭并释放 Roxy 环境名额');
const result = await deleteClosedRoxyProfile(config, dirId);
// The proxy remains attached to the account for quota/RT requests after
// its browser environment has moved to the recoverable recycle bin.
for (const binding of current.profiles.filter((entry) => entry.dirId === dirId)) {
const account = findChild(binding.childId);
if (account && binding.proxyId && (current.profileMode !== 'pooled' || !account.roxyProxyId)) account.roxyProxyId = binding.proxyId;
}
current.profiles = current.profiles.filter((entry) => entry.dirId !== dirId);
current.poolProfiles = (current.poolProfiles || []).filter((entry) => entry.dirId !== dirId);
addHistory('释放 Roxy 环境', failed
? `${child.email} 本次授权失败,授权环境已移入 Roxy 回收站,可在运行日志中重试`
: `${child.email} 的授权 JSON 已保留,授权环境已移入 Roxy 回收站`);
await persist();
onProgress(failed ? 'roxy_failed_profile_deleted' : 'roxy_profile_deleted', failed
? '失败环境已移入 Roxy 回收站,窗口用量已释放,可在运行日志中重试'
: '授权环境已移入 Roxy 回收站,窗口用量已释放,本机 JSON 保留');
return { deleted: result.deleted, released: true };
});
roxyProfileProvisioning = pending;
return pending;
}
async function runChildBrowserAuthorization(child, { browserLogin, callbackUrl, onProgress }, operation, finalizeLogin = (result) => result) {
try {
// Called only after cached AT/RT paths and credential checks. A completed
// manual callback never needs a Roxy window or consumes an environment slot.
if (browserLogin && !callbackUrl && state.settings.integrations?.roxy?.enabled && findChild(child.id) === child) {
onProgress('roxy_queued', '等待空闲 Roxy 窗口,前一个窗口释放后继续');
// Historical cleanup holds several consecutive profile deletions. A
// newly queued login must not lease a profile between those deletions.
if (roxyProfileCleanupJobs) await roxyProfileCleanupFinished;
return await runRoxyQueued(() => roxyExecution.run({}, async () => {
const execution = roxyExecution.getStore();
let result;
let failureError;
try {
try {
await verifyRoxyCleanup();
if (state.settings.integrations.roxy.profileMode === 'pooled') await prepareRoxyPoolAccount(child, execution, onProgress);
else await ensureChildRoxyProfile(child, execution, onProgress);
result = await withChildAuthorizationNetwork(child, browserLogin, async () =>
finalizeLogin(await operation(automaticBrowserOptions(child, browserLogin))));
} catch (error) {
if (!(error instanceof RoxyBrowserError)) throw error;
result = await finalizeLogin({ ok: false, status: error.status, code: error.code, message: error.message, browserRequired: true, needsInput: true });
}
return result;
} catch (error) {
failureError = error;
throw error;
} finally {
try {
const owned = execution.openedProfile || execution.preparedProfile;
const token = result?.tokenScope === 'team' ? workspaceTokenFor(child, result.accountId) : child;
// The account wrapper later reports incomplete credentials as
// json_incomplete even if the OAuth exchange itself returned ok.
const failed = result?.ok !== true || result.exportable && (!token?.accessToken || !token?.refreshToken);
const releaseSuccess = !failed && result.exportable && token?.accessToken && token?.refreshToken
&& execution.openedProfile?.config.deleteProfileAfterSuccess === true;
if (releaseSuccess || failed && owned?.config.deleteProfileAfterFailure === true) {
let cleanup;
try { cleanup = await releaseSavedRoxyProfile(child, owned.config, owned.dirId, onProgress, { failed }); }
catch {
// Cleanup is secondary: keep the saved JSON or original
// authorization failure, never rerun OAuth or invent release.
cleanup = { deleted: false, released: false, code: 'roxy_profile_delete_failed' };
onProgress(failed ? 'roxy_failed_profile_delete_failed' : 'roxy_profile_delete_failed', failed
? '授权失败且环境名额未释放,请检查 Roxy 窗口状态后再重试'
: 'JSON 已保存,但环境名额未释放,请检查 Roxy 回收站或重试清理');
addHistory('释放 Roxy 环境', failed
? `${child.email} 本次授权失败,且 Roxy 环境名额未能释放`
: `${child.email} 的 JSON 已保存,但 Roxy 环境名额未能释放`, 'partial');
await persist().catch(() => {});
}
if (result) result.roxyCleanup = cleanup;
else if (failureError && typeof failureError === 'object') roxyAuthorizationFailureCleanup.set(failureError, cleanup);
}
} finally {
if (execution.slot !== undefined) activeRoxyPoolSlots.delete(execution.slot);
}
}
}));
}
return await withChildAuthorizationNetwork(child, browserLogin, async () => finalizeLogin(await operation(automaticBrowserOptions(child, browserLogin))));
} catch (error) {
if (!(error instanceof RoxyBrowserError)) throw error;
return finalizeLogin({ ok: false, status: error.status, code: error.code, message: error.message, browserRequired: true, needsInput: true });
}
}
function bindRoxyProxyProfiles(config, proxy = state.settings.proxy) {
const entries = Array.isArray(proxy?.entries) ? proxy.entries.filter((entry) => entry?.url && entry.enabled !== false) : [];
if (config.profileMode === 'pooled') {
if (config.enabled && config.useProxyPool) {
if (!proxy?.enabled) throw new RoxyBrowserError('roxy_proxy_pool_disabled', { status: 400 });
if (!entries.length) throw new RoxyBrowserError('roxy_proxy_missing', { status: 400 });
}
return config;
}
if (config.profiles.some((profile) => profile.proxyId && !entries.some((entry) => entry.id === profile.proxyId))) {
throw new RoxyBrowserError('roxy_proxy_missing', { status: 400 });
}
if (!config.enabled || !config.useProxyPool) return config;
if (!proxy?.enabled) throw new RoxyBrowserError('roxy_proxy_pool_disabled', { status: 400 });
if (!entries.length) throw new RoxyBrowserError('roxy_proxy_missing', { status: 400 });
const counts = new Map(entries.map((entry) => [entry.id, config.profiles.filter((profile) => profile.proxyId === entry.id).length]));
for (const profile of config.profiles) {
if (profile.proxyId) {
roxyProxyInfo(entries.find((entry) => entry.id === profile.proxyId));
continue;
}
const retained = entries.find((entry) => entry.id === findChild(profile.childId)?.roxyProxyId);
const selected = retained || entries.reduce((best, entry) => counts.get(entry.id) < counts.get(best.id) ? entry : best, entries[0]);
roxyProxyInfo(selected);
profile.proxyId = selected.id;
counts.set(selected.id, counts.get(selected.id) + 1);
}
return config;
}
async function withChildAuthorizationNetwork(child, browserLogin, operation) {
const current = state.settings.integrations?.roxy;
if (authorizationNetwork.getStore()?.childId === child.id) return operation();
if (browserLogin === false || !current?.enabled || !current.useProxyPool) return operation();
const pooled = current.profileMode === 'pooled';
const profile = pooled ? roxyExecution.getStore()?.profile || (child.roxyProxyId ? { proxyId: child.roxyProxyId } : null)
: current.profiles.find((entry) => entry.childId === child.id) || (child.roxyProxyId ? { proxyId: child.roxyProxyId } : null);
// Cached credentials/callbacks remain usable without a browser mapping.
// A new profile is provisioned only when browser login is actually needed.
if (!profile) return operation();
let proxyEntry;
let config;
try {
config = bindRoxyProxyProfiles(structuredClone(current));
proxyEntry = structuredClone(state.settings.proxy.entries.find((entry) => entry.id === (pooled ? child.roxyProxyId : config.profiles.find((entry) => entry.childId === child.id)?.proxyId || child.roxyProxyId)));
if (!proxyEntry || proxyEntry.enabled === false) throw new RoxyBrowserError('roxy_proxy_missing');
} catch (error) {
const safe = error instanceof RoxyBrowserError ? error : new RoxyBrowserError('roxy_proxy_missing');
return { ok: false, status: safe.status, code: safe.code, message: safe.message, needsInput: true, child: publicChild(child) };
}
const proxySettings = { ...structuredClone(state.settings.proxy), enabled: true,
strategy: 'failover', maxRetries: 0, entries: [proxyEntry] };
// Reserve before awaiting persistence so deletion cannot race startup.
const network = { childId: child.id, config, proxyEntry, proxySettings, reservedProxyIds: new Set() };
reserveAuthorizationProxy(network, proxyEntry.id);
try {
if (!profile.proxyId) {
profile.proxyId = proxyEntry.id;
await persist();
}
return await authorizationNetwork.run(network, operation);
} finally {
for (const id of network.reservedProxyIds) {
const remaining = activeAuthorizationProxies.get(id) - 1;
if (remaining > 0) activeAuthorizationProxies.set(id, remaining);
else activeAuthorizationProxies.delete(id);
}
}
}
function reserveAuthorizationProxy(network, id) {
if (network.reservedProxyIds.has(id)) return;
network.reservedProxyIds.add(id);
activeAuthorizationProxies.set(id, (activeAuthorizationProxies.get(id) || 0) + 1);
}
async function switchRoxyAuthorizationProxy(child, network, attempted) {
const current = state.settings.integrations?.roxy;
if (network?.childId !== child.id || !current?.enabled || !current.useProxyPool
|| !state.settings.proxy.enabled || findChild(child.id) !== child || attempted.size >= 3) return false;
const entries = state.settings.proxy.entries.filter((entry) => entry?.url && entry.enabled !== false);
const index = entries.findIndex((entry) => entry.id === network.proxyEntry.id);
const ordered = [...entries.slice(index + 1), ...entries.slice(0, index + 1)];
const selected = ordered.find((entry) => !attempted.has(entry.id));
if (!selected) return false;
roxyProxyInfo(selected);
const pooled = current.profileMode === 'pooled';
const profile = pooled ? roxyExecution.getStore()?.profile : current.profiles.find((entry) => entry.childId === child.id);
if (!profile) throw new RoxyBrowserError('roxy_profile_missing');
reserveAuthorizationProxy(network, selected.id);
profile.proxyId = selected.id;
child.roxyProxyId = selected.id;
// Mutate the existing per-account context: the OAuth code exchange and later
// requests in the outer authorization operation must use the winning route.
network.config = structuredClone(current);
network.proxyEntry = structuredClone(selected);
network.proxySettings = { ...structuredClone(state.settings.proxy), enabled: true,
strategy: 'failover', maxRetries: 0, entries: [network.proxyEntry] };
await persist();
return true;
}
function childLoginConfiguration(child) {
const roxy = state.settings.integrations?.roxy;
if (!roxy?.enabled) return 'protocol';
const profile = roxy.profiles.find((entry) => entry.childId === child.id);
const proxy = roxy.useProxyPool ? state.settings.proxy?.entries?.find((entry) => entry.id === (roxy.profileMode === 'pooled' ? child.roxyProxyId : profile?.proxyId || child.roxyProxyId)) : null;
return createHash('sha256').update(JSON.stringify({ baseUrl: roxy.baseUrl, workspaceId: roxy.workspaceId,
profileMode: roxy.profileMode || 'dedicated', dirId: roxy.profileMode === 'pooled' ? undefined : profile?.dirId, headless: roxy.headless === true, useProxyPool: roxy.useProxyPool === true,
proxyId: proxy?.id, proxyUrl: proxy?.url,
localProxyEnabled: state.settings.proxy?.localProxyEnabled === true,
localProxyUrl: state.settings.proxy?.localProxyEnabled ? state.settings.proxy.localProxyUrl : '',
})).digest('hex');
}
async function acquireChildAuth(child, options = {}) {
if (!child) return { ok: false, status: 404, message: 'child_not_found' };
const key = String(child.id || child.email || 'unknown');
const previous = childAuthQueues.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withChildAuthorizationNetwork(child, options.browserLogin, () => acquireChildAuthInternal(child, options)));
childAuthQueues.set(key, current);
try {
return await current;
} finally {
if (childAuthQueues.get(key) === current) childAuthQueues.delete(key);
}
}
function automaticBrowserOptions(child, browserLogin) {
// Browser automation is opt-in through the saved Roxy switch. Without
// Roxy, the original protocol login remains the default authorization path.
if (!browserLogin || state.settings.integrations?.roxy?.enabled !== true) return { browserLogin: false, protocolOnly: true };
const network = authorizationNetwork.getStore();
return {
browserLogin: true,
protocolOnly: false,
allowLocalAuthorizationFallback: true,
browserOAuth: (options) => runRoxyQueued(async () => {
const execution = autoJsonExecution.getStore() || roxyExecution.getStore() || {};
const attempted = execution.proxyAttempts ||= new Set();
const attempt = async () => {
const config = network?.childId === child.id ? { ...network.config,
...(network.config.profileMode === 'pooled'
? { poolProfiles: structuredClone(state.settings.integrations.roxy.poolProfiles || []) }
: { profiles: structuredClone(state.settings.integrations.roxy.profiles || []) }) }
: structuredClone(state.settings.integrations.roxy);
await verifyRoxyCleanup();
if (findChild(child.id) !== child || child.email !== options.email) {
throw Object.assign(new Error('账号已变更,未启动浏览器'), { code: 'token_identity_mismatch', status: 409 });
}
if (execution?.mother && (findMother(execution.mother.id) !== execution.mother
|| configuredTeamId(execution.mother) !== execution.workspaceId)) {
throw Object.assign(new Error('目标 Team 已变更,未启动浏览器'), { code: 'team_workspace_changed', status: 409 });
}
if (execution?.membership && membershipFor(child, execution.mother.team) !== execution.membership) {
throw Object.assign(new Error('账号成员状态已变更,未启动浏览器'), { code: 'team_membership_changed', status: 409 });
}
let pendingAcquisition;
let pendingClose;
let oauthFinished = false;
try { return await completeBrowserOAuth({
...options,
acquireSession: ({ timeoutMs }) => {
pendingAcquisition = (async () => {
const poolProfile = roxyExecution.getStore()?.profile;
const dirId = poolProfile?.dirId || config.profiles.find((profile) => profile.childId === child.id)?.dirId;
let session;
try { session = await openRoxyBrowserSession({ config, childId: child.id, timeoutMs,
...(poolProfile ? { profile: poolProfile } : {}),
onProfileAcquired: (acquiredDirId) => {
const lease = roxyExecution.getStore();
if (lease) lease.preparedProfile = { config: state.settings.integrations.roxy, dirId: acquiredDirId };
},
proxyEntry: network?.childId === child.id ? network.proxyEntry : null,
localProxyUrl: network?.childId === child.id && network.proxySettings?.localProxyEnabled
? network.proxySettings.localProxyUrl : '' }); }
catch (error) {
if (dirId && error?.code === 'roxy_cleanup_failed') roxyCleanupBlocks.set(`${config.baseUrl}:${dirId}`, { config, dirId });
throw error;
}
const lease = roxyExecution.getStore();
if (lease) lease.openedProfile = { config: state.settings.integrations.roxy, dirId };
const closeFailedWindow = config.deleteProfileAfterFailure === true || config.profileMode === 'pooled'
|| config.useProxyPool || autoJsonExecution.getStore()?.closeFailedWindow === true;
const owned = { ...session, keepOpenOnFailure: !closeFailedWindow && session.keepOpenOnFailure,
close: (closeOptions) => pendingClose ||= (async () => {
try { await session.close(closeFailedWindow || oauthFinished ? { keepOpen: false } : closeOptions); }
catch (error) {
if (dirId) roxyCleanupBlocks.set(`${config.baseUrl}:${dirId}`, { config, dirId });
throw error;
}
})() };
if (oauthFinished) await owned.close();
return owned;
})();
return pendingAcquisition;
},
}); } finally {
oauthFinished = true;
// An OAuth timeout can win while the Local API is still opening a
// window. Retain the slot until that owned window is closed as well.
await pendingAcquisition?.catch(() => {});
await pendingClose?.catch(() => {});
}
};
for (;;) {
if (network?.childId === child.id && network.proxyEntry) {
attempted.add(network.proxyEntry.id);
options.onProgress?.('roxy_proxy_attempt', `正在使用第 ${attempted.size}/3 条代理授权,账号会话独立`);
}
try { return await attempt(); }
catch (error) {
// Retry only a diagnosed proxy/tunnel connection failure. Official
// rejection, credential errors and Roxy API failures keep their cause.
if (error?.code !== 'browser_oauth_proxy_connection_failed' || !network?.proxyEntry) throw error;
await verifyRoxyCleanup();
if (!await switchRoxyAuthorizationProxy(child, network, attempted)) {
options.onProgress?.('roxy_proxy_exhausted', `已尝试 ${attempted.size} 条代理,本账号停止重试;批量任务继续下一个账号`);
throw error;
}
if (options.workspaceMode === 'team') {
child.teamLoginConfigurations ||= {};
child.teamLoginConfigurations[options.workspaceId] = childLoginConfiguration(child);
} else child.loginConfiguration = childLoginConfiguration(child);
options.onProgress?.('roxy_proxy_switch', `代理连接失败,旧窗口已关闭,切换第 ${attempted.size + 1}/3 条代理`);
}
}
}),
};
}
function retryWithChangedLoginBackend(child, workspaceId = '') {
const currentBackend = state.settings.integrations?.roxy?.enabled === true ? 'roxy' : 'protocol';
let previousBackend = workspaceId ? child.teamLoginBackends?.[workspaceId] : child.loginBackend;
const login = workspaceId ? teamLoginFor(child, workspaceId) : {
code: child.loginErrorCode, status: child.loginStatus, browserRequired: child.loginBrowserRequired,
};
const session = workspaceId ? child.teamAuthSessions?.[workspaceId] : child.authSession;
// Older browser attempts did not record a backend. Infer it only from an
// actual browser stage/error; a generic verification status is insufficient.
if (!previousBackend && (/^(browser_|roxy_)/.test(session?.phase || '')
|| /^browser_oauth_/.test(login?.code || ''))) previousBackend = 'legacy_browser';
const previousConfiguration = workspaceId ? child.teamLoginConfigurations?.[workspaceId] : child.loginConfiguration;
const configurationChanged = currentBackend === 'roxy' && previousBackend === 'roxy'
&& (previousConfiguration ? previousConfiguration !== childLoginConfiguration(child) : state.settings.integrations.roxy.useProxyPool === true);
if (!configurationChanged && (previousBackend === currentBackend || (!previousBackend && currentBackend === 'protocol'))) return false;
// Changing login mode permits one attempt in that mode. Persisted backend
// metadata then pauses repeat challenges; OTP/password/phone errors stay paused.
return Boolean(login?.browserRequired && (
['browser_verification_required', 'verification_required', 'protocol_verification_required', 'workspace_list_unavailable', 'browser_oauth_verification_required'].includes(login.code)
|| !login.code && ['verification_required', 'browser_verification_required'].includes(login.status)
));
}
function sessionForChangedLoginBackend(session, changed) {
if (!changed || !session) return session;
// Restart at the authorization entry in the new mode, but keep the original
// state/PKCE/provider session so an already-open browser link remains valid.
return { ...session, currentUrl: '', phase: 'initializing', cookies: {}, baselineMailbox: null };
}
async function acquireChildAuthInternal(child, { refresh = false, verificationCode = '', callbackUrl = '', allowCredentialLogin = false, syncSub2Api = true, browserLogin = true, onProgress = () => {} } = {}) {
if (!child) return { ok: false, status: 404, message: 'child_not_found' };
if (childIsBanned(child)) return { ok: false, status: 409, code: 'account_banned', message: child.banReason || '账号已封禁,不能刷新或重新获取 JSON', banned: true, child: publicChild(child) };
const callbackFailure = explicitOAuthCallbackFailure(child.authSession, callbackUrl);
if (callbackFailure) return { ...callbackFailure, child: publicChild(child) };
const completedCallback = pendingOAuthCallback(child.authSession, callbackUrl);
const changedLoginBackend = !completedCallback && retryWithChangedLoginBackend(child);
if (!completedCallback && child.accessToken && !refresh && !freeTokenNeedsRefresh(child)) {
setChildLoginState(child, 'ready', '已有可用 AT');
await persist();
return { ok: true, status: 200, source: 'access_token', child: publicChild(child) };
}
if (child.accessToken && !refresh && freeTokenNeedsRefresh(child)) refresh = true;
if (!completedCallback && child.refreshToken) {
onProgress('refreshing');
setChildLoginState(child, 'refreshing', '正在使用 refresh token 更新 AT');
await persist();
const refreshed = await refreshOpenAiAccessToken(child.refreshToken, child.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
if (refreshed.ok) {
if (!applyRefreshedChildToken(child, refreshed)) return rejectChildTokenIdentity(child);
addHistory('刷新 Free AT', `${child.email} 已通过 refresh token 更新凭据`);
await persist();
return { ok: true, status: 200, source: 'refresh_token', child: publicChild(child) };
}
const refreshBanMessage = explicitAccountBanMessage(refreshed);
if (refreshBanMessage) {
markChildBanned(child, null, { source: 'free_refresh_token', status: refreshed.status, code: refreshed.code, message: refreshBanMessage });
await persist();
return { ok: false, status: refreshed.status || 403, code: 'account_banned', message: refreshBanMessage, banned: true, child: publicChild(child) };
}
setChildLoginState(child, 'login_required', 'refresh token 已失效,需要重新登录获取 AT');
if (!childIsBanned(child)) child.status = 'login_required';
await persist();
if (!allowCredentialLogin) {
return { ok: false, status: refreshed.status || 502, code: 'refresh_failed', message: 'refresh token 已失效,需要重新登录', child: publicChild(child) };
}
}
if (!completedCallback && child.accessToken && !allowCredentialLogin && !freeTokenNeedsRefresh(child)) {
setChildLoginState(child, 'ready', '当前账号已有 AT,但没有可用 refresh token');
await persist();
return { ok: true, status: 200, source: 'access_token', child: publicChild(child) };
}
if (!completedCallback && (!child.email || !child.password)) {
setChildLoginState(child, 'credentials_required', '请先填写邮箱和密码');
child.loginBrowserRequired = false;
child.loginUrl = null;
await persist();
return { ok: false, status: 400, code: 'credentials_required', message: '请先填写邮箱和密码', child: publicChild(child) };
}
if (child.loginStatus === 'phone_verification_required' && !verificationCode && !callbackUrl) {
child.authSession = null;
child.loginUrl = null;
}
setChildLoginState(child, 'authenticating', completedCallback ? '正在处理已完成的浏览器授权' : '正在使用邮箱、密码和 2FA 登录 OpenAI');
if (!childIsBanned(child)) child.status = 'login_pending';
child.loginBrowserRequired = false;
await persist();
const mailboxConfig = state.settings?.integrations?.mailbox || {};
const mailboxUrl = child.mailboxUrl || (mailboxConfig.enabled && mailboxConfig.endpoint ? mailboxConfig.endpoint : '');
const mailboxHeaders = mailboxConfig.apiKey
? { authorization: `Bearer ${mailboxConfig.apiKey}`, 'x-api-key': mailboxConfig.apiKey }
: {};
const sentinelProxyEntry = state.settings?.proxy?.enabled
? (state.settings.proxy.entries || []).find((entry) => entry?.url)
: null;
const sentinelProxy = sentinelProxyEntry ? {
server: `${sentinelProxyEntry.protocol || 'http'}://${sentinelProxyEntry.host}:${sentinelProxyEntry.port}`,
username: sentinelProxyEntry.username || '',
password: sentinelProxyEntry.password || '',
} : null;
const sub2apiConfig = syncSub2Api ? sub2ApiConfigs()[0] || null : null;
const oauthAdapter = sub2ApiOAuthAdapter(sub2apiConfig, child, { scope: 'free' });
const authSession = completedCallback ? child.authSession : oauthSessionForAdapter(sessionForChangedLoginBackend(child.authSession, changedLoginBackend), oauthAdapter);
const activeOAuthAdapter = authSession?.state && authSession?.codeVerifier && !authSession?.authorization
? null
: oauthAdapter;
return runChildBrowserAuthorization(child, { browserLogin,
callbackUrl: completedCallback || pendingOAuthCallback(authSession), onProgress }, async (browserOptions) => {
if (!completedCallback) {
child.loginBackend = browserOptions.browserLogin ? 'roxy' : 'protocol';
child.loginConfiguration = childLoginConfiguration(child);
}
return loginFreeAccount({
email: child.email,
password: child.password,
totp: child.totp || child.secret,
mailboxUrl,
mailboxHeaders,
sentinelProxy,
verificationCode,
callbackUrl: completedCallback || pendingOAuthCallback(authSession),
session: authSession,
workspaceId: '',
workspaceMode: 'free',
singleAttempt: autoJsonExecution.getStore()?.singleAttempt === true,
...browserOptions,
fetch: proxyFetch,
timeoutMs: OPENAI_REQUEST_TIMEOUT_MS,
onProgress: (phase, message) => {
setChildLoginState(child, phase, message);
onProgress(phase, message);
},
...(activeOAuthAdapter || {}),
});
}, async (login) => {
if (login.ok) {
const needsPostSync = !login.sub2api && sub2apiConfig?.enabled === true;
if (needsPostSync) {
// Persist the new OAuth credentials before attempting the optional
// integration so a broken Sub2API cannot discard a successful login.
if (!applyLoggedInChildToken(child, login)) return rejectChildTokenIdentity(child);
await persist();
login.sub2api = await syncSub2ApiAfterLocalOAuth(sub2apiConfig, child, login, { scope: 'free' });
}
if (!applyLoggedInChildToken(child, login)) return rejectChildTokenIdentity(child);
if (login.oauthFallback) {
child.loginMessage = `Sub2API OAuth 不可用,已自动回退本地 OAuth;${child.loginMessage}`;
}
const syncPartial = Boolean(login.sub2api && !login.sub2api.synced);
const syncMessage = login.sub2api?.synced
? `,已同步到 ${login.sub2api.integrationName || 'Sub2API'}`
: syncPartial ? `,Sub2API 同步未完成:${login.sub2api.message || 'unknown_error'}` : '';
const fallbackMessage = login.oauthFallback ? `,已从 ${login.oauthFallback.code} 自动回退本地 OAuth` : '';
addHistory('登录 Free 账号', `${child.email} 已完成 Codex OAuth 并生成 Free JSON${fallbackMessage}${syncMessage}`, syncPartial ? 'partial' : 'success');
await persist();
return { ok: true, status: 200, code: 'ready', source: login.oauthFallback ? 'email_password_2fa' : login.sub2api ? 'sub2api_oauth_email_password_2fa' : 'email_password_2fa', format: 'free-json', partial: syncPartial, oauthFallback: login.oauthFallback || null, sub2api: login.sub2api || null, child: publicChild(child), exportable: true };
}
const loginBanMessage = explicitAccountBanMessage(login);
if (loginBanMessage) markChildBanned(child, null, { source: 'free_oauth_login', status: login.status, code: login.code, message: loginBanMessage });
const phoneRequired = login.code === 'phone_verification_required';
const roxyFailure = String(login.code || '').startsWith('roxy_');
child.loginErrorCode = login.code || null;
child.loginFailureStage = login.stage || null;
child.loginHttpStatus = login.upstreamStatus || login.status || null;
child.authSession = phoneRequired ? null : login.session || child.authSession || null;
child.loginUrl = login.authUrl || null;
child.loginBrowserRequired = Boolean(login.browserRequired);
const waiting = login.code === 'email_otp_required' || login.code === 'email_otp_timeout' || login.code === 'totp_required' || login.code === 'totp_invalid';
const browserRequired = Boolean(login.browserRequired) || login.code === 'browser_verification_required';
const status = phoneRequired ? 'phone_verification_required' : roxyFailure ? 'login_required' : browserRequired ? 'verification_required' : waiting ? 'waiting_code' : 'login_required';
const message = phoneRequired
? '账号需要手机号验证,自动登录已停止,请完成接码后重试'
: roxyFailure ? new RoxyBrowserError(login.code).message
: login.code === 'oauth_session_expired' ? login.message
: browserRequired ? '登录需要浏览器验证,请打开授权链接完成验证后重试'
: login.message || (waiting ? '登录需要验证码,请填写验证码后重试' : '登录失败,请检查凭据或稍后重试');
setChildLoginState(child, status, message);
if (!childIsBanned(child)) child.status = phoneRequired ? 'phone_verification_required' : status === 'waiting_code' || status === 'verification_required' ? 'login_pending' : 'login_required';
addHistory('登录 Free 账号', `${child.email} ${message}`, 'partial');
await persist();
return { ok: false, status: login.status || 202, upstreamStatus: login.upstreamStatus || null, code: loginBanMessage ? 'account_banned' : login.code || (browserRequired ? 'verification_required' : 'login_failed'), message: loginBanMessage || message, banned: Boolean(loginBanMessage), stage: login.stage, browserRequired, needsInput: login.needsInput, authUrl: login.authUrl || null, child: publicChild(child) };
});
}
async function acquireTeamAuth(mother, child, options = {}) {
if (!child) return acquireTeamAuthInternal(mother, child, options);
const id = child.id;
childTeamAuthOperations.set(id, (childTeamAuthOperations.get(id) || 0) + 1);
const key = String(child.id || child.email || 'unknown');
const previous = childAuthQueues.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withChildAuthorizationNetwork(child, options.browserLogin, () => acquireTeamAuthInternal(mother, child, options)));
childAuthQueues.set(key, current);
try {
return await current;
} finally {
if (childAuthQueues.get(key) === current) childAuthQueues.delete(key);
const pending = childTeamAuthOperations.get(id) - 1;
if (pending) childTeamAuthOperations.set(id, pending);
else childTeamAuthOperations.delete(id);
}
}
async function acquireTeamAuthInternal(mother, child, { verificationCode = '', callbackUrl = '', force = false, syncSub2Api = true, persistOwner = true, browserLogin = true, expectedMembership = null, onProgress = () => {} } = {}) {
const workspaceId = String(mother?.accountId || '').trim();
if (!mother || !child || !workspaceId) return { ok: false, status: 400, code: 'workspace_id_required', message: '请先配置目标 Team ID' };
const originalEmail = child.email;
const trackedMother = findMother(mother.id) === mother;
const trackedChild = findChild(child.id) === child;
if (expectedMembership && membershipFor(child, mother.team) !== expectedMembership) {
return { ok: false, status: 409, code: 'team_membership_changed', message: '账号成员状态已变更,请重新核验后授权' };
}
if (childIsBanned(child)) return { ok: false, status: 409, code: 'account_banned', message: child.banReason || '账号已封禁,不能生成 Team JSON', banned: true, child: publicChild(child, mother.team) };
const originalSession = child.teamAuthSessions?.[workspaceId] || null;
const callbackFailure = explicitOAuthCallbackFailure(originalSession, callbackUrl);
if (callbackFailure) return { ...callbackFailure, child: publicChild(child, mother.team) };
const completedCallback = pendingOAuthCallback(originalSession, callbackUrl);
const changedLoginBackend = !completedCallback && retryWithChangedLoginBackend(child, workspaceId);
const existing = workspaceTokenFor(child, workspaceId);
if (!completedCallback && existing?.accessToken && !force) {
resetTeamLoginState(child, workspaceId);
return { ok: true, status: 200, code: 'ready', source: 'stored_team_token', format: 'team-json', tokenScope: 'team', child: publicChild(child, mother.team) };
}
if (!completedCallback && (!child.email || !child.password)) {
setTeamLoginState(child, workspaceId, { status: 'credentials_required', code: 'credentials_required', message: 'Team JSON 生成需要 Free 账号邮箱和密码' });
await persist();
return { ok: false, status: 400, code: 'credentials_required', message: 'Team JSON 生成需要 Free 账号邮箱和密码', child: publicChild(child, mother.team) };
}
const authMembership = membershipFor(child, mother.team, true);
authMembership.teamAuthAttemptedAt = now();
authMembership.workspaceTokenStatus = 'authenticating';
child.teamLoginAttemptedAt = authMembership.teamAuthAttemptedAt;
if (!child.teamAuthSessions || typeof child.teamAuthSessions !== 'object' || Array.isArray(child.teamAuthSessions)) child.teamAuthSessions = {};
const session = child.teamAuthSessions[workspaceId] || null;
const mailboxConfig = state.settings?.integrations?.mailbox || {};
const mailboxUrl = child.mailboxUrl || (mailboxConfig.enabled && mailboxConfig.endpoint ? mailboxConfig.endpoint : '');
const mailboxHeaders = mailboxConfig.apiKey
? { authorization: `Bearer ${mailboxConfig.apiKey}`, 'x-api-key': mailboxConfig.apiKey }
: {};
const sentinelProxyEntry = state.settings?.proxy?.enabled
? (state.settings.proxy.entries || []).find((entry) => entry?.url)
: null;
const sentinelProxy = sentinelProxyEntry ? {
server: `${sentinelProxyEntry.protocol || 'http'}://${sentinelProxyEntry.host}:${sentinelProxyEntry.port}`,
username: sentinelProxyEntry.username || '',
password: sentinelProxyEntry.password || '',
} : null;
const sub2apiConfig = syncSub2Api ? sub2ApiConfigForMother(mother) : null;
const oauthAdapter = sub2ApiOAuthAdapter(sub2apiConfig, child, { scope: 'team', workspaceId });
const compatibleSession = completedCallback ? session : oauthSessionForAdapter(sessionForChangedLoginBackend(session, changedLoginBackend), oauthAdapter);
const activeOAuthAdapter = compatibleSession?.state && compatibleSession?.codeVerifier && !compatibleSession?.authorization
? null
: oauthAdapter;
child.teamLoginStatus = 'authenticating';
child.teamLoginMessage = completedCallback ? '正在处理已完成的 Team 浏览器授权' : `正在通过 OAuth 登录并选择 Team ${workspaceId}`;
setTeamLoginState(child, workspaceId, { status: 'authenticating', message: child.teamLoginMessage });
return runChildBrowserAuthorization(child, { browserLogin,
callbackUrl: completedCallback || pendingOAuthCallback(compatibleSession), onProgress }, async (browserOptions) => {
if (!completedCallback) {
if (!child.teamLoginBackends || typeof child.teamLoginBackends !== 'object' || Array.isArray(child.teamLoginBackends)) child.teamLoginBackends = {};
child.teamLoginBackends[workspaceId] = browserOptions.browserLogin ? 'roxy' : 'protocol';
if (!child.teamLoginConfigurations || typeof child.teamLoginConfigurations !== 'object' || Array.isArray(child.teamLoginConfigurations)) child.teamLoginConfigurations = {};
child.teamLoginConfigurations[workspaceId] = childLoginConfiguration(child);
}
await persist();
return loginFreeAccount({
email: child.email,
password: child.password,
totp: child.totp || child.secret,
mailboxUrl,
mailboxHeaders,
sentinelProxy,
verificationCode,
callbackUrl: completedCallback || pendingOAuthCallback(compatibleSession),
session: compatibleSession,
workspaceId,
workspaceMode: 'team',
singleAttempt: autoJsonExecution.getStore()?.singleAttempt === true,
...browserOptions,
fetch: proxyFetch,
timeoutMs: OPENAI_REQUEST_TIMEOUT_MS,
onProgress: (phase, message) => {
child.teamLoginStatus = phase;
child.teamLoginMessage = message;
setTeamLoginState(child, workspaceId, { status: phase, message });
onProgress(phase, message);
},
...(activeOAuthAdapter || {}),
});
}, async (login) => {
// The browser intentionally runs outside the Team maintenance lock. Never
// persist a callback against a deleted/replaced account or changed workspace.
if (trackedMother && findMother(mother.id) !== mother || String(mother.accountId || '').trim() !== workspaceId) {
return { ok: false, status: 409, code: 'team_workspace_changed', message: '目标 Team 已变更,请刷新后重试' };
}
if (trackedChild && findChild(child.id) !== child || child.email !== originalEmail) {
return { ok: false, status: 409, code: 'token_identity_mismatch', message: '账号已变更,未保存本次授权' };
}
if (expectedMembership && membershipFor(child, mother.team) !== expectedMembership) {
return { ok: false, status: 409, code: 'team_membership_changed', message: '账号成员状态已变更,未保存本次授权' };
}
if (!login.ok) {
const banMessage = explicitAccountBanMessage(login);
if (banMessage) markChildBanned(child, mother, { source: 'team_oauth_login', status: login.status, code: login.code, message: banMessage });
child.teamAuthSessions[workspaceId] = login.session || session || null;
child.teamLoginStatus = login.stage || 'login_required';
child.teamLoginMessage = login.message || 'Team OAuth 登录失败';
authMembership.workspaceTokenStatus = 'login_required';
authMembership.teamAuthLastError = child.teamLoginMessage;
authMembership.teamAuthRetryAfter = new Date(Date.now() + teamAuthRetryBackoffMs()).toISOString();
const browserRequired = Boolean(login.browserRequired);
const needsInput = Boolean(login.needsInput);
setTeamLoginState(child, workspaceId, {
status: browserRequired ? 'browser_verification_required' : needsInput ? (login.code === 'phone_verification_required' ? 'phone_verification_required' : 'waiting_code') : 'login_required',
message: child.teamLoginMessage,
code: banMessage ? 'account_banned' : login.code || 'team_login_failed',
stage: login.stage,
httpStatus: login.upstreamStatus || login.status,
browserRequired,
needsInput,
authUrl: login.authUrl,
attemptedAt: authMembership.teamAuthAttemptedAt,
retryAfter: authMembership.teamAuthRetryAfter,
});
addHistory('Team OAuth 登录', `${child.email} 登录 ${mother.team} 失败:${child.teamLoginMessage}`, 'partial', {
summary: { team: mother.team, childId: child.id, code: login.code || 'team_login_failed', status: login.status || null },
});
await persist();
return {
ok: false,
status: login.status || 202,
upstreamStatus: login.upstreamStatus || null,
code: banMessage ? 'account_banned' : login.code || 'team_login_failed',
message: banMessage || login.message || 'Team OAuth 登录失败',
banned: Boolean(banMessage),
stage: login.stage,
needsInput,
browserRequired,
authUrl: teamLoginFor(child, workspaceId)?.authUrl || null,
child: publicChild(child, mother.team),
};
}
const claims = login.claims || accessTokenClaims(login.accessToken);
const tokenClaims = accessTokenClaims(login.accessToken);
const idClaims = accessTokenClaims(login.idToken);
const emailMatches = (email) => !email || workspaceIdKey(email) === workspaceIdKey(child.email);
const consistentSubject = !idClaims.userId || !tokenClaims.userId || idClaims.userId === tokenClaims.userId;
const confirmedEmail = tokenClaims.email || (consistentSubject && idClaims.userId && tokenClaims.userId ? idClaims.email : '');
const knownIdentity = Boolean(child.chatgptUserId && child.chatgptUserId === tokenClaims.userId)
|| Boolean(confirmedEmail && workspaceIdKey(confirmedEmail) === workspaceIdKey(child.email));
if (!childTokenIdentityMatches(child, login.accessToken) || !emailMatches(claims.email) || !emailMatches(idClaims.email)
|| !consistentSubject || (claims.userId && tokenClaims.userId && claims.userId !== tokenClaims.userId) || !knownIdentity) {
child.teamLoginStatus = 'login_required';
child.teamLoginMessage = 'Team 授权返回的账号身份不一致,已保留原凭据';
authMembership.workspaceTokenStatus = 'login_required';
authMembership.teamAuthLastError = child.teamLoginMessage;
setTeamLoginState(child, workspaceId, { status: 'login_required', code: 'token_identity_mismatch', message: child.teamLoginMessage });
await persist();
return { ok: false, status: 502, code: 'token_identity_mismatch', message: child.teamLoginMessage, child: publicChild(child, mother.team) };
}
if (claims.accountId !== workspaceId || !['team', 'business', 'self_serve_business_prolite'].includes(String(claims.planType || '').toLowerCase())) {
child.teamLoginStatus = 'login_required';
child.teamLoginMessage = 'OAuth 登录后返回的空间不是目标 Team';
authMembership.workspaceTokenStatus = 'login_required';
authMembership.teamAuthLastError = child.teamLoginMessage;
authMembership.teamAuthRetryAfter = new Date(Date.now() + teamAuthRetryBackoffMs()).toISOString();
setTeamLoginState(child, workspaceId, { status: 'login_required', code: 'team_workspace_mismatch', message: child.teamLoginMessage, retryAfter: authMembership.teamAuthRetryAfter });
await persist();
return { ok: false, status: 502, code: 'team_workspace_mismatch', message: 'OAuth 登录后未选择目标 Team 空间', accountId: claims.accountId || null, child: publicChild(child, mother.team) };
}
// Bind the account only after this same OAuth response has matched its
// email and subject; ATs sometimes omit email while their ID token has it.
if (!child.chatgptUserId && tokenClaims.userId && confirmedEmail) child.chatgptUserId = tokenClaims.userId;
child.cookies = login.cookies || child.cookies || null;
const needsPostSync = !login.sub2api && sub2apiConfig?.enabled === true;
const workspaceToken = saveWorkspaceToken(child, workspaceId, login.accessToken, claims, {
refreshToken: login.refreshToken || '',
idToken: login.idToken || '',
clientId: login.clientId || child.clientId || '',
sub2api: login.sub2api || null,
source: 'team_oauth_email_password_2fa',
});
if (needsPostSync) {
// Quota recovery must use the new Team token immediately. checkTeam runs
// its repair_only push after the quota recheck, so do not wait on a broken
// Sub2API connection here or accidentally create a second account.
login.sub2api = deferredSub2ApiSync(
sub2apiConfig,
login.oauthFallback?.message || 'sub2api_repair_deferred_until_quota_recheck',
);
workspaceToken.sub2api = login.sub2api;
}
delete child.teamAuthSessions[workspaceId];
child.teamLoginStatus = 'ready';
const syncPartial = Boolean(login.sub2api && !login.sub2api.synced);
child.teamLoginMessage = login.sub2api?.synced
? '已通过 Codex OAuth 生成 Team JSON 并同步 Sub2API'
: syncPartial
? `已生成 Team JSON,Sub2API 同步未完成:${login.sub2api.message || 'unknown_error'}`
: '已通过 OAuth 登录并生成 Team JSON';
if (login.oauthFallback) {
child.teamLoginMessage = `Sub2API OAuth 不可用,已自动回退本地 OAuth;${child.teamLoginMessage}`;
}
resetTeamLoginState(child, workspaceId, child.teamLoginMessage);
child.pendingWorkspaceId = null;
const membership = authMembership;
membership.workspaceTokenStatus = 'ready';
membership.workspaceTokenUpdatedAt = workspaceToken.acquiredAt;
membership.teamAuthRetryAfter = null;
membership.teamAuthLastError = null;
if (!childIsBanned(child) && isChildMemberOfTeam(child, mother.team) && ['login_required', 'login_pending', 'ready'].includes(child.status)) child.status = 'active';
if (persistOwner && (childIsWorkspaceOwner(child, mother) || childMatchesKnownTeamOwner(child, mother))) upsertTeamOwnerFromChild(mother, child, workspaceId, workspaceToken);
const syncMessage = login.sub2api?.synced
? `,已同步到 ${login.sub2api.integrationName || 'Sub2API'}`
: syncPartial ? `,Sub2API 同步未完成:${login.sub2api.message || 'unknown_error'}` : '';
const fallbackMessage = login.oauthFallback ? `,已从 ${login.oauthFallback.code} 自动回退本地 OAuth` : '';
addHistory('生成 Team JSON', `${child.email} 已通过 OAuth 登录并选择 ${mother.team}${fallbackMessage}${syncMessage}`, syncPartial ? 'partial' : 'success');
await persist();
return { ok: true, status: 200, code: 'ready', source: login.oauthFallback ? 'oauth_email_password_2fa_team' : login.sub2api ? 'sub2api_oauth_email_password_2fa_team' : 'oauth_email_password_2fa_team', format: 'team-json', tokenScope: 'team', accountId: workspaceId, expiresAt: workspaceToken.expiresAt, partial: syncPartial, oauthFallback: login.oauthFallback || null, sub2api: login.sub2api || null, exportable: true, child: publicChild(child, mother.team) };
});
}
function hasTeamLoginCredentials(child) {
return Boolean(child?.email && child?.password && (child?.totp || child?.secret));
}
function teamAuthRetryBackoffMs() {
return Math.max(60_000, Number(process.env.TEAM_AUTH_RETRY_BACKOFF_MS) || 300_000);
}
function activeTeamAuthBackoff(child, teamId) {
const membership = membershipFor(child, teamId);
const retryAt = Date.parse(membership?.teamAuthRetryAfter || '');
return Number.isFinite(retryAt) && retryAt > Date.now()
? { retryAfter: membership.teamAuthRetryAfter, message: membership.teamAuthLastError || child?.teamLoginMessage || 'Team OAuth 登录暂时失败' }
: null;
}
function acquireTeamJson(mother, child, { verificationCode = '', callbackUrl = '', browserLogin = true, onProgress = () => {} } = {}) {
browserLogin = browserLogin && state.settings.integrations?.roxy?.enabled === true;
const callbackFailure = explicitOAuthCallbackFailure(child?.teamAuthSessions?.[mother?.accountId], callbackUrl);
if (callbackFailure) return Promise.resolve({ ...callbackFailure, child: child ? publicChild(child, mother?.team) : undefined });
if (pendingOAuthCallback(child?.teamAuthSessions?.[mother?.accountId], callbackUrl)) {
return acquireTeamAuth(mother, child, { force: true, verificationCode, callbackUrl, browserLogin, onProgress });
}
const existing = workspaceTokenFor(child, mother?.accountId);
if (existing?.refreshToken && teamTokenDetails(existing.accessToken, mother?.accountId)) {
adoptAccountTeamToken(mother, child, existing);
resetTeamLoginState(child, mother.accountId);
return Promise.resolve({ ok: true, status: 200, source: 'stored_team_token', format: 'team-json', tokenScope: 'team' });
}
if (!verificationCode && !callbackUrl) {
const teamLogin = teamLoginFor(child, mother?.accountId);
const session = child?.teamAuthSessions?.[mother?.accountId];
if (session?.state && (teamLogin?.needsInput || teamLogin?.browserRequired)
&& !(browserLogin && teamLogin.code === 'workspace_list_unavailable')
&& !String(teamLogin.code || '').startsWith('roxy_')
&& !retryWithChangedLoginBackend(child, mother?.accountId)) {
return Promise.resolve({
ok: false, status: 202, code: teamLogin.code || 'team_authorization_pending',
message: teamLogin.message || '请先完成此 Team 的官方授权后继续',
needsInput: teamLogin.needsInput, browserRequired: teamLogin.browserRequired,
authUrl: teamLogin.authUrl, retryAfter: teamLogin.retryAfter,
child: publicChild(child, mother.team),
});
}
const backoff = activeTeamAuthBackoff(child, mother?.team);
if (backoff) {
return Promise.resolve({
ok: false, status: 429, code: 'team_auth_retry_backoff',
message: teamLogin?.message || 'Team OAuth 重试正在退避,请稍后重试',
retryAfter: backoff.retryAfter, needsInput: false, browserRequired: false, authUrl: null,
child: publicChild(child, mother.team),
});
}
}
return hasTeamLoginCredentials(child) || (browserLogin && child?.email && child?.password)
? acquireTeamAuth(mother, child, { force: true, verificationCode, callbackUrl, browserLogin, onProgress })
: switchWorkspaceWithFreeRecovery(child, mother.accountId, { verificationCode, callbackUrl });
}
function canAutoPushRenewedTeamJson(mother) {
const config = sub2ApiConfigForMother(mother) || {};
const groupId = Number(config.groupId);
return config.enabled === true
&& Boolean(sub2ApiRoot(config.baseUrl))
&& Boolean(config.apiKey)
&& (Number.isFinite(groupId) && groupId > 0 || Boolean(String(config.groupName || '').trim()));
}
async function renewUnauthorizedTeamToken(mother, child, { allowCredentialLogin = false } = {}) {
if (!mother?.accountId || !child) return { ok: false, status: 400, message: 'workspace_id_or_child_missing', freeRefreshed: false };
const workspaceId = String(mother.accountId).trim();
const stored = workspaceTokenFor(child, workspaceId) || workspaceTokenFor(child, mother.team);
const accountToken = accountTeamTokenFor(child, workspaceId, { allowExpired: true });
const childEmail = String(child.email || '').trim().toLowerCase();
const importedTeamOwner = [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])]
.find((owner) => childEmail && String(owner?.email || '').trim().toLowerCase() === childEmail && String(owner?.accountId || workspaceId).trim() === workspaceId);
const refreshSource = stored?.refreshToken ? stored : accountToken?.refreshToken ? accountToken : importedTeamOwner;
const teamRefreshToken = refreshSource?.refreshToken || '';
const teamClientId = refreshSource?.clientId || child.clientId || '';
if (teamRefreshToken) {
const refreshed = await refreshOpenAiAccessToken(teamRefreshToken, teamClientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
const claims = refreshed.claims || accessTokenClaims(refreshed.accessToken);
const refreshedAccountToken = accountTeamTokenFor({ ...child, accessToken: refreshed.accessToken }, workspaceId);
if (refreshed.ok && claims.accountId === workspaceId && childTokenIdentityMatches(child, refreshed.accessToken)
&& (refreshSource?.source !== 'account_team_token' || refreshedAccountToken)) {
const workspaceToken = saveWorkspaceToken(child, workspaceId, refreshed.accessToken, claims, {
refreshToken: refreshed.refreshToken || teamRefreshToken,
idToken: refreshed.idToken || refreshSource?.idToken || '',
clientId: teamClientId,
source: 'team_refresh_token',
});
const membership = membershipFor(child, mother.team, true);
membership.workspaceTokenStatus = 'ready';
membership.workspaceTokenUpdatedAt = workspaceToken.acquiredAt;
membership.teamAuthRetryAfter = null;
membership.teamAuthLastError = null;
child.teamLoginStatus = 'ready';
child.teamLoginMessage = '已通过 Team refresh token 自动恢复授权';
resetTeamLoginState(child, workspaceId, child.teamLoginMessage);
if (!childIsBanned(child) && isChildMemberOfTeam(child, mother.team) && ['login_required', 'login_pending', 'ready'].includes(child.status)) child.status = 'active';
if (childIsWorkspaceOwner(child, mother) || childMatchesKnownTeamOwner(child, mother)) upsertTeamOwnerFromChild(mother, child, workspaceId, workspaceToken);
addHistory('刷新 Team JSON', `${child.email} 已使用 ${mother.team} 自己的 refresh token 更新 OAuth 授权`);
await persist();
return { ok: true, status: 200, message: 'team_token_renewed', source: 'team_refresh_token', freeRefreshed: false, credentialLogin: false, credentialLoginAttempted: false };
}
}
if (!allowCredentialLogin) {
return { ok: false, status: 401, code: 'team_token_refresh_failed', message: 'Team AT 已失效,未找到可用 RT;额度检测不会启动登录流程', freeRefreshed: false, credentialLogin: false, credentialLoginAttempted: false };
}
const backoff = activeTeamAuthBackoff(child, mother.team);
if (backoff) {
return { ok: false, status: 429, code: 'team_auth_retry_backoff', message: `Team OAuth 自动重登正在退避:${backoff.message}`, retryAfter: backoff.retryAfter, freeRefreshed: false, credentialLogin: false, credentialLoginAttempted: false };
}
const teamOAuth = await acquireTeamAuth(mother, child, { force: true });
if (!teamOAuth.ok) {
return {
ok: false,
status: teamOAuth.status || 502,
code: teamOAuth.code || 'team_oauth_refresh_failed',
message: teamOAuth.message || 'team_oauth_refresh_failed',
freeRefreshed: false,
credentialLogin: false,
credentialLoginAttempted: true,
needsInput: Boolean(teamOAuth.needsInput),
browserRequired: Boolean(teamOAuth.browserRequired),
authUrl: teamOAuth.authUrl || null,
};
}
const workspaceToken = workspaceTokenFor(child, workspaceId) || workspaceTokenFor(child, mother.team);
if (!workspaceToken?.accessToken) return { ok: false, status: 502, message: 'renewed_workspace_token_missing', freeRefreshed: false, credentialLogin: true, credentialLoginAttempted: true };
if (childIsWorkspaceOwner(child, mother) || childMatchesKnownTeamOwner(child, mother)) {
upsertTeamOwnerFromChild(mother, child, workspaceId, workspaceToken);
}
const fallbackMessage = teamOAuth.oauthFallback ? `,Sub2API OAuth 不可用时已自动回退本地 OAuth(${teamOAuth.oauthFallback.code})` : '';
const syncMessage = teamOAuth.sub2api && !teamOAuth.sub2api.synced ? `,Sub2API 同步未完成:${teamOAuth.sub2api.message || 'unknown_error'}` : '';
addHistory('刷新 Team JSON', `${child.email} 已重新完成 OAuth 登录并选择 ${mother.team}${fallbackMessage}${syncMessage}`, teamOAuth.partial ? 'partial' : 'success');
await persist();
return { ok: true, status: 200, message: 'team_token_renewed', source: 'team_oauth_email_password_2fa', freeRefreshed: false, credentialLogin: true, credentialLoginAttempted: true, partial: Boolean(teamOAuth.partial), oauthFallback: teamOAuth.oauthFallback || null, sub2api: teamOAuth.sub2api || null };
}
async function pushRenewedTeamJson(mother, { emails = [], mode = 'create_only' } = {}) {
if (!canAutoPushRenewedTeamJson(mother)) {
return { attempted: false, ok: null, status: null, message: 'sub2api_auto_push_not_configured', pushed: 0, failed: 0 };
}
const entries = teamSub2ApiEntries([mother.id], emails);
const result = await pushSub2ApiEntries(entries, mode === 'repair_only' ? '修复 Team Sub2API' : '推送新 Team 账号', {
config: sub2ApiConfigForMother(mother),
mode,
});
return {
attempted: true,
ok: result.ok === true,
status: result.status || null,
message: result.message || null,
pushed: result.pushed?.length || 0,
failed: result.failed?.length || 0,
skipped: result.skipped?.length || 0,
};
}
async function checkTeam(motherId, { childIds = [], allowCredentialLogin = true } = {}) {
const mother = findMother(motherId);
if (!mother) return { ok: false, status: 404, message: 'mother_not_found' };
let managerRecovery = await recoverTeamManagerToken(mother, { allowCredentialLogin: false });
let workspace = await syncMotherWorkspace(mother, { allowCredentialLogin: false }).catch((error) => ({ ok: false, message: error?.message || 'workspace_sync_failed', members: [] }));
if (allowCredentialLogin && !workspace.ok && isOpenAiAuthFailure(workspace)) {
// Only a confirmed unauthorized response may enter the credential-login
// fallback. The sync routine refreshes the primary mother token separately
// because subscription/seat data cannot be queried through a child owner.
workspace = await syncMotherWorkspace(mother, { allowCredentialLogin: true }).catch((error) => ({ ok: false, message: error?.message || 'workspace_sync_failed', members: [] }));
managerRecovery = { ok: workspace.ok, status: workspace.status, source: workspace.ok ? 'workspace_401_recovery' : null };
}
const selectedChildren = new Set(Array.isArray(childIds) ? childIds.map(String) : []);
const members = state.children.filter((child) => (!selectedChildren.size || selectedChildren.has(String(child.id)))
&& isChildMemberOfTeam(child, mother.team)
&& membershipFor(child, mother.team)?.joinStatus !== 'seat_type_mismatch');
const ownerAttempt = await withTeamManager(mother, (manager) => probeUsage(manager.accessToken, mother.accountId), { email: mother.email });
const ownerProbe = ownerAttempt?.result || await probeUsage('', mother.accountId);
applyMotherQuotaResult(mother, ownerProbe);
const ownerBanReason = explicitAccountBanMessage(ownerProbe);
Object.assign(mother, quotaHealth(ownerProbe, ownerBanReason));
mother.banReason = ownerBanReason || '';
mother.bannedAt = ownerBanReason ? (mother.bannedAt || now()) : null;
mother.managementStatus = managementHealth(workspace.membersResult, workspace.subscriptionResult);
const kickWindow = selectedKickWindow(mother);
const results = await mapWithConcurrency(members, async (child) => {
try {
const teamOwner = teamOwnerRecords(mother).find((owner) => String(owner.email || '').toLowerCase() === String(child.email || '').toLowerCase());
const selectedToken = workspaceTokenFor(child, mother.accountId) || workspaceTokenFor(child, mother.team);
const workspaceToken = adoptAccountTeamToken(mother, child, selectedToken) || selectedToken;
const importedOwnerToken = childTokenIdentityMatches(child, teamOwner?.accessToken)
? teamTokenDetails(teamOwner?.accessToken, mother.accountId)?.accessToken || ''
: '';
const quotaToken = teamTokenDetails(workspaceToken?.accessToken, mother.accountId)?.accessToken || importedOwnerToken;
let result = await probeUsage(quotaToken, mother.accountId);
let tokenRecovery = null;
let liveness = null;
const initialBanMessage = explicitAccountBanMessage(result);
if (!initialBanMessage && !result.ok && !isChallenge(result)) {
liveness = await probeAccountLiveness(quotaToken, mother.accountId);
}
const livenessBanMessage = liveness?.banned ? explicitAccountBanMessage(liveness) || 'OpenAI 账号已被停用或封禁' : '';
if (!initialBanMessage && !livenessBanMessage && isOpenAiAuthFailure(result)) {
tokenRecovery = await renewUnauthorizedTeamToken(mother, child, { allowCredentialLogin });
if (tokenRecovery.ok) {
const renewedToken = workspaceTokenFor(child, mother.accountId) || workspaceTokenFor(child, mother.team);
result = await probeUsage(renewedToken?.accessToken || '', mother.accountId);
if (!result.ok && !isChallenge(result)) liveness = await probeAccountLiveness(renewedToken?.accessToken || '', mother.accountId);
}
}
applyQuotaResult(child, result, mother.team, kickWindow, { createMembership: state.settings.delayedKickEnabled && kickWindow !== 'time' });
const banMessage = initialBanMessage || livenessBanMessage || explicitAccountBanMessage(result, liveness, tokenRecovery);
if (banMessage) markChildBanned(child, mother, {
source: tokenRecovery && !tokenRecovery.ok ? 'team_oauth_recovery' : 'team_quota_probe',
status: tokenRecovery && !tokenRecovery.ok ? tokenRecovery.status : result.status,
code: tokenRecovery && !tokenRecovery.ok ? tokenRecovery.code : result.errorCode,
message: banMessage,
});
const membership = membershipFor(child, mother.team);
return { id: child.id, email: child.email, ...result, banned: Boolean(banMessage), banReason: banMessage || null, liveness: liveness ? { ok: liveness.ok, status: liveness.status, message: liveness.message, banned: Boolean(liveness.banned), latencyMs: liveness.latencyMs } : null, quotaSource: quotaToken ? 'team' : 'team_token_missing', quota5h: membership?.quota5h ?? null, quota7d: membership?.quota7d ?? null, tokenRecovery: tokenRecovery ? { attempted: true, ok: tokenRecovery.ok, status: tokenRecovery.status, code: tokenRecovery.code || null, message: tokenRecovery.message, retryAfter: tokenRecovery.retryAfter || null, freeRefreshed: tokenRecovery.freeRefreshed, credentialLogin: Boolean(tokenRecovery.credentialLogin), credentialLoginAttempted: Boolean(tokenRecovery.credentialLoginAttempted), needsInput: Boolean(tokenRecovery.needsInput), browserRequired: Boolean(tokenRecovery.browserRequired), partial: Boolean(tokenRecovery.partial), oauthFallback: tokenRecovery.oauthFallback || null, sub2api: tokenRecovery.sub2api || null } : null };
} catch (error) {
applyQuotaResult(child, { ok: false, status: 502, message: error?.message || 'quota_probe_failed' }, mother.team, kickWindow, { createMembership: state.settings.delayedKickEnabled && kickWindow !== 'time' });
return { id: child.id, email: child.email, ok: false, status: 502, message: error?.message || 'quota_probe_failed', quota5h: null, quota7d: null, tokenRecovery: null };
}
});
const successfulRenewals = results.filter((result) => result.tokenRecovery?.ok && result.ok === true);
const renewedTeamTokens = successfulRenewals.length;
const renewedEmails = successfulRenewals.map((result) => result.email).filter(Boolean);
const renewedFreeTokens = successfulRenewals.filter((result) => result.tokenRecovery.freeRefreshed).length;
const reloggedTeamAccounts = successfulRenewals.filter((result) => result.tokenRecovery.credentialLogin).length;
const bannedAccounts = results.filter((result) => result.banned === true);
const sub2apiPush = renewedTeamTokens > 0
? await pushRenewedTeamJson(mother, { emails: renewedEmails, mode: 'repair_only' })
: { attempted: false, ok: null, status: null, message: null, pushed: 0, failed: 0 };
if (renewedTeamTokens > 0 && workspace.ok !== true) {
workspace = await syncMotherWorkspace(mother, { allowCredentialLogin: false }).catch((error) => ({ ok: false, message: error?.message || 'workspace_sync_failed', members: [] }));
}
mother.lastCheck = now();
const probesOk = ownerProbe.ok === true && results.every((result) => result.ok === true);
const syncOk = workspace.ok === true;
const pushOk = !sub2apiPush.attempted || sub2apiPush.ok === true;
const failedProbes = results.filter((result) => result.ok !== true).length;
const renewalDetail = !renewedTeamTokens
? ''
: `,刷新 ${renewedTeamTokens} 个 Team JSON${reloggedTeamAccounts ? `,重新完成 ${reloggedTeamAccounts} 个 Team OAuth` : ''}${sub2apiPush.attempted ? `,Sub2API 修复 ${sub2apiPush.pushed} 个` : ',Sub2API 未修复(未启用或未配置分组)'}`;
const failureDetail = failedProbes || !syncOk || !pushOk
? `,失败 ${failedProbes} 个${!syncOk ? ',Team 同步失败' : ''}${!pushOk ? ',Sub2API 修复失败' : ''}`
: '';
addHistory('额度检测', `${mother.team} 检测 ${members.length} 个子号,席位 ${mother.used ?? '-'} / ${mother.seats ?? '-'}${bannedAccounts.length ? `,发现封禁 ${bannedAccounts.length} 个` : ''}${renewalDetail}${failureDetail}`, syncOk && probesOk && pushOk && !bannedAccounts.length ? 'success' : 'partial');
await persist();
return {
ok: syncOk && probesOk && pushOk,
status: syncOk && probesOk && pushOk ? 200 : 207,
motherId,
ownerProbe,
checked: results.length,
results,
seatSnapshot: workspace.seatSnapshot || mother.seatSnapshot || null,
members: workspace.members || mother.members || [],
syncOk,
probesOk,
renewedTeamTokens,
renewedFreeTokens,
reloggedTeamAccounts,
kickWindow,
kickAfterHours: kickWindow === 'time' ? normalizeKickAfterHours(state.settings?.kickAfterHours) : null,
bannedAccounts: bannedAccounts.map((result) => ({ id: result.id, email: result.email, reason: result.banReason })),
sub2apiPush,
managerRecovery,
};
}
async function checkAllTeams() {
const configured = state.mothers.filter(teamHasManagementPath);
const teams = await mapWithConcurrency(configured, async (mother) => {
try {
return await withTeamMaintenanceLock(mother.id, 'check_all', () => checkTeam(mother.id));
} catch (error) {
return { ok: false, status: 502, motherId: mother.id, checked: 0, results: [], message: error?.message || 'team_check_failed' };
}
});
const checked = teams.reduce((sum, result) => sum + (Number(result.checked) || 0), 0);
const failed = teams.filter((result) => result.ok !== true).length;
return {
ok: configured.length > 0 && failed === 0,
status: failed ? 207 : 200,
checked,
teamCount: configured.length,
succeeded: Math.max(0, configured.length - failed),
failed,
teams,
};
}
const ROTATION_STAGES = [
['sync', '同步 Team'],
['kick', '移出耗尽账号'],
['free_auth', '准备 Free 凭据'],
['request', '申请加入'],
['approve', '同意进入'],
['member', '确认 Team 成员'],
['owner', '设置所有者'],
['team_json', '获取 Team JSON'],
['verify', '复核席位'],
['quota', '检测真实额度'],
['push', '同步 Sub2API'],
];
function beginRotationProgress(mother) {
const startedAt = now();
mother.rotationProgress = {
status: 'running',
current: 'sync',
message: '正在同步 Team 席位和成员',
account: '',
startedAt,
updatedAt: startedAt,
durationMs: null,
steps: ROTATION_STAGES.map(([id, label]) => ({ id, label, status: 'pending', startedAt: null, endedAt: null, durationMs: null })),
};
updateRotationProgress(mother, 'sync', { message: '正在同步 Team 席位和成员' });
}
function updateRotationProgress(mother, stage, { message = '', account = '' } = {}) {
const progress = mother?.rotationProgress;
if (!progress) return;
const updatedAt = now();
for (const step of progress.steps || []) {
if (step.status === 'running' && step.id !== stage) {
step.status = 'completed';
step.endedAt = updatedAt;
step.durationMs = Math.max(0, Date.parse(updatedAt) - Date.parse(step.startedAt || updatedAt));
}
}
const target = (progress.steps || []).find((step) => step.id === stage);
if (target) {
target.status = 'running';
target.startedAt = target.startedAt || updatedAt;
target.endedAt = null;
target.durationMs = null;
}
progress.status = 'running';
progress.current = stage;
progress.message = message || target?.label || stage;
progress.account = account || '';
progress.updatedAt = updatedAt;
}
function finishRotationProgress(mother, status, message, summary = {}) {
const progress = mother?.rotationProgress;
if (!progress) return;
const endedAt = now();
for (const step of progress.steps || []) {
if (step.status === 'running') {
step.status = status === 'failed' ? 'failed' : 'completed';
step.endedAt = endedAt;
step.durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(step.startedAt || endedAt));
} else if (step.status === 'pending') {
step.status = 'skipped';
}
}
progress.status = status;
progress.message = message;
progress.account = '';
progress.endedAt = endedAt;
progress.updatedAt = endedAt;
progress.durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(progress.startedAt || endedAt));
progress.summary = summary;
}
async function refillTeam(motherId, options = {}) {
const mother = findMother(motherId);
if (!mother) return { ok: false, status: 404, message: 'mother_not_found' };
beginRotationProgress(mother);
const reservedCandidates = new Set();
try {
if (state.settings.delayedKickEnabled && ['5h', '7d'].includes(selectedKickWindow(mother))
&& !options.manualTimersOnly && !options.quotaChecked) {
await checkTeam(motherId);
}
return await refillTeamInternal(motherId, mother, { ...options, reservedCandidates });
} catch (error) {
finishRotationProgress(mother, 'failed', error?.message || '自动轮换失败');
await persist();
throw error;
} finally {
for (const id of reservedCandidates) {
if (refillCandidateReservations.get(id) === mother.id) refillCandidateReservations.delete(id);
}
}
}
const refillCandidateReservations = new Map();
async function withChildJoinReservation(child, motherId, operation) {
if (!child) return { ok: false, status: 404, message: 'child_not_found' };
if (refillCandidateReservations.has(child.id)) {
return { ok: false, status: 409, message: 'account_join_in_progress' };
}
refillCandidateReservations.set(child.id, motherId);
try {
return await operation();
} finally {
if (refillCandidateReservations.get(child.id) === motherId) refillCandidateReservations.delete(child.id);
}
}
async function refillTeamInternal(motherId, mother, { manualTimersOnly = false, reservedCandidates = new Set() } = {}) {
let managerRecovery = await recoverTeamManagerToken(mother);
let workspace = await syncMotherWorkspace(mother).catch((error) => ({ ok: false, message: error?.message || 'workspace_sync_failed', members: [] }));
if (!workspace.ok && [401, 403].includes(Number(workspace.status))) {
managerRecovery = await recoverTeamManagerToken(mother, { force: true });
if (managerRecovery.ok) workspace = await syncMotherWorkspace(mother).catch((error) => ({ ok: false, message: error?.message || 'workspace_sync_failed', members: [] }));
}
if (workspace.ok !== true) {
const message = workspace.message || 'workspace_sync_failed';
finishRotationProgress(mother, 'failed', `Team 同步失败:${message}`);
addHistory('自动补位', `${mother.team} 空间同步失败,跳过远端补位:${message}`, 'partial');
await persist();
return {
ok: false,
status: 207,
motherId,
kicked: [],
joined: [],
kickFailures: [],
joinFailures: [{ ok: false, status: workspace.status || 502, message }],
seatsInUse: mother.used,
seatsOpen: null,
seatSnapshot: workspace.seatSnapshot || mother.seatSnapshot || null,
};
}
const ownerRoleRetryFailures = [];
const workspaceTokenRetryFailures = [];
const recoveredTeamJson = [];
const attemptedTeamJsonIds = new Set();
const currentMemberEmails = new Set((workspace.members || []).filter(memberIsActive).map((member) => String(member.email || '').trim().toLowerCase()));
const promoteJoinedAccounts = state.settings?.promoteJoinedAccounts !== false;
const pendingOwnerRoles = promoteJoinedAccounts ? state.children.filter((child) => {
if (childIsBanned(child)) return false;
if (!currentMemberEmails.has(String(child.email || '').trim().toLowerCase())) return false;
if (!isChildMemberOfTeam(child, mother.team)) return false;
const membership = membershipFor(child, mother.team);
const ownerRoleStatus = membership?.ownerRoleStatus || child.ownerRoleStatus;
const joinStatus = membership?.joinStatus || child.joinStatus;
return ownerRoleStatus === 'failed' || joinStatus === 'owner_role_failed' || joinStatus === 'approved_pending_owner';
}) : [];
for (const child of pendingOwnerRoles) {
const membership = membershipFor(child, mother.team, true);
const promoted = await promoteJoinedMemberToOwner(mother, child, mother.accountId);
if (promoted.ok) {
child.joinStatus = 'owner_confirmed';
child.ownerRoleStatus = 'applied';
membership.joinStatus = 'owner_confirmed';
membership.ownerRoleStatus = 'applied';
membership.role = 'account-owner';
const promotedSeatType = observedSeatType(promoted.member?.seatType);
if (promotedSeatType) {
membership.seatType = promotedSeatType;
const existingClaim = seatClaimForChild(mother, child);
if (existingClaim) existingClaim.seatType = promotedSeatType;
}
child.ownerRoleUpdatedAt = now();
child.ownerRoleError = null;
addHistory('重试 Team 所有者', `${child.email} 已设置为 ${mother.team} 所有者`);
attemptedTeamJsonIds.add(child.id);
const switched = await acquireTeamJson(mother, child, { browserLogin: true,
onProgress: (stage, message) => updateRotationProgress(mother, stage, { message, account: child.email }) });
if (!switched.ok) {
child.joinStatus = 'owner_confirmed_token_pending';
membership.joinStatus = 'owner_confirmed_token_pending';
membership.workspaceTokenStatus = 'pending';
workspaceTokenRetryFailures.push({ id: child.id, email: child.email, ok: false, status: switched.status || 502, phase: 'workspace_token_retry', code: switched.code || null, message: switched.message || 'workspace_token_exchange_failed', needsInput: Boolean(switched.needsInput), browserRequired: Boolean(switched.browserRequired), authUrl: switched.authUrl || null, retryAfter: switched.retryAfter || null });
} else recoveredTeamJson.push(child);
} else {
child.joinStatus = 'owner_role_failed';
child.ownerRoleStatus = 'failed';
membership.joinStatus = 'owner_role_failed';
membership.ownerRoleStatus = 'failed';
child.ownerRoleError = { status: promoted.status || 502, message: promoted.message || 'owner_role_failed', at: now() };
ownerRoleRetryFailures.push({ id: child.id, email: child.email, ok: false, status: promoted.status || 502, phase: 'owner_role_retry', message: promoted.message || 'owner_role_failed' });
}
}
const pendingWorkspaceTokens = state.children.filter((child) => {
if (childIsBanned(child)) return false;
if (attemptedTeamJsonIds.has(child.id)) return false;
if (!currentMemberEmails.has(String(child.email || '').trim().toLowerCase())) return false;
if (!isChildMemberOfTeam(child, mother.team)) return false;
const membership = membershipFor(child, mother.team);
if (membership?.joinStatus === 'seat_type_mismatch') return false;
const workspaceToken = workspaceTokenFor(child, mother.accountId) || workspaceTokenFor(child, mother.team);
return membership?.workspaceTokenStatus === 'pending'
|| ['team_token_failed', 'owner_confirmed_token_pending', 'member_confirmed_token_pending'].includes(membership?.joinStatus || child.joinStatus)
|| !teamTokenDetails(workspaceToken?.accessToken, mother.accountId);
});
for (const child of pendingWorkspaceTokens) {
const switched = await acquireTeamJson(mother, child, { browserLogin: true,
onProgress: (stage, message) => updateRotationProgress(mother, stage, { message, account: child.email }) });
const membership = membershipFor(child, mother.team, true);
const roleConfirmedStatus = childIsWorkspaceOwner(child, mother) ? 'owner_confirmed' : 'member_confirmed';
if (switched.ok) {
child.joinStatus = roleConfirmedStatus;
membership.joinStatus = roleConfirmedStatus;
membership.workspaceTokenStatus = 'ready';
recoveredTeamJson.push(child);
} else {
child.joinStatus = `${roleConfirmedStatus}_token_pending`;
membership.joinStatus = child.joinStatus;
membership.workspaceTokenStatus = 'pending';
workspaceTokenRetryFailures.push({ id: child.id, email: child.email, ok: false, status: switched.status || 502, phase: 'workspace_token_retry', code: switched.code || null, message: switched.message || 'workspace_token_exchange_failed', needsInput: Boolean(switched.needsInput), browserRequired: Boolean(switched.browserRequired), authUrl: switched.authUrl || null, retryAfter: switched.retryAfter || null });
}
}
const rotationBudgetBefore = dailyRotationBudget(mother);
const kickAfterHours = normalizeKickAfterHours(state.settings?.kickAfterHours);
const kickWindow = selectedKickWindow(mother);
const kickDescription = manualTimersOnly
? '手动倒计时到期的账号'
: kickWindow === 'time'
? `加入超过 ${kickAfterHours} 小时或手动倒计时到期的账号`
: `${state.settings.delayedKickEnabled ? '额度耗尽且延迟到期' : '额度耗尽'}或手动倒计时到期的账号`;
updateRotationProgress(mother, 'kick', { message: `${manualTimersOnly ? '正在检查' : '正在检查封禁和'}${kickDescription}(今日 ${rotationBudgetBefore.count}/${rotationBudgetBefore.limit})` });
const active = state.children.filter((child) => isChildMemberOfTeam(child, mother.team));
const removalCandidates = active.filter((child) => childManualKickTimerExpired(child, mother.team)
|| (!manualTimersOnly && (childIsBanned(child)
|| (kickWindow === 'time'
? timeKickExpired(child, mother.team, kickAfterHours)
: quotaKickEligible(child, mother.team, kickWindow)))))
.sort((left, right) => Number(childIsBanned(right)) - Number(childIsBanned(left)));
const rotationLimitSkipped = [];
const kicked = [];
const kickFailures = [];
const removedMemberIds = new Set();
const removedMemberEmails = new Set();
for (const child of removalCandidates) {
if (dailyRotationBudget(mother).remaining <= 0) {
rotationLimitSkipped.push({ id: child.id, email: child.email, banned: childIsBanned(child) });
continue;
}
if (!teamManagerContext(mother) || !mother.accountId) {
kickFailures.push({ id: child.id, email: child.email, ok: false, status: 400, message: 'workspace_credentials_required' });
continue;
}
const latestMembers = await queryAllWorkspaceMembers(mother);
if (!latestMembers.ok) {
kickFailures.push({ id: child.id, email: child.email, ok: false, status: latestMembers.status || 502, message: latestMembers.message || 'workspace_members_refresh_failed' });
continue;
}
mother.members = excludePreviouslyRemovedMembers(latestMembers.items, removedMemberIds, removedMemberEmails);
reconcileSeatClaimsWithMembers(mother, mother.members);
const currentMemberCount = workspaceMemberCount(mother, active.length + 1);
if (currentMemberCount <= 1) {
kickFailures.push({ id: child.id, email: child.email, ok: false, status: 409, message: 'minimum_workspace_member_required' });
break;
}
const member = mother.members.find((item) => item.email && child.email && item.email.toLowerCase() === child.email.toLowerCase());
if (!member?.id) {
kickFailures.push({ id: child.id, email: child.email, ok: false, status: 502, message: 'member_snapshot_unavailable' });
continue;
}
// A fixed primary owner is protected during ordinary rotation, but a
// confirmed banned account must still be removed to allow replacement.
if (memberIsProtected(member, mother) && !childIsBanned(child)) {
kickFailures.push({ id: child.id, email: child.email, ok: false, status: 403, message: 'protected_workspace_member' });
continue;
}
const remote = await removeWorkspaceMember(mother, member);
if (!remote.ok) { kickFailures.push({ id: child.id, email: child.email, ...remote }); continue; }
if (member.id) removedMemberIds.add(String(member.id));
if (member.accountUserId) removedMemberIds.add(String(member.accountUserId));
if (member.email) removedMemberEmails.add(String(member.email).trim().toLowerCase());
mother.members = (mother.members || []).filter((item) => item.id !== member.id);
releaseSeatClaim(mother, seatClaimForChild(mother, child));
const removedAt = now();
const banned = childIsBanned(child);
const membership = membershipFor(child, mother.team, true);
const manualTimer = manualKickTimerState(membership);
const manualTimerTriggered = !banned && manualTimer.expired;
clearQuotaKickDelay(membership);
const retryAfter = banned
? null
: manualTimerTriggered
? manualKickCooldownAt(manualTimer, removedAt)
: quotaRetryAfter(child, kickWindow, mother.team);
const retryReason = banned ? 'account_banned' : manualTimerTriggered ? 'manual_time_elapsed' : quotaKickReason(child, kickWindow);
clearManualKickTimer(membership, manualTimerTriggered ? 'completed' : banned ? 'account_banned' : 'membership_removed', removedAt);
Object.assign(membership, {
status: 'kicked',
removedAt,
reason: retryReason,
retryAfter,
rejoinEligible: banned ? false : Boolean(retryAfter),
});
child.status = banned ? 'banned' : 'kicked';
child.retryReason = retryReason;
child.rejoinEligible = banned ? false : Boolean(retryAfter);
if (workspaceIdKey(child.team) === workspaceIdKey(mother.team)) {
const replacement = (child.workspaceHistory || []).find((entry) => entry.status === 'active' && entry.team);
child.team = replacement?.team || null;
}
removeTeamOwnerForChild(mother, child);
if (child.team && !banned) child.status = 'active';
kicked.push(child);
consumeDailyRotation(mother);
if (manualTimerTriggered) {
addHistory('成员倒计时到期', `${child.email} 已从 ${teamDisplayName(mother)} 移出,原倒计时 ${manualTimer.durationMinutes} 分钟`, 'success', {
summary: {
team: mother.team,
childId: child.id,
email: child.email || '',
reason: retryReason,
durationMinutes: manualTimer.durationMinutes,
startedAt: manualTimer.startedAt,
kickAt: manualTimer.kickAt,
removedAt,
retryAfter,
},
});
}
await persist();
}
const joinFailures = [...ownerRoleRetryFailures, ...workspaceTokenRetryFailures];
let seatCapacityReady = true;
if (kicked.length) {
updateRotationProgress(mother, 'sync', { message: '正在刷新移出后的分类席位余量' });
const refreshedSubscription = await refreshMotherSubscription(mother).catch((error) => ({ ok: false, status: 0, message: error?.message || 'seat_capacity_refresh_failed' }));
if (!refreshedSubscription.ok) {
seatCapacityReady = false;
joinFailures.push({ id: null, email: '', ok: false, status: refreshedSubscription.status || 502, phase: 'seat_capacity', code: 'seat_capacity_refresh_failed', message: refreshedSubscription.message || 'seat_capacity_refresh_failed' });
}
}
const seatsEntitled = mother.seats !== null && mother.seats !== '' && Number.isFinite(Number(mother.seats)) ? Number(mother.seats) : null;
const usedBeforeRefill = mother.used !== null && mother.used !== '' && Number.isFinite(Number(mother.used))
? Number(mother.used)
: Math.max(0, workspaceMemberCount(mother, active.length) - kicked.length);
const open = seatCapacityReady
? totalSeatAvailability(seatAdmissionSnapshot(mother), seatClaimReservations(mother))?.remaining ?? 0
: 0;
const eligiblePool = state.children.filter((child) => (
!childIsBanned(child)
&& (state.settings.joinMode === 'invite' || canJoinByEmailDomain(mother, child))
&& canRejoinTeam(child, mother.team)
&& !seatClaimForChild(mother, child)
&& Boolean(child.accessToken || child.refreshToken || (child.email && child.password))
&& !(child.workspaceHistory || []).some((entry) => entry.team === mother.team && entry.status === 'active')
));
const candidatePool = eligiblePool.filter((child) => !freeAuthRequiresManualInput(child) && !freeAuthRetryBackoffActive(child));
if (open > 0) {
for (const child of eligiblePool.filter((item) => !candidatePool.includes(item))) {
joinFailures.push({
id: child.id, email: child.email, ok: false, status: 202,
phase: 'free_auth_waiting', code: child.loginStatus || 'verification_required',
message: child.loginMessage || '该账号等待官方登录验证或重试冷却,尚未发送邀请',
needsInput: freeAuthRequiresManualInput(child), browserRequired: Boolean(child.loginBrowserRequired),
authUrl: child.loginBrowserRequired ? child.loginUrl || null : null,
});
}
}
const joined = [];
const seatReservations = seatClaimReservations(mother);
let acceptedSeats = 0;
let seatSelectionBlocked = false;
let candidateCursor = 0;
while (acceptedSeats < open && candidateCursor < candidatePool.length && !seatSelectionBlocked) {
const batchSize = Math.min(open - acceptedSeats, normalizeConcurrency(state.settings.concurrency), candidatePool.length - candidateCursor);
const batch = candidatePool.slice(candidateCursor, candidateCursor + batchSize).filter((child) => {
if (refillCandidateReservations.has(child.id)) return false;
refillCandidateReservations.set(child.id, mother.id);
reservedCandidates.add(child.id);
return true;
});
candidateCursor += batchSize;
if (!batch.length) continue;
updateRotationProgress(mother, 'free_auth', { message: `正在并发准备 ${batch.length} 个 Free 账号` });
const prepared = await mapWithConcurrency(batch, async (child) => {
try {
return { child, freeAuth: await ensureChildFreeAuth(child, { browserLogin: true,
onProgress: (stage, message) => updateRotationProgress(mother, stage, { message, account: child.email }) }) };
} catch (error) {
return { child, freeAuth: { ok: false, status: 502, code: 'free_auth_failed', message: error?.message || 'free_auth_failed' } };
}
});
for (const { child, freeAuth } of prepared) {
if (acceptedSeats >= open) break;
if (!freeAuth.ok) {
const banMessage = explicitAccountBanMessage(freeAuth);
if (banMessage) markChildBanned(child, mother, { source: 'free_oauth_before_team_join', status: freeAuth.status, code: freeAuth.code, message: banMessage });
joinFailures.push({ id: child.id, email: child.email, ok: false, status: freeAuth.status || 502, phase: 'free_auth', code: freeAuth.code || null, message: freeAuth.message || 'free_auth_required', needsInput: Boolean(freeAuth.needsInput), browserRequired: Boolean(freeAuth.browserRequired), authUrl: freeAuth.authUrl || null });
continue;
}
if (!teamManagerContext(mother) || !mother.accountId) { joinFailures.push({ id: child.id, email: child.email, ok: false, status: 400, message: 'workspace_credentials_required' }); continue; }
const seatSelection = selectTeamSeatType(mother, seatAdmissionSnapshot(mother), seatReservations);
if (!seatSelection.ok) {
joinFailures.push({ id: child.id, email: child.email, ok: false, status: seatSelection.code === 'seat_capacity_exhausted' || seatSelection.code === 'seat_type_capacity_exhausted' ? 409 : 422, phase: 'seat_capacity', ...seatSelection });
seatSelectionBlocked = true;
break;
}
const membership = membershipFor(child, mother.team, true);
const remote = await joinWorkspace(child, {
motherId,
workspaceId: mother.accountId,
approve: true,
pushTeamJson: false,
onProgress: (stage, message) => updateRotationProgress(mother, stage, { message, account: child.email }),
}, {
selectedSeatType: seatSelection.seatType,
seatReservations,
browserLogin: true,
});
if (remote.seatClaimed) {
seatReservations[remote.seatType || seatSelection.seatType] += 1;
}
if (remote.accepted) acceptedSeats += 1;
if (!remote.ok) {
const banMessage = ['free_auth', 'request', 'team_token'].includes(remote.phase)
? explicitAccountBanMessage(remote, remote.teamAuth)
: '';
if (banMessage) markChildBanned(child, mother, { source: 'team_join_oauth', status: remote.status, code: remote.code, message: banMessage });
if (!remote.accepted) child.workspaceHistory = (child.workspaceHistory || []).filter((entry) => entry !== membership);
joinFailures.push({ id: child.id, email: child.email, ...remote });
continue;
}
const teamAuth = remote.teamAuth || { ok: false, status: 502, message: 'team_token_not_generated' };
child.pendingWorkspaceId = mother.accountId;
if (!teamAuth.ok) {
const banMessage = explicitAccountBanMessage(teamAuth);
if (banMessage) markChildBanned(child, mother, { source: 'team_json_oauth', status: teamAuth.status, code: teamAuth.code, message: banMessage });
child.joinStatus = `${promoteJoinedAccounts ? 'owner' : 'member'}_confirmed_token_pending`;
membership.joinStatus = child.joinStatus;
membership.workspaceTokenStatus = 'pending';
joinFailures.push({ id: child.id, email: child.email, ok: false, status: teamAuth.status || 502, phase: 'team_token', code: teamAuth.code || null, message: teamAuth.message || 'team_token_failed', needsInput: Boolean(teamAuth.needsInput), browserRequired: Boolean(teamAuth.browserRequired), authUrl: teamAuth.authUrl || null });
continue;
}
joined.push(child);
}
}
updateRotationProgress(mother, 'verify', { message: '正在复核成员和席位' });
const synced = await syncMotherWorkspace(mother, {
force: true,
excludedMemberIds: removedMemberIds,
excludedMemberEmails: removedMemberEmails,
}).catch(() => ({ ok: false, members: [] }));
const joinedEmails = new Set((synced.members || []).filter(memberIsActive).map((member) => String(member.email).toLowerCase()));
const confirmedJoined = [];
const pendingSync = [];
for (const child of joined) {
if (!joinedEmails.has(String(child.email || '').toLowerCase())) {
const membership = membershipFor(child, mother.team, true);
child.status = 'active';
child.team = child.team || mother.team;
child.joinStatus = 'approved_pending_sync';
child.joinedAt = child.joinedAt || membership.joinedAt || now();
Object.assign(membership, {
team: mother.team,
joinedAt: child.joinedAt,
status: 'active',
seatType: membership.seatType || null,
joinStatus: 'approved_pending_sync',
workspaceTokenStatus: membership.workspaceTokenStatus || 'ready',
rejoinEligible: null,
retryAfter: null,
reason: null,
});
pendingSync.push(child);
joinFailures.push({ id: child.id, email: child.email, ok: false, status: 202, message: 'membership_not_visible_after_approval' });
} else {
child.status = 'active'; child.team = mother.team; child.joinedAt = child.joinedAt || now();
const membership = membershipFor(child, mother.team);
const joinedMember = (synced.members || []).find((member) => String(member.email || '').toLowerCase() === String(child.email || '').toLowerCase());
const role = joinedMember?.role || (promoteJoinedAccounts ? 'account-owner' : 'standard-user');
if (joinedMember) {
child.memberId = joinedMember.id || child.memberId;
child.accountUserId = joinedMember.accountUserId || child.accountUserId;
child.memberSnapshot = joinedMember;
}
child.joinStatus = promoteJoinedAccounts ? 'owner_confirmed' : 'member_confirmed';
child.ownerRoleStatus = promoteJoinedAccounts ? 'applied' : 'skipped';
const previousHistory = (child.workspaceHistory || []).filter((entry) => !(entry.team === mother.team && entry.status === 'active' && entry !== membership));
const activeMembership = membershipFor(child, mother.team, true) || membership;
Object.assign(activeMembership, { team: mother.team, joinedAt: child.joinedAt, status: 'active', role, seatType: joinedMember?.seatType || activeMembership.seatType || null, joinStatus: child.joinStatus, ownerRoleStatus: child.ownerRoleStatus, memberId: child.memberId || activeMembership.memberId || null, quota5h: activeMembership.quota5h ?? null, quota7d: activeMembership.quota7d ?? null, quotaSnapshot: activeMembership.quotaSnapshot || null, quotaUpdatedAt: activeMembership.quotaUpdatedAt || null, workspaceTokenStatus: 'ready', rejoinEligible: null, retryAfter: null, reason: null });
child.workspaceHistory = [...previousHistory.filter((entry) => entry !== activeMembership), activeMembership];
confirmedJoined.push(child);
}
}
if (acceptedSeats > 0) {
mother.used = reconcileAcceptedSeatUsage({
syncedUsed: mother.used,
usedBeforeRefill,
acceptedSeats,
seatsEntitled,
});
}
const confirmedRecovered = recoveredTeamJson.filter((child) => !childIsBanned(child)
&& isChildMemberOfTeam(child, mother.team)
&& joinedEmails.has(String(child.email || '').trim().toLowerCase()));
const pushAccounts = [...joined, ...confirmedRecovered]
.filter((child, index, list) => child?.email && list.findIndex((item) => String(item?.email || '').toLowerCase() === String(child.email).toLowerCase()) === index);
// A completed membership/JSON pipeline should leave a real quota snapshot
// on the member immediately; it must not require a separate UI click. Only
// inspect confirmed members with credentials from this run. A failed probe
// stays unknown and cannot turn a successful invitation into a failed one.
const quotaAccounts = [...confirmedJoined, ...confirmedRecovered]
.filter((child, index, list) => child?.id && list.findIndex((item) => item?.id === child.id) === index);
let quotaCheck = null;
if (quotaAccounts.length) {
updateRotationProgress(mother, 'quota', { message: `正在检测 ${quotaAccounts.length} 个成员的真实额度` });
quotaCheck = await checkTeam(mother.id, { childIds: quotaAccounts.map((child) => child.id), allowCredentialLogin: false });
}
updateRotationProgress(mother, 'push', { message: pushAccounts.length ? `正在推送 ${pushAccounts.length} 个新 Team JSON` : '没有新的 Team JSON 需要推送' });
const joinedSub2apiPush = pushAccounts.length > 0
? await pushRenewedTeamJson(mother, { emails: pushAccounts.map((child) => child.email), mode: 'create_only' })
: { attempted: false, ok: null, status: null, message: null, pushed: 0, failed: 0 };
mother.lastCheck = now();
const pushDetail = joinedSub2apiPush.attempted ? `,Team JSON 推送 ${joinedSub2apiPush.pushed} 个` : '';
const rotationBudgetAfter = dailyRotationBudget(mother);
const quotaSucceeded = (quotaCheck?.results || []).filter((result) => result.ok === true).length;
const quotaFailureCount = (quotaCheck?.results || []).filter((result) => result.ok !== true).length;
const quotaFailed = Boolean(quotaCheck && !quotaCheck.ok);
const quotaDetail = quotaCheck ? `,额度检测成功 ${quotaSucceeded} 个${quotaFailed ? `,额度检测未完成${quotaFailureCount ? ` ${quotaFailureCount} 个` : ''}` : ''}` : '';
addHistory('自动补位', `${mother.team} 移出 ${kicked.length} 个,确认加入 ${acceptedSeats} 个,Team JSON 就绪 ${pushAccounts.length} 个${pendingSync.length ? `(${pendingSync.length} 个等待成员同步)` : ''}${rotationLimitSkipped.length ? `,${rotationLimitSkipped.length} 个受每日轮转上限限制` : ''}${quotaDetail}${pushDetail}${kickFailures.length || joinFailures.length ? `,未完成步骤 ${kickFailures.length + joinFailures.length} 个` : ''}`, kickFailures.length || joinFailures.length || quotaFailed || joinedSub2apiPush.ok === false ? 'partial' : 'success', {
flow: mother.rotationProgress,
summary: {
team: mother.team,
kicked: kicked.map((child) => ({ id: child.id, email: child.email || '', reason: child.retryReason || null })),
joined: joined.map((child) => ({ id: child.id, email: child.email || '', pendingSync: pendingSync.includes(child) })),
kickFailures: kickFailures.map((item) => ({ id: item.id, email: item.email || '', phase: item.phase || 'kick', status: item.status || null, message: item.message || '' })),
joinFailures: joinFailures.map((item) => ({ id: item.id, email: item.email || '', phase: item.phase || 'join', status: item.status || null, code: item.code || null, message: item.message || '', needsInput: Boolean(item.needsInput), browserRequired: Boolean(item.browserRequired), hasAuthUrl: Boolean(item.authUrl), retryAfter: item.retryAfter || null })),
pushed: joinedSub2apiPush.pushed || 0,
pushFailed: joinedSub2apiPush.failed || 0,
teamJsonReady: pushAccounts.length,
quotaSucceeded,
quotaFailed: quotaFailureCount,
quotaCheckOk: quotaCheck?.ok ?? null,
},
});
const pushFailed = joinedSub2apiPush.attempted && joinedSub2apiPush.ok === false;
const ok = kickFailures.length === 0 && joinFailures.length === 0 && !pushFailed && !quotaFailed;
finishRotationProgress(mother, ok ? 'completed' : 'partial', rotationLimitSkipped.length ? `已达到今日轮转上限 ${rotationBudgetAfter.count}/${rotationBudgetAfter.limit}` : ok ? '轮换链路执行完成' : '轮换完成,但存在未成功步骤', { kicked: kicked.length, joined: joined.length, accepted: acceptedSeats, teamJsonReady: pushAccounts.length, quotaSucceeded, quotaFailed: quotaFailureCount, quotaCheckOk: quotaCheck?.ok ?? null, pendingSync: pendingSync.length, pushed: joinedSub2apiPush.pushed || 0, skipped: (joinedSub2apiPush.skipped || 0) + rotationLimitSkipped.length, failed: kickFailures.length + joinFailures.length + (joinedSub2apiPush.failed || 0) + quotaFailureCount });
await persist();
const remainingSeats = totalSeatAvailability(seatAdmissionSnapshot(mother), seatClaimReservations(mother));
return { ok, status: ok ? 200 : 207, kicked: kicked.map((child) => publicChild(child)), joined: joined.map((child) => publicChild(child)), acceptedSeats, teamJsonReady: pushAccounts.length, pendingSync: pendingSync.map((child) => publicChild(child)), kickFailures, joinFailures, quotaCheck, rotationLimitSkipped, kickWindow, kickAfterHours: kickWindow === 'time' ? kickAfterHours : null, dailyRotationUsage: rotationBudgetAfter, sub2apiPush: joinedSub2apiPush, rotationProgress: mother.rotationProgress, seatsInUse: remainingSeats?.inUse ?? null, seatsHeld: remainingSeats?.held ?? null, seatsOpen: remainingSeats?.remaining ?? null, seatSnapshot: mother.seatSnapshot || workspace.seatSnapshot || null };
}
async function refillAllTeams() {
const configured = state.mothers.filter(teamHasManagementPath);
const teams = await mapWithConcurrency(configured, async (mother) => {
try {
return await withTeamMaintenanceLock(mother.id, 'refill_all', () => refillTeam(mother.id));
} catch (error) {
return { ok: false, status: 502, motherId: mother.id, kicked: [], joined: [], message: error?.message || 'team_refill_failed' };
}
});
return {
ok: configured.length > 0 && teams.every((result) => result.ok === true),
status: teams.some((result) => result.ok !== true) ? 207 : 200,
teamCount: configured.length,
kicked: teams.flatMap((result) => result.kicked || []),
joined: teams.flatMap((result) => result.joined || []),
teams,
};
}
let maintenanceTimer;
let maintenanceQueue = Promise.resolve();
const teamMaintenanceQueues = new Map();
async function withTeamMaintenanceLock(motherId, owner, operation, { skipIfBusy = false } = {}) {
const key = resolveMotherId(motherId) || String(motherId || 'unknown');
let queue = teamMaintenanceQueues.get(key);
if (skipIfBusy && queue?.pending) {
return { ok: false, status: 409, message: 'team_maintenance_in_progress', owner: queue.owner, motherId };
}
if (!queue) {
queue = { tail: Promise.resolve(), pending: 0, owner: null };
teamMaintenanceQueues.set(key, queue);
}
queue.pending += 1;
const run = queue.tail.then(async () => {
queue.owner = owner;
try {
return await operation();
} finally {
queue.owner = null;
}
});
queue.tail = run.catch(() => {});
try {
return await run;
} finally {
queue.pending -= 1;
if (!queue.pending && teamMaintenanceQueues.get(key) === queue) teamMaintenanceQueues.delete(key);
}
}
async function withMaintenanceLock(_owner, operation) {
const run = maintenanceQueue.then(operation);
maintenanceQueue = run.catch(() => {});
return run;
}
function freeAuthBatchResult(child, acquired, extra = {}) {
const login = acquired.child?.login || publicChild(child).login;
const waitingForOtherInput = [acquired.code, login.status, child.status].some((status) => (
['credentials_required', 'waiting_code', 'email_otp_required', 'email_otp_timeout', 'totp_required', 'totp_invalid', 'phone_verification_required'].includes(status)
));
const browserRequired = !waitingForOtherInput && Boolean(acquired.browserRequired ?? login.browserRequired);
return {
id: child.id,
email: child.email,
ok: acquired.ok === true,
status: acquired.status || null,
code: acquired.code || null,
message: acquired.message || acquired.child?.login?.message || null,
source: acquired.source || null,
needsInput: Boolean(acquired.needsInput),
browserRequired,
authUrl: waitingForOtherInput ? null : acquired.authUrl || (browserRequired ? login.authUrl || null : null),
...extra,
};
}
async function prepareFreeJsonPool() {
const candidates = state.children.filter((child) => (
!childIsBanned(child)
&& freeTokenNeedsRefresh(child)
&& Boolean(child.refreshToken || (child.email && child.password))
&& !freeAuthRequiresManualInput(child)
&& !freeAuthRetryBackoffActive(child)
));
const results = await mapWithConcurrency(candidates, async (child) => {
try {
return freeAuthBatchResult(child, await ensureChildFreeAuth(child));
} catch (error) {
return freeAuthBatchResult(child, { ok: false, status: 502, code: 'free_auth_failed', message: error?.message || 'free_auth_failed' });
}
});
return {
attempted: results.length,
acquired: results.filter((result) => result.ok).length,
failed: results.filter((result) => !result.ok).length,
results,
};
}
async function acquireMissingFreeJson({ retryVerification = false } = {}) {
const missing = state.children.filter((child) => !childIsBanned(child) && !child.accessToken);
const candidates = [];
const skipped = [];
for (const child of missing) {
// Once the user has completed the matching browser authorization, this is
// an exchange request, not another automatic password-login attempt.
if (pendingOAuthCallback(child.authSession)) {
candidates.push(child);
continue;
}
const waitingForCode = ['waiting_code', 'email_otp_required', 'email_otp_timeout', 'totp_required', 'totp_invalid'].includes(child.loginStatus);
const retryBrowserVerification = retryVerification
&& child.status !== 'phone_verification_required'
&& child.loginStatus !== 'phone_verification_required'
&& !waitingForCode
&& (child.loginBrowserRequired || ['verification_required', 'browser_verification_required'].includes(child.loginStatus));
if ((freeAuthRequiresManualInput(child) || waitingForCode) && !retryBrowserVerification) {
skipped.push(freeAuthBatchResult(child, {
ok: false,
status: 202,
code: child.loginStatus || child.status || 'verification_required',
message: child.loginMessage || '账号需要人工完成登录验证',
needsInput: true,
browserRequired: child.loginBrowserRequired,
}, { skipped: true }));
continue;
}
if (!child.refreshToken && !(child.email && child.password)) {
skipped.push(freeAuthBatchResult(child, {
ok: false,
status: 400,
code: 'credentials_required',
message: '缺少 refresh token 或邮箱密码',
}, { skipped: true }));
continue;
}
candidates.push(child);
}
const attempted = await mapWithConcurrency(candidates, async (child) => {
try {
const result = await acquireChildAuth(child, { refresh: true, allowCredentialLogin: true });
return freeAuthBatchResult(child, result);
} catch (error) {
return freeAuthBatchResult(child, { ok: false, status: 502, code: 'free_auth_failed', message: error?.message || 'free_auth_failed' });
}
});
const results = [...attempted, ...skipped];
const acquired = attempted.filter((result) => result.ok).length;
const failed = attempted.length - acquired;
addHistory('批量获取 Free JSON', `缺少 JSON ${missing.length} 个,并发 ${normalizeConcurrency(state.settings.concurrency)},成功 ${acquired} 个,失败 ${failed} 个,跳过 ${skipped.length} 个`, failed || skipped.length ? 'partial' : 'success');
await persist();
return {
ok: failed === 0 && skipped.length === 0,
status: failed || skipped.length ? 207 : 200,
concurrency: normalizeConcurrency(state.settings.concurrency),
totalMissing: missing.length,
attempted: attempted.length,
acquired,
failed,
skipped: skipped.length,
results,
};
}
// Jobs hold only public progress, never OAuth credentials or upstream payloads.
const autoJsonJobs = new Map();
const autoJsonAccountWork = new Map();
const autoJsonAccountQueues = new Map();
const AUTO_JSON_JOB_TTL_MS = 60 * 60 * 1000;
const AUTO_JSON_MAX_JOBS = 100;
const AUTO_JSON_MAX_LOGS = 3000;
const AUTO_JSON_PROGRESS_MESSAGES = {
initializing: '正在建立登录会话',
oauth_link: '正在获取 OAuth 授权入口',
oauth_local_fallback: '授权链接服务暂不可用,改用本机 OAuth 入口继续本次登录',
authenticating: '正在后台校验账号登录',
protocol_email: '正在通过协议提交账号邮箱',
protocol_password: '正在通过协议校验账号密码',
protocol_totp: '正在通过协议提交六位 2FA 验证码',
protocol_workspace: '正在通过协议选择目标工作空间',
protocol_sentinel: '正在检查官方登录验证要求',
refreshing: '正在使用已有 RT 更新授权',
waiting_code: '正在等待邮箱验证码',
workspace: '正在选择授权工作空间',
oauth_exchange: '正在交换 OAuth 凭据',
phone_verification_required: '登录要求手机号验证,自动流程已暂停',
browser_verification_required: '登录要求浏览器验证,自动流程已暂停',
browser_start: '正在启动独立浏览器,打开官方授权页面',
roxy_start: '正在打开账号绑定的 Roxy 窗口',
roxy_queued: '等待空闲 Roxy 窗口,前一个窗口释放后继续',
roxy_pool_preparing: '正在分配空闲 Roxy 环境',
roxy_pool_acquired: '已分配空闲环境,本次账号使用隔离登录会话',
roxy_profile_preparing: '正在核验账号绑定的 Roxy 环境,失效时重新分配',
roxy_profile_bound: '已保存该账号的独立 Roxy 窗口绑定',
roxy_profile_deleting: 'JSON 已保存,正在确认 Roxy 窗口关闭并释放环境名额',
roxy_profile_deleted: '授权环境已移入 Roxy 回收站,窗口用量已释放,本机 JSON 保留',
roxy_profile_delete_failed: 'JSON 已保存,但 Roxy 环境名额未释放,请检查 Roxy 后重试清理',
roxy_failed_profile_deleting: '本次授权失败,正在确认 Roxy 窗口关闭并释放环境名额',
roxy_failed_profile_deleted: '失败环境已移入 Roxy 回收站,窗口用量已释放,可在运行日志中重试',
roxy_failed_profile_delete_failed: '本次授权失败,且 Roxy 环境名额未释放,请检查窗口状态后再重试',
roxy_proxy_attempt: '正在使用代理授权,每账号最多尝试 3 条',
roxy_proxy_switch: '代理连接失败,已确认旧窗口关闭,正在切换下一条代理',
roxy_proxy_exhausted: '可用代理尝试已结束,本账号停止重试,批量任务继续下一个',
roxy_cleanup_failed: 'Roxy 窗口关闭未完成,请在 Roxy 中检查并关闭本次窗口',
roxy_window_left_open: '自动登录已暂停,有头 Roxy 窗口已保留;请查看页面提示,关闭窗口后再重试',
browser_email: '正在提交账号邮箱',
browser_password: '正在提交账号密码',
browser_totp: '正在提交当前六位 2FA 验证码',
browser_workspace: '正在选择目标授权工作空间',
browser_workspace_wait: '正在等待目标 Team 工作空间加载',
browser_navigation: '已连接 Roxy 浏览器,正在打开官方授权页面',
browser_oauth_proxy_connection_failed: '浏览器代理连接失败,请检查本机代理和上游代理',
browser_oauth_network_failed: '官方授权页面网络连接失败,本账号已停止',
browser_oauth_browser_disconnected: '授权过程中浏览器连接已断开,本账号已停止',
browser_oauth_navigation_timeout: '打开官方授权页面超时,本账号已停止',
browser_callback: '已收到授权回调,正在兑换并保存 JSON',
browser_http_forbidden: '官方登录页面或认证接口返回 HTTP 403,自动流程已暂停',
};
function appendAutoJsonLog(job, child, stage, status, message, httpStatus = null) {
const logs = job.logs;
const previous = logs.findLast((entry) => entry.childId === (child?.id || null));
if (previous?.stage === stage && previous.status === status && previous.message === message) return;
logs.push({ sequence: ++job.logSequence, time: now(), childId: child?.id || null, email: child?.email || '', stage, status, message,
...(Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}) });
if (logs.length > AUTO_JSON_MAX_LOGS) logs.splice(0, logs.length - AUTO_JSON_MAX_LOGS);
}
function autoJsonLoginProgress(job, child, stage) {
return (phase, detail) => {
let message = Object.hasOwn(AUTO_JSON_PROGRESS_MESSAGES, phase) ? AUTO_JSON_PROGRESS_MESSAGES[phase] : null;
if (['roxy_proxy_attempt', 'roxy_proxy_switch', 'roxy_proxy_exhausted'].includes(phase)) {
const count = String(detail || '').match(/^(?:正在使用第 |代理连接失败,旧窗口已关闭,切换第 |已尝试 )([1-3])(?:\/3 条代理| 条代理)/)?.[1];
// Only a bounded counter enters the public log, never arbitrary detail
// from an upstream exception, authorization URL or proxy credential.
if (count) message += `(${count}/3)`;
}
if (message) appendAutoJsonLog(job, child, stage, ['roxy_cleanup_failed', 'roxy_proxy_exhausted', 'roxy_profile_delete_failed', 'roxy_failed_profile_delete_failed'].includes(phase) ? 'warning' : ['phone_verification_required', 'browser_verification_required'].includes(phase) ? 'needs_attention' : 'running', message);
};
}
function recordAutoJsonResult(job, result) {
// Completion is terminal for this account in this job. Shared work and
// exceptional cleanup must not append another result or start another try.
if (job.results.some((entry) => entry.id === result.id)) return;
job.results.push(result);
appendAutoJsonLog(job, { id: result.id, email: result.email }, result.status === 'ready' ? 'completed' : result.phase,
result.status, result.message, result.httpStatus);
}
function publicAutoJsonJob(job) {
return {
id: job.id,
scope: job.scope,
motherId: job.motherId || null,
workspaceId: job.workspaceId || null,
retryJobId: job.retryJobId || null,
retryAccount: Boolean(job.retryAccount),
retriedIds: [...(job.retriedIds || [])],
status: job.status,
total: job.ids.length,
completed: job.results.length,
succeeded: job.results.filter((result) => result.status === 'ready').length,
failed: job.results.filter((result) => result.status === 'failed').length,
needsAttention: job.results.filter((result) => result.status === 'needs_attention').length,
concurrency: job.concurrency,
createdAt: job.createdAt,
finishedAt: job.finishedAt || null,
logs: job.logs.map((entry) => ({ ...entry })),
logsTruncated: job.logSequence > job.logs.length,
results: job.results.map((result) => ({ ...result })),
};
}
function safeAutoJsonAuthUrl(value) {
if (!value) return null;
try {
const url = new URL(value);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return null;
if (url.pathname === '/auth/callback') return null;
for (const key of [...url.searchParams.keys()]) {
if (/^(code|access_token|refresh_token|id_token|code_verifier|password|totp|secret)$/i.test(key)) url.searchParams.delete(key);
}
return url.toString();
} catch {
return null;
}
}
function autoJsonResult(child, acquired, { mother = null, phase = 'free_auth', authScope = mother ? 'team' : 'free' } = {}) {
const login = child ? (authScope === 'team' ? teamLoginFor(child, configuredTeamId(mother)) || {} : publicChild(child).login) : {};
const ready = acquired.ok === true && (mother ? Boolean(completeTeamJsonFor(child, mother)) : Boolean(child?.accessToken && child?.refreshToken) && !freeTokenNeedsRefresh(child));
const code = String(acquired.ok === true && !ready ? 'json_incomplete' : acquired.code || (ready ? 'ready' : login.status || 'auto_json_failed'));
const attention = !ready && (Boolean(acquired.needsInput || acquired.browserRequired)
|| ['credentials_required', 'waiting_code', 'email_otp_required', 'email_otp_timeout', 'totp_required', 'totp_invalid',
'phone_verification_required', 'verification_required', 'browser_verification_required', 'oauth_session_expired',
'password_invalid', 'workspace_list_unavailable', 'membership_pending'].includes(code));
const messages = {
protocol_verification_required: '官方认证接口要求浏览器验证,协议登录已暂停',
browser_verification_required: '官方要求浏览器安全验证,自动登录已暂停',
workspace_list_unavailable: '官方授权页面未返回可确认的目标空间列表,尚未取得 Team JSON',
credentials_required: '请先填写账号邮箱和密码',
totp_required: '请先填写账号现有的 2FA Secret',
totp_invalid: '账号 2FA 验证失败,请检查 Secret 后重试',
waiting_code: '需要填写验证码后继续',
email_otp_required: '需要邮箱验证码,请手动完成验证',
email_otp_timeout: '未取得邮箱验证码,请手动完成验证',
phone_verification_required: '需要手机号验证,请在浏览器完成验证',
password_invalid: '登录密码校验失败,请检查账号凭据',
browser_oauth_credentials_rejected: '官方提示邮箱、密码或验证码不正确,请核对该账号保存的凭据',
browser_oauth_input_mismatch: '浏览器输入与已保存凭据不一致,已停止提交,请检查页面输入控件',
browser_oauth_timeout: '授权等待超时,本账号已停止;可检查网络或页面提示后勾选重试',
browser_oauth_proxy_connection_failed: '浏览器代理连接失败,请检查本机代理和上游代理后勾选重试',
browser_oauth_network_failed: '官方授权页面网络连接失败,请检查网络后勾选重试',
browser_oauth_browser_disconnected: '授权过程中浏览器连接已断开,请检查 Roxy 后勾选重试',
browser_oauth_navigation_timeout: '打开官方授权页面超时,请检查网络后勾选重试',
browser_oauth_failed: '浏览器授权未完成,请根据阶段日志检查后勾选重试',
oauth_session_expired: '原授权会话已过期,请通过手动获取重新授权',
account_banned: '账号已封禁,无法获取 JSON',
token_identity_mismatch: '授权账号身份不一致,已保留原凭据',
child_not_found: '账号已从本地记录移除',
json_incomplete: '返回凭据缺少有效 AT 或 RT,尚未生成完整 JSON',
team_not_invited: '此账号尚未受邀加入所选 Team,请先在 Team 详情发送邀请',
invitation_identity_mismatch: '邀请邮箱与当前账号不一致,请重新核实邀请',
membership_pending: '邀请已接受,等待 Team 成员列表确认后重试',
member_verification_failed: '无法确认目标 Team 成员身份,请检查母号权限',
team_workspace_changed: '目标 Team 配置已改变,请重新选择 Team',
team_membership_changed: '账号已移出目标 Team 或成员状态已变更,请重新核验后授权',
team_token_changed: 'Team 授权已更新,请重新获取当前状态',
team_token_refresh_failed: '已有 Team JSON 的令牌刷新失败,已保留原凭据;请检查网络或通过手动获取重新授权',
seat_capacity_unavailable: '无法确认已购席位,未接受邀请',
seat_capacity_exhausted: '目标 Team 没有可用的已购席位,未接受邀请',
seat_type_mismatch: '成员实际席位与预期席位不一致,已停止后续 Team 授权,请核实 Team 默认席位设置',
seat_type_unconfirmed: '成员已加入,但实际席位类型尚未确认,已暂停后续 Team 授权',
};
// Do not forward upstream error messages: they may contain response payloads,
// one-time authorization codes, or a proxy URL with its credentials.
const roxyError = new RoxyBrowserError(code);
const message = ready ? (acquired.source === 'stored_team_token' ? '此账号已有成功保存的 Team JSON,已跳过重复登录' : acquired.source === 'access_token' ? '已有可用 JSON,已保留' : 'JSON 已获取并保存')
: messages[code] || (code.startsWith('roxy_') && roxyError.code === code ? roxyError.message : null)
|| (attention ? '需要浏览器验证,请使用手动获取继续' : '自动获取失败,请检查账号或代理配置后重试');
return {
id: child?.id || acquired.id,
email: child?.email || '',
status: ready ? 'ready' : attention ? 'needs_attention' : 'failed',
scope: mother ? 'team' : 'free',
authScope,
phase,
...(mother ? { motherId: mother.id, workspaceId: configuredTeamId(mother), quotaChecked: false, quotaStatus: 'unknown', quota5h: null, quota7d: null } : {}),
code,
message,
loginStage: ready ? null : acquired.stage || login.stage || null,
httpStatus: ready ? null : Number(acquired.upstreamStatus || acquired.status || login.httpStatus) || null,
source: acquired.source || null,
reused: ready && acquired.source === 'stored_team_token',
...(acquired.roxyCleanup ? { roxyCleanup: acquired.roxyCleanup } : {}),
browserRequired: !ready && Boolean(acquired.browserRequired ?? login.browserRequired),
authUrl: attention ? safeAutoJsonAuthUrl(acquired.authUrl || login.authUrl) : null,
};
}
async function withAutoJsonAccountWork(id, scope, operation) {
const key = JSON.stringify([id, scope]);
if (autoJsonAccountWork.has(key)) return autoJsonAccountWork.get(key);
const previous = autoJsonAccountQueues.get(id) || Promise.resolve();
const work = previous.catch(() => {}).then(operation);
autoJsonAccountWork.set(key, work);
autoJsonAccountQueues.set(id, work);
try { return await work; } finally {
if (autoJsonAccountWork.get(key) === work) autoJsonAccountWork.delete(key);
if (autoJsonAccountQueues.get(id) === work) autoJsonAccountQueues.delete(id);
}
}
async function performAutoFreeJson(id, onProgress = () => {}, { retryFailed = false } = {}) {
const child = findChild(id);
if (!child) return autoJsonResult(null, { id, ok: false, code: 'child_not_found' });
try {
if (childIsBanned(child)) return autoJsonResult(child, { ok: false, code: 'account_banned' });
const callback = pendingOAuthCallback(child.authSession);
const completeJson = child.refreshToken && !freeTokenNeedsRefresh(child);
if (!retryFailed && !callback && !completeJson && !String(child.loginErrorCode || '').startsWith('roxy_')
&& !retryWithChangedLoginBackend(child) && (freeAuthRequiresManualInput(child)
|| ['waiting_code', 'verification_required', 'browser_verification_required'].includes(child.loginStatus))) {
return autoJsonResult(child, {
ok: false, code: child.loginErrorCode || child.loginStatus || child.status || 'verification_required',
stage: child.loginFailureStage, status: child.loginHttpStatus,
needsInput: true, browserRequired: child.loginBrowserRequired,
});
}
const result = await acquireChildAuth(child, {
refresh: Boolean(callback) || !completeJson,
allowCredentialLogin: true,
callbackUrl: callback,
browserLogin: true,
onProgress,
});
if (findChild(id) !== child) return autoJsonResult(null, { id, ok: false, code: 'child_not_found' });
return autoJsonResult(child, result);
} catch (error) {
return autoJsonResult(child, { ok: false, code: 'auto_json_failed', roxyCleanup: roxyAuthorizationFailureCleanup.get(error) });
}
}
function autoAcquireFreeJson(id, onProgress) {
return withAutoJsonAccountWork(id, 'free', () => performAutoFreeJson(id, onProgress));
}
function storedAutoTeamJsonResult(job, child, mother) {
const latestMembership = latestMembershipHistoryFor(child, mother.team);
if (!isChildMemberOfTeam(child, mother.team) || (latestMembership && latestMembership.status !== 'active')
|| !completeTeamJsonFor(child, mother)) return null;
const savedQuota = membershipFor(child, mother.team) || { ...child, team: mother.team, status: 'active' };
const quota = teamQuotaState({ member: true, workspaceId: job.workspaceId, membership: savedQuota });
return {
...autoJsonResult(child, { ok: true, source: 'stored_team_token' }, { mother, phase: 'team_auth' }),
quotaCached: true,
quotaStatus: quota.hasData ? savedQuota.quotaStatus || 'available' : 'unknown',
quota5h: quota.quota5h,
quota7d: quota.quota7d,
quotaUpdatedAt: quota.updatedAt,
};
}
async function refreshStoredAutoTeamJson(job, child, mother) {
const latestMembership = latestMembershipHistoryFor(child, mother.team);
if (!isChildMemberOfTeam(child, mother.team) || (latestMembership && latestMembership.status !== 'active')
|| !completeTeamJsonFor(child, mother, { allowExpired: true })) return null;
const workspaceId = job.workspaceId;
const email = child.email;
const userId = child.chatgptUserId;
const membership = membershipFor(child, mother.team);
const team = mother.team;
const fail = (code, status = null) => autoJsonResult(child, { ok: false, code, status }, { mother, phase: 'team_auth' });
const currentFailure = () => {
if (findChild(child.id) !== child || child.email !== email || child.chatgptUserId !== userId) return fail('token_identity_mismatch');
if (findMother(mother.id) !== mother || configuredTeamId(mother) !== workspaceId || mother.team !== team) return fail('team_workspace_changed');
if (membershipFor(child, team) !== membership || !isChildMemberOfTeam(child, team)
|| latestMembershipHistoryFor(child, team) !== latestMembership) return fail('team_membership_changed');
return childIsBanned(child) ? fail('account_banned') : null;
};
const key = String(child.id);
const previous = childAuthQueues.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(async () => {
if (currentFailure()) return currentFailure();
const ready = storedAutoTeamJsonResult(job, child, mother);
if (ready) return ready;
const token = completeTeamJsonFor(child, mother, { allowExpired: true });
if (!token) return fail('team_token_changed');
const beforeAccessToken = token.accessToken;
const beforeRefreshToken = token.refreshToken;
appendAutoJsonLog(job, child, 'team_auth', 'running', '已有 Team JSON,正在刷新到期令牌,无需重复浏览器登录');
try {
const result = await withChildAuthorizationNetwork(child, true, async () => {
if (currentFailure()) return currentFailure();
const refreshed = await refreshOpenAiAccessToken(beforeRefreshToken, token.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
if (currentFailure()) return currentFailure();
const latest = child.workspaceTokens?.[workspaceId];
if (latest?.accessToken !== beforeAccessToken || latest?.refreshToken !== beforeRefreshToken) return fail('team_token_changed');
const candidate = { ...token, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken || beforeRefreshToken };
if (!refreshed.ok || !completeTeamJsonFor({ ...child, workspaceTokens: { [workspaceId]: candidate } }, mother)) {
return fail('team_token_refresh_failed', refreshed.status);
}
const saved = saveWorkspaceToken(child, workspaceId, refreshed.accessToken, accessTokenClaims(refreshed.accessToken), {
refreshToken: candidate.refreshToken, idToken: refreshed.idToken || token.idToken || '',
clientId: token.clientId || '', source: 'team_refresh_token',
});
resetTeamLoginState(child, workspaceId, '已有 Team JSON 的令牌已刷新');
if (membership) Object.assign(membership, { workspaceTokenStatus: 'ready', workspaceTokenUpdatedAt: saved.acquiredAt,
teamAuthLastError: null, teamAuthRetryAfter: null });
await persist();
if (currentFailure()) return currentFailure();
const stored = storedAutoTeamJsonResult(job, child, mother);
return stored ? { ...stored, refreshed: true, message: '已有 Team JSON 的令牌已刷新,已跳过重复登录' } : fail('team_token_changed');
});
return result?.id === child.id ? result : fail('team_token_refresh_failed');
} catch {
return fail('team_token_refresh_failed');
}
});
childAuthQueues.set(key, current);
try { return await current; } finally {
if (childAuthQueues.get(key) === current) childAuthQueues.delete(key);
}
}
async function autoAcquireTeamJson(job, id) {
return withAutoJsonAccountWork(id, `team:${job.workspaceId}`, async () => {
const child = findChild(id);
const mother = findMother(job.motherId);
const context = { mother: mother || { id: job.motherId, accountId: job.workspaceId }, phase: 'accept_invitation' };
if (!child) return autoJsonResult(null, { id, ok: false, code: 'child_not_found' }, context);
if (!mother || configuredTeamId(mother) !== job.workspaceId) return autoJsonResult(child, { ok: false, code: 'team_workspace_changed' }, context);
if (childIsBanned(child)) return autoJsonResult(child, { ok: false, code: 'account_banned' }, context);
// Successful authorization is already the requested outcome. Check the
// exact workspace, identity, plan, expiry and RT before touching callbacks,
// member APIs, token refresh or browser login. Quota has its own refresh UI.
const storedResult = storedAutoTeamJsonResult(job, child, mother);
if (storedResult) return storedResult;
// A completed browser callback can replace an invalid old RT. Consume it
// through the existing verified callback path instead of retrying that RT.
if (!pendingOAuthCallback(child.teamAuthSessions?.[job.workspaceId])) {
const refreshedResult = await refreshStoredAutoTeamJson(job, child, mother);
if (refreshedResult) return refreshedResult;
}
const email = child.email;
let confirmedMembership = null;
const currentFailure = () => {
if (findChild(id) !== child) return autoJsonResult(null, { id, ok: false, code: 'child_not_found' }, context);
if (findMother(job.motherId) !== mother || configuredTeamId(mother) !== job.workspaceId) return autoJsonResult(child, { ok: false, code: 'team_workspace_changed' }, context);
if (child.email !== email) return autoJsonResult(child, { ok: false, code: 'invitation_identity_mismatch' }, context);
if (childIsBanned(child)) return autoJsonResult(child, { ok: false, code: 'account_banned' }, context);
if (confirmedMembership && membershipFor(child, mother.team) !== confirmedMembership) return autoJsonResult(child, { ok: false, code: 'team_membership_changed' }, context);
return null;
};
try {
// Hold the Team lock only while reading/updating shared membership and
// seat reservations. Login can take minutes and must not serialize the
// independent account windows of the same Team.
const synchronizeMembership = (acceptInvitation) => withTeamMaintenanceLock(job.motherId, 'auto_team_json_membership', async () => {
const invalid = currentFailure();
if (invalid) return { result: invalid };
appendAutoJsonLog(job, child, 'member_check', 'running', '正在核验目标 Team 的成员身份');
const association = invitationForTeam(child, mother);
let expectedSeatType = observedSeatType(association?.seatType || seatClaimForChild(mother, child)?.seatType);
const failed = (code) => ({ result: autoJsonResult(child, { ok: false, code }, context) });
if (!association && !isChildMemberOfTeam(child, mother.team)) return failed('team_not_invited');
if (association?.email && workspaceIdKey(association.email) !== workspaceIdKey(child.email)) return failed('invitation_identity_mismatch');
if (!teamManagerContext(mother)) await recoverTeamManagerToken(mother, { allowCredentialLogin: false });
let members = await queryAllWorkspaceMembers(mother);
if (currentFailure()) return { result: currentFailure() };
if (!members.ok) {
appendAutoJsonLog(job, child, 'member_check', 'failed', '目标 Team 成员接口核验失败,未继续登录或接受邀请', Number(members.status));
return failed('member_verification_failed');
}
mother.members = members.items;
reconcileSeatClaimsWithMembers(mother, members.items);
let member = members.items.find((item) => memberIsActive(item) && workspaceIdKey(item.email) === workspaceIdKey(child.email));
if (!member) {
if (!association || association.status !== 'invited') return failed('team_not_invited');
if (!canRejoinTeam(child, mother.team)) return failed('account_team_cooldown');
if (!acceptInvitation) return { needsFree: true };
appendAutoJsonLog(job, child, 'invitation_check', 'running', '正在核验母号已发送的邀请');
const pending = await pendingWorkspaceInvitation(mother, job.workspaceId, child.email);
if (currentFailure()) return { result: currentFailure() };
if (!pending.ok || !pending.inviteId) return failed('team_not_invited');
appendAutoJsonLog(job, child, 'seat_check', 'running', '正在核验已购席位及邀请预约');
const subscription = await refreshMotherSubscription(mother, { allowCredentialLogin: false });
if (currentFailure()) return { result: currentFailure() };
if (!subscription.ok) return failed('seat_capacity_unavailable');
const claim = seatClaimForChild(mother, child);
const reservations = seatClaimReservations(mother);
if (claim) reservations[claim.seatType === 'prolite' ? 'prolite' : 'default'] = Math.max(0, reservations[claim.seatType === 'prolite' ? 'prolite' : 'default'] - 1);
const officialDefaultSeatType = normalizeDefaultSeatType(mother.defaultSeatType);
if (expectedSeatType && expectedSeatType !== officialDefaultSeatType) return failed('seat_type_mismatch');
const capacity = selectInviteSeatType(officialDefaultSeatType, seatAdmissionSnapshot(mother), reservations);
if (!capacity.ok) return failed(capacity.code);
expectedSeatType = capacity.seatType;
appendAutoJsonLog(job, child, 'accept_invitation', 'running', '正在通过子号接受 Team 邀请');
const accepted = await withChildJoinReservation(child, mother.id, () => acceptWorkspaceInvitation(mother, child, job.workspaceId, pending.inviteId, undefined, { allowAuthRefresh: false }));
if (currentFailure()) return { result: currentFailure() };
if (!accepted.ok) {
if (claim && accepted.acceptAttempted) Object.assign(claim, { phase: 'approval_unknown', updatedAt: now() });
await persist();
return { result: autoJsonResult(child, { ...accepted, code: accepted.code || 'invitation_accept_failed' }, context) };
}
member = accepted.member;
appendAutoJsonLog(job, child, 'member_check', 'running', '邀请已接受,正在复核实际成员身份');
members = await queryAllWorkspaceMembers(mother);
if (currentFailure()) return { result: currentFailure() };
if (!members.ok) return failed('member_verification_failed');
mother.members = members.items;
}
recordConfirmedTeamMember(mother, child, member);
if (expectedSeatType && observedSeatType(member?.seatType) !== expectedSeatType) {
const membership = membershipFor(child, mother.team);
membership.joinStatus = 'seat_type_mismatch';
membership.requestedSeatType = expectedSeatType;
membership.workspaceTokenStatus = 'pending';
child.joinStatus = 'seat_type_mismatch';
saveTeamInvitation(child, mother, { status: 'joined', seatType: expectedSeatType });
await persist();
return failed(observedSeatType(member?.seatType) ? 'seat_type_mismatch' : 'seat_type_unconfirmed');
}
await persist();
appendAutoJsonLog(job, child, 'member_check', 'ready', '已确认子号加入目标 Team');
confirmedMembership = membershipFor(child, mother.team);
const execution = autoJsonExecution.getStore();
if (execution) execution.membership = confirmedMembership;
return {};
});
let membership = await synchronizeMembership(false);
if (membership.result) return membership.result;
if (membership.needsFree) {
appendAutoJsonLog(job, child, 'free_auth', 'running', '先获取接受邀请所需的个人授权;加入确认后会继续获取目标 Team JSON');
const free = await performAutoFreeJson(id, autoJsonLoginProgress(job, child, 'free_auth'), { retryFailed: job.retryFailed || job.retryAccount });
if (currentFailure()) return currentFailure();
if (free.status !== 'ready') return { ...free, scope: 'team', authScope: 'free', phase: 'free_auth', motherId: mother.id, workspaceId: job.workspaceId, quotaChecked: false, quotaStatus: 'unknown', quota5h: null, quota7d: null };
appendAutoJsonLog(job, child, 'free_auth', 'ready', '子号授权已就绪');
membership = await synchronizeMembership(true);
// A failed prerequisite is terminal for this run. An expired invitation
// token can be retried explicitly after reviewing the account result.
if (membership.result) return membership.result;
}
if (currentFailure()) return currentFailure();
// A callback handled while membership was being checked may have already
// saved the JSON. It wins over any older pending callback/session.
const newlyStoredResult = storedAutoTeamJsonResult(job, child, mother);
if (newlyStoredResult) return newlyStoredResult;
context.phase = 'team_auth';
appendAutoJsonLog(job, child, 'team_auth', 'running', '正在获取目标工作空间的 Team 授权');
const onProgress = autoJsonLoginProgress(job, child, 'team_auth');
const callback = pendingOAuthCallback(child.teamAuthSessions?.[job.workspaceId]);
let acquired;
if (callback) acquired = await acquireTeamAuth(mother, child, { force: true, callbackUrl: callback, persistOwner: false, browserLogin: true, expectedMembership: confirmedMembership, onProgress });
else {
const login = teamLoginFor(child, job.workspaceId);
if (!job.retryFailed && !job.retryAccount && (login?.needsInput || login?.browserRequired) && login.code !== 'workspace_list_unavailable'
&& !String(login.code || '').startsWith('roxy_')
&& !retryWithChangedLoginBackend(child, job.workspaceId)) acquired = { ok: false, code: login.code || login.status, stage: login.stage, status: login.httpStatus, needsInput: true, browserRequired: login.browserRequired, authUrl: login.authUrl };
else acquired = await acquireTeamAuth(mother, child, { force: true, persistOwner: false, browserLogin: true, expectedMembership: confirmedMembership, onProgress });
}
if (currentFailure()) return currentFailure();
const result = autoJsonResult(child, acquired, context);
if (result.status !== 'ready') return result;
const teamToken = completeTeamJsonFor(child, mother);
appendAutoJsonLog(job, child, 'team_json', 'ready', 'Team JSON 已核验并保存在本机');
context.phase = 'quota';
appendAutoJsonLog(job, child, 'quota', 'running', '正在使用 Team 授权查询真实额度');
// A quota transport failure must never turn a saved JSON into an auth
// failure (and invite an unnecessary second browser login).
const quota = await withChildAuthorizationNetwork(child, true, () => probeUsage(teamToken.accessToken, job.workspaceId))
.catch(() => ({ ok: false, status: 502, message: 'quota_query_failed' }));
return withTeamMaintenanceLock(job.motherId, 'auto_team_json_quota', async () => {
if (currentFailure()) return currentFailure();
applyQuotaResult(child, quota, mother.team, selectedKickWindow(mother));
await persist();
const measured = quota.ok && (quota.primary?.usedPercent != null || quota.secondary?.usedPercent != null);
const membership = membershipFor(child, mother.team);
appendAutoJsonLog(job, child, 'quota', measured ? 'ready' : 'warning', measured ? '真实额度已检测并保存' : '额度查询失败或没有返回额度数据,未填入虚假额度', Number(quota.status));
return { ...result, phase: 'quota', quotaChecked: Boolean(measured), quotaStatus: measured ? membership?.quotaStatus || 'available' : 'unknown', quota5h: measured ? membership?.quota5h ?? null : null, quota7d: measured ? membership?.quota7d ?? null : null,
quotaHttpStatus: Number(quota.status) || null, message: measured ? 'Team JSON 已保存,真实额度已检测' : 'Team JSON 已保存,额度查询失败或未返回额度数据,请重试检测' };
}).catch(() => {
const invalid = currentFailure();
if (invalid) return invalid;
// acquireTeamAuth already persisted the JSON before reaching quota.
// A malformed quota payload or later storage failure cannot undo that
// success or justify another login attempt.
appendAutoJsonLog(job, child, 'quota', 'warning', 'Team JSON 已保存,额度处理或保存失败,请重新检测额度');
return { ...result, phase: 'quota', quotaChecked: false, quotaStatus: 'unknown', quota5h: null, quota7d: null,
quotaCode: 'quota_save_failed', quotaHttpStatus: Number(quota.status) || null,
message: 'Team JSON 已保存,额度处理或保存失败,请重新检测额度' };
});
} catch (error) {
return autoJsonResult(child, { ok: false, code: 'auto_json_failed', roxyCleanup: roxyAuthorizationFailureCleanup.get(error) }, context);
}
});
}
async function runAutoJsonJob(job) {
job.status = 'running';
appendAutoJsonLog(job, null, 'job', 'running', '后台自动获取任务已启动');
try {
await mapWithConcurrency(job.ids, async (id) => {
const child = findChild(id);
appendAutoJsonLog(job, child, 'queued', 'queued', '账号已排队,等待执行');
if (job.scope !== 'team') appendAutoJsonLog(job, child, 'free_auth', 'running', '正在获取账号 JSON');
try {
recordAutoJsonResult(job, await autoJsonExecution.run({ closeFailedWindow: job.ids.length > 1, singleAttempt: job.scope === 'team',
mother: job.scope === 'team' ? findMother(job.motherId) : null, workspaceId: job.workspaceId }, () => (
job.scope === 'team' ? autoAcquireTeamJson(job, id) : autoAcquireFreeJson(id, autoJsonLoginProgress(job, child, 'free_auth')))));
} catch {
// One unexpected account failure must not settle other in-flight work
// prematurely or prevent the next queued account from being attempted.
recordAutoJsonResult(job, autoJsonResult(findChild(id), { id, ok: false, code: 'auto_json_failed' }, job.scope === 'team'
? { mother: findMother(job.motherId) || { id: job.motherId, accountId: job.workspaceId } } : {}));
}
}, job.concurrency);
addHistory('自动获取 JSON', `处理 ${job.ids.length} 个 ${job.scope === 'team' ? 'Team' : 'Free'} 账号,成功 ${job.results.filter((result) => result.status === 'ready').length} 个,需要手动处理 ${job.results.filter((result) => result.status === 'needs_attention').length} 个`, job.results.every((result) => result.status === 'ready') ? 'success' : 'partial');
await persist();
} catch {
for (const id of job.ids) {
if (!job.results.some((result) => result.id === id)) recordAutoJsonResult(job, autoJsonResult(findChild(id), { id, ok: false, code: 'auto_json_failed' }, job.scope === 'team' ? { mother: findMother(job.motherId) || { id: job.motherId, accountId: job.workspaceId } } : {}));
}
} finally {
job.status = 'complete';
job.finishedAt = now();
appendAutoJsonLog(job, null, 'job', 'complete', '任务已结束,请查看各账号执行结果');
}
}
function startAutoJsonJob(body) {
const scope = body.scope === undefined ? 'free' : String(body.scope);
if (!['free', 'team'].includes(scope)) return { status: 400, payload: { message: 'invalid_json_scope' } };
const mother = scope === 'team' ? findMother(String(body.motherId || '')) : null;
if (scope === 'team' && !mother) return { status: 404, payload: { message: 'mother_not_found' } };
const workspaceId = mother ? configuredTeamId(mother) : null;
if (mother && !workspaceId) return { status: 400, payload: { message: 'workspace_id_required' } };
if (body.ids !== undefined && (!Array.isArray(body.ids) || !body.ids.length || body.ids.length > 1000)) {
return { status: 400, payload: { message: 'child_ids_required' } };
}
const ids = body.ids === undefined
? state.children.filter((child) => !childIsBanned(child) && (mother
? (invitationForTeam(child, mother)?.status === 'invited' || isChildMemberOfTeam(child, mother.team))
&& !storedAutoTeamJsonResult({ workspaceId }, child, mother)
: !child.refreshToken || freeTokenNeedsRefresh(child))).map((child) => child.id)
: [...new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))];
if (body.ids !== undefined && !ids.length) return { status: 400, payload: { message: 'child_ids_required' } };
if (ids.some((id) => !findChild(id))) return { status: 404, payload: { message: 'child_not_found' } };
const retryAccount = body.retryAccount === true;
if ((body.retryAccount !== undefined && typeof body.retryAccount !== 'boolean')
|| (retryAccount && (scope !== 'team' || body.ids === undefined || ids.length !== 1
|| body.retryFailed !== undefined || body.retryJobId !== undefined))) {
return { status: 400, payload: { message: 'invalid_retry_request' } };
}
const retryFailed = body.retryFailed === true;
const retryJobId = retryFailed ? String(body.retryJobId || '').trim() : null;
if ((body.retryFailed !== undefined && typeof body.retryFailed !== 'boolean') || (!retryFailed && body.retryJobId !== undefined)) {
return { status: 400, payload: { message: 'invalid_retry_request' } };
}
let retrySource = null;
if (retryFailed) {
if (scope !== 'team' || body.ids === undefined || !retryJobId) return { status: 400, payload: { message: 'invalid_retry_request' } };
retrySource = autoJsonJobs.get(retryJobId);
if (!retrySource || Date.now() - Date.parse(retrySource.finishedAt) > AUTO_JSON_JOB_TTL_MS) return { status: 404, payload: { message: 'retry_job_not_found' } };
if (retrySource.status !== 'complete') return { status: 409, payload: { message: 'retry_job_not_complete' } };
if (retrySource.scope !== 'team' || retrySource.motherId !== mother.id || retrySource.workspaceId !== workspaceId) return { status: 409, payload: { message: 'retry_team_mismatch' } };
const failedIds = new Set(retrySource.results.filter((result) => ['failed', 'needs_attention'].includes(result.status)).map((result) => result.id));
if (ids.some((id) => !failedIds.has(id))) return { status: 400, payload: { message: 'retry_failed_accounts_only' } };
}
const key = JSON.stringify([scope, mother?.id || null, workspaceId, [...ids].sort(), retryJobId, retryAccount]);
for (const [id, job] of autoJsonJobs) {
if (job.status === 'complete' && Date.now() - Date.parse(job.finishedAt) > AUTO_JSON_JOB_TTL_MS) autoJsonJobs.delete(id);
else if (job.key === key && (job.status !== 'complete' || retryFailed)) return { status: 202, payload: { job: publicAutoJsonJob(job), reused: true } };
}
if (retrySource && ids.some((id) => retrySource.retriedIds?.has(id))) return { status: 409, payload: { message: 'retry_already_started' } };
while (autoJsonJobs.size >= AUTO_JSON_MAX_JOBS) {
const complete = [...autoJsonJobs].find(([, job]) => job.status === 'complete');
if (!complete) return { status: 409, payload: { message: 'auto_json_jobs_busy' } };
autoJsonJobs.delete(complete[0]);
}
const concurrency = state.settings.integrations?.roxy?.enabled
? roxyConcurrency() : normalizeConcurrency(state.settings.concurrency);
const job = { id: randomUUID(), key, ids, scope, motherId: mother?.id || null, workspaceId, retryFailed, retryJobId, retryAccount, status: 'queued', concurrency, createdAt: now(), finishedAt: null, results: [], logs: [], logSequence: 0 };
if (retrySource) {
retrySource.retriedIds ||= new Set();
ids.forEach((id) => retrySource.retriedIds.add(id));
}
appendAutoJsonLog(job, null, 'queued', 'queued', '自动获取任务已创建');
autoJsonJobs.set(job.id, job);
setImmediate(() => { void runAutoJsonJob(job); });
return { status: 202, payload: { job: publicAutoJsonJob(job), reused: false } };
}
async function runMaintenanceCycle() {
if (state.settings.autoRefill !== true) return { ok: false, status: 204, message: 'auto_refill_disabled' };
const automaticTeams = automaticRotationTeams(state.mothers, teamHasManagementPath);
if (!automaticTeams.length) {
return { ok: true, status: 200, message: 'no_automatic_rotation_teams', freeJson: null, teams: [], skipped: state.mothers.length };
}
const teams = await mapWithConcurrency(automaticTeams, async (mother) => {
try {
return await withTeamMaintenanceLock(mother.id, 'scheduled', async () => {
const checked = await checkTeam(mother.id);
const expiredManualTimers = teamHasExpiredManualKickTimers(mother);
const automaticKickEnabled = state.settings.kickOnExhausted !== false;
const refilled = checked.syncOk && (automaticKickEnabled || expiredManualTimers)
? await refillTeam(mother.id, { manualTimersOnly: !automaticKickEnabled && expiredManualTimers, quotaChecked: true })
: null;
return { motherId: mother.id, checked, refilled };
}, { skipIfBusy: true });
} catch (error) {
addHistory('自动检测', `${mother.team} 检测失败,继续处理其他 Team:${error?.message || 'unknown_error'}`, 'partial');
await persist();
return { motherId: mother.id, ok: false, status: 502, message: error?.message || 'team_maintenance_failed' };
}
});
// Refill acquires credentials for its own candidates; background pool
// preparation runs after probes so slow Free OAuth cannot delay them.
const freeJson = await prepareScheduledFreeJsonPool();
return { ok: teams.every((team) => team.status === 409 || (team.checked?.syncOk !== false && team.refilled?.ok !== false)), status: teams.some((team) => team.checked?.syncOk === false || team.refilled?.ok === false) ? 207 : 200, freeJson, teams };
}
let scheduledFreeJsonPromise = null;
function prepareScheduledFreeJsonPool() {
if (!scheduledFreeJsonPromise) {
scheduledFreeJsonPromise = prepareFreeJsonPool()
.catch((error) => ({ ok: false, status: 502, message: error?.message || 'free_json_pool_failed' }))
.finally(() => { scheduledFreeJsonPromise = null; });
}
return scheduledFreeJsonPromise;
}
function configureMaintenanceTimer() {
if (maintenanceTimer) clearInterval(maintenanceTimer);
const seconds = Math.max(30, Number(state.settings.checkInterval) || 60);
maintenanceTimer = setInterval(() => { void runMaintenanceCycle(); }, seconds * 1000);
maintenanceTimer.unref?.();
}
async function joinWorkspace(child, body, internal = {}) {
const mother = findMother(body.motherId);
const onProgress = typeof body.onProgress === 'function' ? body.onProgress : () => {};
const joinMode = normalizeJoinMode(state.settings.joinMode);
if (!child || !mother) return { ok: false, status: 404, message: 'account_not_found' };
if (childIsBanned(child)) return { ok: false, status: 409, code: 'account_banned', message: child.banReason || '账号已封禁,不能再加入 Team' };
if (!body.workspaceId) return { ok: false, status: 400, message: 'workspace_id_required' };
if (mother.accountId && workspaceIdKey(body.workspaceId) !== configuredTeamKey(mother)) {
return { ok: false, status: 409, code: 'workspace_mismatch', message: 'workspace_id_does_not_match_team' };
}
if (joinMode === 'request' && !canJoinByEmailDomain(mother, child)) {
return { ok: false, status: 409, code: 'email_domain_mismatch', message: '申请模式要求 Free 账号与 Team 母号使用相同邮箱域名' };
}
if (body.approve !== false && seatClaimForChild(mother, child)) {
return { ok: false, status: 409, code: 'seat_claim_pending', message: '该账号已有待核验席位占位,请先同步 Team 成员' };
}
if (body.approve !== false && !teamManagerContext(mother)) await recoverTeamManagerToken(mother, { force: true });
if (body.approve !== false && !teamManagerContext(mother)) return { ok: false, status: 400, message: 'workspace_owner_token_required' };
let seatSelection = null;
const seatReservations = internal.seatReservations || seatClaimReservations(mother);
if (body.approve !== false) {
if (!internal.selectedSeatType) {
const subscription = await refreshMotherSubscription(mother);
if (!subscription.ok) {
return { ok: false, status: subscription.status || 502, phase: 'seat_capacity', code: 'seat_capacity_unavailable', message: subscription.message || 'seat_capacity_unavailable' };
}
seatSelection = selectTeamSeatType(mother, seatAdmissionSnapshot(mother), seatReservations);
} else {
seatSelection = selectInviteSeatType(internal.selectedSeatType, seatAdmissionSnapshot(mother), seatReservations);
}
if (!seatSelection.ok) {
return { ok: false, status: seatSelection.code === 'seat_type_capacity_exhausted' || seatSelection.code === 'seat_capacity_exhausted' ? 409 : 422, phase: 'seat_capacity', ...seatSelection };
}
}
if (freeTokenNeedsRefresh(child)) onProgress('free_auth', '正在准备 Free OAuth 凭据');
const freeAuth = await ensureChildFreeAuth(child, { verificationCode: body.verificationCode, callbackUrl: body.callbackUrl, browserLogin: internal.browserLogin === true,
onProgress: (stage) => onProgress(stage, '正在完成子号官方登录') });
if (!freeAuth.ok) {
return {
ok: false,
status: freeAuth.status || 502,
phase: 'free_auth',
code: freeAuth.code || 'free_auth_required',
message: freeAuth.message || 'free_auth_required',
needsInput: Boolean(freeAuth.needsInput),
browserRequired: Boolean(freeAuth.browserRequired),
authUrl: freeAuth.authUrl || null,
child: publicChild(child),
};
}
const workspaceId = encodeURIComponent(body.workspaceId);
onProgress('request', joinMode === 'invite' ? '正在由管理员邀请 Free 账号' : '正在申请加入 Team');
const requestResult = joinMode === 'invite'
? await inviteWorkspaceMember(mother, body.workspaceId, child.email, body.deviceId)
: await fetchChatGptJson(child.accessToken, `/backend-api/accounts/${workspaceId}/invites/request`, {
method: 'POST', accountId: body.workspaceId,
targetPath: `/backend-api/accounts/${body.workspaceId}/invites/request`, targetRoute: '/backend-api/accounts/{account_id}/invites/request',
body: {}, headers: { 'content-type': 'application/json', 'oai-device-id': body.deviceId || randomUUID() },
});
const payload = requestResult.payload || {};
if (!requestResult.ok) return { ok: false, status: requestResult.status || 502, phase: 'request', payload, message: requestResult.message };
const inviteId = requestResult.inviteId || payload.id || payload.invite_id || payload.inviteId;
child.pendingInviteId = inviteId;
child.pendingWorkspaceId = body.workspaceId;
child.joinStatus = joinMode === 'invite' ? 'invite_pending' : 'requested';
addHistory(joinMode === 'invite' ? '管理员邀请 Team' : '申请加入 Team', joinMode === 'invite'
? `${child.email} ${requestResult.reused ? '复用待处理邀请' : '已受邀加入'} ${mother.team}`
: `${child.email} 已向 ${mother.team} 发起申请`);
if (body.approve !== false) {
if (!internal.selectedSeatType) {
const latestSubscription = await refreshMotherSubscription(mother);
if (!latestSubscription.ok) {
await persist();
return { ok: false, status: latestSubscription.status || 502, phase: 'seat_capacity', code: 'seat_capacity_unavailable', message: latestSubscription.message || 'seat_capacity_unavailable', request: payload };
}
seatSelection = selectTeamSeatType(mother, seatAdmissionSnapshot(mother), seatReservations);
if (!seatSelection.ok) {
await persist();
return { ok: false, status: seatSelection.code === 'seat_type_capacity_exhausted' || seatSelection.code === 'seat_capacity_exhausted' ? 409 : 422, phase: 'seat_capacity', request: payload, ...seatSelection };
}
}
const promoteJoinedAccounts = state.settings?.promoteJoinedAccounts !== false;
const approvedRole = promoteJoinedAccounts ? 'account-owner' : 'standard-user';
onProgress('approve', joinMode === 'invite' ? '正在接受 Team 邀请' : '正在同意加入申请');
if (workspaceIdKey(body.workspaceId) !== configuredTeamKey(mother)) {
return { ok: false, status: 409, phase: 'seat_capacity', code: 'workspace_changed', message: 'team_workspace_changed_during_join', request: payload };
}
const seatClaim = reserveSeatClaim(mother, child, seatSelection.seatType, inviteId, body.workspaceId);
if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
await persist();
if (workspaceIdKey(body.workspaceId) !== configuredTeamKey(mother)) {
releaseSeatClaim(mother, seatClaim);
await persist();
return { ok: false, status: 409, phase: 'seat_capacity', code: 'workspace_changed', message: 'team_workspace_changed_during_join', request: payload };
}
let approved = joinMode === 'invite'
? await acceptWorkspaceInvitation(mother, child, body.workspaceId, inviteId, body.deviceId)
: await approveWorkspaceRequest(mother, body.workspaceId, child.email, inviteId, body.deviceId, approvedRole);
if (!approved.ok) {
if (approved.acceptAttempted) {
const verified = await queryAllWorkspaceMembers(mother, child.email);
const acceptedMember = (verified.items || []).filter(memberIsActive)
.find((member) => String(member.email || '').trim().toLowerCase() === String(child.email || '').trim().toLowerCase());
if (acceptedMember) {
approved = { ...approved, ok: true, status: 200, message: 'approved_verified_by_member_sync', recovered: true, member: acceptedMember, seatType: acceptedMember.seatType || null };
}
}
if (!approved.ok) {
const status = Number(approved.status) || 0;
const definitiveFailure = !shouldRetainSeatClaim(approved);
if (definitiveFailure) releaseSeatClaim(mother, seatClaim);
else Object.assign(seatClaim, { phase: 'approval_unknown', updatedAt: now(), error: approved.message || `http_${status}` });
if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
await persist();
return { ok: false, status: approved.status || 502, phase: joinMode === 'invite' ? 'invite_accept' : 'admin_approve', request: payload, ...approved, accepted: false, seatClaimed: !definitiveFailure, seatType: seatSelection.seatType };
}
}
const actualSeatType = observedSeatType(approved.member?.seatType || approved.seatType);
Object.assign(seatClaim, { seatType: actualSeatType || seatClaim.seatType, phase: 'accepted_pending_sync', acceptedAt: now(), updatedAt: now(), error: null });
child.status = 'active';
child.team = mother.team;
child.joinedAt = child.joinedAt || now();
const membership = membershipFor(child, mother.team, true);
membership.role = approved.member?.role || (joinMode === 'invite' ? 'standard-user' : approvedRole);
membership.seatType = actualSeatType;
membership.joinStatus = promoteJoinedAccounts ? 'approved_pending_owner' : 'member_confirmed';
membership.ownerRoleStatus = promoteJoinedAccounts ? 'pending' : 'skipped';
child.joinStatus = membership.joinStatus;
child.ownerRoleStatus = membership.ownerRoleStatus;
child.ownerRoleError = null;
if (joinMode === 'invite') {
child.pendingInviteId = null;
child.pendingWorkspaceId = null;
}
if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
await persist();
onProgress('member', '账号已进入 Team,正在确认身份');
addHistory(joinMode === 'invite' ? '接受邀请进入空间' : '同意进入空间',
membership.seatType === 'prolite'
? `${child.email} 已以高级席位进入 ${mother.team}`
: membership.seatType === 'default'
? `${child.email} 已以普通席位进入 ${mother.team}`
: `${child.email} 已进入 ${mother.team},席位类型等待同步`);
if (promoteJoinedAccounts) {
onProgress('owner', '正在设置为 Team 所有者');
const promoted = await promoteJoinedMemberToOwner(mother, child, body.workspaceId);
if (!promoted.ok) {
child.joinStatus = 'owner_role_failed';
child.ownerRoleStatus = 'failed';
membership.joinStatus = 'owner_role_failed';
membership.ownerRoleStatus = 'failed';
child.ownerRoleError = { status: promoted.status || 502, message: promoted.message || 'owner_role_failed', at: now() };
addHistory('设置 Team 所有者', `${child.email} 进入 ${mother.team} 后提升所有者失败:${promoted.message || 'owner_role_failed'}`, 'error');
await persist();
return { ok: false, status: promoted.status || 502, phase: 'owner_role', inviteId: inviteId || null, message: promoted.message || 'owner_role_failed', accepted: true, seatClaimed: true, seatType: membership.seatType };
}
child.joinStatus = 'owner_confirmed';
child.ownerRoleStatus = 'applied';
membership.joinStatus = 'owner_confirmed';
membership.ownerRoleStatus = 'applied';
membership.role = 'account-owner';
child.ownerRoleUpdatedAt = now();
child.ownerRoleError = null;
addHistory('设置 Team 所有者', `${child.email} 已设置为 ${mother.team} 所有者`);
}
onProgress('team_json', '正在获取该空间的 Team JSON');
const teamAuth = await acquireTeamJson(mother, child, { verificationCode: body.verificationCode, callbackUrl: body.callbackUrl, browserLogin: internal.browserLogin === true,
onProgress: (stage) => onProgress(stage, '正在完成目标 Team 官方授权') });
if (!teamAuth.ok) {
child.joinStatus = `${promoteJoinedAccounts ? 'owner' : 'member'}_confirmed_token_pending`;
membership.joinStatus = child.joinStatus;
membership.workspaceTokenStatus = 'pending';
await persist();
return { ok: false, status: teamAuth.status || 502, phase: 'team_token', inviteId: inviteId || null, message: teamAuth.message || 'team_token_failed', code: teamAuth.code, needsInput: Boolean(teamAuth.needsInput), browserRequired: Boolean(teamAuth.browserRequired), authUrl: teamAuth.authUrl || null, child: publicChild(child, mother.team), accepted: true, seatClaimed: true, seatType: membership.seatType };
}
membership.workspaceTokenStatus = 'ready';
onProgress('team_json', 'Team JSON 已获取');
const sub2apiPush = body.pushTeamJson === false
? { attempted: false, ok: null, status: null, message: 'deferred_to_batch', pushed: 0, failed: 0 }
: await pushRenewedTeamJson(mother, { emails: [child.email], mode: 'create_only' });
await persist();
const phase = promoteJoinedAccounts ? 'owner_confirmed' : 'member_confirmed';
return { ok: true, phase, inviteId: inviteId || null, payload, freeAuth: { source: freeAuth.source || null }, teamAuth, sub2apiPush, accepted: true, seatClaimed: true, seatType: membership.seatType };
}
await persist();
return { ok: true, phase: body.approve === false ? joinMode === 'invite' ? 'invited' : 'requested' : 'owner_confirmed', inviteId: inviteId || null, payload };
}
async function approveWorkspaceRequest(mother, workspaceId, email, inviteId, deviceId, role = 'account-owner') {
const approval = { role: role === 'standard-user' ? 'standard-user' : 'account-owner', accept_request: true };
const base = `${CHATGPT_BASE_URL}/backend-api/accounts/${encodeURIComponent(workspaceId)}`;
const attempted = await withTeamManager(mother, async (manager) => {
const common = chatGptHeaders(manager.accessToken, workspaceId, `/backend-api/accounts/${workspaceId}/invites`, '/backend-api/accounts/{account_id}/invites', { 'oai-device-id': deviceId || manager.deviceId || randomUUID(), 'cache-control': 'no-cache' });
let selectedInviteId = inviteId;
if (!selectedInviteId) {
const list = await proxyFetch(`${base}/invites?include_pending=false&include_requests=true&offset=0&limit=100&query=${encodeURIComponent(email)}`, { headers: common }).catch((error) => ({ ok: false, status: 0, json: async () => ({ error: error.message }) }));
const data = await list.json().catch(() => ({}));
const candidates = Array.isArray(data.items) ? data.items : Array.isArray(data.invites) ? data.invites : [];
const match = candidates.find((item) => String(item.email || item.target_email || '').toLowerCase() === email.toLowerCase());
selectedInviteId = match?.id || match?.invite_id;
if (!selectedInviteId) return { ok: false, status: list.status || 404, approvalAttempted: false, acceptAttempted: false, seatAssignmentAttempted: false, seatAssigned: false, message: 'pending_invite_not_found', payload: data };
}
const approveHeaders = { ...common, 'content-type': 'application/json', 'x-openai-target-path': `/backend-api/accounts/${workspaceId}/invites/${selectedInviteId}`, 'x-openai-target-route': '/backend-api/accounts/{account_id}/invites/{invite_id}' };
const response = await proxyFetch(`${base}/invites/${encodeURIComponent(selectedInviteId)}`, {
method: 'PATCH',
headers: approveHeaders,
body: JSON.stringify(approval),
}).catch((error) => ({ ok: false, status: 0, json: async () => ({ error: error.message }) }));
const payload = await response.json().catch(() => ({}));
const payloadMember = normalizeMember(payload.member || payload.account_user || payload.accountUser);
const member = payloadMember.id || payloadMember.email ? payloadMember : null;
return { ok: response.ok, status: response.status, phase: response.ok ? 'approved' : 'admin_approve', approvalAttempted: true, acceptAttempted: true, seatAssignmentAttempted: false, inviteId: selectedInviteId, seatType: member?.seatType || observedSeatType(payload.seat_type || payload.seatType), seatAssigned: false, member, payload, message: response.ok ? 'approved' : (payload.detail || payload.error || `http_${response.status}`) };
});
return attempted?.result || { ok: false, status: 401, approvalAttempted: false, acceptAttempted: false, seatAssignmentAttempted: false, seatAssigned: false, message: 'workspace_owner_token_required' };
}
async function pendingWorkspaceInvitation(mother, workspaceId, email) {
const targetEmail = String(email || '').trim().toLowerCase();
const base = `/backend-api/accounts/${encodeURIComponent(workspaceId)}/invites`;
const attempted = await withTeamManager(mother, async (manager) => {
let offset = 0;
let expectedTotal = null;
const seen = new Set();
for (let page = 0; page < 200; page += 1) {
const params = new URLSearchParams({ include_pending: 'true', include_requests: 'false', offset: String(offset), limit: '100', query: targetEmail });
const listed = await fetchChatGptJson(manager.accessToken, `${base}?${params}`, {
accountId: workspaceId, targetPath: base, targetRoute: '/backend-api/accounts/{account_id}/invites',
headers: { ...(manager.cookie ? { cookie: manager.cookie } : {}) },
});
if (!listed.ok) return listed;
if (listPayloadFailed(listed.payload)) {
return { ok: false, status: 502, message: 'invalid_invitation_list' };
}
const rows = listed.payload?.items || listed.payload?.invites || listed.payload?.account_invites;
if (!Array.isArray(rows)) return { ok: false, status: 502, message: 'invalid_invitation_list' };
const suppliedTotal = listed.payload?.total;
const total = suppliedTotal == null ? null : Number(suppliedTotal);
if (listed.payload?.offset != null && Number(listed.payload.offset) !== offset
|| total != null && (!Number.isInteger(total) || total < offset + rows.length
|| expectedTotal != null && total !== expectedTotal)) {
return { ok: false, status: 502, message: 'invitation_list_incomplete' };
}
if (total != null) expectedTotal = total;
for (const item of rows) {
const rowId = item?.id || item?.invite_id || item?.inviteId;
const rowEmail = workspaceIdKey(item?.email_address || item?.email || item?.target_email);
if (!item || typeof item !== 'object' || !rowId || !rowEmail || seen.has(String(rowId))) {
return { ok: false, status: 502, message: 'invalid_invitation_list' };
}
seen.add(String(rowId));
}
const found = rows.find((item) => String(item.email_address || item.email || item.target_email || '').trim().toLowerCase() === targetEmail);
if (found) return { ok: true, inviteId: found.id || found.invite_id || found.inviteId, invitation: found };
offset += rows.length;
if (!rows.length && total != null && offset < total) return { ok: false, status: 502, message: 'invitation_list_incomplete' };
if (!rows.length || (total != null && offset >= total)) return { ok: true, inviteId: null };
}
return { ok: false, status: 502, message: 'invitation_list_incomplete' };
});
return attempted?.result || { ok: false, status: 401, message: 'workspace_owner_token_required' };
}
function recordConfirmedTeamMember(mother, child, member) {
const email = String(child.email || '').trim().toLowerCase();
if (!memberIsActive(member) || String(member.email || '').trim().toLowerCase() !== email) return false;
child.accountUserId = member.accountUserId || child.accountUserId;
child.memberId = member.id || child.memberId;
child.memberSnapshot = member;
child.team = child.team || mother.team;
child.joinedAt = child.joinedAt || now();
if (!childIsBanned(child)) child.status = 'active';
const membership = membershipFor(child, mother.team, true);
Object.assign(membership, { role: member.role || 'standard-user', seatType: member.seatType || null, joinStatus: 'member_confirmed', ownerRoleStatus: 'skipped' });
child.joinStatus = 'member_confirmed';
saveTeamInvitation(child, mother, { status: 'joined' });
releaseSeatClaim(mother, seatClaimForChild(mother, child));
if (workspaceIdKey(child.pendingWorkspaceId) === configuredTeamKey(mother)) {
child.pendingWorkspaceId = null;
child.pendingInviteId = null;
}
return true;
}
async function inviteSelectedChildren(motherId, body) {
const mother = findMother(motherId);
if (!mother) return { status: 404, payload: { message: 'mother_not_found' } };
if (!configuredTeamId(mother)) return { status: 400, payload: { message: 'workspace_id_required' } };
if (!Array.isArray(body.childIds) || !body.childIds.length || body.childIds.length > 1000) return { status: 400, payload: { message: 'child_ids_required' } };
const ids = [...new Set(body.childIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!ids.length) return { status: 400, payload: { message: 'child_ids_required' } };
const workspaceId = configuredTeamId(mother);
if (body.workspaceId !== undefined && workspaceIdKey(body.workspaceId) !== workspaceIdKey(workspaceId)) return { status: 409, payload: { message: 'workspace_id_does_not_match_team' } };
if (!teamManagerContext(mother)) await recoverTeamManagerToken(mother, { allowCredentialLogin: false });
const capacity = await refreshInvitationCapacity(mother);
const listed = capacity.membersResult;
const subscription = capacity.subscriptionResult;
const results = [];
for (const id of ids) {
const child = findChild(id);
let result = { id, email: child?.email || '', status: 'failed', code: null, message: '邀请失败,请检查 Team 管理凭据或网络配置' };
if (!child) result = { ...result, code: 'child_not_found', message: '账号不存在' };
else if (childIsBanned(child)) result = { ...result, status: 'skipped', code: 'account_banned', message: '账号已封禁' };
else if (!child.email) result = { ...result, code: 'invite_email_required', message: '请先填写账号邮箱' };
else if (!listed.ok) result.code = 'member_verification_failed';
else {
const member = listed.items.find((item) => memberIsActive(item) && workspaceIdKey(item.email) === workspaceIdKey(child.email));
if (member) {
recordConfirmedTeamMember(mother, child, member);
result = { ...result, status: 'already_member', code: 'already_workspace_member', message: '账号已在此 Team 中' };
} else if (!canRejoinTeam(child, mother.team)) {
result = { ...result, status: 'skipped', code: 'account_team_cooldown', message: '账号在此 Team 的冷却时间尚未结束,未发送邀请' };
} else {
const invitation = await pendingWorkspaceInvitation(mother, workspaceId, child.email);
const previousClaim = seatClaimForChild(mother, child);
if (!invitation.ok) result.code = 'invitation_list_unavailable';
else if (invitation.inviteId) {
saveTeamInvitation(child, mother, { status: 'invited', inviteId: invitation.inviteId, seatType: normalizeDefaultSeatType(mother.defaultSeatType) });
const claim = previousClaim || reserveSeatClaim(mother, child, observedSeatType(invitation.invitation?.seat_type) || mother.defaultSeatType, invitation.inviteId, workspaceId);
Object.assign(claim, { phase: 'invited', inviteId: invitation.inviteId, updatedAt: now() });
result = { ...result, status: 'invited', code: 'invitation_reused', message: '已复用此账号的待接受邀请' };
} else if (previousClaim) {
result = { ...result, status: 'skipped', code: 'seat_claim_pending', message: '该账号有待核验的席位预约,请先同步成员' };
} else if (!subscription?.ok) {
result = { ...result, code: 'seat_capacity_unavailable', message: '无法读取已购席位,已停止发送邀请' };
} else {
const selected = selectInviteSeatType(normalizeDefaultSeatType(mother.defaultSeatType), seatAdmissionSnapshot(mother), seatClaimReservations(mother));
if (!selected.ok) result = { ...result, status: 'skipped', code: selected.code, message: '没有可用的已购席位,未发送邀请' };
else {
const invited = await withChildJoinReservation(child, mother.id, () => inviteWorkspaceMember(mother, workspaceId, child.email));
if (invited.ok) {
saveTeamInvitation(child, mother, { status: 'invited', inviteId: invited.inviteId, seatType: selected.seatType });
const claim = reserveSeatClaim(mother, child, selected.seatType, invited.inviteId, workspaceId);
Object.assign(claim, { phase: 'invited', updatedAt: now() });
result = { ...result, status: 'invited', code: invited.reused ? 'invitation_reused' : 'invitation_sent', message: invited.reused ? '已复用待接受邀请' : '邀请已发送,请到 Free 账号页自动获取 JSON' };
} else result.code = invited.code || 'invitation_failed';
}
}
}
}
results.push(result);
if (mother.subscription) applyMotherSubscription(mother, mother.subscription);
await persist();
}
const invited = results.filter((item) => item.status === 'invited').length;
const alreadyMember = results.filter((item) => item.status === 'already_member').length;
const failed = results.length - invited - alreadyMember;
addHistory('手动邀请 Free 账号', `${mother.team} 发送或复用 ${invited} 个邀请,已有成员 ${alreadyMember} 个,未完成 ${failed} 个`, failed ? 'partial' : 'success');
await persist();
return { status: failed ? 207 : 200, payload: { ok: failed === 0, total: results.length, invited, alreadyMember, failed, motherId, workspaceId, results } };
}
async function inviteWorkspaceMember(mother, workspaceId, email, deviceId) {
const targetEmail = String(email || '').trim().toLowerCase();
if (!targetEmail) return { ok: false, status: 400, message: 'invite_email_required', payload: {} };
const members = await queryAllWorkspaceMembers(mother, targetEmail);
if (!members.ok) return { ...members, payload: {} };
if (members.items.some((member) => memberIsActive(member) && String(member.email || '').trim().toLowerCase() === targetEmail)) {
return { ok: false, status: 409, message: 'already_workspace_member', payload: {} };
}
const base = `/backend-api/accounts/${encodeURIComponent(workspaceId)}/invites`;
const attempted = await withTeamManager(mother, async (manager) => {
const headers = { 'content-type': 'application/json', 'oai-device-id': deviceId || manager.deviceId || randomUUID(), ...(manager.cookie ? { cookie: manager.cookie } : {}) };
const options = { accountId: workspaceId, targetPath: base, targetRoute: '/backend-api/accounts/{account_id}/invites', headers };
let offset = 0;
for (let page = 0; page < 200; page += 1) {
const params = new URLSearchParams({ include_pending: 'true', include_requests: 'false', offset: String(offset), limit: '100', query: targetEmail });
const listed = await fetchChatGptJson(manager.accessToken, `${base}?${params}`, options);
if (!listed.ok) return listed;
const rows = listed.payload?.items || listed.payload?.invites || listed.payload?.account_invites;
if (!Array.isArray(rows)) return { ok: false, status: 502, message: 'invalid_invitation_list', payload: listed.payload };
const existing = rows.find((item) => String(item.email_address || item.email || item.target_email || '').trim().toLowerCase() === targetEmail);
if (existing) {
const inviteId = existing.id || existing.invite_id || existing.inviteId;
return inviteId
? { ok: true, status: 200, reused: true, inviteId, payload: existing }
: { ok: false, status: 502, message: 'pending_invitation_id_missing', payload: existing };
}
offset += rows.length;
const total = Number(listed.payload?.total);
if (!rows.length || (Number.isFinite(total) && offset >= total)) break;
if (page === 199) return { ok: false, status: 502, message: 'invitation_list_incomplete', payload: listed.payload };
}
const invited = await fetchChatGptJson(manager.accessToken, base, {
...options, method: 'POST', body: { email_addresses: [targetEmail], role: 'standard-user', resend_emails: false },
});
if (!invited.ok || invited.payload?.success === false) {
return { ...invited, ok: false, status: invited.ok ? 502 : invited.status, upstreamStatus: invited.status, message: invited.payload?.message || invited.message || 'invitation_rejected' };
}
const rows = invited.payload?.account_invites || invited.payload?.items || invited.payload?.invites;
const invitation = Array.isArray(rows)
? rows.find((item) => String(item.email_address || item.email || item.target_email || '').trim().toLowerCase() === targetEmail)
: null;
const inviteId = invitation?.id || invitation?.invite_id || invitation?.inviteId;
return inviteId
? { ...invited, inviteId }
: { ok: false, status: 502, message: 'invitation_not_created_for_target', payload: invited.payload };
});
return attempted?.result || { ok: false, status: 401, message: 'workspace_owner_token_required', payload: {} };
}
async function acceptWorkspaceInvitation(mother, child, workspaceId, inviteId, deviceId, { allowAuthRefresh = true } = {}) {
if (!inviteId) return { ok: false, status: 400, message: 'invitation_id_required', acceptAttempted: false };
const path = `/backend-api/accounts/${encodeURIComponent(workspaceId)}/invites/accept`;
const options = { method: 'POST', accountId: workspaceId, targetPath: path,
targetRoute: '/backend-api/accounts/{account_id}/invites/accept',
headers: { 'content-type': 'application/json', 'oai-device-id': deviceId || child.deviceId || randomUUID(), referer: `${CHATGPT_BASE_URL}/` } };
let accepted = await fetchChatGptJson(child.accessToken, path, options);
if (accepted.status === 401) {
if (!allowAuthRefresh) return { ok: false, status: 401, code: 'invitation_token_expired', acceptAttempted: false, seatAssigned: false };
const refreshed = await ensureChildFreeAuth(child, { forceRefresh: true });
if (refreshed.ok) accepted = await fetchChatGptJson(child.accessToken, path, options);
else return { ...refreshed, acceptAttempted: false, seatAssigned: false };
}
// A successful accept is not enough to count a seat until the Team owner sees the member.
const members = await queryAllWorkspaceMembers(mother, child.email);
const member = members.ok && members.items.find((item) => memberIsActive(item)
&& String(item.email || '').trim().toLowerCase() === String(child.email || '').trim().toLowerCase());
if (member) {
return { ok: true, status: 200, member, seatType: member.seatType || null, inviteId, acceptAttempted: true, seatAssignmentAttempted: false, seatAssigned: false, payload: accepted.payload };
}
if (accepted.payload?.success === false) {
return { ok: false, status: accepted.ok ? 502 : accepted.status || 502, upstreamStatus: accepted.status, message: accepted.payload?.message || 'invitation_accept_rejected', payload: accepted.payload, acceptAttempted: false, seatAssigned: false };
}
const definiteRejection = [400, 401, 403, 404, 422].includes(Number(accepted.status));
if (!accepted.ok && definiteRejection) return { ...accepted, acceptAttempted: false, seatAssigned: false };
return { ok: false, status: accepted.ok ? 202 : accepted.status || 502, phase: 'invite_accept', code: members.ok ? 'membership_pending' : 'member_verification_failed', message: members.ok ? '邀请已接受,等待成员列表确认' : members.message || 'member_verification_failed', acceptAttempted: true, seatAssignmentAttempted: false, seatAssigned: false, payload: accepted.payload };
}
async function switchWorkspace(child, body) {
if (!child || !body.workspaceId) return { ok: false, status: 400, message: 'child_and_workspace_required' };
if (childIsBanned(child)) return { ok: false, status: 409, code: 'account_banned', message: child.banReason || '账号已封禁,不能切换 Team 空间', banned: true };
if (!child.accessToken) return { ok: false, status: 400, message: 'access_token_required' };
const workspaceId = String(body.workspaceId).trim();
const query = new URLSearchParams({
exchange_workspace_token: 'true',
workspace_id: workspaceId,
reason: 'team_rotation',
});
const result = await fetchChatGptJson(child.accessToken, `/api/auth/session?${query}`, {
accountId: workspaceId,
targetPath: '/api/auth/session',
targetRoute: '/api/auth/session',
headers: { referer: `${CHATGPT_BASE_URL}/` },
});
child.lastWorkspaceSelectAt = now();
if (!result.ok) {
addHistory('切换空间', `${child.email} 切换到 ${workspaceId} 失败:${result.message}`, 'partial');
await persist();
return { ok: false, status: result.status || 502, message: result.message || 'workspace_session_exchange_failed', payload: result.payload };
}
const accessToken = accessTokenFromSessionPayload(result.payload);
if (!accessToken) {
addHistory('切换空间', `${child.email} 切换到 ${workspaceId} 后未返回 Team AT`, 'partial');
await persist();
return { ok: false, status: 502, message: 'workspace_access_token_missing', payload: result.payload };
}
const claims = accessTokenClaims(accessToken);
if (!claims.accountId || claims.accountId !== workspaceId) {
addHistory('切换空间', `${child.email} 返回的 AT 未匹配目标 Team`, 'partial');
await persist();
return { ok: false, status: 502, message: 'workspace_access_token_mismatch', accountId: claims.accountId || null };
}
if (!childTokenIdentityMatches(child, accessToken)) {
addHistory('切换空间', `${child.email} 返回的 Team AT 不属于当前账号,未保存`, 'partial');
await persist();
return { ok: false, status: 502, code: 'token_identity_mismatch', message: '返回的 Team AT 不属于当前账号,请使用该账号完成授权' };
}
const workspaceToken = saveWorkspaceToken(child, workspaceId, accessToken, claims);
const mother = state.mothers.find((item) => item.accountId === workspaceId || item.team === workspaceId);
const knownOwner = childMatchesKnownTeamOwner(child, mother);
if (mother && String(mother.email || '').trim().toLowerCase() === String(child.email || '').trim().toLowerCase()) {
mother.accessToken = accessToken;
mother.token = preview(accessToken);
mother.expiresAt = workspaceToken.expiresAt || mother.expiresAt || null;
}
if (mother && (childIsWorkspaceOwner(child, mother) || knownOwner)) upsertTeamOwnerFromChild(mother, child, workspaceId, workspaceToken);
const membership = mother ? membershipFor(child, mother.team, true) : membershipFor(child, workspaceId, true);
if (membership) {
membership.workspaceTokenStatus = 'ready';
membership.workspaceTokenUpdatedAt = workspaceToken.acquiredAt;
}
addHistory('切换空间', `${child.email} 已取得 ${workspaceId} 的 Team AT`);
await persist();
return { ok: true, status: result.status, accountId: workspaceId, expiresAt: workspaceToken.expiresAt, tokenScope: 'team' };
}
async function switchWorkspaceWithFreeRecovery(child, workspaceId, { verificationCode = '', callbackUrl = '', forceRefreshFirst = false } = {}) {
const initialAuth = await ensureChildFreeAuth(child, { forceRefresh: forceRefreshFirst && Boolean(child?.refreshToken), verificationCode, callbackUrl });
if (!initialAuth.ok) {
return {
ok: false,
status: initialAuth.status || 502,
message: initialAuth.message || 'free_auth_required',
code: initialAuth.code || 'free_auth_required',
needsInput: Boolean(initialAuth.needsInput),
browserRequired: Boolean(initialAuth.browserRequired),
authUrl: initialAuth.authUrl || null,
freeAuth: { attempted: true, ok: false, source: initialAuth.source || null },
};
}
let result = await switchWorkspace(child, { workspaceId });
if (result.ok || result.status !== 401) return { ...result, freeAuth: { attempted: true, ok: true, source: initialAuth.source || null } };
// The Free token can be revoked before its JWT expiry. Refresh it once and
// repeat the workspace exchange before surfacing the Team failure.
const refreshed = await ensureChildFreeAuth(child, { forceRefresh: true, verificationCode, callbackUrl });
if (!refreshed.ok) {
return {
ok: false,
status: refreshed.status || result.status || 502,
message: refreshed.message || result.message || 'free_auth_refresh_failed',
code: refreshed.code || 'free_auth_refresh_failed',
needsInput: Boolean(refreshed.needsInput),
browserRequired: Boolean(refreshed.browserRequired),
authUrl: refreshed.authUrl || null,
freeAuth: { attempted: true, ok: false, source: refreshed.source || null, status: refreshed.status || null, code: refreshed.code || null },
};
}
result = await switchWorkspace(child, { workspaceId });
return {
...result,
freeAuth: { attempted: true, ok: result.ok, source: refreshed.source || null, retriedAfter401: true },
};
}
function sub2ApiRoot(value) {
const base = String(value || '').trim().replace(/\/+$/, '');
if (!base) return '';
return /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
}
function sub2ApiHeaders(apiKey) {
const key = String(apiKey || '').trim();
return { accept: 'application/json', ...(key ? { authorization: `Bearer ${key}`, 'x-api-key': key } : {}) };
}
async function sub2ApiRequest(config, requestPath, { method = 'GET', body } = {}) {
const root = sub2ApiRoot(config.baseUrl);
const headers = { ...sub2ApiHeaders(config.apiKey), ...(body !== undefined ? { 'content-type': 'application/json' } : {}) };
const response = await proxyFetch(`${root}${requestPath}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(OPENAI_REQUEST_TIMEOUT_MS) });
const payload = await response.json().catch(() => ({}));
return { ok: response.ok, status: response.status, payload, data: payload?.data ?? payload, message: response.ok ? 'ok' : (payload?.message || payload?.error || `http_${response.status}`) };
}
function sub2ApiOAuthError(code, message, status = 502) {
const error = new Error(String(message || code || 'sub2api_oauth_failed'));
error.code = code;
error.status = status;
return error;
}
function sub2ApiOAuthMessage(result, fallback) {
const value = result?.message || result?.payload?.message || result?.payload?.error || fallback;
if (typeof value === 'string') return value;
try { return JSON.stringify(value); } catch { return String(fallback || 'sub2api_oauth_failed'); }
}
function sub2ApiOAuthSessionExpired(result) {
// Only an explicit, non-transient provider rejection invalidates the
// original callback. A timeout/rate limit/server failure must keep its code.
const status = Number(result?.status);
if (!Number.isFinite(status) || status < 400 || status >= 500 || status === 408 || status === 429) return false;
const payload = result?.payload;
const code = String(payload?.error?.code || payload?.code || '').toLowerCase();
if (['session_not_found', 'session_expired', 'oauth_session_not_found', 'oauth_session_expired'].includes(code)) return true;
const message = sub2ApiOAuthMessage(result, '');
return /\bsession (?:not found(?: or expired)?|(?:has |is )?expired)\b/i.test(message);
}
function sub2ApiOAuthReady(config) {
const groupId = Number(config?.groupId);
return config?.enabled === true
&& Boolean(sub2ApiRoot(config?.baseUrl))
&& Boolean(String(config?.apiKey || '').trim())
&& ((Number.isFinite(groupId) && groupId > 0) || Boolean(String(config?.groupName || '').trim()));
}
function oauthSessionForAdapter(session, adapter) {
const authorization = session?.authorization;
if (!adapter) return authorization?.source === 'sub2api' ? null : session;
if (!authorization && session?.state && session?.codeVerifier) return session;
if (!authorization || authorization.source !== 'sub2api') return null;
return authorization.providerId === adapter.providerId ? session : null;
}
function deferredSub2ApiSync(config, message, group = null) {
return {
integrationId: config?.id || null,
integrationName: config?.name || '',
groupId: group?.id || (Number.isFinite(Number(config?.groupId)) ? Number(config.groupId) : null),
groupName: group?.name || String(config?.groupName || '').trim(),
accountId: null,
action: 'sync_deferred',
synced: false,
message: String(message || 'sub2api_sync_failed'),
};
}
async function syncSub2ApiAfterLocalOAuth(config, child, login, { scope = 'free', workspaceId = '' } = {}) {
if (!sub2ApiRoot(config?.baseUrl) || !String(config?.apiKey || '').trim()) {
return deferredSub2ApiSync(config, 'sub2api_connection_required');
}
try {
const group = await resolveSub2ApiGroup(config);
if (!group.ok) return deferredSub2ApiSync(config, group.message || 'sub2api_group_lookup_failed');
const tokenClaims = login?.claims || accessTokenClaims(login?.accessToken);
const claims = {
...tokenClaims,
email: tokenClaims.email || child?.email || '',
accountId: tokenClaims.accountId || workspaceId || '',
planType: tokenClaims.planType || (scope === 'team' ? 'team' : 'free'),
};
const fields = credentialFields({
...login,
email: claims.email,
accountId: claims.accountId,
planType: claims.planType,
clientId: login?.clientId || child?.clientId || '',
});
return await syncSub2ApiOAuthAccount(config, group, child, fields, claims, { scope, workspaceId });
} catch (error) {
return deferredSub2ApiSync(config, error?.name === 'TimeoutError' ? 'timeout' : error?.message || 'network_error');
}
}
function sub2ApiOAuthAdapter(config, child, { scope = 'free', workspaceId = '' } = {}) {
if (!sub2ApiOAuthReady(config)) return null;
const providerId = String(config.id || DEFAULT_SUB2API_ID);
let resolvedGroup = null;
const ensureGroup = async () => {
if (resolvedGroup) return resolvedGroup;
const group = await resolveSub2ApiGroup(config);
if (!group.ok) throw sub2ApiOAuthError('sub2api_oauth_group_unavailable', group.message || 'Sub2API OAuth 目标分组不可用', group.status || 502);
resolvedGroup = group;
return group;
};
return {
providerId,
authorizationProvider: async () => {
const result = await sub2ApiRequest(config, '/admin/openai/generate-auth-url', { method: 'POST', body: {} });
if (!result.ok) {
throw sub2ApiOAuthError('sub2api_oauth_url_failed', sub2ApiOAuthMessage(result, 'Sub2API OAuth 授权链接获取失败'), result.status || 502);
}
const data = result.data && typeof result.data === 'object' ? result.data : {};
const authUrl = String(data.auth_url || data.authUrl || data.url || '').trim();
const sessionId = String(data.session_id || data.sessionId || '').trim();
let parsed = null;
try { parsed = new URL(authUrl); } catch { parsed = null; }
const stateValue = String(data.state || parsed?.searchParams.get('state') || '').trim();
const redirectUri = String(data.redirect_uri || data.redirectUri || parsed?.searchParams.get('redirect_uri') || 'http://localhost:1455/auth/callback').trim();
if (!parsed || !sessionId || !stateValue) {
throw sub2ApiOAuthError('sub2api_oauth_url_invalid', 'Sub2API OAuth 响应缺少 auth_url、state 或 session_id', 502);
}
return { source: 'sub2api', providerId, authUrl, sessionId, state: stateValue, redirectUri };
},
callbackHandler: async ({ code, state: oauthState, authorization }) => {
if (!authorization?.sessionId || authorization.providerId !== providerId) {
throw sub2ApiOAuthError('sub2api_oauth_session_invalid', 'Sub2API OAuth 会话缺失或连接已切换', 409);
}
const exchanged = await sub2ApiRequest(config, '/admin/openai/exchange-code', {
method: 'POST',
body: {
session_id: authorization.sessionId,
code,
state: oauthState,
redirect_uri: authorization.redirectUri || undefined,
},
});
if (!exchanged.ok) {
if (sub2ApiOAuthSessionExpired(exchanged)) {
throw sub2ApiOAuthError('sub2api_oauth_session_expired', 'Sub2API 授权会话已过期或不存在,请重新授权', 409);
}
throw sub2ApiOAuthError('sub2api_oauth_exchange_failed', sub2ApiOAuthMessage(exchanged, 'Sub2API OAuth 换取凭据失败'), exchanged.status || 502);
}
const tokenInfo = exchanged.data && typeof exchanged.data === 'object' ? exchanged.data : {};
const fields = credentialFields(tokenInfo);
if (!fields.accessToken || !fields.refreshToken) {
throw sub2ApiOAuthError('sub2api_oauth_token_incomplete', 'Sub2API OAuth 返回缺少 access_token 或 refresh_token', 502);
}
const accessClaims = accessTokenClaims(fields.accessToken);
const claims = {
...accessClaims,
email: accessClaims.email || fields.email || child?.email || '',
accountId: accessClaims.accountId || fields.accountId || '',
userId: accessClaims.userId || fields.chatgptUserId || '',
planType: accessClaims.planType || fields.planType || (scope === 'team' ? 'team' : 'free'),
};
// Do this before any optional account creation/update in Sub2API. The
// browser may have authorized a different signed-in account, even though
// its callback state belongs to this account's original login request.
const expectedEmail = workspaceIdKey(child?.email);
const expectedUserId = String(child?.chatgptUserId || '').trim();
const returnedEmail = workspaceIdKey(accessClaims.email || fields.email);
const returnedUserId = String(accessClaims.userId || fields.chatgptUserId || '').trim();
if ((expectedEmail && returnedEmail && returnedEmail !== expectedEmail)
|| (expectedUserId && returnedUserId && returnedUserId !== expectedUserId)) {
throw sub2ApiOAuthError('token_identity_mismatch', '浏览器授权返回的账号身份不一致,已停止 Sub2API 同步并保留原凭据', 409);
}
if (scope === 'team' && workspaceId && claims.accountId !== workspaceId) {
throw sub2ApiOAuthError('team_workspace_mismatch', 'OAuth 登录后未选择目标 Team 空间', 409);
}
let sub2api;
try {
const group = await ensureGroup();
sub2api = await syncSub2ApiOAuthAccount(config, group, child, fields, claims, { scope, workspaceId });
} catch (error) {
// Token acquisition is independent from the optional Sub2API sync.
// A later repair push can retry after the destination becomes available.
sub2api = deferredSub2ApiSync(config, error?.message || 'sub2api_group_lookup_failed');
}
return {
accessToken: fields.accessToken,
refreshToken: fields.refreshToken,
idToken: fields.idToken,
clientId: fields.clientId,
organizationId: fields.organizationId,
subscriptionExpiresAt: fields.subscriptionExpiresAt,
expiresAt: fields.expiresAt,
claims,
sub2api,
};
},
};
}
async function syncSub2ApiOAuthAccount(config, group, child, fields, claims, { scope = 'free', workspaceId = '' } = {}) {
const email = String(fields.email || claims.email || child?.email || '').trim();
const accountId = String(claims.accountId || fields.accountId || workspaceId || '').trim();
const metadata = {
integrationId: config.id || null,
integrationName: config.name || '',
groupId: group.id,
groupName: group.name || config.groupName || '',
accountId: null,
action: '',
synced: false,
message: '',
};
if (!email || !accountId) return { ...metadata, message: 'sub2api_identity_requires_email_and_chatgpt_account_id' };
const record = {
...(child || {}),
email,
accessToken: fields.accessToken,
refreshToken: fields.refreshToken,
idToken: fields.idToken,
clientId: fields.clientId,
organizationId: fields.organizationId,
subscriptionExpiresAt: fields.subscriptionExpiresAt,
expiresAt: fields.expiresAt || claims.expiresAt,
accountId,
plan: fields.planType || claims.planType || (scope === 'team' ? 'team' : 'free'),
planType: fields.planType || claims.planType || (scope === 'team' ? 'team' : 'free'),
tokenScope: scope,
team: scope === 'team' ? workspaceId || accountId : child?.team,
};
const payload = { ...sub2ApiAccountFromChild(record), group_ids: [group.id] };
try {
const lookup = await sub2ApiRequest(config, `/admin/accounts?page=1&page_size=100&search=${encodeURIComponent(email)}`);
if (!lookup.ok) return { ...metadata, message: sub2ApiOAuthMessage(lookup, 'sub2api_lookup_failed') };
const lookupItems = (lookupData) => Array.isArray(lookupData)
? lookupData
: Array.isArray(lookupData?.items) ? lookupData.items
: Array.isArray(lookupData?.accounts) ? lookupData.accounts : [];
const matchesIdentity = (item) => {
const itemEmail = String(item?.email || item?.credentials?.email || '').trim().toLowerCase();
const itemAccountId = String(item?.credentials?.chatgpt_account_id || item?.credentials?.account_id || item?.account_id || '').trim().toLowerCase();
return itemEmail === email.toLowerCase() && itemAccountId === accountId.toLowerCase();
};
const items = lookupItems(lookup.data);
let existing = items.find(matchesIdentity);
// Sub2API's `search` filter currently matches account.name only. If an
// existing OAuth account uses a different display name, walk the OpenAI
// account pages and perform the required email + workspace identity match.
if (!existing) {
for (let page = 1; page <= 20 && !existing; page += 1) {
const pageResult = await sub2ApiRequest(config, `/admin/accounts?page=${page}&page_size=100&platform=openai&type=oauth`);
if (!pageResult.ok) break;
const pageItems = lookupItems(pageResult.data);
if (!pageItems.length) break;
existing = pageItems.find(matchesIdentity);
if (pageItems.length < 100) break;
}
}
const synced = existing?.id
? await sub2ApiRequest(config, `/admin/accounts/${encodeURIComponent(existing.id)}`, { method: 'PUT', body: payload })
: await sub2ApiRequest(config, '/admin/accounts', { method: 'POST', body: payload });
if (!synced.ok) return { ...metadata, accountId: existing?.id || null, action: existing?.id ? 'update_failed' : 'create_failed', message: sub2ApiOAuthMessage(synced, 'sub2api_sync_failed') };
const saved = synced.data && typeof synced.data === 'object' ? synced.data : {};
return { ...metadata, accountId: saved.id || existing?.id || null, action: existing?.id ? 'updated' : 'created', synced: true, message: 'ok' };
} catch (error) {
return { ...metadata, message: error?.name === 'TimeoutError' ? 'timeout' : error?.message || 'network_error' };
}
}
async function querySub2ApiGroups(config = sub2ApiConfigs()[0] || {}) {
const root = sub2ApiRoot(config.baseUrl);
if (!root || !config.apiKey) return { ok: false, status: 400, message: 'sub2api_connection_required', groups: [] };
try {
const result = await sub2ApiRequest(config, '/admin/groups/all');
if (!result.ok) return { ok: false, status: result.status, message: result.message, groups: [] };
const data = result.data;
const items = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : Array.isArray(data?.groups) ? data.groups : [];
const groups = items.map((item) => ({ id: Number(item?.id ?? item?.group_id), name: String(item?.name || item?.group_name || item?.display_name || item?.displayName || `分组 ${item?.id ?? item?.group_id ?? ''}`).trim(), platform: item?.platform || null })).filter((item) => Number.isFinite(item.id));
return { ok: true, status: result.status, groups };
} catch (error) {
return { ok: false, status: 0, message: error?.name === 'TimeoutError' ? 'timeout' : 'network_error', groups: [] };
}
}
async function resolveSub2ApiGroup(config) {
const groupId = Number(config?.groupId);
const groupName = String(config?.groupName || '').trim();
if (Number.isFinite(groupId) && groupId > 0) return { ok: true, id: groupId, name: groupName };
if (!groupName) return { ok: false, status: 400, message: 'sub2api_group_required' };
const result = await querySub2ApiGroups(config);
if (!result.ok) return { ok: false, status: result.status || 502, message: result.message || 'sub2api_group_lookup_failed' };
const target = groupName.toLocaleLowerCase();
const matches = result.groups.filter((group) => group.name.toLocaleLowerCase() === target);
if (!matches.length) return { ok: false, status: 400, message: 'sub2api_group_not_found' };
return { ok: true, id: matches[0].id, name: matches[0].name };
}
function sub2ApiCredentials(child) {
return Object.fromEntries(Object.entries({
email: child.email,
access_token: child.accessToken,
refresh_token: child.refreshToken,
chatgpt_account_id: child.accountId,
chatgpt_user_id: child.chatgptUserId,
client_id: child.clientId,
id_token: child.idToken,
organization_id: child.organizationId,
model_mapping: child.modelMapping,
expires_at: child.expiresAt,
subscription_expires_at: child.subscriptionExpiresAt,
plan_type: child.plan && child.plan !== '待检测' ? child.plan : child.tokenScope === 'team' ? 'team' : 'free',
}).filter(([, value]) => value));
}
function sub2ApiExtra(child) {
const primary = child?.quotaSnapshot?.primary || {};
const secondary = child?.quotaSnapshot?.secondary || {};
const quotaNumber = (value) => !['number', 'string'].includes(typeof value) || String(value).trim() === '' || !Number.isFinite(Number(value)) ? null : Number(value);
const remaining5h = quotaNumber(child?.quota5h);
const remaining7d = quotaNumber(child?.quota7d);
const used5h = quotaNumber(primary.usedPercent) ?? (remaining5h == null ? null : Math.max(0, 100 - remaining5h));
const used7d = quotaNumber(secondary.usedPercent) ?? (remaining7d == null ? null : Math.max(0, 100 - remaining7d));
const extra = {
...(child?.extra && typeof child.extra === 'object' ? child.extra : {}),
};
// Rebuild all quota fields from the selected scope. Keeping stale codex_*
// keys here would leak a previous Team's windows into a new export.
for (const key of Object.keys(extra)) if (/^codex_(5h|7d|primary|secondary|usage_)/.test(key)) delete extra[key];
if (child?.tokenScope !== 'team') {
// A Free export is reusable for joining spaces. Team-specific usage must
// not follow that credential into another Team's record.
return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== null && value !== undefined && value !== ''));
}
const reset5h = child?.quota5hResetAfterSeconds ?? primary.resetAfterSeconds ?? null;
const reset7d = child?.quota7dResetAfterSeconds ?? secondary.resetAfterSeconds ?? null;
const resetAt5h = child?.quota5hResetAt || primary.resetAt || null;
const resetAt7d = child?.quota7dResetAt || secondary.resetAt || null;
if (used5h != null) extra.codex_5h_used_percent = used5h;
if (used7d != null) extra.codex_7d_used_percent = used7d;
if (reset5h != null) extra.codex_5h_reset_after_seconds = reset5h;
if (reset7d != null) extra.codex_7d_reset_after_seconds = reset7d;
if (resetAt5h) extra.codex_5h_reset_at = resetAt5h;
if (resetAt7d) extra.codex_7d_reset_at = resetAt7d;
if (used5h != null || reset5h != null || resetAt5h) extra.codex_5h_window_minutes = primary.windowMinutes ?? 300;
if (used7d != null || reset7d != null || resetAt7d) extra.codex_7d_window_minutes = secondary.windowMinutes ?? 10080;
const quotaUpdatedAt = child?.quotaUpdatedAt || child?.lastQuotaCheckAt || child?.quotaSnapshot?.updatedAt;
if (quotaUpdatedAt) extra.codex_usage_updated_at = quotaUpdatedAt;
return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== null && value !== undefined && value !== ''));
}
function sub2ApiAccountFromChild(child) {
return {
name: child.name || child.email || child.id,
email: child.email || undefined,
platform: 'openai',
type: 'oauth',
credentials: sub2ApiCredentials(child),
extra: sub2ApiExtra(child),
team: child.team || undefined,
concurrency: 10,
priority: 1,
rate_multiplier: child.rateMultiplier,
auto_pause_on_expired: child.autoPauseOnExpired,
};
}
function teamOwnerRecords(mother) {
if (!mother) return [];
const byEmail = new Map();
const workspaceId = mother.accountId || mother.team || '';
const joinedOwners = state.children.flatMap((child) => {
if (!isChildMemberOfTeam(child, mother.team) || !childIsWorkspaceOwner(child, mother)) return [];
const workspaceToken = workspaceTokenFor(child, workspaceId) || workspaceTokenFor(child, mother.team);
if (!workspaceToken?.accessToken) return [];
const quotaSource = membershipFor(child, mother.team)
|| (workspaceIdKey(child.team) === workspaceIdKey(mother.team) ? child : {});
return [{
...child,
accessToken: workspaceToken.accessToken,
refreshToken: workspaceToken.refreshToken || '',
idToken: workspaceToken.idToken || '',
clientId: workspaceToken.clientId || child.clientId || '',
accountId: workspaceId,
team: mother.team || workspaceId,
plan: 'team',
planType: 'team',
tokenScope: 'team',
expiresAt: workspaceToken.expiresAt || child.expiresAt || null,
...quotaRecordFields(quotaSource),
}];
});
for (const source of [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : []), ...joinedOwners]) {
const email = String(source?.email || '').trim();
if (!email) continue;
const key = email.toLowerCase();
const existing = byEmail.get(key) || {};
const merged = { ...existing };
for (const [field, value] of Object.entries(source)) {
if (value !== undefined && value !== null && value !== '') merged[field] = value;
}
merged.email = email;
merged.name = merged.name || email;
merged.accountId = mother.accountId || mother.team || merged.accountId || '';
merged.team = mother.team || mother.accountId || '';
merged.plan = 'team';
merged.planType = 'team';
merged.tokenScope = 'team';
byEmail.set(key, merged);
}
return [...byEmail.values()].map((owner) => {
const joinedOwner = joinedOwners.find((candidate) => workspaceIdKey(candidate.email) === workspaceIdKey(owner.email));
const ownerProjection = publicTeamOwnerRecord(mother, owner.email);
const quotaSource = hasQuotaObservation(joinedOwner) ? joinedOwner : ownerProjection || joinedOwner || {};
return { ...owner, ...quotaRecordFields(quotaSource), lastProbe: joinedOwner?.lastProbe || quotaSource.lastProbe || null };
}).filter((owner) => teamTokenDetails(owner.accessToken, workspaceId));
}
function teamJsonRecords(mother) {
const workspaceId = mother?.accountId || mother?.team || '';
const mismatchedEmails = new Set(state.children
.filter((child) => membershipFor(child, mother?.team)?.joinStatus === 'seat_type_mismatch')
.map((child) => String(child.email || '').trim().toLowerCase()).filter(Boolean));
const byEmail = new Map(teamOwnerRecords(mother).map((record) => [String(record.email || '').toLowerCase(), record]));
for (const child of state.children) {
if (!isChildMemberOfTeam(child, mother?.team)) continue;
const workspaceToken = workspaceTokenFor(child, workspaceId) || workspaceTokenFor(child, mother.team);
if (!teamTokenDetails(workspaceToken?.accessToken, workspaceId)) continue;
const membership = membershipFor(child, mother.team);
const existing = byEmail.get(String(child.email || '').toLowerCase());
const childQuotaSource = membership
|| (workspaceIdKey(child.team) === workspaceIdKey(mother.team) ? child : {});
const quotaSource = hasQuotaObservation(childQuotaSource) ? childQuotaSource : existing || childQuotaSource;
const record = {
...child,
accessToken: workspaceToken.accessToken,
refreshToken: workspaceToken.refreshToken || '',
idToken: workspaceToken.idToken || '',
clientId: workspaceToken.clientId || child.clientId || '',
accountId: workspaceId,
team: mother.team || workspaceId,
plan: 'team',
planType: 'team',
tokenScope: 'team',
expiresAt: workspaceToken.expiresAt || child.expiresAt || null,
...quotaRecordFields(quotaSource),
lastProbe: childQuotaSource.lastProbe || quotaSource.lastProbe || null,
};
const key = String(child.email || '').toLowerCase();
if (key) byEmail.set(key, { ...(byEmail.get(key) || {}), ...record });
}
return [...byEmail.values()].filter((record) => !mismatchedEmails.has(String(record.email || '').trim().toLowerCase()));
}
function accountExportCandidates() {
const sources = state.children.map((child) => ({ scope: 'free', childId: child.id, record: child }));
for (const mother of state.mothers) {
const workspaceId = String(mother.accountId || mother.team || '').trim();
if (!workspaceId) continue;
const teamFields = { scope: 'team', motherId: mother.id, workspaceId, teamName: teamDisplayName(mother) };
// Export is independent from automatic Sub2API push preferences. Each
// source must carry its own AT + RT; never combine a manager session with
// a different account's refresh token or a child's personal credentials.
for (const owner of [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])]) {
const linked = state.children.find((child) => workspaceIdKey(child.email) === workspaceIdKey(owner.email));
if (linked && membershipFor(linked, mother.team)?.joinStatus === 'seat_type_mismatch') continue;
sources.push({ ...teamFields, childId: linked?.id || null, role: 'account-owner', record: owner });
}
for (const child of state.children) {
if (!isChildMemberOfTeam(child, mother.team)) continue;
const membership = membershipFor(child, mother.team);
if (membership?.joinStatus === 'seat_type_mismatch') continue;
const member = (mother.members || []).find((item) => workspaceIdKey(item.email) === workspaceIdKey(child.email));
const role = childIsWorkspaceOwner(child, mother) ? 'account-owner' : membership?.role || member?.role || 'standard-user';
const quota = membership || (workspaceIdKey(child.team) === workspaceIdKey(mother.team) ? child : {});
const savedTokens = [accountTeamTokenFor(child, workspaceId, { allowExpired: true }), child.workspaceTokens?.[workspaceId]];
for (const saved of savedTokens) {
if (!saved?.accessToken) continue;
sources.push({ ...teamFields, childId: child.id, role, record: {
...child, ...quotaRecordFields(quota),
accessToken: saved.accessToken, refreshToken: saved.refreshToken || '',
idToken: saved.idToken || '', clientId: saved.clientId || child.clientId || '',
accountId: saved.accountId || workspaceId,
expiresAt: saved.expiresAt || null,
} });
}
}
}
return buildAccountExportCandidates(sources, sub2ApiAccountFromChild);
}
function sub2ApiAccountFromMotherOwner(mother, owner) {
const record = {
...mother,
...owner,
...quotaRecordFields(owner),
accountId: mother.accountId || mother.team || owner.accountId,
team: mother.team || mother.accountId || owner.team,
plan: 'team',
planType: 'team',
tokenScope: 'team',
};
return sub2ApiAccountFromChild(record);
}
const quickJsonTickets = new Map();
const QUICK_JSON_AUTH_SESSION_TTL_MS = 15 * 60 * 1000;
function quickJsonAuthSessionKey(scope, email, workspaceId = '') {
return JSON.stringify([scope, workspaceIdKey(email), String(workspaceId || '').trim()]);
}
async function quickJsonAuthSessions() {
let changed = false;
if (!state.quickJsonAuthSessions || typeof state.quickJsonAuthSessions !== 'object' || Array.isArray(state.quickJsonAuthSessions)) {
state.quickJsonAuthSessions = {};
changed = true;
}
for (const [key, entry] of Object.entries(state.quickJsonAuthSessions)) {
if (!entry?.session?.state || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= Date.now()) {
if (entry?.session?.state) clearOAuthCallback(entry.session.state, entry.session.callbackUrl || '');
delete state.quickJsonAuthSessions[key];
changed = true;
}
}
if (changed) await persist();
return state.quickJsonAuthSessions;
}
async function saveQuickJsonAuthSession(scope, email, workspaceId, child) {
const sessions = await quickJsonAuthSessions();
const key = quickJsonAuthSessionKey(scope, email, workspaceId);
const session = scope === 'team' ? child.teamAuthSessions?.[workspaceId] : child.authSession;
if (session?.state) {
const previous = sessions[key];
sessions[key] = {
scope,
email,
workspaceId,
expiresAt: previous?.session?.state === session.state ? previous.expiresAt : Date.now() + QUICK_JSON_AUTH_SESSION_TTL_MS,
session: structuredClone(session),
};
} else {
delete sessions[key];
}
await persist();
}
async function acquireQuickJson(body = {}) {
const scope = String(body.scope || '').trim();
const email = String(body.email || '').trim();
if (!['free', 'team'].includes(scope)) return { ok: false, status: 400, code: 'invalid_scope', message: '请选择 Free 或 Team JSON' };
if (!email) return { ok: false, status: 400, code: 'email_required', message: '请填写账号邮箱' };
const matches = state.children.filter((item) => workspaceIdKey(item.email) === workspaceIdKey(email));
if (matches.length > 1) return { ok: false, status: 409, code: 'duplicate_free_email', message: 'Free 号池存在同邮箱的多个账号,请先整理重复账号' };
const storedChild = matches[0] || null;
const explicitCredentials = body.password !== undefined || body.totp !== undefined;
if (explicitCredentials && (!String(body.password || '') || !String(body.totp || '').trim())) {
return { ok: false, status: 400, code: 'credentials_incomplete', message: '手动登录请同时填写密码和 2FA Secret' };
}
if (storedChild && childIsBanned(storedChild)) return { ok: false, status: 409, code: 'account_banned', message: '该账号已被标记封禁' };
const mother = scope === 'team'
? findMother(String(body.motherId || '').trim())
|| state.mothers.find((item) => configuredTeamKey(item) === workspaceIdKey(body.motherId))
: null;
if (scope === 'team' && !body.motherId) return { ok: false, status: 400, code: 'mother_id_required', message: '请选择目标 Team' };
if (scope === 'team' && !mother) return { ok: false, status: 404, code: 'mother_not_found', message: '目标 Team 不存在' };
if (scope === 'team' && storedChild && membershipFor(storedChild, mother.team)?.joinStatus === 'seat_type_mismatch') {
return { ok: false, status: 409, code: 'seat_type_mismatch', message: '该成员席位类型与目标不符,请先在 Team 中人工调整席位' };
}
const workspaceId = scope === 'team' ? configuredTeamId(mother) : '';
if (scope === 'team' && !workspaceId) return { ok: false, status: 400, code: 'workspace_id_required', message: '目标 Team 尚未配置空间 ID' };
const savedSession = (await quickJsonAuthSessions())[quickJsonAuthSessionKey(scope, email, workspaceId)]?.session || null;
const callbackFailure = explicitOAuthCallbackFailure(savedSession, body.callbackUrl);
if (callbackFailure) return callbackFailure;
const completedCallback = pendingOAuthCallback(savedSession, body.callbackUrl);
if (!explicitCredentials && !storedChild && !completedCallback) {
return { ok: false, status: 404, code: 'free_account_not_found', message: 'Free 号池未找到该邮箱,请填写密码和 2FA Secret' };
}
// A quick acquisition must never persist a speculative membership or replace
// credentials already managed in the Free pool. Its OAuth session is kept
// independently so manual authorization can resume without cloning a pool
// account's state/PKCE verifier or losing a temporary account on restart.
const child = explicitCredentials
? childFromImportedAccount({ email, password: String(body.password), totp: String(body.totp).trim(), mailboxUrl: storedChild?.mailboxUrl || '' })
: storedChild ? structuredClone(storedChild) : childFromImportedAccount({ email });
child.authSession = scope === 'free' && savedSession ? structuredClone(savedSession) : null;
child.teamAuthSessions = scope === 'team' && savedSession ? { [workspaceId]: structuredClone(savedSession) } : {};
if (scope === 'free') {
if (!completedCallback && !child.refreshToken && !(child.password && (child.totp || child.secret))) {
return { ok: false, status: 400, code: 'refresh_token_required', message: 'Free JSON 缺少 RT;请补充邮箱密码和 2FA 以重新授权' };
}
if (!child.refreshToken) child.accessToken = '';
const result = await acquireChildAuth(child, {
allowCredentialLogin: true,
verificationCode: body.verificationCode,
callbackUrl: body.callbackUrl,
syncSub2Api: false,
});
await saveQuickJsonAuthSession(scope, email, workspaceId, child);
if (!result.ok) return result;
} else {
const existing = !explicitCredentials ? workspaceTokenFor(child, workspaceId) : null;
if (!completedCallback && (!teamTokenDetails(existing?.accessToken, workspaceId) || !existing?.refreshToken) && !explicitCredentials) {
const owner = [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])]
.find((record) => workspaceIdKey(record.email) === workspaceIdKey(email));
const refreshToken = existing?.refreshToken || owner?.refreshToken || '';
if (refreshToken) {
const refreshed = await refreshOpenAiAccessToken(refreshToken, existing?.clientId || owner?.clientId || child.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch);
const claims = refreshed.claims || accessTokenClaims(refreshed.accessToken);
if (refreshed.ok && claims.accountId === workspaceId && (!claims.email || workspaceIdKey(claims.email) === workspaceIdKey(email))) {
saveWorkspaceToken(child, workspaceId, refreshed.accessToken, claims, {
refreshToken: refreshed.refreshToken || refreshToken,
idToken: refreshed.idToken || existing?.idToken || owner?.idToken || '',
clientId: existing?.clientId || owner?.clientId || child.clientId || '',
source: 'quick_json_team_refresh',
});
}
}
}
const current = workspaceTokenFor(child, workspaceId);
if (!completedCallback && !current?.refreshToken && !hasTeamLoginCredentials(child)) {
return { ok: false, status: 400, code: 'refresh_token_required', message: 'Team JSON 缺少 RT;请补充邮箱密码和 2FA 以重新授权' };
}
if (completedCallback || !teamTokenDetails(current?.accessToken, workspaceId) || !current?.refreshToken || explicitCredentials) {
const result = await acquireTeamAuth(mother, child, {
force: true,
verificationCode: body.verificationCode,
callbackUrl: body.callbackUrl,
syncSub2Api: false,
persistOwner: false,
});
await saveQuickJsonAuthSession(scope, email, workspaceId, child);
if (!result.ok) return result;
}
}
delete (await quickJsonAuthSessions())[quickJsonAuthSessionKey(scope, email, workspaceId)];
const workspaceToken = scope === 'team' ? workspaceTokenFor(child, workspaceId) : null;
const token = scope === 'team' ? workspaceToken?.accessToken : child.accessToken;
const claims = accessTokenClaims(token);
if (!token || (scope === 'team' && !teamTokenDetails(token, workspaceId))) {
return { ok: false, status: 409, code: 'scope_token_unavailable', message: '未获取到目标空间的有效 AT' };
}
if (!(scope === 'team' ? workspaceToken?.refreshToken : child.refreshToken)) {
return { ok: false, status: 409, code: 'refresh_token_required', message: '登录未返回 RT,无法生成完整 JSON;请检查账号的 OAuth 授权' };
}
const tokenMatchesKnownTeam = state.mothers.some((item) => configuredTeamKey(item) === workspaceIdKey(claims.accountId));
if (scope === 'free' && (!claims.accountId || freeTokenNeedsRefresh({ accessToken: token }) || claims.planType === 'team' || tokenMatchesKnownTeam)) {
return { ok: false, status: 409, code: 'free_token_unavailable', message: '未获取到有效的 Free AT' };
}
if (claims.email && workspaceIdKey(claims.email) !== workspaceIdKey(email)) {
return { ok: false, status: 409, code: 'token_email_mismatch', message: 'AT 所属邮箱与请求邮箱不一致' };
}
const membership = scope === 'team' ? membershipFor(child, mother.team) : null;
const record = scope === 'team' ? {
...child,
accessToken: token,
refreshToken: workspaceToken.refreshToken || '',
idToken: workspaceToken.idToken || '',
clientId: workspaceToken.clientId || child.clientId || '',
accountId: workspaceId,
team: mother.team || workspaceId,
plan: 'team',
tokenScope: 'team',
expiresAt: claims.expiresAt || workspaceToken.expiresAt || null,
quota5h: membership?.quota5h ?? null,
quota7d: membership?.quota7d ?? null,
quotaSnapshot: membership?.quotaSnapshot || null,
extra: membership?.extra && typeof membership.extra === 'object' ? membership.extra : {},
} : {
...child,
accountId: claims.accountId || child.accountId || '',
team: null,
plan: 'free',
tokenScope: 'free',
expiresAt: claims.expiresAt || child.expiresAt || null,
};
const account = sub2ApiAccountFromChild(record);
const exportedAt = now();
for (const [key, value] of quickJsonTickets) if (value.expiresAt <= Date.now()) quickJsonTickets.delete(key);
const ticket = randomUUID();
quickJsonTickets.set(ticket, { scope, email, motherId: mother?.id || null, account, expiresAt: Date.now() + 300_000 });
addHistory('快捷获取 JSON', `${email} 已取得 ${scope === 'team' ? teamDisplayName(mother) : 'Free'} JSON`);
await persist();
return { ok: true, status: 200, scope, email, ...(mother ? { motherId: mother.id } : {}), ticket, exported_at: exportedAt, exportedAt, proxies: [], accounts: [account], account };
}
async function pushQuickJson(body = {}) {
const scope = String(body.scope || '').trim();
if (!['free', 'team'].includes(scope)) return { ok: false, status: 400, code: 'invalid_scope', message: '请选择 Free 或 Team JSON' };
const ticket = quickJsonTickets.get(String(body.ticket || '').trim());
if (!ticket || ticket.expiresAt <= Date.now() || ticket.scope !== scope || (scope === 'team' && ticket.motherId !== String(body.motherId || ''))) {
return { ok: false, status: 409, code: 'quick_json_ticket_expired', message: 'JSON 已失效,请重新获取后推送' };
}
const mother = scope === 'team' ? findMother(String(body.motherId || '').trim()) : null;
if (scope === 'team' && !mother) return { ok: false, status: 404, code: 'mother_not_found', message: '目标 Team 不存在' };
const configId = String(body.sub2apiIntegrationId || '').trim();
const config = configId ? sub2ApiConfigById(configId) : sub2ApiConfigs()[0];
if (!config) return { ok: false, status: 404, code: 'sub2api_integration_not_found', message: '目标 Sub2API 不存在' };
const parsed = singleSub2ApiAccount({ account: ticket.account });
if (!parsed.ok) return { ok: false, status: 400, code: parsed.message, message: parsed.message };
const fields = credentialFields(parsed.account);
const email = String(fields.email || '').trim();
const claims = accessTokenClaims(fields.accessToken);
const workspaceId = mother ? configuredTeamId(mother) : '';
if (!email || !fields.accessToken || !fields.refreshToken || !fields.accountId || claims.accountId !== fields.accountId
|| (claims.email && workspaceIdKey(claims.email) !== workspaceIdKey(email))
|| (scope === 'team' && (fields.accountId !== workspaceId || !teamTokenDetails(fields.accessToken, workspaceId)))
|| (scope === 'free' && (isTeamAccount(parsed.account) || claims.planType === 'team' || state.mothers.some((item) => configuredTeamKey(item) === workspaceIdKey(fields.accountId)) || freeTokenNeedsRefresh({ accessToken: fields.accessToken })))) {
return { ok: false, status: 400, code: 'invalid_scope_account', message: 'JSON 的邮箱、AT 和目标空间不匹配,或 AT 已过期' };
}
const account = parsed.account;
return pushSub2ApiEntries([{
id: email,
motherId: mother?.id || null,
email,
payload: account,
}], `快捷推送 ${scope === 'team' ? 'Team' : 'Free'} 到 Sub2API`, { config, mode: 'upsert' });
}
async function pushSub2ApiEntries(entries = [], historyLabel = '推送 Sub2API', { config = sub2ApiConfigs()[0] || {}, mode = 'create_only' } = {}) {
const root = sub2ApiRoot(config.baseUrl);
if (!root || !config.apiKey) return { ok: false, status: 400, message: 'sub2api_connection_required', pushed: [], failed: [] };
const group = await resolveSub2ApiGroup(config);
if (!group.ok) return { ok: false, status: group.status || 400, message: group.message, pushed: [], failed: [] };
const groupId = group.id;
const outcomes = await mapWithConcurrency(entries, async (entry) => {
const payload = { ...entry.payload, group_ids: [groupId] };
const email = String(entry.email || payload.email || payload.credentials?.email || '').trim();
const targetAccountId = String(payload.credentials?.chatgpt_account_id || '').trim();
if (!email || !targetAccountId) {
return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: 400, phase: 'identity', message: 'sub2api_identity_requires_email_and_chatgpt_account_id' } };
}
try {
const lookup = await sub2ApiRequest(config, `/admin/accounts?page=1&page_size=100&search=${encodeURIComponent(email)}`);
if (!lookup.ok) return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: lookup.status, phase: 'lookup', message: lookup.message || 'sub2api_lookup_failed' } };
const lookupItems = (data) => Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : Array.isArray(data?.accounts) ? data.accounts : [];
const matchesIdentity = (item) => {
if (String(item?.email || item?.credentials?.email || '').trim().toLowerCase() !== email.toLowerCase()) return false;
const credentials = item?.credentials || {};
const accountId = String(credentials.chatgpt_account_id || credentials.account_id || item?.account_id || '').trim();
return Boolean(accountId) && targetAccountId.toLowerCase() === accountId.toLowerCase();
};
let existing = lookupItems(lookup.data).find(matchesIdentity);
// Search may match the account display name but not its email.
if (!existing) {
for (let page = 1; page <= 20 && !existing; page += 1) {
const pageResult = await sub2ApiRequest(config, `/admin/accounts?page=${page}&page_size=100&platform=openai&type=oauth`);
if (!pageResult.ok) return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: pageResult.status, phase: 'lookup', message: pageResult.message || 'sub2api_lookup_failed' } };
const pageItems = lookupItems(pageResult.data);
existing = pageItems.find(matchesIdentity);
if (!existing && page === 20 && pageItems.length >= 100) {
return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: 409, phase: 'lookup', message: 'sub2api_identity_scan_incomplete' } };
}
if (pageItems.length < 100) break;
}
}
if (mode === 'create_only' && existing?.id) {
return { ok: true, skipped: true, value: { id: entry.id, motherId: entry.motherId, email, targetGroupId: groupId, targetGroupName: group.name || config.groupName || '', integrationId: config.id || null, integrationName: config.name || '', action: 'skipped_existing', sub2apiAccountId: existing.id } };
}
if (mode === 'repair_only' && !existing?.id) {
return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: 404, phase: 'repair_lookup', message: 'sub2api_account_not_found_for_repair' } };
}
const result = (mode === 'repair_only' || mode === 'upsert') && existing?.id
? await sub2ApiRequest(config, `/admin/accounts/${encodeURIComponent(existing.id)}`, { method: 'PUT', body: { ...payload, group_ids: [groupId] } })
: await sub2ApiRequest(config, '/admin/accounts', { method: 'POST', body: payload });
if (!result.ok) return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: result.status, message: result.message } };
return { ok: true, skipped: false, value: { id: entry.id, motherId: entry.motherId, email, targetGroupId: groupId, targetGroupName: group.name || config.groupName || '', integrationId: config.id || null, integrationName: config.name || '', action: mode === 'repair_only' ? 'repaired' : existing?.id ? 'updated' : 'created' } };
} catch (error) {
return { ok: false, value: { id: entry.id, motherId: entry.motherId, email, status: 0, message: error?.name === 'TimeoutError' ? 'timeout' : 'network_error' } };
}
});
const pushed = outcomes.filter((outcome) => outcome.ok && !outcome.skipped).map((outcome) => outcome.value);
const skipped = outcomes.filter((outcome) => outcome.ok && outcome.skipped).map((outcome) => outcome.value);
const failed = outcomes.filter((outcome) => !outcome.ok).map((outcome) => outcome.value);
addHistory(historyLabel, `${config.name || 'Sub2API'} / ${group.name || `分组 ${groupId}`} ${mode === 'repair_only' ? '修复' : mode === 'upsert' ? '同步' : '新增'} ${pushed.length} 个账号${skipped.length ? `,已存在跳过 ${skipped.length} 个` : ''}${failed.length ? `,失败 ${failed.length} 个` : ''}`, failed.length ? 'partial' : 'success');
await persist();
return { ok: failed.length === 0, status: failed.length ? 207 : 200, mode, integrationId: config.id || null, integrationName: config.name || '', targetGroupId: groupId, targetGroupName: group.name || config.groupName || '', pushed, skipped, failed };
}
async function pushSub2ApiAccounts(ids = []) {
const idSet = new Set(Array.isArray(ids) ? ids.map(String) : []);
const entries = state.children
.filter((child) => child.accessToken && (!idSet.size || idSet.has(String(child.id))))
.map((child) => ({ id: child.id, email: child.email, payload: sub2ApiAccountFromChild(child) }));
return pushSub2ApiEntries(entries, '推送 Free 到 Sub2API', { config: sub2ApiConfigs()[0] || {}, mode: 'create_only' });
}
function teamSub2ApiEntries(motherIds = [], emails = []) {
const idSet = new Set(Array.isArray(motherIds) ? motherIds.map(String) : []);
const emailSet = new Set(Array.isArray(emails) ? emails.map((email) => String(email || '').trim().toLowerCase()).filter(Boolean) : []);
return state.mothers
.filter((mother) => !idSet.size || idSet.has(String(mother.id)) || idSet.has(String(mother.accountId || mother.team)))
.flatMap((mother) => selectTeamSub2ApiRecords(teamJsonRecords(mother), mother, mother.syncOwnerToSub2api === false).filter((owner) => !emailSet.size || emailSet.has(String(owner.email || '').trim().toLowerCase())).map((owner) => ({
id: `${mother.id}:${owner.email}`,
motherId: mother.id,
email: owner.email,
payload: sub2ApiAccountFromMotherOwner(mother, owner),
})));
}
async function pushSub2ApiTeams(motherIds = []) {
const idSet = new Set(Array.isArray(motherIds) ? motherIds.map(String) : []);
const mothers = state.mothers.filter((mother) => !idSet.size || idSet.has(String(mother.id)) || idSet.has(String(mother.accountId || mother.team)));
const results = await mapWithConcurrency(mothers, (mother) => pushSub2ApiEntries(teamSub2ApiEntries([mother.id]), '推送 Team 到 Sub2API', {
config: sub2ApiConfigForMother(mother) || {},
mode: 'create_only',
}));
const pushed = results.flatMap((result) => result.pushed || []);
const skipped = results.flatMap((result) => result.skipped || []);
const failed = results.flatMap((result, index) => {
if (result.failed?.length) return result.failed;
if (result.ok === false) return [{ motherId: mothers[index]?.id || null, status: result.status || 400, message: result.message || 'sub2api_push_failed' }];
return [];
});
return {
ok: results.every((result) => result.ok === true),
status: failed.length ? 207 : 200,
pushed,
skipped,
failed,
targets: results.map((result) => ({ integrationId: result.integrationId || null, integrationName: result.integrationName || '', targetGroupId: result.targetGroupId || null, targetGroupName: result.targetGroupName || '', ok: result.ok })),
};
}
// Streamable HTTP MCP transport. The tools deliberately use the same public
// projections as the dashboard so credentials and access tokens never leave
// the server through an Agent call.
const MCP_PROTOCOL_VERSION = '2025-06-18';
const mcpSessions = new Map();
const mcpTools = [
{ name: 'get_state', description: '获取 team轮转当前状态、Team 汇总、账号状态和自动化设置。', inputSchema: { type: 'object', properties: { includeHistory: { type: 'boolean', description: '是否同时返回最近操作历史,默认 true。' } }, additionalProperties: false } },
{ name: 'list_teams', description: '列出所有 Team 空间、所有者、席位和当前成员。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
{ name: 'list_accounts', description: '列出 Free 账号池中的登录状态、加入过的 Team 和凭据可用性。额度只在 Team 检测工具中返回。', inputSchema: { type: 'object', properties: { status: { type: 'string', description: '按状态筛选,例如 ready、active、warning、exhausted。' }, teamId: { type: 'string', description: '只返回当前属于指定 Team 的账号。' } }, additionalProperties: false } },
{ name: 'get_history', description: '获取额度检测、移除、加入和设置变更记录。', inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 200, description: '最多返回多少条,默认 50。' } }, additionalProperties: false } },
{ name: 'check_team_quota', description: '检测一个 Team 及其成员的 5h / 7d 额度,并同步席位和成员快照。', inputSchema: { type: 'object', properties: { teamId: { type: 'string', description: 'Team 记录 id、accountId 或 team id。' } }, required: ['teamId'], additionalProperties: false } },
{ name: 'check_all_teams', description: '检测所有已配置真实凭据的 Team。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
{ name: 'refill_team', description: '对一个 Team 按当前踢出条件移除账号并从待加入池补位。', inputSchema: { type: 'object', properties: { teamId: { type: 'string', description: 'Team 记录 id、accountId 或 team id。' } }, required: ['teamId'], additionalProperties: false } },
{ name: 'refill_all_teams', description: '对所有已配置真实凭据的 Team 执行移除和补位。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
{ name: 'acquire_missing_free_json', description: '按全局并发设置,为所有尚无可导出 JSON 的 Free 账号获取 AT/RT。', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
{ name: 'update_settings', description: '更新自动补位、加入方式、账号角色、额度预警阈值、检测周期、并发数和自动踢出窗口。', inputSchema: { type: 'object', properties: { autoRefill: { type: 'boolean' }, joinMode: { type: 'string', enum: ['request', 'invite'] }, promoteJoinedAccounts: { type: 'boolean' }, threshold: { type: 'number', minimum: 1, maximum: 100 }, checkInterval: { type: 'number', minimum: 30 }, concurrency: { type: 'integer', minimum: 1, maximum: 10 }, kickWindow: { type: 'string', enum: ['5h', '7d', 'time'] }, kickAfterHours: { type: 'number', minimum: 1, maximum: 720 }, delayedKickEnabled: { type: 'boolean' }, delayedKickMinutes: { type: 'integer', minimum: 1, maximum: 1440 } }, additionalProperties: false } },
];
function mcpAuthOk(req) {
const expected = String(process.env.MCP_AUTH_TOKEN || apiAuthToken || '').trim();
if (!expected) return true;
const authorization = String(req.headers.authorization || '');
const supplied = authorization.toLowerCase().startsWith('bearer ') ? authorization.slice(7).trim() : String(req.headers['x-mcp-token'] || '').trim();
return secureTokenEqual(supplied, expected);
}
function mcpSend(res, status, payload, headers = {}) {
const body = JSON.stringify(payload);
res.writeHead(status, { ...responseHeaders(res), 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(body);
}
function mcpRpcError(id, code, message, data) {
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } };
}
function resolveMotherId(value) {
const needle = String(value || '').trim();
const mother = state.mothers.find((item) => item.id === needle || item.accountId === needle || item.team === needle);
return mother?.id || null;
}
function mcpToolPayload(name, args = {}) {
if (name === 'get_state') {
const snapshot = publicState({ includeHistory: args.includeHistory !== false });
if (args.includeHistory === false) delete snapshot.history;
return snapshot;
}
if (name === 'list_teams') return publicState().teams;
if (name === 'list_accounts') {
const requestedTeam = String(args.teamId || '').trim();
const teamId = requestedTeam ? (state.mothers.find((mother) => mother.id === requestedTeam || mother.accountId === requestedTeam || mother.team === requestedTeam)?.team || requestedTeam) : '';
const accounts = state.children
.filter((child) => !args.status || child.status === args.status)
.filter((child) => !teamId || isChildMemberOfTeam(child, teamId))
.map((child) => publicChild(child));
return { count: accounts.length, accounts };
}
if (name === 'get_history') {
const limit = Math.min(200, Math.max(1, Number(args.limit) || 50));
return publicHistory().slice(0, limit);
}
return null;
}
async function mcpCallTool(name, args = {}) {
const readPayload = mcpToolPayload(name, args);
if (readPayload !== null) return readPayload;
if (name === 'check_team_quota') {
const motherId = resolveMotherId(args.teamId);
if (!motherId) return { ok: false, status: 404, message: 'team_not_found' };
return withTeamMaintenanceLock(motherId, 'mcp_check_team', () => checkTeam(motherId));
}
if (name === 'check_all_teams') return checkAllTeams();
if (name === 'refill_team') {
const motherId = resolveMotherId(args.teamId);
if (!motherId) return { ok: false, status: 404, message: 'team_not_found' };
return withTeamMaintenanceLock(motherId, 'mcp_refill_team', () => refillTeam(motherId));
}
if (name === 'refill_all_teams') return refillAllTeams();
if (name === 'acquire_missing_free_json') return withMaintenanceLock('mcp_acquire_missing_free_json', () => acquireMissingFreeJson());
if (name === 'update_settings') {
const input = args && typeof args === 'object' ? args : {};
const previousKickWindow = state.settings.kickWindow;
const previousDelayedKickEnabled = state.settings.delayedKickEnabled;
const previousDelayedKickMinutes = state.settings.delayedKickMinutes;
if (input.autoRefill !== undefined) state.settings.autoRefill = Boolean(input.autoRefill);
if (input.joinMode !== undefined) state.settings.joinMode = normalizeJoinMode(input.joinMode);
if (input.promoteJoinedAccounts !== undefined) state.settings.promoteJoinedAccounts = Boolean(input.promoteJoinedAccounts);
if (input.threshold !== undefined) state.settings.threshold = Math.min(100, Math.max(1, Number(input.threshold) || state.settings.threshold));
if (input.checkInterval !== undefined) state.settings.checkInterval = Math.max(30, Number(input.checkInterval) || state.settings.checkInterval);
if (input.concurrency !== undefined) state.settings.concurrency = normalizeConcurrency(input.concurrency, state.settings.concurrency);
if (input.kickWindow !== undefined) state.settings.kickWindow = normalizeKickWindow(input.kickWindow, state.settings.kickWindow);
if (input.kickAfterHours !== undefined) state.settings.kickAfterHours = normalizeKickAfterHours(input.kickAfterHours, state.settings.kickAfterHours);
if (input.delayedKickEnabled !== undefined) state.settings.delayedKickEnabled = input.delayedKickEnabled === true;
if (input.delayedKickMinutes !== undefined) state.settings.delayedKickMinutes = normalizeDelayedKickMinutes(input.delayedKickMinutes, state.settings.delayedKickMinutes);
reconcileQuotaKickDelaySettings(previousKickWindow, previousDelayedKickEnabled, previousDelayedKickMinutes);
drainOutboundRequestQueue();
configureMaintenanceTimer();
addHistory('更新设置', 'Agent 通过 MCP 更新自动化策略');
await persist();
return publicState().settings;
}
throw new Error(`unknown_tool: ${name}`);
}
async function mcpHandleMessage(message, req, res) {
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return mcpRpcError(message?.id, -32600, 'Invalid Request');
const { id, method, params = {} } = message;
if (method === 'initialize') {
const sessionId = randomUUID();
const requested = String(params.protocolVersion || '');
const protocolVersion = requested === MCP_PROTOCOL_VERSION || requested === '2025-03-26' ? requested : MCP_PROTOCOL_VERSION;
mcpSessions.set(sessionId, protocolVersion);
return { response: { jsonrpc: '2.0', id, result: { protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'team-rotation', version: currentVersion }, instructions: '使用工具管理 Team 额度和补位;服务器不会通过 MCP 返回完整凭据或 access token。' } }, sessionId, protocolVersion };
}
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return { notification: true };
if (method === 'ping') return { response: { jsonrpc: '2.0', id, result: {} } };
if (method === 'tools/list') return { response: { jsonrpc: '2.0', id, result: { tools: mcpTools } } };
if (method === 'tools/call') {
const name = params?.name;
if (!mcpTools.some((tool) => tool.name === name)) return { response: mcpRpcError(id, -32602, `Unknown tool: ${name}`) };
try {
const payload = await mcpCallTool(name, params.arguments || {});
const failed = payload && payload.ok === false && Number(payload.status) >= 400;
return { response: { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(payload) }], isError: Boolean(failed), structuredContent: payload } } };
} catch (error) {
return { response: { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: error?.message || 'tool_failed' }], isError: true } } };
}
}
return { response: mcpRpcError(id, -32601, `Method not found: ${method}`) };
}
async function handleMcp(req, res) {
if (req.method === 'OPTIONS') {
res.writeHead(204, { ...responseHeaders(res), 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', 'access-control-allow-headers': 'content-type, authorization, mcp-session-id, mcp-protocol-version, x-mcp-token', 'access-control-max-age': '600' });
res.end();
return;
}
if (!mcpAuthOk(req)) return mcpSend(res, 401, { error: 'mcp_auth_required' }, { 'www-authenticate': 'Bearer' });
if (req.method === 'GET') return mcpSend(res, 405, { error: 'mcp_post_required' }, { allow: 'POST, DELETE' });
if (req.method === 'DELETE') { const sessionId = String(req.headers['mcp-session-id'] || ''); if (sessionId) mcpSessions.delete(sessionId); res.writeHead(204, responseHeaders(res)); res.end(); return; }
if (req.method !== 'POST') return mcpSend(res, 405, { error: 'method_not_allowed' }, { allow: 'POST, DELETE' });
if (!String(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) return mcpSend(res, 415, { error: 'json_content_type_required' });
const sessionId = String(req.headers['mcp-session-id'] || '');
if (sessionId && !mcpSessions.has(sessionId)) return mcpSend(res, 404, mcpRpcError(null, -32000, 'Unknown MCP session'));
let message;
try { message = await bodyOf(req); } catch (error) { return mcpSend(res, 400, mcpRpcError(null, -32700, error?.message || 'Parse error')); }
const messages = Array.isArray(message) ? message : [message];
const results = [];
let responseSessionId = sessionId || null;
let responseProtocolVersion = mcpSessions.get(sessionId) || MCP_PROTOCOL_VERSION;
for (const item of messages) {
const result = await mcpHandleMessage(item, req, res);
if (result.sessionId) responseSessionId = result.sessionId;
if (result.protocolVersion) responseProtocolVersion = result.protocolVersion;
if (result.response) results.push(result.response);
}
if (!results.length) { res.writeHead(202, responseHeaders(res)); res.end(); return; }
const payload = Array.isArray(message) ? results : results[0];
return mcpSend(res, 200, payload, { ...(responseSessionId ? { 'mcp-session-id': responseSessionId } : {}), 'mcp-protocol-version': responseProtocolVersion });
}
async function handleApi(req, res, url) {
const method = req.method || 'GET';
const segments = url.pathname.split('/').filter(Boolean);
if (method === 'OPTIONS') {
res.writeHead(204, { ...responseHeaders(res), 'access-control-allow-methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS', 'access-control-allow-headers': 'content-type, authorization, x-api-token', 'access-control-max-age': '600' });
res.end();
return;
}
if (method === 'GET' && url.pathname === '/api/auth/session') {
return sendJson(res, 200, { authenticated: dashboardAuth.authenticated(req) });
}
if (method === 'POST' && url.pathname === '/api/auth/login') {
if (!String(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
return sendJson(res, 415, { code: 'json_content_type_required', message: 'Content-Type 必须是 application/json' });
}
const body = await bodyOf(req);
const token = dashboardAuth.login(body?.password, req);
if (!token) return sendJson(res, dashboardAuth.isRateLimited(req) ? 429 : 401, { code: 'invalid_login', message: dashboardAuth.isRateLimited(req) ? '尝试次数过多,请稍后重试' : '密码错误' });
res.setHeader('set-cookie', dashboardAuth.cookie(token, req));
return sendJson(res, 200, { authenticated: true });
}
if (method === 'POST' && url.pathname === '/api/auth/logout') {
dashboardAuth.logout(req);
res.setHeader('set-cookie', dashboardAuth.clearCookie(req));
return sendJson(res, 200, { authenticated: false });
}
if (!dashboardAuth.authenticated(req) && !(apiAuthToken && apiAuthOk(req))) {
return sendJson(res, 401, { code: 'login_required', message: '请先登录' });
}
if (!apiAuthOk(req)) return sendJson(res, 401, { code: 'api_auth_required', message: 'API Token 无效或未提供' });
const hasJsonBody = ['POST', 'PATCH', 'PUT'].includes(method)
|| method === 'DELETE' && url.pathname === '/api/proxy/entries';
if (hasJsonBody && !String(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
return sendJson(res, 415, { code: 'json_content_type_required', message: 'Content-Type 必须是 application/json' });
}
const body = hasJsonBody ? await bodyOf(req) : {};
if (method === 'GET' && url.pathname === '/api/state') {
const includeHistory = !['false', '0', 'no'].includes(String(url.searchParams.get('includeHistory') || '').toLowerCase());
return sendJson(res, 200, publicState({ includeHistory }));
}
if (method === 'GET' && url.pathname === '/api/history') {
const hasPagination = ['page', 'pageSize', 'page_size', 'limit', 'offset'].some((key) => url.searchParams.has(key));
const allHistory = publicHistory();
if (!hasPagination) return sendJson(res, 200, allHistory);
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
const requestedPageSize = Number.parseInt(url.searchParams.get('pageSize') || url.searchParams.get('page_size') || url.searchParams.get('limit') || '20', 10);
const pageSize = Number.isFinite(requestedPageSize) ? Math.min(200, Math.max(1, requestedPageSize)) : 20;
const total = allHistory.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const requestedOffset = Number.parseInt(url.searchParams.get('offset') || '', 10);
const offsetPage = Number.isFinite(requestedOffset) ? Math.floor(Math.max(0, requestedOffset) / pageSize) + 1 : null;
const page = Number.isFinite(requestedPage) && !url.searchParams.has('offset')
? Math.min(totalPages, Math.max(1, requestedPage))
: Math.min(totalPages, Math.max(1, offsetPage || 1));
const start = (page - 1) * pageSize;
const items = allHistory.slice(start, start + pageSize);
return sendJson(res, 200, { items, page, pageSize, offset: start, limit: pageSize, total, totalPages, hasPreviousPage: page > 1, hasNextPage: page < totalPages });
}
if (method === 'GET' && url.pathname === '/api/health') return sendJson(res, 200, { ok: true, updatedAt: state.updatedAt });
if (segments[1] === 'mailbox-vault') {
try {
if (segments.length === 2 && method === 'GET') {
return sendJson(res, 200, { records: state.mailboxVault.map(publicMailboxVaultRecord) });
}
if (segments.length === 2 && method === 'POST') {
const record = createMailboxVaultRecord(body, state.mailboxVault);
state.mailboxVault.unshift(record);
await persist();
return sendJson(res, 201, { record: publicMailboxVaultRecord(record) });
}
if (segments.length === 3 && segments[2] === 'import' && method === 'POST') {
const result = importMailboxVaultRecords(body, state.mailboxVault);
if (result.added.length) {
state.mailboxVault.unshift(...result.added);
await persist();
}
return sendJson(res, 200, { imported: result.added.length, skipped: result.skipped, records: result.added.map(publicMailboxVaultRecord) });
}
if (segments.length === 3 && segments[2] === 'export' && method === 'POST') {
return sendJson(res, 200, exportMailboxVaultRecords(state.mailboxVault, body?.ids));
}
if (segments.length === 3 && segments[2] === 'mark-exported' && method === 'POST') {
const selected = markMailboxVaultRecordsExported(state.mailboxVault, body?.records);
const updated = new Map(selected.map((record) => [record.id, record]));
state.mailboxVault = state.mailboxVault.map((record) => updated.get(record.id) || record);
await persist();
return sendJson(res, 200, { records: selected.map(publicMailboxVaultRecord) });
}
if (segments.length === 3 && segments[2] === 'batch-delete' && method === 'POST') {
const selected = selectMailboxVaultRecords(state.mailboxVault, body?.ids);
const ids = new Set(selected.map((record) => record.id));
state.mailboxVault = state.mailboxVault.filter((record) => !ids.has(record.id));
await persist();
return sendJson(res, 200, { deleted: selected.length });
}
if (segments.length === 3 && ['GET', 'PATCH', 'DELETE'].includes(method)) {
const index = state.mailboxVault.findIndex((record) => record.id === segments[2]);
if (index < 0) return sendJson(res, 404, { code: 'mailbox_vault_not_found', message: '此邮箱记录不存在,请刷新列表' });
const record = state.mailboxVault[index];
if (method === 'GET') return sendJson(res, 200, { record: fullMailboxVaultRecord(record) });
if (method === 'PATCH') {
const updated = updateMailboxVaultRecord(record, body, state.mailboxVault);
state.mailboxVault[index] = updated;
await persist();
return sendJson(res, 200, { record: publicMailboxVaultRecord(updated) });
}
state.mailboxVault.splice(index, 1);
await persist();
return sendJson(res, 200, { deleted: 1 });
}
return sendJson(res, 404, { code: 'mailbox_vault_route_not_found', message: '未找到此操作' });
} catch (error) {
if (!(error instanceof MailboxVaultError)) throw error;
return sendJson(res, error.status, { code: error.code, message: error.message, ...(error.errors ? { errors: error.errors } : {}) });
}
}
if (segments[1] === 'mother-vault') {
try {
if (segments.length === 2 && method === 'GET') {
return sendJson(res, 200, { records: state.motherVault.map(publicMotherVaultRecord) });
}
if (segments.length === 2 && method === 'POST') {
const record = createMotherVaultRecord(body, state.motherVault);
state.motherVault.unshift(record);
await persist();
return sendJson(res, 201, { record: publicMotherVaultRecord(record) });
}
if (segments.length === 3 && segments[2] === 'import' && method === 'POST') {
const result = importMotherVaultRecords(body, state.motherVault);
if (result.added.length) {
state.motherVault.unshift(...result.added);
await persist();
}
return sendJson(res, 200, { imported: result.added.length, skipped: result.skipped, records: result.added.map(publicMotherVaultRecord) });
}
if (segments.length === 3 && segments[2] === 'export' && method === 'POST') {
return sendJson(res, 200, exportMotherVaultRecords(state.motherVault, body?.ids));
}
if (segments.length === 3 && segments[2] === 'batch-delete' && method === 'POST') {
const selected = selectMotherVaultRecords(state.motherVault, body?.ids);
const ids = new Set(selected.map((record) => record.id));
state.motherVault = state.motherVault.filter((record) => !ids.has(record.id));
await persist();
return sendJson(res, 200, { deleted: selected.length });
}
if (segments.length === 3 && ['GET', 'PATCH', 'DELETE'].includes(method)) {
const index = state.motherVault.findIndex((record) => record.id === segments[2]);
if (index < 0) return sendJson(res, 404, { code: 'mother_vault_not_found', message: '此账号记录不存在,请刷新列表' });
const record = state.motherVault[index];
if (method === 'GET') return sendJson(res, 200, { record: fullMotherVaultRecord(record) });
if (method === 'PATCH') {
const updated = updateMotherVaultRecord(record, body, state.motherVault);
state.motherVault[index] = updated;
await persist();
return sendJson(res, 200, { record: publicMotherVaultRecord(updated) });
}
state.motherVault.splice(index, 1);
await persist();
return sendJson(res, 200, { deleted: 1 });
}
return sendJson(res, 404, { code: 'mother_vault_route_not_found', message: '未找到此操作' });
} catch (error) {
if (!(error instanceof MotherVaultError)) throw error;
return sendJson(res, error.status, { code: error.code, message: error.message, ...(error.errors ? { errors: error.errors } : {}) });
}
}
if (method === 'GET' && url.pathname === '/api/version') return sendJson(res, 200, { currentVersion });
if (method === 'POST' && url.pathname === '/api/tools/quick-json/acquire') {
const result = await withMaintenanceLock('http_quick_json_acquire', () => acquireQuickJson(body));
return sendJson(res, result.status || (result.ok ? 200 : 502), result);
}
if (method === 'POST' && url.pathname === '/api/tools/quick-json/push') {
const result = await withMaintenanceLock('http_quick_json_push', () => pushQuickJson(body));
return sendJson(res, result.status || (result.ok ? 200 : 502), result);
}
if (method === 'PATCH' && url.pathname === '/api/settings') {
const autoRefillWasEnabled = state.settings.autoRefill === true;
const previousKickWindow = state.settings.kickWindow;
const previousDelayedKickEnabled = state.settings.delayedKickEnabled;
const previousDelayedKickMinutes = state.settings.delayedKickMinutes;
const allowed = ['autoRefill', 'joinMode', 'promoteJoinedAccounts', 'threshold', 'checkInterval', 'concurrency', 'kickOnExhausted', 'kickWindow', 'kickAfterHours', 'delayedKickEnabled', 'delayedKickMinutes'];
for (const key of allowed) {
if (body[key] === undefined) continue;
if (key === 'autoRefill' || key === 'promoteJoinedAccounts' || key === 'kickOnExhausted') state.settings[key] = Boolean(body[key]);
else if (key === 'joinMode') state.settings.joinMode = normalizeJoinMode(body[key]);
else if (key === 'delayedKickEnabled') state.settings[key] = body[key] === true;
else if (key === 'kickWindow') state.settings.kickWindow = normalizeKickWindow(body[key], state.settings.kickWindow);
else if (key === 'kickAfterHours') state.settings.kickAfterHours = normalizeKickAfterHours(body[key], state.settings.kickAfterHours);
else if (key === 'delayedKickMinutes') state.settings.delayedKickMinutes = normalizeDelayedKickMinutes(body[key], state.settings.delayedKickMinutes);
else if (key === 'concurrency') state.settings.concurrency = normalizeConcurrency(body[key], state.settings.concurrency);
else state.settings[key] = Math.max(1, Number(body[key]) || state.settings[key]);
}
reconcileQuotaKickDelaySettings(previousKickWindow, previousDelayedKickEnabled, previousDelayedKickMinutes);
drainOutboundRequestQueue();
configureMaintenanceTimer();
addHistory('更新设置', '自动化策略已更新');
await persist();
if (!autoRefillWasEnabled && state.settings.autoRefill === true) setTimeout(() => { void runMaintenanceCycle(); }, 0).unref?.();
return sendJson(res, 200, { settings: publicState({ includeHistory: false }).settings, state: publicState({ includeHistory: false }) });
}
if (method === 'PATCH' && url.pathname === '/api/proxy') {
const current = structuredClone(state.settings.proxy || emptyState.settings.proxy);
if (body.localProxyEnabled !== undefined && typeof body.localProxyEnabled !== 'boolean') {
return sendJson(res, 400, { code: 'local_proxy_config_invalid', message: '本机前置代理开关必须为布尔值' });
}
if (body.localProxyUrl !== undefined) {
try { current.localProxyUrl = normalizeLocalProxyUrl(body.localProxyUrl); }
catch { return sendJson(res, 400, { code: 'local_proxy_url_invalid', message: '本机前置代理必须使用本机 HTTP 或 SOCKS5 地址,不能包含账号密码或路径' }); }
}
if (body.localProxyEnabled !== undefined) current.localProxyEnabled = body.localProxyEnabled;
if (body.enabled !== undefined) current.enabled = Boolean(body.enabled);
if (body.strategy !== undefined) current.strategy = body.strategy === 'round_robin' ? 'round_robin' : 'failover';
if (body.timeoutMs !== undefined) { const value = Number(body.timeoutMs); if (Number.isFinite(value)) current.timeoutMs = Math.min(120000, Math.max(1000, value)); }
if (body.maxRetries !== undefined) { const value = Number(body.maxRetries); if (Number.isFinite(value)) current.maxRetries = Math.min(5, Math.max(0, value)); }
state.settings.proxy = current;
addHistory('更新代理设置', current.enabled ? `代理池已启用(${current.entries?.length || 0} 条)` : '代理池已停用');
await persist();
return sendJson(res, 200, { proxy: publicState({ includeHistory: false }).settings.proxy, settings: publicState({ includeHistory: false }).settings, state: publicState({ includeHistory: false }) });
}
if (method === 'POST' && url.pathname === '/api/proxy/entries') {
const rawValues = Array.isArray(body.values)
? body.values
: typeof body.values === 'string'
? body.values.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)
: body.value !== undefined ? [body] : [];
const current = state.settings.proxy || (state.settings.proxy = structuredClone(emptyState.settings.proxy));
if (!Array.isArray(current.entries)) current.entries = [];
const added = [];
const errors = [];
for (const item of rawValues) {
const raw = typeof item === 'string' ? item : item?.value || item?.proxy || item?.url;
const label = typeof item === 'object' ? String(item?.label || '').trim() : '';
try {
const parsed = parseProxyInput(raw);
const duplicate = current.entries.some((entry) => entry.url === parsed.url);
if (duplicate) {
errors.push({ message: 'proxy_duplicate' });
continue;
}
const entry = {
id: randomUUID(),
label: label || (parsed.sourceType === 'local' ? '本机代理' : '家宽代理'),
...parsed,
display: maskProxyUrl(parsed.url),
createdAt: now(),
};
current.entries.push(entry);
added.push(entry);
} catch (error) {
errors.push({ message: error?.message || 'invalid_proxy' });
}
}
if (added.length) {
addHistory('添加代理', `新增 ${added.length} 条代理${errors.length ? `,${errors.length} 条未添加` : ''}`);
await persist();
}
if (!added.length && errors.length) return sendJson(res, 400, { added: [], errors, proxy: publicState({ includeHistory: false }).settings.proxy });
const addedPublic = added.map((entry) => publicProxySettings({ proxy: { entries: [entry] } }).entries[0]);
return sendJson(res, 200, { added: addedPublic, errors, proxy: publicState({ includeHistory: false }).settings.proxy, state: publicState({ includeHistory: false }) });
}
if (method === 'DELETE' && segments[1] === 'proxy' && segments[2] === 'entries' && !segments[4]) {
const current = state.settings.proxy || (state.settings.proxy = structuredClone(emptyState.settings.proxy));
const requested = segments[3] ? [decodeURIComponent(segments[3])] : body.ids;
if (!Array.isArray(requested) || !requested.length || requested.length > 1000
|| requested.some((id) => typeof id !== 'string' || !id.trim() || id.length > 128)) {
return sendJson(res, 400, { code: 'proxy_ids_required', message: '请选择需要删除的代理' });
}
const ids = new Set(requested);
const entries = Array.isArray(current.entries) ? current.entries : [];
if ([...ids].some((id) => !entries.some((entry) => entry.id === id))) {
return sendJson(res, 404, { code: 'proxy_not_found', message: '部分代理已不存在,请刷新列表后重试' });
}
if ([...ids].some((id) => activeAuthorizationProxies.has(id))) {
return sendJson(res, 409, { code: 'proxy_in_use', message: '所选代理正在用于账号授权,请等待当前任务结束后删除' });
}
current.entries = entries.filter((entry) => !ids.has(entry.id));
if (!current.entries.length) current.enabled = false;
for (const profile of state.settings.integrations?.roxy?.profiles || []) {
if (ids.has(profile.proxyId)) profile.proxyId = '';
}
for (const child of state.children) {
if (ids.has(child.roxyProxyId)) child.roxyProxyId = '';
}
addHistory('删除代理', `已移除 ${ids.size} 条代理;相关窗口下次授权时重新分配代理`);
await persist();
return sendJson(res, 200, { deletedIds: [...ids], proxy: publicState({ includeHistory: false }).settings.proxy, state: publicState({ includeHistory: false }) });
}
if (method === 'PATCH' && url.pathname === '/api/integrations') {
const current = state.settings.integrations || (state.settings.integrations = structuredClone(emptyState.settings.integrations));
const integrationBefore = structuredClone(current);
if (Object.hasOwn(body, 'roxy')) {
try {
if (!body.roxy || typeof body.roxy !== 'object' || Array.isArray(body.roxy)) throw new RoxyBrowserError('roxy_config_invalid', { status: 400 });
// An omitted key preserves the encrypted secret; an explicit empty key clears it.
const incoming = { ...body.roxy };
if (!Object.hasOwn(incoming, 'apiKey')) incoming.apiKey = current.roxy?.apiKey || '';
if (current.roxy?.poolProfiles && ((incoming.workspaceId !== undefined && incoming.workspaceId !== current.roxy?.workspaceId)
|| (incoming.baseUrl !== undefined && incoming.baseUrl !== current.roxy?.baseUrl))) incoming.poolProfiles = [];
const nextRoxy = normalizeRoxyConfig(incoming, current.roxy || { ...DEFAULT_ROXY_CONFIG, profiles: [] });
if (nextRoxy.profiles.some((entry) => !findChild(entry.childId))) {
return sendJson(res, 400, { code: 'roxy_child_not_found', message: 'Roxy 绑定的本地账号不存在,请刷新账号列表' });
}
if ((childAuthQueues.size || roxyProfileCleanupJobs) && JSON.stringify(nextRoxy) !== JSON.stringify(current.roxy)) {
return sendJson(res, 409, { code: 'roxy_profile_busy', message: '账号授权正在运行,请等待任务结束后修改 Roxy 配置' });
}
current.roxy = bindRoxyProxyProfiles(nextRoxy);
} catch (error) {
state.settings.integrations = integrationBefore;
const status = Number(error?.status) >= 400 && Number(error?.status) < 500 ? error.status : 400;
return sendJson(res, status, { code: error?.code || 'roxy_config_invalid', message: error?.message || 'Roxy 配置无效' });
}
}
if (Array.isArray(body.sub2apis)) {
replaceSub2ApiConfigs(body.sub2apis);
} else if (body.sub2api && typeof body.sub2api === 'object') {
const input = body.sub2api;
const first = sub2ApiConfigs()[0] || normalizeSub2ApiConfig({}, 0);
replaceSub2ApiConfigs([{ ...first, ...input, apiKey: input.apiKey === undefined ? first.apiKey : String(input.apiKey || '').trim() }, ...sub2ApiConfigs().slice(1)]);
}
if (body.mailbox && typeof body.mailbox === 'object') {
const input = body.mailbox;
current.mailbox = { ...current.mailbox, ...(input.serviceType !== undefined ? { serviceType: String(input.serviceType || 'manual') } : {}), ...(input.endpoint !== undefined ? { endpoint: String(input.endpoint || '').trim() } : {}), ...(input.enabled !== undefined ? { enabled: Boolean(input.enabled) } : {}) };
if (input.apiKey !== undefined) current.mailbox.apiKey = String(input.apiKey || '').trim();
}
addHistory('更新集成', '集成配置已更新');
await persist();
return sendJson(res, 200, { settings: publicState({ includeHistory: false }).settings, state: publicState({ includeHistory: false }) });
}
if (method === 'POST' && url.pathname === '/api/integrations/roxy/cleanup-successful') {
const config = state.settings.integrations?.roxy;
if (!config?.enabled || config.deleteProfileAfterSuccess !== true) {
return sendJson(res, 409, { code: 'roxy_cleanup_disabled', message: '请先开启成功后释放环境名额' });
}
if (childAuthQueues.size || activeRoxyPoolSlots.size || roxyProfileCleanupJobs) {
return sendJson(res, 409, { code: 'roxy_profile_busy', message: '请等待授权任务结束后清理已成功账号的环境' });
}
if (!Array.isArray(body.childIds) || !body.childIds.length || body.childIds.length > 1000) {
return sendJson(res, 400, { code: 'child_ids_required', message: '请选择已保存 JSON 的账号' });
}
const ids = new Set(body.childIds.map(String));
const seen = new Set();
const candidates = config.profiles.filter((profile) => {
const child = findChild(profile.childId);
const claims = accessTokenClaims(child?.accessToken);
const knownIdentity = claims.email && workspaceIdKey(claims.email) === workspaceIdKey(child?.email)
|| claims.userId && child?.chatgptUserId === claims.userId;
const saved = child && (child.accessToken && child.refreshToken && knownIdentity && childTokenIdentityMatches(child, child.accessToken)
&& Number.isFinite(Date.parse(claims.expiresAt || ''))
|| state.mothers.some((mother) => completeTeamJsonFor(child, mother, { allowExpired: true })));
if (!ids.has(profile.childId) || !saved || seen.has(profile.dirId)) return false;
seen.add(profile.dirId);
return true;
});
roxyProfileCleanupJobs += 1;
let finishCleanup;
roxyProfileCleanupFinished = new Promise((resolve) => { finishCleanup = resolve; });
const results = [];
try {
for (const profile of candidates) {
try {
const result = await releaseSavedRoxyProfile(findChild(profile.childId), config, profile.dirId);
results.push({ childId: profile.childId, dirId: profile.dirId, ...result });
} catch {
results.push({ childId: profile.childId, dirId: profile.dirId, deleted: false, released: false, code: 'roxy_profile_delete_failed' });
}
}
} finally { roxyProfileCleanupJobs -= 1; finishCleanup(); }
return sendJson(res, 200, { ok: results.every((result) => result.released),
deleted: results.filter((result) => result.deleted).length, results,
state: publicState({ includeHistory: false }) });
}
if (method === 'POST' && url.pathname === '/api/integrations/roxy/check') {
try {
const current = state.settings.integrations?.roxy || { ...DEFAULT_ROXY_CONFIG, profiles: [] };
if (Object.hasOwn(body, 'roxy') && (!body.roxy || typeof body.roxy !== 'object' || Array.isArray(body.roxy))) throw new RoxyBrowserError('roxy_config_invalid', { status: 400 });
const input = body.roxy && typeof body.roxy === 'object' ? { ...body.roxy } : {};
if (!Object.hasOwn(input, 'apiKey')) input.apiKey = current.apiKey || '';
// Checking the Local API also works before the user chooses a workspace.
const config = normalizeRoxyConfig({ ...input, enabled: false }, current);
const client = createRoxyApiClient(config, { timeoutMs: 10000 });
await client.health();
const workspaces = await client.listWorkspaces({ pageIndex: 1, pageSize: 100 });
const workspaceId = config.workspaceId;
const profiles = workspaceId ? await client.listProfiles(workspaceId, { pageIndex: 1, pageSize: 100 }) : { total: 0, rows: [] };
return sendJson(res, 200, { ok: true,
workspaces: workspaces.rows.map((item) => ({ id: item.id, name: item.workspaceName })),
profiles: profiles.rows.map((item) => ({ dirId: item.dirId, name: item.windowName || String(item.windowSortNum || item.dirId) })),
workspacesTotal: workspaces.total, profilesTotal: profiles.total,
truncated: workspaces.total > workspaces.rows.length || profiles.total > profiles.rows.length,
message: workspaceId && !profiles.total ? 'Roxy API 已连接,所选团队没有可用窗口' : 'Roxy 本机 API 连接成功' });
} catch (error) {
const safe = error instanceof RoxyBrowserError ? error : new RoxyBrowserError('roxy_api_unavailable', { status: 502 });
return sendJson(res, safe.status, { ok: false, code: safe.code, message: safe.message });
}
}
if (method === 'GET' && url.pathname === '/api/integrations/sub2api/groups') {
const config = sub2ApiConfigById(url.searchParams.get('integrationId')) || sub2ApiConfigs()[0] || {};
return sendJson(res, 200, await querySub2ApiGroups(config));
}
if (method === 'POST' && url.pathname === '/api/teams/billing-preview') {
return withMaintenanceLock('http_billing_preview_all', async () => {
const result = await scanWorkspaceBillingPreviews({
motherIds: body.motherIds,
thresholdDays: body.thresholdDays,
});
return sendJson(res, result.status || 200, result);
});
}
if (method === 'POST' && url.pathname === '/api/mothers') {
return withMaintenanceLock('http_create_team', async () => {
if (body.sub2apiIntegrationId !== undefined && !sub2ApiConfigById(body.sub2apiIntegrationId)) return sendJson(res, 400, { message: 'sub2api_integration_not_found' });
if (body.defaultSeatType !== undefined && !['default', 'prolite'].includes(String(body.defaultSeatType).trim().toLowerCase())) return sendJson(res, 400, { message: 'invalid_default_seat_type' });
const imported = motherFromImportedAccount(body);
const importedTeam = body.team !== undefined ? String(body.team || '').trim() : (body.accountId ? String(body.accountId).trim() : imported.team);
const mother = { ...imported, id: body.id || imported.id, email: String(body.email || imported.email), name: String(body.name || imported.name), team: importedTeam, teamName: String(body.teamName || body.displayName || ''), rotationMode: body.rotationMode === 'rotating' ? 'rotating' : 'fixed', rotationEnabled: normalizeTeamRotationEnabled(body.rotationEnabled ?? imported.rotationEnabled), defaultSeatType: normalizeDefaultSeatType(body.defaultSeatType, imported.defaultSeatType), dailyRotationLimit: normalizeDailyRotationLimit(body.dailyRotationLimit ?? imported.dailyRotationLimit), primaryOwnerEmail: String(body.primaryOwnerEmail || imported.primaryOwnerEmail || body.email || imported.email || ''), syncOwnerToSub2api: body.syncOwnerToSub2api !== false, sub2apiIntegrationId: sub2ApiConfigById(body.sub2apiIntegrationId)?.id || sub2ApiConfigs()[0]?.id || DEFAULT_SUB2API_ID, seats: body.seats == null ? imported.seats : Number(body.seats), used: body.used == null ? imported.used : Number(body.used), status: 'unconfigured', lastCheck: null };
if (!body.teamName && !body.displayName) mother.teamName = imported.teamName || '';
if (body.accountId !== undefined) mother.accountId = String(body.accountId || '').trim();
if (mother.accountId) mother.team = mother.accountId;
const incomingTeamId = configuredTeamId(mother);
const duplicate = state.mothers.find((item) => incomingTeamId
? configuredTeamKey(item) === workspaceIdKey(incomingTeamId)
: !configuredTeamId(item) && mother.email && item.email && item.email.toLowerCase() === mother.email.toLowerCase());
if (duplicate) {
const existingId = duplicate.id;
const importedFields = Object.fromEntries(Object.entries(mother).filter(([, value]) => value !== '' && value !== null && value !== undefined));
if (body.rotationEnabled === undefined && body.rotation_enabled === undefined) delete importedFields.rotationEnabled;
if (body.syncOwnerToSub2api === undefined) delete importedFields.syncOwnerToSub2api;
const defaultSeatTypeChanged = body.defaultSeatType !== undefined
&& normalizeDefaultSeatType(body.defaultSeatType) !== normalizeDefaultSeatType(duplicate.defaultSeatType);
if (defaultSeatTypeChanged) {
const prospective = { ...duplicate, ...importedFields, id: existingId };
const remote = await setWorkspaceDefaultSeatType(prospective, configuredTeamId(prospective), body.defaultSeatType);
if (!remote.ok) return sendJson(res, remote.status || 502, { message: 'default_seat_type_update_failed', detail: remote.message, upstreamStatus: remote.status || null });
}
Object.assign(duplicate, importedFields);
duplicate.id = existingId;
addHistory('更新母号', `${duplicate.email} 已从导入记录更新`);
await persist();
return sendJson(res, 200, publicState({ includeHistory: false }));
}
if (body.defaultSeatType !== undefined) {
const remote = await setWorkspaceDefaultSeatType(mother, configuredTeamId(mother), body.defaultSeatType);
if (!remote.ok) return sendJson(res, remote.status || 502, { message: 'default_seat_type_update_failed', detail: remote.message, upstreamStatus: remote.status || null });
}
state.mothers.push(mother);
addHistory('添加母号', `${mother.email} 已加入母号列表`);
await persist();
return sendJson(res, 201, publicState({ includeHistory: false }));
});
}
if (method === 'GET' && segments[1] === 'mothers' && segments[2] && segments[3] === 'invitation-capacity' && !segments[4]) {
return withTeamMaintenanceLock(segments[2], 'http_invitation_capacity', async () => {
const mother = findMother(segments[2]);
if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const result = await refreshInvitationCapacity(mother);
const { membersResult, subscriptionResult, ...safe } = result;
return sendJson(res, 200, { ...safe, state: publicState({ includeHistory: false }) });
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'invitations' && !segments[4]) {
return withTeamMaintenanceLock(segments[2], 'http_invite_selected_children', async () => {
const result = await inviteSelectedChildren(segments[2], body);
return sendJson(res, result.status, result.payload);
});
}
if (method === 'DELETE' && segments[1] === 'mothers' && segments[2] && segments[3] === 'invitations' && segments[4] && !segments[5]) {
return withTeamMaintenanceLock(segments[2], 'http_cancel_invitation', async () => {
const result = await cancelTeamInvitation(segments[2], segments[4]);
return sendJson(res, result.status, result.payload);
});
}
if (method === 'PATCH' && segments[1] === 'mothers' && segments[2]) {
return withTeamMaintenanceLock(segments[2], `http_update_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
if (body.sub2apiIntegrationId !== undefined && !sub2ApiConfigById(body.sub2apiIntegrationId)) return sendJson(res, 400, { message: 'sub2api_integration_not_found' });
if (body.defaultSeatType !== undefined && !['default', 'prolite'].includes(String(body.defaultSeatType).trim().toLowerCase())) return sendJson(res, 400, { message: 'invalid_default_seat_type' });
const fields = credentialFields(body);
const previousTeam = canonicalTeamId(mother);
const nextAccountId = fields.accountId
|| (body.accountId !== undefined ? String(body.accountId || '').trim() : undefined)
|| (body.team !== undefined ? String(body.team || '').trim() : mother.accountId);
const nextTeam = body.team !== undefined
? String(body.team || '').trim()
: (body.accountId !== undefined ? nextAccountId : mother.team);
const prospectiveTeamId = configuredTeamId({
...mother,
...(nextAccountId !== undefined ? { accountId: nextAccountId } : {}),
...(nextTeam !== undefined ? { team: nextTeam } : {}),
});
const workspaceChanges = workspaceIdKey(previousTeam) !== workspaceIdKey(prospectiveTeamId);
if (workspaceChanges && seatClaimEntriesForWorkspace(mother).length) {
return sendJson(res, 409, { message: 'team_has_pending_seat_claims' });
}
if (workspaceChanges && teamHasActiveManualKickTimers(mother)) {
return sendJson(res, 409, { message: 'team_has_active_manual_kick_timers' });
}
if (prospectiveTeamId && state.mothers.some((candidate) => candidate !== mother && configuredTeamKey(candidate) === workspaceIdKey(prospectiveTeamId))) {
return sendJson(res, 409, { message: 'team_workspace_exists' });
}
const nextDefaultSeatType = body.defaultSeatType === undefined
? normalizeDefaultSeatType(mother.defaultSeatType, mother.inviteSeatType)
: normalizeDefaultSeatType(body.defaultSeatType);
if (body.defaultSeatType !== undefined && nextDefaultSeatType !== normalizeDefaultSeatType(mother.defaultSeatType, mother.inviteSeatType)) {
const prospective = {
...mother,
...(body.email !== undefined ? { email: String(body.email || '') } : {}),
...(nextAccountId !== undefined ? { accountId: nextAccountId } : {}),
...(nextTeam !== undefined ? { team: nextTeam || nextAccountId || mother.id } : {}),
...(fields.accessToken ? { accessToken: fields.accessToken } : {}),
...(fields.refreshToken ? { refreshToken: fields.refreshToken } : {}),
...(fields.password ? { password: fields.password } : {}),
...(fields.totp ? { totp: fields.totp } : {}),
};
const remote = await setWorkspaceDefaultSeatType(prospective, prospectiveTeamId, nextDefaultSeatType);
if (!remote.ok) return sendJson(res, remote.status || 502, { message: 'default_seat_type_update_failed', detail: remote.message, upstreamStatus: remote.status || null });
}
Object.assign(mother, ['email', 'name', 'teamName', 'displayName', 'seats'].reduce((out, key) => body[key] !== undefined ? { ...out, [key]: key === 'seats' ? (body[key] == null ? null : Number(body[key])) : body[key] } : out, {}));
if (body.primaryOwnerEmail !== undefined) mother.primaryOwnerEmail = String(body.primaryOwnerEmail || '').trim() || mother.email || '';
if (nextAccountId !== undefined) mother.accountId = nextAccountId;
if (nextTeam !== undefined) mother.team = nextTeam || mother.accountId || mother.id;
if (body.rotationMode !== undefined) mother.rotationMode = body.rotationMode === 'rotating' ? 'rotating' : 'fixed';
if (body.syncOwnerToSub2api !== undefined) mother.syncOwnerToSub2api = body.syncOwnerToSub2api === true;
if (body.rotationEnabled !== undefined) mother.rotationEnabled = normalizeTeamRotationEnabled(body.rotationEnabled, mother.rotationEnabled);
if (body.defaultSeatType !== undefined) mother.defaultSeatType = nextDefaultSeatType;
if (body.dailyRotationLimit !== undefined) mother.dailyRotationLimit = normalizeDailyRotationLimit(body.dailyRotationLimit, mother.dailyRotationLimit);
if (body.sub2apiIntegrationId !== undefined) mother.sub2apiIntegrationId = String(body.sub2apiIntegrationId);
if (fields.accessToken) mother.accessToken = fields.accessToken;
if (fields.refreshToken) mother.refreshToken = fields.refreshToken;
if (fields.password) mother.password = fields.password;
if (fields.totp) mother.totp = fields.totp;
if (body.mailboxUrl !== undefined || body.mailbox_url !== undefined) mother.mailboxUrl = fields.mailboxUrl;
if (fields.accountId) mother.accountId = fields.accountId;
if (fields.chatgptUserId) mother.chatgptUserId = fields.chatgptUserId;
if (fields.planType) mother.planType = fields.planType;
promotePrimaryOwner(mother);
if (mother.accountId) mother.team = String(mother.accountId).trim();
migrateTeamMemberships(previousTeam, canonicalTeamId(mother));
addHistory('更新母号', `${mother.email} 配置已更新`);
await persist();
return sendJson(res, 200, publicState({ includeHistory: false }));
});
}
if (method === 'DELETE' && segments[1] === 'mothers' && segments[2] && !segments[3]) {
return withTeamMaintenanceLock(segments[2], `http_delete_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]);
if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
if (seatClaimEntriesForWorkspace(mother).length) return sendJson(res, 409, { message: 'team_has_pending_seat_claims' });
if (teamHasActiveManualKickTimers(mother)) return sendJson(res, 409, { message: 'team_has_active_manual_kick_timers' });
const teamId = canonicalTeamId(mother);
const keys = new Set([teamId, mother.team, mother.accountId].filter(Boolean).map(workspaceIdKey));
let detached = 0;
for (const child of state.children) {
let changed = false;
for (const membership of child.workspaceHistory || []) {
if (!keys.has(workspaceIdKey(membership.team || membership.workspaceId)) || membership.status !== 'active') continue;
Object.assign(membership, { status: 'team_removed', removedAt: now(), reason: 'team_deleted_local', retryAfter: null, rejoinEligible: false });
changed = true;
}
for (const key of Object.keys(child.workspaceTokens || {})) {
if (keys.has(workspaceIdKey(key))) delete child.workspaceTokens[key];
}
if (keys.has(workspaceIdKey(child.pendingWorkspaceId))) {
child.pendingWorkspaceId = null;
child.pendingInviteId = null;
}
if (changed || keys.has(workspaceIdKey(child.team))) {
const replacement = (child.workspaceHistory || []).find((entry) => entry.status === 'active' && !keys.has(workspaceIdKey(entry.team || entry.workspaceId)));
child.team = replacement?.team || null;
if (!childIsBanned(child)) child.status = child.team ? 'active' : child.accessToken ? 'ready' : child.password ? 'login_required' : 'unconfigured';
}
if (changed) detached += 1;
}
state.mothers = state.mothers.filter((item) => item.id !== mother.id);
addHistory('删除 Team', `${teamDisplayName(mother)} 已从本项目删除,解除 ${detached} 个账号关联;未操作远端 Team 或 Sub2API`);
await persist();
return sendJson(res, 200, { ok: true, deleted: { id: mother.id, teamId, detached }, state: publicState({ includeHistory: false }) });
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'recover') {
return withTeamMaintenanceLock(segments[2], `http_recover_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]);
if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const fields = credentialFields(body);
if (fields.email) mother.email = fields.email;
if (fields.password) mother.password = fields.password;
if (fields.totp) mother.totp = fields.totp;
if (body.mailboxUrl !== undefined || body.mailbox_url !== undefined) mother.mailboxUrl = fields.mailboxUrl;
const result = await recoverTeamManagerToken(mother, { force: true, allowCredentialLogin: true, primaryOnly: true, bypassCooldown: true });
mother.lastManagerRecovery = { ok: result.ok === true, status: result.status || null, code: result.code || null, message: result.message || result.source || null, attemptedAt: now() };
await persist();
return sendJson(res, result.ok ? 200 : (result.status || 502), { ...result, mother: publicMother(mother) });
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'billing-preview') {
return withTeamMaintenanceLock(segments[2], `http_billing_preview:${segments[2]}`, async () => {
const mother = findMother(segments[2]);
if (!mother) return sendJson(res, 404, { ok: false, status: 404, message: 'mother_not_found' });
const result = await queryWorkspaceBillingPreview(mother, { thresholdDays: body.thresholdDays });
const detail = result.ok
? `${teamDisplayName(mother)} 到期 ${result.activeUntil || '未知'},新增普通席位 ${result.formattedAmount || '费用未知'}`
: `${teamDisplayName(mother)} 查询失败:${result.message || 'billing_preview_failed'}`;
addHistory('查询临期 Team', detail, result.ok ? 'success' : 'partial', { motherId: mother.id });
await persist();
return sendJson(res, result.ok ? 200 : (result.status || 502), result);
});
}
if (method === 'POST' && url.pathname === '/api/children') {
const fields = credentialFields(body);
if (!fields.email) return sendJson(res, 400, { message: 'email_required' });
const duplicate = state.children.find((item) => String(item.email || '').toLowerCase() === String(fields.email).toLowerCase());
if (duplicate) return sendJson(res, 409, { message: 'child_email_exists', child: publicChild(duplicate) });
const child = childFromImportedAccount({ ...body, planType: 'free', source: body.source || 'manual' });
child.id = body.id || child.id;
child.status = fields.accessToken ? 'ready' : fields.password ? 'login_required' : 'unconfigured';
state.children.unshift(child);
addHistory('添加 Free 账号', `${child.email} 已加入普通账号池`);
await persist();
return sendJson(res, 201, { child: publicChild(child), state: publicState({ includeHistory: false }) });
}
if (method === 'GET' && segments[1] === 'mothers' && segments[2] && segments[3] === 'members') {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const result = await queryWorkspaceMembersPage(mother, { offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit'), query: url.searchParams.get('query') || '' });
return sendJson(res, result.ok ? 200 : 502, { ok: result.ok, status: result.status, message: result.message, accountId: result.accountId, items: result.items, total: result.total, offset: result.offset, limit: result.limit });
}
if (method === 'GET' && segments[1] === 'mothers' && segments[2] && segments[3] === 'subscription') {
return withTeamMaintenanceLock(segments[2], `http_subscription:${segments[2]}`, async () => {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const result = await queryWorkspaceSubscription(mother);
if (result.ok && result.subscription) { applyMotherSubscription(mother, result.subscription); mother.lastSubscriptionProbe = { ok: true, status: result.status, message: result.message, latencyMs: result.latencyMs }; await persist(); }
return sendJson(res, result.ok ? 200 : 502, { ok: result.ok, status: result.status, message: result.message, accountId: result.accountId, subscription: result.subscription });
});
}
if (method === 'GET' && segments[1] === 'mothers' && segments[2] && segments[3] === 'sync') {
return withTeamMaintenanceLock(segments[2], `http_sync_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
return sendJson(res, 200, await syncMotherWorkspace(mother, { query: url.searchParams.get('query') || '' }));
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'sync') {
return withTeamMaintenanceLock(segments[2], `http_sync_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
return sendJson(res, 200, await syncMotherWorkspace(mother, { query: body.query || '', force: true }));
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'probe') {
return withTeamMaintenanceLock(segments[2], `http_probe_team:${segments[2]}`, async () => {
const mother = findMother(segments[2]); if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const result = await probeMother(mother); await persist(); return sendJson(res, result.ok ? 200 : 502, result);
});
}
if (method === 'POST' && segments[1] === 'mothers' && segments[2] && segments[3] === 'member-kick-timers') {
return withTeamMaintenanceLock(segments[2], `http_member_kick_timers:${segments[2]}`, async () => {
const result = await updateManualKickTimers(segments[2], body);
return sendJson(res, result.status || 500, result);
});
}
if (method === 'POST' && (url.pathname === '/api/children/import' || url.pathname === '/api/accounts/import' || url.pathname === '/api/sub2api/import')) {
return withMaintenanceLock('http_import_accounts', async () => {
let rawItems = Array.isArray(body.accounts) ? body.accounts : Array.isArray(body.items) ? body.items : [];
if (!rawItems.length && body.text) {
try {
const parsed = JSON.parse(body.text);
rawItems = Array.isArray(parsed.accounts) ? parsed.accounts : Array.isArray(parsed.items) ? parsed.items : [];
} catch {
rawItems = String(body.text).split(/\r?\n/).filter(Boolean);
}
}
const asObject = (item) => {
if (item && typeof item === 'object') return item;
const parts = splitCredentialLine(item);
return { email: parts[0] || '', mailboxUrl: parts.length === 2 ? parts[1] : '', password: parts[1] || '', totp: parts[2] || '' };
};
const motherTarget = body.target === 'mothers' || body.kind === 'mother';
const incoming = rawItems.map(asObject).filter((item) => credentialFields(item).email || credentialFields(item).accessToken);
if (motherTarget) {
const added = [];
const updated = [];
for (const item of incoming) {
const next = motherFromImportedAccount({ ...item, ...(body.sub2apiIntegrationId ? { sub2apiIntegrationId: body.sub2apiIntegrationId } : {}) });
const teamId = configuredTeamId(next);
const existing = [...added, ...state.mothers].find((mother) => teamId
? configuredTeamKey(mother) === workspaceIdKey(teamId)
: !configuredTeamId(mother) && next.email && mother.email && next.email.toLowerCase() === mother.email.toLowerCase());
if (existing) {
mergeImportedMother(existing, item);
if (!added.includes(existing) && !updated.includes(existing)) updated.push(existing);
} else {
added.push(next);
}
}
state.mothers = [...added, ...state.mothers];
linkFreeAccountsToImportedTeams();
addHistory('导入 Team', `新增 ${added.length} 个 Team${updated.length ? `,合并 ${updated.length} 个已有 Team` : ''}`);
await persist();
return sendJson(res, 201, { added: added.map(publicMother), updated: updated.map(publicMother), state: publicState({ includeHistory: false }) });
}
const added = [];
const updated = [];
const mothersAdded = [];
const mothersUpdated = [];
const source = body.source || (url.pathname === '/api/sub2api/import' ? 'sub2api' : null);
for (const item of incoming) {
const fields = credentialFields(item);
if (isTeamAccount(item)) {
const teamId = String(fields.accountId || item.team || item.workspaceId || '').trim();
const matchingMother = [...mothersAdded, ...state.mothers].find((mother) => configuredTeamKey(mother) === workspaceIdKey(teamId) && teamId);
if (matchingMother) {
mergeImportedMother(matchingMother, item);
if (mothersAdded.includes(matchingMother)) continue;
mothersUpdated.push(matchingMother);
} else {
const nextMother = motherFromImportedAccount({ ...item, team: teamId });
mothersAdded.push(nextMother);
}
continue;
}
const matchingMother = state.mothers.find((mother) => (
fields.email && mother.email && fields.email.toLowerCase() === mother.email.toLowerCase()
));
if (matchingMother && String(fields.planType || '').toLowerCase() !== 'free') {
const importedMother = motherFromImportedAccount(item);
const importedTeamKey = configuredTeamKey(importedMother);
const matchingTeamKey = configuredTeamKey(matchingMother);
const workspaceMother = importedTeamKey
? [...mothersAdded, ...state.mothers].find((mother) => configuredTeamKey(mother) === importedTeamKey)
: null;
if (workspaceMother && workspaceMother !== matchingMother) {
mergeImportedMother(workspaceMother, item);
if (!mothersAdded.includes(workspaceMother)) mothersUpdated.push(workspaceMother);
} else if (importedTeamKey && matchingTeamKey && importedTeamKey !== matchingTeamKey) {
mothersAdded.push(importedMother);
} else {
Object.assign(matchingMother, Object.fromEntries(Object.entries(importedMother).filter(([key, value]) => !['id', 'status', 'lastCheck', 'createdAt'].includes(key) && value !== '' && value !== null && value !== undefined)));
if (matchingMother.accountId) matchingMother.team = String(matchingMother.accountId).trim();
mothersUpdated.push(matchingMother);
}
continue;
}
const next = childFromImportedAccount(source ? { ...item, source } : item);
const existing = state.children.find((child) => child.email && next.email && child.email.toLowerCase() === next.email.toLowerCase());
if (existing) {
const history = existing.workspaceHistory || [];
const merged = Object.fromEntries(Object.entries(next).filter(([key, value]) => {
if (key === 'id' || key === 'workspaceHistory' || value === '' || value === null || value === undefined) return false;
// Re-importing credentials must not erase a live membership snapshot.
if (key === 'status' && childIsBanned(existing)) return false;
if (key === 'status' && value === 'ready' && existing.status && existing.status !== 'ready' && !item.status) return false;
if (key === 'plan' && value === '待检测' && existing.plan && existing.plan !== '待检测' && !item.plan && !item.planType) return false;
if (key === 'token' && value === '待登录获取 AT' && existing.accessToken) return false;
return true;
}));
Object.assign(existing, merged, { id: existing.id, workspaceHistory: history.length ? history : next.workspaceHistory });
updated.push(existing);
} else {
added.push(next);
}
}
state.mothers = [...mothersAdded, ...state.mothers];
state.children = [...added, ...state.children];
linkFreeAccountsToImportedTeams();
addHistory('导入账号', `新增 ${added.length} 个 Free 账号${updated.length ? `,更新 ${updated.length} 个已有账号` : ''}${mothersAdded.length ? `,新增 ${mothersAdded.length} 个 Team 空间` : ''}${mothersUpdated.length ? `,更新 ${mothersUpdated.length} 个 Team 所有者` : ''}`);
await persist();
return sendJson(res, 201, { added: added.map((child) => publicChild(child)), updated: updated.map((child) => publicChild(child)), mothersAdded: mothersAdded.map(publicMother), mothersUpdated: mothersUpdated.map(publicMother), state: publicState({ includeHistory: false }) });
});
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'login') {
const child = findChild(segments[2]); if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const callbackFailure = explicitOAuthCallbackFailure(child.authSession, body.callbackUrl);
if (callbackFailure) return sendJson(res, callbackFailure.status, { ...callbackFailure, child: publicChild(child) });
const fields = credentialFields(body);
if (fields.email && !child.email) child.email = fields.email;
if (fields.password) child.password = fields.password;
if (fields.totp) child.totp = fields.totp;
if (fields.mailboxUrl) child.mailboxUrl = fields.mailboxUrl;
if (!fields.accessToken) {
const result = await acquireChildAuth(child, { refresh: body.refresh === true, verificationCode: body.verificationCode, callbackUrl: body.callbackUrl });
return sendJson(res, result.status || 202, result);
}
child.accessToken = fields.accessToken;
child.refreshToken = fields.refreshToken || child.refreshToken;
child.accountId = fields.accountId || child.accountId;
child.chatgptUserId = fields.chatgptUserId || child.chatgptUserId;
child.clientId = fields.clientId || child.clientId;
child.idToken = fields.idToken || child.idToken;
child.organizationId = fields.organizationId || child.organizationId;
child.expiresAt = fields.expiresAt || child.expiresAt;
child.subscriptionExpiresAt = fields.subscriptionExpiresAt || child.subscriptionExpiresAt;
child.password = fields.password || child.password;
child.totp = fields.totp || child.totp;
child.mailboxUrl = fields.mailboxUrl || child.mailboxUrl;
child.plan = fields.planType || child.plan || '待检测';
child.token = preview(fields.accessToken);
child.authAt = now();
child.sub2apiImported = true;
setChildLoginState(child, 'ready', 'AT 已保存,可导出 Free JSON');
if (!childIsBanned(child)) child.status = child.team ? 'active' : 'ready';
if (fields.quota5h != null) child.quota5h = fields.quota5h;
if (fields.quota7d != null) child.quota7d = fields.quota7d;
child.quota5hResetAfterSeconds = fields.quota5hResetAfterSeconds ?? child.quota5hResetAfterSeconds ?? null;
child.quota7dResetAfterSeconds = fields.quota7dResetAfterSeconds ?? child.quota7dResetAfterSeconds ?? null;
child.quota5hResetAt = fields.quota5hResetAt || child.quota5hResetAt || null;
child.quota7dResetAt = fields.quota7dResetAt || child.quota7dResetAt || null;
child.quotaUpdatedAt = fields.quotaUpdatedAt || child.quotaUpdatedAt || null;
if (fields.extra && Object.keys(fields.extra).length) child.extra = { ...(child.extra || {}), ...fields.extra };
if (fields.quotaSnapshot && (fields.quotaSnapshot.primary || fields.quotaSnapshot.secondary)) child.quotaSnapshot = fields.quotaSnapshot;
addHistory('获取 AT', `${child.email} 已保存新的 AT`); await persist(); return sendJson(res, 200, publicChild(child));
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'acquire') {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const callbackFailure = explicitOAuthCallbackFailure(child.authSession, body.callbackUrl);
if (callbackFailure) return sendJson(res, callbackFailure.status, { ...callbackFailure, child: publicChild(child) });
const fields = credentialFields(body);
if (fields.email && !child.email) child.email = fields.email;
if (fields.password) child.password = fields.password;
if (fields.totp) child.totp = fields.totp;
if (fields.mailboxUrl) child.mailboxUrl = fields.mailboxUrl;
const acquireMode = body.mode || body.action || '';
const shouldRefresh = body.refresh === true || acquireMode === 'refresh-at' || acquireMode === 'free-json';
const result = await acquireChildAuth(child, {
refresh: shouldRefresh,
allowCredentialLogin: acquireMode === 'free-json',
verificationCode: body.verificationCode,
callbackUrl: body.callbackUrl,
});
return sendJson(res, result.status || 200, result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'team-auth') {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const mother = findMother(body.motherId) || state.mothers.find((item) => item.accountId === String(body.workspaceId || '').trim());
if (!mother) return sendJson(res, 404, { message: 'mother_not_found' });
const result = await acquireTeamAuth(mother, child, {
force: body.force !== false,
verificationCode: body.verificationCode,
callbackUrl: body.callbackUrl,
});
return sendJson(res, result.status || 200, result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'export') {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
if (!child.accessToken) return sendJson(res, 409, { message: 'access_token_required' });
const exportedAt = now();
return sendJson(res, 200, { exported_at: exportedAt, exportedAt, scope: 'free', account: sub2ApiAccountFromChild(child) });
}
if (method === 'PUT' && segments[1] === 'children' && segments[2] && segments[3] === 'sub2api') {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const parsed = singleSub2ApiAccount(body);
if (!parsed.ok) return sendJson(res, 400, { message: parsed.message });
const fields = credentialFields(parsed.account);
if (isTeamAccount(parsed.account)) return sendJson(res, 409, { message: 'team_sub2api_json_not_allowed' });
if (!fields.accessToken && !fields.refreshToken) return sendJson(res, 400, { message: 'sub2api_token_required' });
const incomingEmail = String(fields.email || '').trim();
const currentEmail = String(child.email || '').trim();
const emailChanged = incomingEmail && currentEmail && incomingEmail.toLowerCase() !== currentEmail.toLowerCase();
const childHasCredentials = Boolean(child.accessToken || child.refreshToken || child.password || child.totp || child.mailboxUrl);
if (emailChanged && childHasCredentials) return sendJson(res, 409, { message: 'child_email_mismatch' });
const duplicate = incomingEmail && state.children.find((item) => (
item.id !== child.id && String(item.email || '').trim().toLowerCase() === incomingEmail.toLowerCase()
));
if (duplicate) return sendJson(res, 409, { message: 'child_email_exists', child: publicChild(duplicate) });
applyFreeSub2ApiAccount(child, parsed.account, fields);
addHistory('录入 Free JSON', `${child.email} 已保存 Sub2API 完整凭据`);
await persist();
return sendJson(res, 200, { child: publicChild(child), state: publicState({ includeHistory: false }) });
}
if (method === 'DELETE' && segments[1] === 'children' && segments[2] && !segments[3]) {
return withMaintenanceLock(`http_delete_child:${segments[2]}`, () => withChildDeletionTeamLocks(async () => {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const verification = await verifyChildLocalDeletion(child);
if (!verification.ok) return sendJson(res, 409, verification);
const currentFailure = verification.checkCurrent();
if (currentFailure) return sendJson(res, 409, currentFailure);
detachDeletedChildLocalWork(child);
state.children = state.children.filter((item) => item.id !== child.id);
addHistory('删除 Free 账号', `${child.email || child.id} 已从本地账号池删除`);
await persist();
return sendJson(res, 200, { ok: true, state: publicState({ includeHistory: false }) });
}));
}
if (method === 'POST' && url.pathname === '/api/children/batch-delete') {
return withMaintenanceLock('http_batch_delete_children', () => withChildDeletionTeamLocks(async () => {
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map((id) => String(id || '').trim()).filter(Boolean))].slice(0, 1000);
if (!ids.length) return sendJson(res, 400, { message: 'child_ids_required' });
const bannedOnly = body.bannedOnly !== false;
const deleted = [];
const skipped = [];
const verified = new Map();
const verificationCache = new Map();
for (const id of ids) {
const child = findChild(id);
if (!child) {
skipped.push({ id, reason: 'child_not_found' });
continue;
}
if (bannedOnly && !childIsBanned(child)) {
skipped.push({ id, email: child.email || '', reason: 'child_not_banned' });
continue;
}
const verification = await verifyChildLocalDeletion(child, verificationCache);
if (!verification.ok) {
skipped.push({ id, email: child.email || '', reason: verification.message,
...(verification.verificationTeams ? { verificationTeams: verification.verificationTeams } : {}) });
continue;
}
deleted.push({ id, email: child.email || '' });
verified.set(id, verification.checkCurrent);
}
// All removals happen together after the asynchronous checks. A Free
// login that started during the batch keeps its record until it ends.
for (let index = deleted.length - 1; index >= 0; index -= 1) {
const entry = deleted[index];
const child = findChild(entry.id);
const changed = verified.get(entry.id)?.();
if (!child || changed || bannedOnly && !childIsBanned(child)) {
deleted.splice(index, 1);
skipped.push({ ...entry, reason: changed?.message || (!child ? 'child_not_found' : 'child_not_banned') });
} else detachDeletedChildLocalWork(child);
}
const deletedIds = new Set(deleted.map((item) => item.id));
state.children = state.children.filter((child) => !deletedIds.has(child.id));
addHistory('批量删除 Free 账号', `删除 ${deleted.length} 个本地账号,跳过 ${skipped.length} 个`, skipped.length ? 'partial' : 'success');
await persist();
return sendJson(res, 200, { ok: skipped.length === 0, deleted, skipped, state: publicState({ includeHistory: false }) });
}));
}
if (method === 'PATCH' && segments[1] === 'children' && segments[2]) {
return withMaintenanceLock(`http_update_child:${segments[2]}`, async () => {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const nextEmail = body.email === undefined ? String(child.email || '').trim() : String(body.email || '').trim();
if (workspaceIdKey(nextEmail) !== workspaceIdKey(child.email) && anySeatClaimForChild(child)) {
return sendJson(res, 409, { message: 'child_has_pending_seat_claim' });
}
const retryInputChanged = Boolean(
(body.password && String(body.password) !== String(child.password || ''))
|| (body.totp && String(body.totp).trim() !== String(child.totp || child.secret || '').trim())
|| (body.mailboxUrl && String(body.mailboxUrl).trim() !== String(child.mailboxUrl || '').trim())
);
if (body.email !== undefined) child.email = nextEmail;
if (body.password) child.password = String(body.password);
if (body.totp) child.totp = String(body.totp).trim();
if (body.mailboxUrl !== undefined) child.mailboxUrl = String(body.mailboxUrl || '').trim();
if (retryInputChanged && !childIsBanned(child) && !child.loginBrowserRequired
&& child.status !== 'phone_verification_required'
&& (['waiting_code', 'email_otp_required', 'email_otp_timeout', 'totp_required', 'totp_invalid'].includes(child.loginStatus)
|| child.loginErrorCode === 'browser_oauth_credentials_rejected')) {
// Updated input allows one normal retry of the existing OAuth step.
// Keep its state/PKCE/cookies, and leave official browser/phone checks
// and independent Team authorizations untouched.
setChildLoginState(child, 'login_required', '登录凭据已更新,可以重新获取 JSON');
if (child.loginErrorCode === 'browser_oauth_credentials_rejected') child.loginErrorCode = null;
if (child.status === 'login_pending') child.status = 'login_required';
}
if (retryInputChanged && !childIsBanned(child)) {
for (const [workspaceId, login] of Object.entries(child.teamLoginStates || {})) {
if (!login.browserRequired && login.status !== 'phone_verification_required' && login.code !== 'phone_verification_required'
&& [login.status, login.code].some((status) => ['credentials_required', 'waiting_code', 'email_otp_required', 'email_otp_timeout', 'totp_required', 'totp_invalid', 'password_invalid', 'browser_oauth_credentials_rejected'].includes(status))) {
setTeamLoginState(child, workspaceId, { status: 'login_required', code: 'credentials_updated', message: '登录凭据已更新,可以重新获取 Team JSON' });
}
}
}
addHistory('更新账号', `${child.email} 的账号记录已更新`);
await persist();
return sendJson(res, 200, publicChild(child));
});
}
if ((method === 'GET' || method === 'POST') && segments[1] === 'children' && segments[2] && (segments[3] === 'probe' || segments[3] === 'quota')) {
const child = findChild(segments[2]);
if (!child) return sendJson(res, 404, { message: 'child_not_found' });
const result = await probeChild(child);
return sendJson(res, result.ok ? 200 : 502, result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'team-quota' && !segments[4]) {
const result = await refreshChildTeamQuota(segments[2], body);
return sendJson(res, result.status || 502, result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'join') {
const result = await withTeamMaintenanceLock(body.motherId || 'unknown', `http_join:${body.motherId || 'unknown'}`, () => {
const child = findChild(segments[2]);
return withChildJoinReservation(child, resolveMotherId(body.motherId) || String(body.motherId || 'unknown'), () => joinWorkspace(child, body));
});
return sendJson(res, result.ok ? (['requested', 'invited'].includes(result.phase) ? 202 : 200) : (result.status || 502), result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'switch') {
const child = findChild(segments[2]);
const result = await switchWorkspaceWithFreeRecovery(child, body.workspaceId, { verificationCode: body.verificationCode, callbackUrl: body.callbackUrl });
return sendJson(res, result.ok ? 200 : (result.status || 502), result);
}
if (method === 'POST' && segments[1] === 'children' && segments[2] && segments[3] === 'kick') {
const target = childKickTarget(segments[2], body);
if (target.failure) return sendJson(res, target.failure.status, target.failure.payload);
const result = await withTeamMaintenanceLock(target.mother.id, `http_kick:${segments[2]}`,
() => kickChildFromWorkspace(segments[2], body, target));
return sendJson(res, result.status || 500, result.payload || { message: 'workspace_member_remove_failed' });
}
if (method === 'POST' && url.pathname === '/api/maintenance/check') {
const result = await withTeamMaintenanceLock(body.motherId || 'unknown', 'http_check_team', () => checkTeam(body.motherId, { childIds: body.childIds }));
return sendJson(res, result.status === 409 ? 409 : 200, result);
}
if (method === 'POST' && url.pathname === '/api/maintenance/check-all') {
const result = await checkAllTeams();
return sendJson(res, result.status === 409 ? 409 : 200, result);
}
if (method === 'POST' && url.pathname === '/api/maintenance/refill') {
const result = await withTeamMaintenanceLock(body.motherId || 'unknown', 'http_refill_team', () => refillTeam(body.motherId));
return sendJson(res, result.status === 409 ? 409 : 200, result);
}
if (method === 'POST' && url.pathname === '/api/maintenance/refill-all') {
const result = await refillAllTeams();
return sendJson(res, result.status === 409 ? 409 : 200, result);
}
if (method === 'POST' && url.pathname === '/api/children/acquire-missing-json') {
const result = await withMaintenanceLock('http_acquire_missing_free_json', () => acquireMissingFreeJson({ retryVerification: body.retryVerification === true }));
return sendJson(res, result.status === 409 ? 409 : 200, result);
}
if (method === 'POST' && url.pathname === '/api/children/auto-json') {
const result = startAutoJsonJob(body);
return sendJson(res, result.status, result.payload);
}
if (method === 'GET' && segments[1] === 'children' && segments[2] === 'auto-json' && segments[3] && !segments[4]) {
const job = autoJsonJobs.get(segments[3]);
if (!job || (job.status === 'complete' && Date.now() - Date.parse(job.finishedAt) > AUTO_JSON_JOB_TTL_MS)) {
if (job) autoJsonJobs.delete(job.id);
return sendJson(res, 404, { message: 'auto_json_job_not_found' });
}
return sendJson(res, 200, { job: publicAutoJsonJob(job) });
}
if (method === 'GET' && url.pathname === '/api/account-exports') {
return sendJson(res, 200, { accounts: accountExportCandidates().map((entry) => entry.metadata) });
}
if (method === 'POST' && url.pathname === '/api/account-exports') {
const selected = selectAccountExports(accountExportCandidates(), body?.ids);
if (!selected.ok) return sendJson(res, selected.status, { code: selected.code, message: selected.message });
const exportedAt = now();
return sendJson(res, 200, { exported_at: exportedAt, exportedAt, proxies: [], accounts: selected.accounts });
}
if (method === 'POST' && url.pathname === '/api/sub2api/export') {
const items = state.children.filter((child) => child.accessToken).map(sub2ApiAccountFromChild);
const exportedAt = now();
return sendJson(res, 200, { exported_at: exportedAt, exportedAt, proxies: [], accounts: items });
}
if (method === 'POST' && url.pathname === '/api/sub2api/team-export') {
const motherIds = Array.isArray(body.motherIds) ? body.motherIds : body.motherId ? [body.motherId] : [];
const accounts = teamSub2ApiEntries(motherIds).map((entry) => entry.payload);
const exportedAt = now();
return sendJson(res, 200, { exported_at: exportedAt, exportedAt, scope: 'team', proxies: [], accounts });
}
if (method === 'POST' && url.pathname === '/api/sub2api/push') {
return sendJson(res, 200, await pushSub2ApiAccounts(body.ids));
}
if (method === 'POST' && url.pathname === '/api/sub2api/team-push') {
const motherIds = Array.isArray(body.motherIds) ? body.motherIds : body.motherId ? [body.motherId] : [];
const ids = new Set(motherIds.map(String));
const targetIds = state.mothers
.filter((mother) => !ids.size || ids.has(String(mother.id)) || ids.has(String(mother.accountId || mother.team)))
.map((mother) => mother.id);
const results = await mapWithConcurrency(targetIds, (motherId) => withTeamMaintenanceLock(motherId, 'http_team_push', () => pushSub2ApiTeams([motherId])));
const flattened = results;
return sendJson(res, 200, flattened.length === 1 ? flattened[0] : {
ok: flattened.every((result) => result.ok === true),
status: flattened.some((result) => result.ok !== true) ? 207 : 200,
teams: flattened,
pushed: flattened.flatMap((result) => result.pushed || []),
skipped: flattened.flatMap((result) => result.skipped || []),
failed: flattened.flatMap((result) => result.failed || []),
targets: flattened.flatMap((result) => result.targets || []),
});
}
return sendJson(res, 404, { message: 'route_not_found' });
}
let requestQueue = Promise.resolve();
function canHandleReadConcurrently(req) {
if (!['GET', 'HEAD'].includes(req.method || 'GET')) return false;
try {
const pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname;
return pathname === '/api/state' || pathname === '/api/history' || pathname === '/api/health'
|| pathname === '/api/version' || pathname === '/api/auth/session'
|| /^\/api\/children\/auto-json\/[^/]+$/.test(pathname)
|| /^\/api\/mothers\/[^/]+\/(members|subscription|sync)$/.test(pathname)
|| !pathname.startsWith('/api/');
} catch {
return false;
}
}
function canHandleScopedMutationConcurrently(req) {
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method || 'GET')) return false;
let pathname;
try { pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname; } catch { return false; }
if (pathname === '/mcp' || pathname === '/api/auth/login' || pathname === '/api/auth/logout') return true;
if (pathname === '/api/children/auto-json') return true;
if (pathname === '/api/integrations/roxy/cleanup-successful') return true;
if (['/api/maintenance/check', '/api/maintenance/check-all', '/api/maintenance/refill', '/api/maintenance/refill-all', '/api/sub2api/team-push'].includes(pathname)) return true;
if (/^\/api\/mothers\/[^/]+$/.test(pathname)) return true;
if (/^\/api\/mothers\/[^/]+\/recover$/.test(pathname)) return true;
if (/^\/api\/mothers\/[^/]+\/(billing-preview|sync|probe|member-kick-timers|invitations)$/.test(pathname)) return true;
if (/^\/api\/children\/[^/]+\/(join|kick|team-quota)$/.test(pathname)) return true;
return false;
}
function canHandleRequestConcurrently(req) {
return canHandleReadConcurrently(req) || canHandleScopedMutationConcurrently(req);
}
async function handleRequest(req, res) {
try {
if (!requestHostAllowed(req)) return sendJson(res, 403, { code: 'host_not_allowed', message: '请求主机不允许' });
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
res.securityHeaders = corsHeaders(req);
if ((url.pathname === '/mcp' || url.pathname.startsWith('/api/')) && !requestOriginAllowed(req)) {
return sendJson(res, 403, { code: 'origin_not_allowed', message: '请求来源不允许' });
}
if (url.pathname === '/mcp') return await handleMcp(req, res);
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
if (req.method !== 'GET' && req.method !== 'HEAD') return sendText(res, 405, 'Method Not Allowed');
const requested = url.pathname === '/' ? '/index.html' : url.pathname;
const distRoot = path.join(root, 'dist');
const file = path.resolve(root, `dist${requested}`);
const relativeFile = path.relative(distRoot, file);
if (relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) return sendText(res, 403, 'Forbidden');
if (!existsSync(file)) return sendText(res, 404, 'Not Found');
const content = await readFile(file);
const type = file.endsWith('.html') ? 'text/html; charset=utf-8' : file.endsWith('.js') ? 'text/javascript; charset=utf-8' : file.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/octet-stream';
res.writeHead(200, {
...responseHeaders(res),
'content-type': type,
'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' http://127.0.0.1:8786 http://localhost:8786; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
}); res.end(content);
} catch (error) { sendJson(res, 400, { message: error?.message || 'request_failed' }); }
}
const server = createServer((req, res) => {
if (canHandleRequestConcurrently(req)) {
void handleRequest(req, res).catch((error) => {
if (!res.headersSent) sendJson(res, 500, { message: error?.message || 'request_failed' });
});
return;
}
requestQueue = requestQueue.then(() => handleRequest(req, res)).catch((error) => {
if (!res.headersSent) sendJson(res, 500, { message: error?.message || 'request_failed' });
});
});
server.listen(port, host, () => {
if (process.env.DISABLE_MAINTENANCE !== 'true') {
configureMaintenanceTimer();
setTimeout(() => { void runMaintenanceCycle(); }, 1500).unref?.();
}
console.log(`team-rotation server listening on http://${host}:${port}`);
});
// OAuth uses a localhost callback that is easier to complete in the user's
// browser than through a second copy of the login flow. Persist a received
// callback only on its exact matching encrypted account session, so restarting
// the service cannot discard an authorization the user has just completed.
const oauthCallbackPort = Number(process.env.OPENAI_CALLBACK_PORT || 1455);
if (process.env.OPENAI_CALLBACK_ENABLED !== 'false' && Number.isInteger(oauthCallbackPort) && oauthCallbackPort > 0 && oauthCallbackPort < 65536) {
const handleOAuthCallback = async (req, res) => {
try {
const callback = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const callbackState = String(callback.searchParams.get('state') || '').trim();
const records = [
...state.children,
...state.mothers.flatMap((mother) => [mother, ...(Array.isArray(mother.ownerAccounts) ? mother.ownerAccounts : [])]),
];
const sessions = [...new Set([
...records.flatMap((record) => [record.authSession, ...Object.values(record.teamAuthSessions || {})]),
...Object.values(await quickJsonAuthSessions()).map((entry) => entry.session),
])].filter((session) => session?.state && session.state === callbackState);
if (req.method !== 'GET' || !isLoopbackHost(callback.hostname) || callback.pathname !== '/auth/callback'
|| sessions.length !== 1 || !storeOAuthCallback(callback.toString())) {
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Invalid OAuth callback');
return;
}
sessions[0].callbackUrl = callback.toString();
sessions[0].callbackReceivedAt = now();
await persist();
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end('<!doctype html><meta charset="utf-8"><title>Quota Hub</title><p>授权回调已收到,可以返回 Quota Hub 继续获取对应账号的 JSON。</p>');
} catch {
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Invalid OAuth callback');
}
};
for (const host of ['127.0.0.1', '::1']) {
const oauthCallbackServer = createServer(handleOAuthCallback);
oauthCallbackServer.on('error', (error) => console.warn(`OAuth callback listener unavailable on ${host}:${oauthCallbackPort}: ${error?.code || error?.message || 'error'}`));
oauthCallbackServer.listen({ port: oauthCallbackPort, host, ipv6Only: host === '::1' }, () => {
console.log(`OAuth callback listener on http://${host.includes(':') ? `[${host}]` : host}:${oauthCallbackPort}/auth/callback`);
});
}
}