feat: add masked token placeholders and desktop icons

This commit is contained in:
cita
2026-03-16 21:36:23 +08:00
parent 2409d9f184
commit 0a06f15e13
37 changed files with 2954 additions and 136 deletions
+121 -13
View File
@@ -9,6 +9,14 @@ type UpstreamApiToken = {
tokenGroup?: string | null;
};
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const;
export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const;
export type AccountTokenValueStatus =
| typeof ACCOUNT_TOKEN_VALUE_STATUS_READY
| typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
if (!token) return '';
const value = token.trim();
@@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•');
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function resolveAccountTokenValueStatus(
value: Pick<AccountTokenRow, 'token' | 'valueStatus'> | string | null | undefined,
): AccountTokenValueStatus {
if (typeof value === 'string' || value == null) {
return normalizeTokenValueStatus(value);
}
const explicit = normalizeTokenValueStatus(value.valueStatus);
if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
return isMaskedTokenValue(value.token)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function isReadyAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& !isMaskedTokenValue(token.token);
}
export function isMaskedPendingAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean {
if (!token) return false;
return token.enabled === true && isReadyAccountToken(token);
}
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim();
if (explicit.length > 0) return explicit;
@@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin
return name;
}
function sameTokenGroup(
leftGroup: string | null | undefined,
leftName: string | null | undefined,
rightGroup: string | null | undefined,
rightName: string | null | undefined,
): boolean {
return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName);
}
async function updateAccountApiToken(accountId: number, tokenValue: string | null) {
await db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
@@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) {
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
.all();
if (tokens.length === 0) return null;
const usableTokens = tokens.filter(isUsableAccountToken);
if (usableTokens.length === 0) return null;
const preferred = tokens.find((t) => t.isDefault) || tokens[0];
const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0];
return preferred;
}
@@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount(
): Promise<number | null> {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
if (isMaskedTokenValue(normalizedToken)) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
@@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount(
name: normalizeTokenName(options?.name, tokens.length + 1),
token: normalizedToken,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || 'manual',
enabled: options?.enabled ?? true,
isDefault: true,
@@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount(
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || target.source || 'manual',
enabled: options?.enabled ?? target.enabled,
isDefault: true,
@@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount(
export async function setDefaultToken(tokenId: number): Promise<boolean> {
const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!target) return false;
if (!target || !isUsableAccountToken(target)) return false;
const now = new Date().toISOString();
await db.update(schema.accountTokens)
@@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) {
.where(eq(schema.accountTokens.accountId, accountId))
.all();
const enabled = tokens.filter((t) => t.enabled);
const enabled = tokens.filter(isUsableAccountToken);
if (enabled.length === 0) {
await updateAccountApiToken(accountId, null);
return null;
@@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
let created = 0;
let updated = 0;
let maskedSkipped = 0;
let maskedPending = 0;
const pendingTokenIds: number[] = [];
let index = existing.length + 1;
for (const upstream of upstreamTokens) {
const tokenValue = normalizeTokenValue(upstream.key);
if (!tokenValue) continue;
if (isMaskedTokenValue(tokenValue)) {
maskedSkipped++;
continue;
}
const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true;
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
const nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const byToken = existing.find((row) => row.token === tokenValue);
const byToken = existing.find((row) => (
row.token === tokenValue
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
));
if (byToken) {
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
@@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
.run();
byToken.name = tokenName;
byToken.tokenGroup = tokenGroup;
byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
byToken.enabled = enabled;
byToken.source = 'sync';
byToken.updatedAt = now;
@@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue;
}
const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
if (matchingPlaceholder) {
const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false;
await db.update(schema.accountTokens)
.set({
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled: nextEnabled,
isDefault: false,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, matchingPlaceholder.id))
.run();
matchingPlaceholder.name = tokenName;
matchingPlaceholder.token = tokenValue;
matchingPlaceholder.tokenGroup = tokenGroup;
matchingPlaceholder.valueStatus = nextValueStatus;
matchingPlaceholder.source = 'sync';
matchingPlaceholder.enabled = nextEnabled;
matchingPlaceholder.isDefault = false;
matchingPlaceholder.updatedAt = now;
updated++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(matchingPlaceholder.id);
}
continue;
}
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled,
enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false,
isDefault: false,
createdAt: now,
updatedAt: now,
@@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
existing.push(createdRow);
created++;
index++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(createdRow.id);
}
}
const repaired = await repairDefaultToken(accountId);
@@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
return {
created,
updated,
maskedSkipped,
maskedPending,
pendingTokenIds,
total: existing.length,
defaultTokenId: repaired?.id || null,
};
@@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) {
const { token, ...tokenMeta } = row.account_tokens;
return {
...tokenMeta,
valueStatus: resolveAccountTokenValueStatus(row.account_tokens),
tokenMasked: maskToken(token, row.sites.platform),
account: {
id: row.accounts.id,
+2
View File
@@ -238,6 +238,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
name: 'default',
token: apiToken,
tokenGroup: 'default',
valueStatus: 'ready',
source: 'legacy',
enabled: true,
isDefault: true,
@@ -520,6 +521,7 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
valueStatus: row.valueStatus ?? 'ready',
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
@@ -336,13 +336,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.accountTokens) {
statements.push({
table: 'account_tokens',
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.name),
asNullableString(row.token),
asNullableString(row.tokenGroup),
asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready',
asNullableString(row.source) ?? 'manual',
asBoolean(row.enabled, true),
asBoolean(row.isDefault, false),
@@ -252,4 +252,52 @@ describe('refreshModelsForAccount credential discovery', () => {
reason: 'adapter_or_status',
});
});
it('does not scan masked_pending placeholders as token credentials', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : []
));
const site = await db.insert(schema.sites).values({
name: 'site-placeholder',
url: 'https://site-placeholder.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'placeholder-user',
accessToken: '',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
}).returning().get();
const placeholder = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'failed',
tokenScanned: 0,
});
const placeholderModels = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, placeholder.id))
.all();
expect(placeholderModels).toEqual([]);
expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username);
});
});
+26 -5
View File
@@ -1,7 +1,13 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
getPreferredAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
} from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
@@ -256,20 +262,27 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
} else {
discoveredApiToken = null;
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
@@ -278,8 +291,13 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
}
}
@@ -310,6 +328,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (isMaskedTokenValue(credential)) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
@@ -446,11 +465,13 @@ export async function rebuildTokenRoutesFromAvailability() {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -487,7 +508,7 @@ export async function rebuildTokenRoutesFromAvailability() {
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
for (const row of usableTokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
+3 -2
View File
@@ -9,6 +9,7 @@ import {
type RouteRoutingStrategy,
} from './routeRoutingStrategy.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
import { isUsableAccountToken } from './accountTokenService.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
@@ -1032,10 +1033,10 @@ export class TokenRouter {
account: typeof schema.accounts.$inferSelect;
site?: typeof schema.sites.$inferSelect | null;
token: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
}): string | null {
if (candidate.channel.tokenId) {
if (!candidate.token) return null;
if (!candidate.token.enabled) return null;
if (!isUsableAccountToken(candidate.token)) return null;
const token = candidate.token.token?.trim();
return token ? token : null;
}