refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildOauthInfoFromAccount,
|
||||
buildStoredOauthStateFromAccount,
|
||||
getOauthInfoFromAccount,
|
||||
} from './oauthAccount.js';
|
||||
|
||||
describe('oauth account identity helpers', () => {
|
||||
it('prefers structured oauth identity columns over extraConfig metadata', () => {
|
||||
const oauth = getOauthInfoFromAccount({
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthAccountKey: 'structured-user@example.com',
|
||||
oauthProjectId: 'structured-project',
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountKey: 'json-user',
|
||||
projectId: 'json-project',
|
||||
refreshToken: 'refresh-token',
|
||||
quota: { status: 'supported' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauth).toEqual(expect.objectContaining({
|
||||
provider: 'gemini-cli',
|
||||
accountId: 'structured-user@example.com',
|
||||
accountKey: 'structured-user@example.com',
|
||||
projectId: 'structured-project',
|
||||
refreshToken: 'refresh-token',
|
||||
quota: { status: 'supported' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('falls back to extraConfig metadata when structured columns are absent', () => {
|
||||
const oauth = getOauthInfoFromAccount({
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountKey: 'json-user',
|
||||
projectId: 'json-project',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauth).toEqual(expect.objectContaining({
|
||||
provider: 'codex',
|
||||
accountId: 'json-user',
|
||||
accountKey: 'json-user',
|
||||
projectId: 'json-project',
|
||||
refreshToken: 'refresh-token',
|
||||
}));
|
||||
});
|
||||
|
||||
it('reconstructs oauth runtime state from extraConfig even when identity fields are stripped', () => {
|
||||
const oauth = getOauthInfoFromAccount({
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthAccountKey: 'structured-user@example.com',
|
||||
oauthProjectId: 'structured-project',
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
email: 'structured-user@example.com',
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauth).toEqual(expect.objectContaining({
|
||||
provider: 'gemini-cli',
|
||||
accountId: 'structured-user@example.com',
|
||||
accountKey: 'structured-user@example.com',
|
||||
projectId: 'structured-project',
|
||||
email: 'structured-user@example.com',
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
}));
|
||||
});
|
||||
|
||||
it('builds patched oauth state from structured identity columns and current runtime state', () => {
|
||||
const oauth = buildOauthInfoFromAccount({
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'structured-account',
|
||||
oauthProjectId: 'structured-project',
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
},
|
||||
}),
|
||||
}, {
|
||||
quota: { status: 'supported' } as any,
|
||||
});
|
||||
|
||||
expect(oauth).toEqual(expect.objectContaining({
|
||||
provider: 'codex',
|
||||
accountId: 'structured-account',
|
||||
accountKey: 'structured-account',
|
||||
projectId: 'structured-project',
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
quota: { status: 'supported' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('strips oauth identity fields when preparing persisted extraConfig state', () => {
|
||||
const oauth = buildStoredOauthStateFromAccount({
|
||||
oauthProvider: 'codex',
|
||||
oauthAccountKey: 'structured-account',
|
||||
oauthProjectId: 'structured-project',
|
||||
extraConfig: JSON.stringify({
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'json-account',
|
||||
accountKey: 'json-account',
|
||||
projectId: 'json-project',
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
},
|
||||
}),
|
||||
}, {
|
||||
quota: { status: 'supported' } as any,
|
||||
});
|
||||
|
||||
expect(oauth).toEqual({
|
||||
refreshToken: 'refresh-token',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
quota: { status: 'supported' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,15 @@ export type OauthInfo = {
|
||||
lastDiscoveredModels?: string[];
|
||||
};
|
||||
|
||||
export type StoredOauthState = Omit<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
|
||||
|
||||
type OauthIdentityCarrier = {
|
||||
extraConfig?: string | null;
|
||||
oauthProvider?: string | null;
|
||||
oauthAccountKey?: string | null;
|
||||
oauthProjectId?: string | null;
|
||||
};
|
||||
|
||||
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
||||
if (!extraConfig) return {};
|
||||
try {
|
||||
@@ -104,14 +113,12 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
||||
function parseStoredOauthState(extraConfig?: string | null): Partial<OauthInfo> | null {
|
||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const provider = asTrimmedString(parsed.provider);
|
||||
if (!provider) return null;
|
||||
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
|
||||
return {
|
||||
provider,
|
||||
provider: asTrimmedString(parsed.provider),
|
||||
accountId: asTrimmedString(parsed.accountId) || accountKey,
|
||||
accountKey,
|
||||
email: asTrimmedString(parsed.email),
|
||||
@@ -129,6 +136,46 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI
|
||||
};
|
||||
}
|
||||
|
||||
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
||||
const parsed = parseStoredOauthState(extraConfig);
|
||||
const provider = parsed?.provider;
|
||||
if (!provider) return null;
|
||||
return {
|
||||
provider,
|
||||
accountId: parsed.accountId || parsed.accountKey,
|
||||
accountKey: parsed.accountKey,
|
||||
email: parsed.email,
|
||||
planType: parsed.planType,
|
||||
projectId: parsed.projectId,
|
||||
tokenExpiresAt: parsed.tokenExpiresAt,
|
||||
refreshToken: parsed.refreshToken,
|
||||
idToken: parsed.idToken,
|
||||
providerData: parsed.providerData,
|
||||
quota: parsed.quota,
|
||||
modelDiscoveryStatus: parsed.modelDiscoveryStatus,
|
||||
lastModelSyncAt: parsed.lastModelSyncAt,
|
||||
lastModelSyncError: parsed.lastModelSyncError,
|
||||
lastDiscoveredModels: parsed.lastDiscoveredModels,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null {
|
||||
if (!account) return null;
|
||||
const stored = parseStoredOauthState(account.extraConfig);
|
||||
const provider = asTrimmedString(account.oauthProvider) || stored?.provider;
|
||||
if (!provider) return null;
|
||||
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
||||
const accountKey = structuredAccountKey || stored?.accountKey || stored?.accountId;
|
||||
const projectId = asTrimmedString(account.oauthProjectId) || stored?.projectId;
|
||||
return {
|
||||
...(stored || {}),
|
||||
provider,
|
||||
accountId: structuredAccountKey || stored?.accountId || accountKey,
|
||||
accountKey,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOauthInfo(
|
||||
extraConfig?: string | null,
|
||||
patch: Partial<OauthInfo> = {},
|
||||
@@ -152,6 +199,47 @@ export function buildOauthInfo(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildOauthInfoFromAccount(
|
||||
account?: OauthIdentityCarrier | null,
|
||||
patch: Partial<OauthInfo> = {},
|
||||
): OauthInfo {
|
||||
const provider = patch.provider || getOauthInfoFromAccount(account)?.provider;
|
||||
if (!provider) {
|
||||
throw new Error('oauth provider is required');
|
||||
}
|
||||
const current = getOauthInfoFromAccount(account);
|
||||
const next: OauthInfo = {
|
||||
provider,
|
||||
...(current || {}),
|
||||
...patch,
|
||||
};
|
||||
if (!next.accountKey && next.accountId) {
|
||||
next.accountKey = next.accountId;
|
||||
}
|
||||
if (!next.accountId && next.accountKey) {
|
||||
next.accountId = next.accountKey;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildStoredOauthState(oauth: OauthInfo): StoredOauthState {
|
||||
const {
|
||||
provider: _provider,
|
||||
accountId: _accountId,
|
||||
accountKey: _accountKey,
|
||||
projectId: _projectId,
|
||||
...stored
|
||||
} = oauth;
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function buildStoredOauthStateFromAccount(
|
||||
account?: OauthIdentityCarrier | null,
|
||||
patch: Partial<OauthInfo> = {},
|
||||
): StoredOauthState {
|
||||
return buildStoredOauthState(buildOauthInfoFromAccount(account, patch));
|
||||
}
|
||||
|
||||
export function isOauthProvider(
|
||||
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
||||
provider?: string,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js';
|
||||
import {
|
||||
buildStoredOauthStateFromAccount,
|
||||
getOauthInfoFromAccount,
|
||||
type OauthInfo,
|
||||
} from './oauthAccount.js';
|
||||
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
|
||||
|
||||
type CodexJwtClaims = {
|
||||
@@ -192,12 +196,12 @@ async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnaps
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
oauth: buildOauthInfo(account.extraConfig, {
|
||||
oauth: buildStoredOauthStateFromAccount(account, {
|
||||
quota: snapshot,
|
||||
}),
|
||||
});
|
||||
@@ -213,7 +217,7 @@ export async function refreshOauthQuotaSnapshot(accountId: number): Promise<Oaut
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
@@ -236,7 +240,7 @@ export async function recordOauthQuotaResetHint(input: {
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
|
||||
if (!account) return null;
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth || oauth.provider !== 'codex') return null;
|
||||
|
||||
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
listOAuthProviderDefinitions,
|
||||
type OAuthProviderDefinition,
|
||||
} from './providers.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||
import {
|
||||
buildOauthInfo,
|
||||
buildOauthInfoFromAccount,
|
||||
buildStoredOauthState,
|
||||
buildStoredOauthStateFromAccount,
|
||||
getOauthInfoFromAccount,
|
||||
} from './oauthAccount.js';
|
||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||
|
||||
@@ -232,7 +238,7 @@ async function upsertOauthAccount(input: {
|
||||
});
|
||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth,
|
||||
oauth: buildStoredOauthState(oauth),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
@@ -511,7 +517,7 @@ export async function listOauthConnections(options: {
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
||||
const oauth = getOauthInfoFromAccount(row.accounts)!;
|
||||
const models = modelMap.get(row.accounts.id) || [];
|
||||
const status = (
|
||||
oauth.modelDiscoveryStatus === 'abnormal'
|
||||
@@ -553,8 +559,8 @@ export async function deleteOauthConnection(accountId: number) {
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
const normalizedOauth = getOauthInfoFromAccount(account);
|
||||
if (!normalizedOauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
||||
@@ -574,7 +580,7 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
@@ -587,10 +593,18 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
|
||||
}
|
||||
|
||||
export function buildOauthProviderHeaders(input: {
|
||||
account?: {
|
||||
extraConfig?: string | null;
|
||||
oauthProvider?: string | null;
|
||||
oauthAccountKey?: string | null;
|
||||
oauthProjectId?: string | null;
|
||||
} | null;
|
||||
extraConfig?: string | null;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}) {
|
||||
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(input.account || {
|
||||
extraConfig: input.extraConfig,
|
||||
});
|
||||
if (!oauth) return {};
|
||||
const definition = getOAuthProviderDefinition(oauth.provider);
|
||||
if (!definition?.buildProxyHeaders) return {};
|
||||
@@ -619,7 +633,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth?.refreshToken) {
|
||||
throw new Error('oauth refresh token missing');
|
||||
}
|
||||
@@ -635,7 +649,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
||||
providerData: oauth.providerData,
|
||||
},
|
||||
});
|
||||
const nextOauth = buildOauthInfo(account.extraConfig, {
|
||||
const nextOauth = buildOauthInfoFromAccount(account, {
|
||||
provider: oauth.provider,
|
||||
accountId: refreshed.accountId || oauth.accountId,
|
||||
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
|
||||
@@ -652,7 +666,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
||||
});
|
||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth: nextOauth,
|
||||
oauth: buildStoredOauthStateFromAccount(account, nextOauth),
|
||||
});
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
|
||||
Reference in New Issue
Block a user