forked from boos/BoosAPI
feat: add masked token placeholders and desktop icons
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user