[codex] Split site API endpoint pool from panel host (#373)

* feat: add site api endpoint schema scaffolding

* feat: wire site api endpoints into backup migration

* feat: add site api endpoint selection service

* feat: add site api endpoint contracts

* feat: route discovery through site api endpoints

* feat: route proxy traffic through site api endpoints

* feat: add site api endpoint pool editor

* fix: keep proxy video tasks on site endpoint pool

* fix: restore core test compatibility for site endpoint pool

* fix: address endpoint pool review findings
This commit is contained in:
Cita
2026-04-01 20:58:37 +08:00
committed by GitHub
parent ce77bde95a
commit 76c85d0a13
49 changed files with 7226 additions and 986 deletions
@@ -0,0 +1,271 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const getModelsMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getModels: (...args: unknown[]) => getModelsMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-api-site-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
verifyTokenMock.mockReset();
getModelsMock.mockReset();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.siteApiEndpoints).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('uses the configured ai endpoint for API key verification', async () => {
getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Panel',
url: 'https://nih.cc',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.nih.cc',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'sk-nihao',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
tokenType: 'apikey',
modelCount: 1,
});
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'sk-nihao', undefined);
expect(verifyTokenMock).not.toHaveBeenCalled();
});
it('rotates API key verification across configured ai endpoints after a retryable failure', async () => {
getModelsMock
.mockRejectedValueOnce(new Error('HTTP 502: temporary upstream failure'))
.mockResolvedValueOnce(['gpt-4o-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Pool',
url: 'https://nih.cc',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values([
{
siteId: site.id,
url: 'https://api-a.nih.cc',
enabled: true,
sortOrder: 0,
},
{
siteId: site.id,
url: 'https://api-b.nih.cc',
enabled: true,
sortOrder: 1,
},
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'sk-rotate',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
tokenType: 'apikey',
modelCount: 1,
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-a.nih.cc', 'sk-rotate', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-b.nih.cc', 'sk-rotate', undefined);
const endpoints = await db.select().from(schema.siteApiEndpoints).all();
const firstEndpoint = endpoints.find((item) => item.url === 'https://api-a.nih.cc');
const secondEndpoint = endpoints.find((item) => item.url === 'https://api-b.nih.cc');
expect(firstEndpoint?.cooldownUntil).toBeTruthy();
expect(firstEndpoint?.lastFailureReason).toContain('HTTP 502');
expect(secondEndpoint?.lastSelectedAt).toBeTruthy();
});
it('keeps session verification on the panel host even when api endpoints exist', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
userInfo: { username: 'nihao-user' },
balance: 12.5,
apiToken: 'sk-derived',
});
const site = await db.insert(schema.sites).values({
name: 'Nihao Panel',
url: 'https://nih.cc',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.nih.cc',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'session-token',
credentialMode: 'session',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
tokenType: 'session',
apiToken: 'sk-derived',
});
expect(verifyTokenMock).toHaveBeenCalledWith('https://nih.cc', 'session-token', undefined);
expect(getModelsMock).not.toHaveBeenCalled();
});
it('uses the configured ai endpoint when adding an API key connection', async () => {
getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Panel',
url: 'https://nih.cc',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values({
siteId: site.id,
url: 'https://api.nih.cc',
enabled: true,
sortOrder: 0,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'sk-nihao-create',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
tokenType: 'apikey',
});
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'sk-nihao-create', undefined);
});
it('rotates API key account creation across configured ai endpoints after a retryable failure', async () => {
getModelsMock
.mockRejectedValueOnce(new Error('HTTP 502: temporary upstream failure'))
.mockResolvedValueOnce(['gpt-4o-mini']);
const site = await db.insert(schema.sites).values({
name: 'Nihao Create Pool',
url: 'https://nih.cc',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values([
{
siteId: site.id,
url: 'https://api-create-a.nih.cc',
enabled: true,
sortOrder: 0,
},
{
siteId: site.id,
url: 'https://api-create-b.nih.cc',
enabled: true,
sortOrder: 1,
},
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'sk-nihao-create-rotate',
credentialMode: 'apikey',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
tokenType: 'apikey',
});
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.nih.cc', 'sk-nihao-create-rotate', undefined);
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.nih.cc', 'sk-nihao-create-rotate', undefined);
});
});
+78 -22
View File
@@ -42,6 +42,7 @@ import {
parseAccountUpdatePayload,
parseAccountVerifyTokenPayload,
} from '../../contracts/accountsRoutePayloads.js';
import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -314,6 +315,31 @@ function isVerificationTimeoutError(error: unknown): boolean {
return lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('abort');
}
function buildAccountVerifyTimeoutMessage(): string {
return `Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`;
}
async function getModelsWithSiteApiEndpointPool(
site: typeof schema.sites.$inferSelect,
adapter: NonNullable<ReturnType<typeof getAdapter>>,
accessToken: string,
platformUserId?: number,
): Promise<string[]> {
const timeoutMessage = buildAccountVerifyTimeoutMessage();
const deadline = Date.now() + ACCOUNT_VERIFY_TIMEOUT_MS;
return runWithSiteApiEndpointPool(site, (target) => {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error(timeoutMessage);
}
return withTimeout(
() => adapter.getModels(target.baseUrl, accessToken, platformUserId),
remainingMs,
timeoutMessage,
);
});
}
function resolveUserIdFailureReason(message: string, hasProvidedUserId: boolean): VerifyFailureReason {
const lowered = String(message || '').trim().toLowerCase();
if (!lowered) return null;
@@ -671,7 +697,9 @@ export async function accountsRoutes(app: FastifyInstance) {
: undefined;
const hasProvidedUserId = parsedPlatformUserId !== undefined;
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
const diagnoseVerificationFailure = async (
options: { useApiEndpointPool?: boolean } = {},
): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
@@ -720,21 +748,39 @@ export async function accountsRoutes(app: FastifyInstance) {
});
}
for (const headers of headerVariants) {
try {
const testRes = await fetch(
`${site.url}/api/user/self`,
withSiteRecordProxyRequestInit(site, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
);
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch { }
const tryBaseUrl = async (baseUrl: string): Promise<VerifyFailureReason> => {
let sawNetworkError = false;
let sawResponse = false;
for (const headers of headerVariants) {
try {
const testRes = await fetch(
`${baseUrl.replace(/\/+$/, '')}/api/user/self`,
withSiteRecordProxyRequestInit(site, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
);
sawResponse = true;
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch {
sawNetworkError = true;
}
}
if (sawNetworkError && !sawResponse) {
throw new Error(`diagnostic request timed out for ${baseUrl}`);
}
return null;
};
if (options.useApiEndpointPool) {
const diagnosticBaseUrl = await requireSiteApiBaseUrl(site);
return await tryBaseUrl(diagnosticBaseUrl);
}
return await tryBaseUrl(site.url);
} catch { }
return null;
@@ -768,7 +814,9 @@ export async function accountsRoutes(app: FastifyInstance) {
};
if (!hasProvidedUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
const preflightReason = await diagnoseVerificationFailure();
const preflightReason = await diagnoseVerificationFailure({
useApiEndpointPool: credentialMode === 'apikey',
});
if (preflightReason === 'needs-user-id') {
return buildVerificationFailureResponse(preflightReason);
}
@@ -776,10 +824,11 @@ export async function accountsRoutes(app: FastifyInstance) {
if (credentialMode === 'apikey') {
try {
const models = await withTimeout(
() => adapter.getModels(site.url, accessToken, parsedPlatformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
const models = await getModelsWithSiteApiEndpointPool(
site,
adapter,
accessToken,
parsedPlatformUserId,
);
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
if (availableModels.length === 0) {
@@ -796,7 +845,9 @@ export async function accountsRoutes(app: FastifyInstance) {
};
} catch (err: any) {
if (isVerificationTimeoutError(err)) {
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure({
useApiEndpointPool: true,
}));
if (failure) return failure;
}
return {
@@ -1114,7 +1165,12 @@ export async function accountsRoutes(app: FastifyInstance) {
if (!apiToken) apiToken = rawAccessToken;
} else {
try {
const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId);
const models = await getModelsWithSiteApiEndpointPool(
site,
adapter,
rawAccessToken,
body.platformUserId,
);
verifiedModels = Array.isArray(models)
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
: [];
@@ -0,0 +1,226 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { asc, eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('sites api endpoints', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-api-endpoints-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.siteApiEndpoints).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('accepts apiEndpoints on create and returns them in stable sort order with server-owned metadata', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'split-host-site',
url: 'https://panel.example.com',
platform: 'new-api',
apiEndpoints: [
{
url: 'https://api-b.example.com',
enabled: false,
sortOrder: 1,
cooldownUntil: '2099-01-01T00:00:00.000Z',
lastFailureReason: 'should-be-ignored',
},
{
url: 'https://api-a.example.com/',
enabled: true,
sortOrder: 0,
lastSelectedAt: '2099-01-01T00:00:00.000Z',
},
],
},
});
expect(response.statusCode).toBe(200);
const payload = response.json() as {
id: number;
apiEndpoints?: Array<Record<string, unknown>>;
};
expect(payload.apiEndpoints).toEqual([
expect.objectContaining({
url: 'https://api-a.example.com',
enabled: true,
sortOrder: 0,
cooldownUntil: null,
lastSelectedAt: null,
lastFailedAt: null,
lastFailureReason: null,
}),
expect.objectContaining({
url: 'https://api-b.example.com',
enabled: false,
sortOrder: 1,
cooldownUntil: null,
lastSelectedAt: null,
lastFailedAt: null,
lastFailureReason: null,
}),
]);
const stored = await db.select().from(schema.siteApiEndpoints)
.where(eq(schema.siteApiEndpoints.siteId, payload.id))
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
.all();
expect(stored.map((row) => row.url)).toEqual([
'https://api-a.example.com',
'https://api-b.example.com',
]);
});
it('replaces apiEndpoints on update', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'replace-site',
url: 'https://panel.example.com',
platform: 'new-api',
apiEndpoints: [
{ url: 'https://api-old.example.com', enabled: true, sortOrder: 0 },
],
},
});
expect(created.statusCode).toBe(200);
const site = created.json() as { id: number };
const updated = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
apiEndpoints: [
{ url: 'https://api-b.example.com', enabled: true, sortOrder: 1 },
{ url: 'https://api-a.example.com', enabled: false, sortOrder: 0 },
],
},
});
expect(updated.statusCode).toBe(200);
const payload = updated.json() as { apiEndpoints?: Array<Record<string, unknown>> };
expect(payload.apiEndpoints).toEqual([
expect.objectContaining({
url: 'https://api-a.example.com',
enabled: false,
sortOrder: 0,
}),
expect.objectContaining({
url: 'https://api-b.example.com',
enabled: true,
sortOrder: 1,
}),
]);
const stored = await db.select().from(schema.siteApiEndpoints)
.where(eq(schema.siteApiEndpoints.siteId, site.id))
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
.all();
expect(stored.map((row) => row.url)).toEqual([
'https://api-a.example.com',
'https://api-b.example.com',
]);
});
it('rejects duplicate api endpoint urls under the same site after normalization', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'duplicate-endpoints-site',
url: 'https://panel.example.com',
platform: 'new-api',
apiEndpoints: [
{ url: 'https://api.example.com/', enabled: true, sortOrder: 0 },
{ url: 'https://api.example.com', enabled: false, sortOrder: 1 },
],
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Duplicate apiEndpoints url');
});
it('includes apiEndpoints in GET /api/sites using stable endpoint order', async () => {
const site = await db.insert(schema.sites).values({
name: 'listed-site',
url: 'https://panel.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
await db.insert(schema.siteApiEndpoints).values([
{
siteId: site.id,
url: 'https://api-b.example.com',
enabled: true,
sortOrder: 1,
cooldownUntil: '2026-03-31T12:05:00.000Z',
lastFailedAt: '2026-03-31T12:00:00.000Z',
lastFailureReason: 'HTTP 502',
},
{
siteId: site.id,
url: 'https://api-a.example.com',
enabled: false,
sortOrder: 0,
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/sites',
});
expect(response.statusCode).toBe(200);
const payload = response.json() as Array<{
id: number;
apiEndpoints?: Array<Record<string, unknown>>;
}>;
const listed = payload.find((row) => row.id === site.id);
expect(listed?.apiEndpoints).toEqual([
expect.objectContaining({
url: 'https://api-a.example.com',
enabled: false,
sortOrder: 0,
}),
expect.objectContaining({
url: 'https://api-b.example.com',
enabled: true,
sortOrder: 1,
cooldownUntil: '2026-03-31T12:05:00.000Z',
lastFailureReason: 'HTTP 502',
}),
]);
});
});
+219 -16
View File
@@ -1,7 +1,7 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { db, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import { and, eq } from 'drizzle-orm';
import { getInsertedRowId } from '../../db/insertHelpers.js';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
@@ -16,6 +16,7 @@ import {
parseSiteUpdatePayload,
} from '../../contracts/siteRoutePayloads.js';
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -103,6 +104,165 @@ function normalizeSitePlatform(value: string | undefined): string | null {
return normalized || null;
}
type SiteApiEndpointInputRow = {
url: string;
enabled: boolean;
sortOrder: number;
};
function normalizeSiteApiEndpointBoolean(input: unknown): boolean | null {
return normalizePinnedFlag(input);
}
function normalizeSiteApiEndpointsInput(input: unknown): {
valid: boolean;
present: boolean;
apiEndpoints: SiteApiEndpointInputRow[];
error?: string;
} {
if (input === undefined) {
return { valid: true, present: false, apiEndpoints: [] };
}
if (input === null) {
return { valid: true, present: true, apiEndpoints: [] };
}
if (!Array.isArray(input)) {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints. Expected an array.',
};
}
const seenUrls = new Set<string>();
const apiEndpoints: SiteApiEndpointInputRow[] = [];
for (let index = 0; index < input.length; index += 1) {
const row = input[index];
if (!row || typeof row !== 'object') {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints item. Expected an object.',
};
}
const rawUrl = typeof (row as { url?: unknown }).url === 'string'
? (row as { url: string }).url
: '';
const normalizedUrl = normalizeSiteApiEndpointBaseUrl(rawUrl);
if (!normalizedUrl) {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints url. Expected a valid http(s) URL.',
};
}
let parsedUrl: URL;
try {
parsedUrl = new URL(normalizedUrl);
} catch {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints url. Expected a valid http(s) URL.',
};
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints url. Expected a valid http(s) URL.',
};
}
if (seenUrls.has(normalizedUrl)) {
return {
valid: false,
present: true,
apiEndpoints: [],
error: `Duplicate apiEndpoints url: ${normalizedUrl}`,
};
}
seenUrls.add(normalizedUrl);
const normalizedEnabled = normalizeSiteApiEndpointBoolean((row as { enabled?: unknown }).enabled);
if ((row as { enabled?: unknown }).enabled !== undefined && normalizedEnabled === null) {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints enabled value. Expected boolean.',
};
}
const normalizedSortOrder = normalizeSortOrder((row as { sortOrder?: unknown }).sortOrder);
if ((row as { sortOrder?: unknown }).sortOrder !== undefined && normalizedSortOrder === null) {
return {
valid: false,
present: true,
apiEndpoints: [],
error: 'Invalid apiEndpoints sortOrder value. Expected non-negative integer.',
};
}
apiEndpoints.push({
url: normalizedUrl,
enabled: normalizedEnabled ?? true,
sortOrder: normalizedSortOrder ?? index,
});
}
return { valid: true, present: true, apiEndpoints };
}
async function loadSiteApiEndpointsBySiteIds(siteIds: number[]) {
if (siteIds.length === 0) {
return new Map<number, Array<typeof schema.siteApiEndpoints.$inferSelect>>();
}
const rows = await db.select().from(schema.siteApiEndpoints)
.where(inArray(schema.siteApiEndpoints.siteId, siteIds))
.orderBy(
asc(schema.siteApiEndpoints.siteId),
asc(schema.siteApiEndpoints.sortOrder),
asc(schema.siteApiEndpoints.id),
)
.all();
const bySiteId = new Map<number, Array<typeof schema.siteApiEndpoints.$inferSelect>>();
for (const row of rows) {
const current = bySiteId.get(row.siteId) || [];
current.push({
...row,
url: normalizeSiteApiEndpointBaseUrl(row.url),
});
bySiteId.set(row.siteId, current);
}
return bySiteId;
}
async function attachSiteApiEndpoints<T extends { id: number }>(siteRows: T[]) {
const bySiteId = await loadSiteApiEndpointsBySiteIds(siteRows.map((row) => row.id));
return siteRows.map((row) => ({
...row,
apiEndpoints: bySiteId.get(row.id) || [],
}));
}
async function loadSiteWithApiEndpoints(siteId: number) {
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return null;
const [hydrated] = await attachSiteApiEndpoints([site]);
return hydrated || null;
}
function getErrorChain(error: unknown): ErrorLike[] {
const chain: ErrorLike[] = [];
const seen = new Set<unknown>();
@@ -272,6 +432,7 @@ export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all();
const siteRowsWithApiEndpoints = await attachSiteApiEndpoints(siteRows);
const accountRows = await db.select({
siteId: schema.accounts.siteId,
balance: schema.accounts.balance,
@@ -285,7 +446,7 @@ export async function sitesRoutes(app: FastifyInstance) {
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
}
return siteRows.map((site) => ({
return siteRowsWithApiEndpoints.map((site) => ({
...site,
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
subscriptionSummary: subscriptionBySiteId[site.id] || null,
@@ -298,7 +459,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!parsedBody.success) {
return reply.code(400).send({ error: parsedBody.error });
}
const createBody = parsedBody.data as typeof parsedBody.data & { apiEndpoints?: unknown };
const {
name,
url,
@@ -312,7 +473,8 @@ export async function sitesRoutes(app: FastifyInstance) {
isPinned,
sortOrder,
globalWeight,
} = parsedBody.data;
apiEndpoints,
} = createBody;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -351,6 +513,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (initializationPresetId != null && initializationPresetId !== '' && !explicitInitializationPreset) {
return reply.code(400).send({ error: 'Invalid initializationPresetId.' });
}
const normalizedApiEndpoints = normalizeSiteApiEndpointsInput(apiEndpoints);
if (!normalizedApiEndpoints.valid) {
return reply.code(400).send({ error: normalizedApiEndpoints.error || 'Invalid apiEndpoints.' });
}
const existingSites = await db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
@@ -380,10 +546,8 @@ export async function sitesRoutes(app: FastifyInstance) {
let inserted;
try {
inserted = await insertAndGetById<typeof schema.sites.$inferSelect>({
table: schema.sites,
idColumn: schema.sites.id,
values: {
inserted = await db.transaction(async (tx) => {
const siteInsert = await tx.insert(schema.sites).values({
name,
url: canonicalUrl,
platform: detectedPlatform,
@@ -395,9 +559,19 @@ export async function sitesRoutes(app: FastifyInstance) {
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
},
insertErrorMessage: 'Create site failed',
loadErrorMessage: 'Create site failed',
}).run();
const siteId = getInsertedRowId(siteInsert);
if (siteId && normalizedApiEndpoints.present && normalizedApiEndpoints.apiEndpoints.length > 0) {
await tx.insert(schema.siteApiEndpoints).values(
normalizedApiEndpoints.apiEndpoints.map((row) => ({
siteId,
url: row.url,
enabled: row.enabled,
sortOrder: row.sortOrder,
})),
).run();
}
return siteInsert;
});
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
@@ -405,9 +579,17 @@ export async function sitesRoutes(app: FastifyInstance) {
}
throw error;
}
const siteId = getInsertedRowId(inserted);
if (!siteId) {
return reply.code(500).send({ error: 'Create site failed' });
}
const result = await loadSiteWithApiEndpoints(siteId);
if (!result) {
return reply.code(500).send({ error: 'Create site failed' });
}
invalidateSiteCaches();
return {
...inserted,
...result,
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
};
});
@@ -430,7 +612,7 @@ export async function sitesRoutes(app: FastifyInstance) {
}
const updates: any = {};
const body = parsedBody.data;
const body = parsedBody.data as typeof parsedBody.data & { apiEndpoints?: unknown };
const normalizedStatus = normalizeSiteStatus(body.status);
if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -463,6 +645,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!normalizedCustomHeaders.valid) {
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
}
const normalizedApiEndpoints = normalizeSiteApiEndpointsInput(body.apiEndpoints);
if (!normalizedApiEndpoints.valid) {
return reply.code(400).send({ error: normalizedApiEndpoints.error || 'Invalid apiEndpoints.' });
}
const canonicalPlatform = normalizeSitePlatform(body.platform);
const nextUrl = body.url !== undefined ? normalizeCanonicalSiteUrl(body.url) : existingSite.url;
@@ -498,7 +684,24 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
updates.updatedAt = new Date().toISOString();
try {
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
await db.transaction(async (tx) => {
await tx.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
if (normalizedApiEndpoints.present) {
await tx.delete(schema.siteApiEndpoints)
.where(eq(schema.siteApiEndpoints.siteId, id))
.run();
if (normalizedApiEndpoints.apiEndpoints.length > 0) {
await tx.insert(schema.siteApiEndpoints).values(
normalizedApiEndpoints.apiEndpoints.map((row) => ({
siteId: id,
url: row.url,
enabled: row.enabled,
sortOrder: row.sortOrder,
})),
).run();
}
}
});
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
@@ -512,7 +715,7 @@ export async function sitesRoutes(app: FastifyInstance) {
invalidateSiteCaches();
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
return await loadSiteWithApiEndpoints(id);
});
// Delete a site