diff --git a/.env.example b/.env.example index 3c1e594..086da61 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,17 @@ ALLOW_INSECURE_SMTP=false # Local/private provider endpoints are rejected unless explicitly enabled. ALLOW_INSECURE_PROVIDER_BASE_URL=false ALLOW_PRIVATE_PROVIDER_BASE_URL=false +# Admin uploads of provider plugin code packages (POST /api/admin/plugins/upload) +# are denied until explicitly enabled. When enabled, the API validates the .mjs +# artifact and stores it in the configured object storage bucket; the worker then +# pulls it back out and dynamically imports it. Both services need the flag: the +# API to gate the endpoint, the worker to decide whether to refresh at all. +ALLOW_PLUGIN_UPLOAD=false +# Optional override for the worker's local plugin artifact cache directory inside +# its container. Compose already defaults it to /tmp/musecanvas-plugin-cache; it +# must never live under /app (an ephemeral image layer, shared with no other +# service — api and worker run from separate images with no shared volume). +# PLUGIN_CACHE_DIR=/tmp/musecanvas-plugin-cache # ===== Upgrade-only legacy crypto (optional, one release) ===== # Read-only fallback for rows encrypted before the APP_MASTER_KEY rollout: diff --git a/.github/workflows/media-quality.yml b/.github/workflows/media-quality.yml index 12d4a54..6816b26 100644 --- a/.github/workflows/media-quality.yml +++ b/.github/workflows/media-quality.yml @@ -63,7 +63,11 @@ jobs: run: pnpm --filter @musecanvas/providers test - name: Test media provider contract integration (no external services) - run: pnpm --filter @musecanvas/providers exec tsx --test ../../tests/integration/media-provider-contract.test.ts + # Quoted glob, not a filename: this step used to name + # media-provider-contract.test.ts specifically, so any test added under + # tests/integration/ was silently never run in CI. The quotes are load + # bearing — the runner, not the shell, expands the pattern. + run: pnpm --filter @musecanvas/providers exec tsx --test "../../tests/integration/*.test.ts" - name: Test domain package (no external services) run: pnpm --filter @musecanvas/domain test diff --git a/apps/api/app/api/[...path]/route.ts b/apps/api/app/api/[...path]/route.ts index 991c75b..701e3bc 100644 --- a/apps/api/app/api/[...path]/route.ts +++ b/apps/api/app/api/[...path]/route.ts @@ -1,574 +1,37 @@ -import { createHash, randomInt, randomUUID } from 'node:crypto' -import { NextResponse, type NextRequest } from 'next/server' -import { - db, - transaction, - getOnboardingState, -} from '../../../../../packages/database/src/index' -import { validateGenerationRequest, prepareRequestDigestInput } from '@musecanvas/domain' -import { RUNTIME_SETTINGS_DEFAULTS, type CreateGenerationRequest } from '@musecanvas/contracts' -import { actorFrom, hashOtp, hashToken, randomToken, shouldUseSecureCookie, verifyOtpHash, type Actor } from '../../../src/auth/security' -import { findActiveInvitationHash } from '../../../src/auth/invitations' -import { writeAudit } from '../../../src/shared/audit' -import { body, clientIpFromRequest, emailValid, fail, mutationOriginValid, ok } from '../../../src/shared/http' -import { - adminJobDto, - jobDto, - modelDto, - publicModelDto, - userDto, - providerCredentialDto, - oauthIdentityDto, - capabilitiesFromRow, - defaultsFromRow, -} from '../../../src/shared/dto' -import { sendMail, signedAssetUrl } from '../../../src/shared/services' -import { limited } from '../../../src/shared/redis' -import { decodeCursor, encodeCursor, boundedLimit, userJobSelect, loadJobInputs, loadSingleJobInputs } from '../../../src/shared/pagination' -import { createGenerationUpload, completeGenerationUpload, deleteGenerationUpload, validateAndAttachGenerationUploads, normalizeGenerationInputs, validateInputsAgainstSlots, GenerationInputError } from '../../../src/modules/generation-uploads' -import { resolvePublicOrigin, resolveRuntimeSettings } from '../../../src/modules/settings/runtime' -import { modelPresets } from '../../../src/admin/model-presets' -import { buildBuiltinProviderTemplates } from '../../../src/admin/provider-templates' -import { derivePurposeKey, encryptForPurpose, decryptForPurpose } from '../../../../../packages/providers/src/index' -import { type OAuthProvider } from '../../../src/auth/oauth' -import { retryPreparation } from '../../../src/generation/job-retry' -import { oauthProviderList, adminOAuthSettings } from '../../../src/modules/auth/oauth-settings' -import { startOAuth, handleOAuthCallback, completeOAuthInvitation } from '../../../src/modules/auth/oauth-flow' -import { upsertModel, deleteModel, modelDeleteIdFromPath } from '../../../src/modules/models/handlers' -import { deleteJobWithAssets } from '../../../src/modules/generations/handlers' -import { createProviderCredential, updateProviderCredential, deleteProviderCredential, testProviderCredential } from '../../../src/modules/admin/provider-credentials' -import { updateOAuthProvider } from '../../../src/modules/admin/oauth' -import { updatePromptOptimizationSettings } from '../../../src/modules/admin/prompt-optimization' -import { - activatePromptTemplateSet, - createPromptTemplateEntry, - deletePromptTemplateEntry, - deletePromptTemplateSet, - exportPromptTemplates, - getAdminPromptTemplates, - getPromptTemplateSetDetail, - importPromptTemplates, - listPromptTemplateSets, - previewPromptTemplate, - updatePromptTemplateEntry, -} from '../../../src/modules/admin/prompt-templates' -import { setupStatus, setupConfig, handleSetupPost } from '../../../src/modules/setup/handlers' - +import type { NextRequest } from 'next/server' +import { dispatchDelete, dispatchGet, dispatchPatch, dispatchPost, dispatchPut } from '../../../src/router' + +/** + * The whole API surface hangs off this one catch-all handler. + * + * It reads the matched path and hands it to the dispatcher, and nothing else. + * Routing lives in `src/router`, and each feature's SQL, validation and DTO + * projection live in `src/modules/`. Keeping this file free of queries + * is the point: `apps/api/README.md` states the same rule. + */ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' + type Context = { params: Promise<{ path: string[] }> } -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -const hasControlChars = (value: string): boolean => { - for (const ch of value) { - const code = ch.codePointAt(0) || 0 - if (code < 32 && code !== 9 && code !== 10 && code !== 13) return true - } - return false -} -async function requireActor(request: NextRequest, admin = false): Promise { - const actor = await actorFrom(request) - if (!actor) return fail('UNAUTHORIZED', '请先登录', 401) - if (admin && actor.role !== 'admin') return fail('FORBIDDEN', '无权执行该操作', 403) - return actor -} -function isResponse(value: Actor | NextResponse): value is NextResponse { return value instanceof NextResponse } -const jobOutputSelect = `SELECT go.asset_id,a.object_key,a.media_kind,a.mime_type,a.width,a.height,a.duration_seconds,a.fps,a.codec,a.has_audio,a.size_bytes,a.poster_asset_id,a.poster_object_key - FROM generation_outputs go JOIN assets a ON a.id=go.asset_id WHERE go.job_id=$1 AND a.deleted_at IS NULL` const cleanPath = (context: Context) => context.params.then(value => value.path.join('/')) -const audit = (client: { query: (sql: string, params: unknown[]) => Promise }, actor: Actor, action: string, type: string, id: string, summary: object = {}) => - writeAudit(client, actor.id, action, type, id, summary) -const optimizationSettingsDto = (row: Record) => ({ - enabled: Boolean(row.enabled), - allowUserReadFinalPrompt: Boolean(row.allow_user_read_final_prompt), - languageModelConfigId: (row.language_model_config_id as string) || null, - timeoutMs: Number(row.timeout_ms || 600000), - updatedAt: row.updated_at ? new Date(row.updated_at as string | number | Date).toISOString() : new Date().toISOString(), -}) export async function GET(request: NextRequest, context: Context) { - const path = await cleanPath(context) - if (path === 'health/ready') { - try { await db().query('SELECT 1') } catch { return fail('DEPENDENCY_UNAVAILABLE', '服务尚未就绪', 503) } - try { derivePurposeKey('session-hmac') } catch { return fail('DEPENDENCY_UNAVAILABLE', '服务尚未就绪', 503) } - let setupComplete = false - try { setupComplete = (await getOnboardingState(db()))?.status === 'complete' } catch { setupComplete = false } - return ok({ status: 'ready', setupComplete }) - } - if (path === 'setup/status') return setupStatus() - if (path === 'setup/config') return setupConfig(request) - if (path === 'registration') { const r = await db().query('SELECT mode FROM registration_settings WHERE singleton=true'); return ok({ requiresInvitation: r.rows[0]?.mode === 'invite_only' }) } - if (path === 'session') { const actor = await requireActor(request); return isResponse(actor) ? actor : ok({ user: actor }) } - - if (path === 'auth/oauth/providers') return ok({ providers: await oauthProviderList() }) - const oauthStart = path.match(/^auth\/oauth\/(github|google)\/start$/) - if (oauthStart) return startOAuth(oauthStart[1] as OAuthProvider, 'login') - const oauthCallback = path.match(/^auth\/oauth\/(github|google)\/callback$/) - if (oauthCallback) return handleOAuthCallback(request, oauthCallback[1] as OAuthProvider) - - const actor = await requireActor(request, path.startsWith('admin/')) - if (isResponse(actor)) return actor - if (path === 'account/oauth') { const r = await db().query('SELECT * FROM oauth_identities WHERE user_id=$1 AND deleted_at IS NULL ORDER BY linked_at', [actor.id]); return ok(r.rows.map(oauthIdentityDto)) } - const linkStart = path.match(/^account\/oauth\/(github|google)\/link\/start$/) - if (linkStart) return startOAuth(linkStart[1] as OAuthProvider, 'link', actor.id) - - if (path === 'models') { - const r = await db().query( - `SELECT m.*, rev.capabilities, rev.defaults, rev.revision, rev.id AS revision_id - FROM model_configs m LEFT JOIN model_config_revisions rev ON rev.id = m.latest_revision_id - WHERE m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL ORDER BY m.sort_order,m.created_at` - ) - return ok(r.rows.map(publicModelDto)) - } - if (path === 'jobs') { - const r = await db().query(`${userJobSelect} WHERE j.created_by=$1 AND j.deleted_at IS NULL ORDER BY j.created_at DESC LIMIT 50`, [actor.id]) - const jobIds = r.rows.map(row => row.id) - const inputsByJobId = await loadJobInputs(db(), jobIds) - return ok({ items: await Promise.all(r.rows.map(async row => jobDto(row, (await db().query(jobOutputSelect, [row.id])).rows, inputsByJobId[row.id as string] || []))), total: r.rowCount, hasMore: false }) - } - const jobMatch = path.match(/^jobs\/([0-9a-f-]+)$/) - if (jobMatch) { - const r = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2 AND j.deleted_at IS NULL`, [jobMatch[1], actor.id]); if (!r.rows[0]) return fail('NOT_FOUND', '任务不存在', 404) - const outputs = await db().query(jobOutputSelect, [jobMatch[1]]) - const inputs = await loadSingleJobInputs(db(), jobMatch[1]) - return ok(await jobDto(r.rows[0], outputs.rows, inputs)) - } - if (path === 'library') { - const r = await db().query(`SELECT a.id,a.object_key,a.media_kind,a.mime_type,a.width,a.height,a.duration_seconds,a.fps,a.codec,a.has_audio,a.size_bytes,a.poster_asset_id,a.poster_object_key,a.created_at,COALESCE(po.input_prompt,a.prompt) input_prompt,po.final_prompt,s.allow_user_read_final_prompt - FROM assets a JOIN generation_jobs j ON j.id=a.job_id LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id AND po.deleted_at IS NULL CROSS JOIN prompt_optimization_settings s - WHERE a.created_by=$1 AND a.deleted_at IS NULL AND j.deleted_at IS NULL ORDER BY a.created_at DESC LIMIT 50`, [actor.id]) - return ok({ - items: await Promise.all(r.rows.map(async row => { - const mediaKind = (row.media_kind as string) || 'image' - const url = await signedAssetUrl(row.object_key as string) - const posterUrl = row.poster_object_key ? await signedAssetUrl(row.poster_object_key as string) : undefined - return { - id: row.id, mediaKind, prompt: row.input_prompt, inputPrompt: row.input_prompt, - finalPrompt: row.allow_user_read_final_prompt ? row.final_prompt || null : null, - canReadFinalPrompt: !!row.allow_user_read_final_prompt, - url, downloadUrl: url, imageUrl: url, posterUrl, posterAssetId: (row.poster_asset_id as string) || undefined, - mimeType: row.mime_type, - width: row.width !== null && row.width !== undefined ? Number(row.width) : undefined, - height: row.height !== null && row.height !== undefined ? Number(row.height) : undefined, - durationSeconds: row.duration_seconds !== null && row.duration_seconds !== undefined ? Number(row.duration_seconds) : undefined, - fps: row.fps !== null && row.fps !== undefined ? Number(row.fps) : undefined, - codec: (row.codec as string) || undefined, - hasAudio: typeof row.has_audio === 'boolean' ? row.has_audio as boolean : undefined, - sizeBytes: row.size_bytes !== undefined ? Number(row.size_bytes) : undefined, - createdAt: (row.created_at as Date).toISOString(), - } - })), total: r.rowCount, hasMore: false, - }) - } - const downloadMatch = path.match(/^library\/([0-9a-f-]+)\/download$/) - if (downloadMatch) { - const r = await db().query('SELECT id,object_key,media_kind,mime_type,duration_seconds FROM assets WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL', [downloadMatch[1], actor.id]) - if (!r.rows[0]) return fail('NOT_FOUND', '资源不存在', 404) - const row = r.rows[0] - return ok({ url: await signedAssetUrl(row.object_key as string), downloadUrl: await signedAssetUrl(row.object_key as string), mediaKind: (row.media_kind as string) || 'image', mimeType: row.mime_type }) - } - - if (path === 'admin/dashboard') { - const r = await db().query(`SELECT (SELECT count(*)::int FROM users WHERE deleted_at IS NULL) total_users,(SELECT count(*)::int FROM generation_jobs WHERE deleted_at IS NULL) total_jobs,(SELECT count(*)::int FROM generation_jobs WHERE status='failed' AND created_at>now()-interval '7 days') failed_jobs_7d,(SELECT COALESCE(round(100.0*count(*) FILTER(WHERE status='succeeded')/NULLIF(count(*) FILTER(WHERE status IN('succeeded','failed')),0),1),0)::float FROM generation_jobs WHERE created_at>now()-interval '7 days') success_rate_7d`) - const x = r.rows[0]; return ok({ totalUsers: x.total_users, totalJobs: x.total_jobs, failedJobs7d: x.failed_jobs_7d, successRate7d: x.success_rate_7d }) - } - if (path === 'admin/registration') { const r = await db().query('SELECT mode FROM registration_settings WHERE singleton=true'); return ok({ requiresInvitation: r.rows[0]?.mode === 'invite_only' }) } - - if (path === 'admin/users') { - const limit = boundedLimit(request); const cursor = decodeCursor(request.nextUrl.searchParams.get('cursor')); const values: unknown[] = []; const conditions = ['deleted_at IS NULL'] - const status = request.nextUrl.searchParams.get('status'); const email = request.nextUrl.searchParams.get('email')?.trim() - if (status === 'active' || status === 'disabled') { values.push(status); conditions.push(`status=$${values.length}`) } - if (email) { values.push(`%${email}%`); conditions.push(`email ILIKE $${values.length}`) } - if (cursor) { values.push(cursor.createdAt, cursor.id); conditions.push(`(created_at,id)<($${values.length - 1}::timestamptz,$${values.length}::uuid)`) } - const where = conditions.join(' AND '); const totalValues = values.slice(0, cursor ? -2 : undefined); const totalWhere = cursor ? conditions.slice(0, -1).join(' AND ') : where - values.push(limit + 1) - const r = await db().query(`SELECT u.id,u.email,u.role,u.status,u.created_at FROM users u WHERE ${where.replaceAll('deleted_at', 'u.deleted_at').replaceAll('created_at', 'u.created_at').replaceAll('(u.created_at,id)', '(u.created_at,u.id)')} ORDER BY u.created_at DESC,u.id DESC LIMIT $${values.length}`, values) - const total = await db().query(`SELECT count(*)::int total FROM users WHERE ${totalWhere}`, totalValues); const hasMore = r.rows.length > limit; const rows = r.rows.slice(0, limit) - return ok({ - items: rows.map(row => userDto(row)), - total: total.rows[0].total, - hasMore, - nextCursor: hasMore && rows.length ? encodeCursor(rows[rows.length - 1]) : undefined, - }) - } - if (path === 'admin/model-presets') return ok(modelPresets) - if (path === 'admin/provider-templates') return ok({ templates: buildBuiltinProviderTemplates() }) - if (path === 'admin/models') { const r = await db().query('SELECT m.*, pc.display_name AS provider_credential_name, rev.capabilities, rev.defaults, rev.revision FROM model_configs m LEFT JOIN provider_credentials pc ON pc.id=m.provider_credential_id AND pc.deleted_at IS NULL LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id WHERE m.deleted_at IS NULL ORDER BY m.sort_order,m.created_at'); return ok(r.rows.map(modelDto)) } - if (path === 'admin/prompt-templates') return getAdminPromptTemplates() - if (path === 'admin/prompt-templates/sets') return listPromptTemplateSets() - if (path === 'admin/prompt-templates/export') return exportPromptTemplates(request.nextUrl.searchParams.get('setId') || undefined) - const promptSetDetail = path.match(/^admin\/prompt-templates\/sets\/([0-9a-fA-F-]+)$/) - if (promptSetDetail) return getPromptTemplateSetDetail(promptSetDetail[1]) - if (path === 'admin/prompt-optimization-settings') { const r = await db().query('SELECT * FROM prompt_optimization_settings WHERE singleton=true'); return ok(optimizationSettingsDto(r.rows[0])) } - if (path === 'admin/jobs') { - const limit = boundedLimit(request); const cursor = decodeCursor(request.nextUrl.searchParams.get('cursor')); const values: unknown[] = []; const conditions = ['deleted_at IS NULL'] - const userId = request.nextUrl.searchParams.get('userId'); const status = request.nextUrl.searchParams.get('status'); const modelId = request.nextUrl.searchParams.get('modelId'); const from = request.nextUrl.searchParams.get('from'); const to = request.nextUrl.searchParams.get('to') - if (userId && /^[0-9a-f-]{36}$/i.test(userId)) { values.push(userId); conditions.push(`created_by=$${values.length}::uuid`) } - if (status && ['queued','running','retry_wait','succeeded','failed','canceled'].includes(status)) { values.push(status); conditions.push(`status=$${values.length}`) } - if (modelId && /^[0-9a-f-]{36}$/i.test(modelId)) { values.push(modelId); conditions.push(`model_id=$${values.length}::uuid`) } - if (from && !Number.isNaN(Date.parse(from))) { values.push(from); conditions.push(`created_at>=$${values.length}::timestamptz`) } - if (to && !Number.isNaN(Date.parse(to))) { values.push(to); conditions.push(`created_at<=$${values.length}::timestamptz`) } - if (cursor) { values.push(cursor.createdAt, cursor.id); conditions.push(`(created_at,id)<($${values.length - 1}::timestamptz,$${values.length}::uuid)`) } - const where = conditions.join(' AND '); const totalValues = values.slice(0, cursor ? -2 : undefined); const totalWhere = cursor ? conditions.slice(0, -1).join(' AND ') : where - values.push(limit + 1); const r = await db().query(`SELECT j.id,j.created_by,j.model_id,j.model_name,j.status,j.phase,j.error_code,j.provider_error,j.provider_reference_id,j.created_at,j.started_at,j.completed_at,po.template_name_snapshot,po.language_model_name_snapshot,po.language_model_vendor_id_snapshot,po.language_model_protocol_snapshot FROM generation_jobs j LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id WHERE ${where.replaceAll('deleted_at', 'j.deleted_at').replaceAll('created_by', 'j.created_by').replaceAll('status=', 'j.status=').replaceAll('model_id', 'j.model_id').replaceAll('created_at', 'j.created_at').replaceAll('(j.created_at,id)', '(j.created_at,j.id)')} ORDER BY j.created_at DESC,j.id DESC LIMIT $${values.length}`, values) - const total = await db().query(`SELECT count(*)::int total FROM generation_jobs WHERE ${totalWhere}`, totalValues); const hasMore = r.rows.length > limit; const rows = r.rows.slice(0, limit) - return ok({ items: rows.map(adminJobDto), total: total.rows[0].total, hasMore, nextCursor: hasMore && rows.length ? encodeCursor(rows[rows.length - 1]) : undefined }) - } - if (path === 'admin/invitations') { const r = await db().query('SELECT id,consumed_at,revoked_at,created_at,code_encrypted FROM invitations ORDER BY created_at DESC LIMIT 100'); return ok({ items: r.rows.map(row => { let code: string | undefined; if (row.code_encrypted) { try { code = decryptForPurpose(row.code_encrypted as string, 'invitation-codes') } catch { code = undefined } } return { id: row.id, code, used: !!row.consumed_at, revoked: !!row.revoked_at, createdAt: row.created_at.toISOString() } }), total: r.rowCount, hasMore: false }) } - if (path === 'admin/oauth-providers') return ok(await adminOAuthSettings()) - if (path === 'admin/provider-credentials') { const r = await db().query('SELECT * FROM provider_credentials WHERE deleted_at IS NULL ORDER BY created_at DESC'); return ok(r.rows.map(providerCredentialDto)) } - return fail('NOT_FOUND', '接口不存在', 404) + return dispatchGet(request, await cleanPath(context)) } export async function POST(request: NextRequest, context: Context) { - if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403) - const path = await cleanPath(context) - if (path === 'setup/complete' || path === 'setup/claim' || path === 'setup/site' || path === 'setup/smtp' || path === 'setup/smtp/test' || path === 'setup/storage' || path === 'setup/storage/test' || path === 'setup/runtime' || path === 'setup/prompt-templates/import' || path === 'setup/admin/request' || path === 'setup/admin/verify') { - return (await handleSetupPost(request, path)) ?? fail('NOT_FOUND', '接口不存在', 404) - } - const input = await body(request) - if (path === 'auth/otp/request') { - if (!emailValid(input.email)) return fail('INVALID_INPUT', '邮箱格式不正确') - const email = input.email.trim().toLowerCase(); const ip = clientIpFromRequest(request) - if (await limited(`otp:${email}:${ip}`, 5, 600)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) - const existing = await db().query('SELECT id,status,deleted_at FROM users WHERE lower(email)=$1 ORDER BY deleted_at NULLS FIRST LIMIT 1', [email]); const account = existing.rows[0] - if (account && (account.deleted_at || account.status !== 'active')) return fail('ACCOUNT_UNAVAILABLE', '账户当前不可用', 403) - const setting = await db().query('SELECT mode FROM registration_settings WHERE singleton=true'); const requiresInvitation = !account && setting.rows[0]?.mode === 'invite_only' - let invitationHash: string | null = null - if (requiresInvitation) { - if (typeof input.invitationCode !== 'string' || !input.invitationCode.trim()) return ok({ accepted: false, nextStep: 'invitation' as const }) - invitationHash = await findActiveInvitationHash(db(), input.invitationCode) - if (!invitationHash) return fail('INVALID_INVITATION', '邀请码无效或已过期') - } - const code = randomInt(100000, 1000000).toString(); await db().query('UPDATE otp_challenges SET consumed_at=now() WHERE lower(email)=$1 AND consumed_at IS NULL', [email]); const challenge = await db().query("INSERT INTO otp_challenges(email,code_hash,invitation_code_hash,expires_at) VALUES($1,$2,$3,now()+interval '10 minutes') RETURNING id", [email, hashOtp(email, code), invitationHash]) - try { await sendMail(email, 'MuseCanvas 登录验证码', `你的 MuseCanvas 验证码是:${code}。10 分钟内有效。`) } - catch (error) { await db().query('UPDATE otp_challenges SET consumed_at=now() WHERE id=$1', [challenge.rows[0].id]); console.error('otp delivery failed', { code: error instanceof Error ? error.message : 'SMTP_ERROR' }); return fail('EMAIL_DELIVERY_FAILED', '验证码发送失败,请稍后重试', 503) } - return ok({ accepted: true, nextStep: 'otp' as const }) - } - if (path === 'auth/otp/verify') { - if (!emailValid(input.email) || typeof input.code !== 'string' || !/^\d{6}$/.test(input.code)) return fail('INVALID_OTP', '验证码无效') - const email = input.email.trim().toLowerCase(); if (await limited(`verify:${email}`, 10, 600)) return fail('RATE_LIMITED', '验证尝试过多,请稍后再试', 429) - const result = await transaction(async client => { - const challengeResult = await client.query("SELECT * FROM otp_challenges WHERE lower(email)=$1 AND consumed_at IS NULL AND expires_at>now() ORDER BY created_at DESC LIMIT 1 FOR UPDATE", [email]); const challenge = challengeResult.rows[0] - if (!challenge || challenge.attempts >= 5 || !verifyOtpHash(challenge.code_hash, email, input.code as string)) { if (challenge) await client.query('UPDATE otp_challenges SET attempts=attempts+1 WHERE id=$1', [challenge.id]); return null } - let userResult = await client.query('SELECT * FROM users WHERE lower(email)=$1 AND deleted_at IS NULL FOR UPDATE', [email]); let user = userResult.rows[0] - if (!user) { - const setting = await client.query('SELECT mode FROM registration_settings WHERE singleton=true FOR UPDATE') - if (setting.rows[0].mode === 'invite_only') { if (!challenge.invitation_code_hash) return null; const invite = await client.query('UPDATE invitations SET consumed_at=now() WHERE code_hash=$1 AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>now() RETURNING id', [challenge.invitation_code_hash]); if (!invite.rows[0]) return null } - userResult = await client.query("INSERT INTO users(email) VALUES($1) RETURNING *", [email]); user = userResult.rows[0] - } - if (user.status !== 'active') return null - await client.query('UPDATE otp_challenges SET consumed_at=now() WHERE id=$1', [challenge.id]); const token = randomToken(); await client.query("INSERT INTO sessions(user_id,token_hash,expires_at) VALUES($1,$2,now()+interval '30 days')", [user.id, hashToken(token)]); await audit(client, user, 'auth.otp.login', 'user', user.id); return { user, token } - }) - if (!result) return fail('INVALID_OTP', '验证码无效或已过期', 401) - const response = ok({ user: userDto(result.user) }); response.cookies.set('muse_session', result.token, { httpOnly: true, secure: shouldUseSecureCookie(await resolvePublicOrigin()), sameSite: 'lax', path: '/', maxAge: 30 * 86400 }); return response - } - if (path === 'auth/logout') { const logoutActor = await actorFrom(request); const token = request.cookies.get('muse_session')?.value; if (token) await db().query('UPDATE sessions SET revoked_at=now() WHERE token_hash=$1', [hashToken(token)]); if (logoutActor) { try { await writeAudit(db(), logoutActor.id, 'auth.logout', 'user', logoutActor.id) } catch (error) { console.error('audit write failed', error) } } const response = ok({ loggedOut: true }); response.cookies.delete('muse_session'); response.cookies.delete('muse_setup'); return response } - if (path === 'auth/oauth/invitation') return completeOAuthInvitation(request, input) - - const actor = await requireActor(request, path.startsWith('admin/')); if (isResponse(actor)) return actor - if (path === 'generation-uploads') return createGenerationUpload(actor, input) - const completeUpload = path.match(/^generation-uploads\/([0-9a-f-]+)\/complete$/) - if (completeUpload) return completeGenerationUpload(actor, completeUpload[1]) - - if (path === 'generations') { - if (await limited(`gen:create:${actor.id}`, 20, 300)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) - const allowedGenerationFields = new Set(['prompt', 'modelId', 'parameters', 'inputs', 'idempotencyKey', 'inputLanguage', 'size', 'quality', 'count', 'inputImageIds']) - if (Object.keys(input).some(key => !allowedGenerationFields.has(key))) return fail('INVALID_INPUT', '生成请求包含不允许的字段') - if (typeof input.prompt !== 'string' || input.prompt.trim().length < 1 || input.prompt.length > 4000 || hasControlChars(input.prompt) || typeof input.modelId !== 'string') return fail('INVALID_INPUT', '生成参数无效') - if (!UUID_PATTERN.test(input.modelId)) return fail('INVALID_INPUT', '模型参数无效') - const modelResult = await db().query( - `SELECT m.*, rev.capabilities, rev.defaults, rev.revision FROM model_configs m - LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id - WHERE m.id=$1 AND m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL`, - [input.modelId], - ) - const model = modelResult.rows[0]; if (!model) return fail('MODEL_NOT_AVAILABLE', '模型当前不可用') - const mediaKind = ((model.model_kind as string) || 'image') as 'image' | 'video' - const capabilities = capabilitiesFromRow(model) - const defaults = defaultsFromRow(model) as Record - // Shared parameters: the unified `parameters` object is primary; legacy image - // fields (size/quality/count) are only a normalized compatibility path. - const hasNewShape = input.parameters !== undefined - let rawParameters: Record - if (hasNewShape) { - if (typeof input.parameters !== 'object' || input.parameters === null || Array.isArray(input.parameters)) { - return fail('INVALID_INPUT', '生成参数无效') - } - rawParameters = input.parameters as Record - } else { - rawParameters = {} - if (typeof input.size === 'string') rawParameters.size = input.size - else if (mediaKind === 'image') return fail('INVALID_INPUT', '生成参数无效') - if (typeof input.quality === 'string') rawParameters.quality = input.quality - if (input.count !== undefined) rawParameters.count = Number(input.count) - } - // Resolved runtime input limits (DB first) enforce both raised and - // lowered settings; canonical defaults are the safe fallback. - let runtimeLimits: { maxImageBytes: number; maxTotalBytes: number; maxInputs: number } = { - maxImageBytes: RUNTIME_SETTINGS_DEFAULTS.maxImageBytes, - maxTotalBytes: RUNTIME_SETTINGS_DEFAULTS.maxTotalBytes, - maxInputs: RUNTIME_SETTINGS_DEFAULTS.maxInputs, - } - try { - const resolved = await resolveRuntimeSettings() - runtimeLimits = { - maxImageBytes: resolved.maxImageBytes, - maxTotalBytes: resolved.maxTotalBytes, - maxInputs: resolved.maxInputs, - } - } catch { - runtimeLimits = { - maxImageBytes: RUNTIME_SETTINGS_DEFAULTS.maxImageBytes, - maxTotalBytes: RUNTIME_SETTINGS_DEFAULTS.maxTotalBytes, - maxInputs: RUNTIME_SETTINGS_DEFAULTS.maxInputs, - } - } - // Role-aware generic inputs with legacy inputImageIds compatibility. - let normalizedInputs: { uploadId: string; role: string; position: number }[] - try { - normalizedInputs = validateInputsAgainstSlots( - normalizeGenerationInputs(input.inputs, input.inputImageIds), - (capabilities.inputSlots as { role: string; required?: boolean; minCount?: number; maxCount?: number }[]) || [], - runtimeLimits, - ) - } catch (err) { - if (err instanceof GenerationInputError) return fail(err.code, err.message, err.status) - return fail('INVALID_INPUT', '参考图参数无效', 400) - } - // Descriptor-driven validation via domain. The immutable revision - // capabilities plus the normalized parameters are the Image request - // contract: image models keep a permissive size/quality shape here and - // the provider plugin owns byte/shape enforcement downstream. No - // adapter-based second gate — revision.pluginId/pluginVersion is the - // only runtime routing identity (adapter columns remain for storage and - // public DTO compatibility only). - const validationCaps = mediaKind === 'image' - ? { - modes: (capabilities.modes.length > 0 ? capabilities.modes : ['text_to_image', 'image_to_image']) as ('text_to_image' | 'image_to_image')[], - parameters: [ - { type: 'text' as const, name: 'size', maxLength: 32 }, - { type: 'text' as const, name: 'quality', maxLength: 32 }, - { type: 'integer' as const, name: 'count', min: 1, max: capabilities.maxCount || 10, defaultValue: 1 }, - ], - inputSlots: ((capabilities.inputSlots as { role: string; maxCount?: number }[]) || []).map(slot => ({ - role: slot.role, required: false, minCount: 0, maxCount: slot.maxCount ?? runtimeLimits?.maxInputs ?? 32, allowedMediaKinds: ['image' as const], - })), - maxCount: capabilities.maxCount, - supportedMediaKinds: ['image' as const], - } - : { - modes: capabilities.modes as ('text_to_video' | 'image_to_video')[], - parameters: capabilities.parameters as never[], - inputSlots: capabilities.inputSlots as never[], - maxCount: capabilities.maxCount, - supportedMediaKinds: ['video' as const], - } - const idempotencyKey = request.headers.get('idempotency-key') || (typeof input.idempotencyKey === 'string' ? input.idempotencyKey : randomUUID()) - const createRequest = { - modelId: input.modelId as string, - prompt: (input.prompt as string).trim(), - parameters: rawParameters, - inputs: normalizedInputs, - idempotencyKey, - inputLanguage: typeof input.inputLanguage === 'string' ? (input.inputLanguage as string).slice(0, 20) : undefined, - } as CreateGenerationRequest - const domainValidation = validateGenerationRequest(validationCaps, createRequest, { defaults: defaults as Record }) - if (!domainValidation.valid) return fail(domainValidation.errorCode, domainValidation.errorMessage) - const normalized = domainValidation.value - const prompt = normalized.prompt - const requestDigest = createHash('sha256').update(prepareRequestDigestInput(normalized)).digest('hex') - const attachLimits = runtimeLimits - let row: Record - try { - row = await transaction(async client => { - const existing = await client.query('SELECT * FROM generation_jobs WHERE created_by=$1 AND idempotency_key=$2', [actor.id, idempotencyKey]) - if (existing.rows[0]) return existing.rows[0] - - // Lock model config and prompt optimization settings in generation transaction - const lockedModelRes = await client.query( - `SELECT m.*, rev.id AS revision_id, rev.capabilities AS revision_capabilities, rev.defaults AS revision_defaults, rev.revision AS revision_number FROM model_configs m - LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id - WHERE m.id=$1 AND m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL FOR SHARE`, - [input.modelId] - ) - const lockedModel = lockedModelRes.rows[0] - if (!lockedModel) throw new Error('MODEL_NOT_AVAILABLE') - - const optRes = await client.query('SELECT * FROM prompt_optimization_settings WHERE singleton=true FOR SHARE') - const optRow = optRes.rows[0] || { singleton: true, enabled: false } - - let credId: string | null = null; let credName: string | null = null; let providerBaseUrl = lockedModel.base_url - if (lockedModel.provider_credential_id) { - const cred = await client.query('SELECT id, display_name, enabled, api_key_encrypted, payload_encrypted, base_url FROM provider_credentials WHERE id=$1 AND deleted_at IS NULL', [lockedModel.provider_credential_id]) - if (!cred.rows[0] || !cred.rows[0].enabled || (!cred.rows[0].api_key_encrypted && !cred.rows[0].payload_encrypted)) throw new Error('PROVIDER_NOT_CONFIGURED') - credId = cred.rows[0].id - credName = cred.rows[0].display_name - providerBaseUrl = cred.rows[0].base_url || lockedModel.base_url - } - - let optSettings = optRow - if (optRow.enabled) { - const fullOpt = await client.query( - `SELECT s.*,m.display_name,m.vendor_model_id,m.adapter,m.language_protocol,m.max_output_tokens,m.temperature,m.reasoning_effort,m.base_url,pc.id credential_id,pc.display_name credential_name,pc.base_url credential_base_url,pc.enabled credential_enabled,COALESCE(NULLIF(pc.payload_encrypted,''),pc.api_key_encrypted) api_key_encrypted - FROM prompt_optimization_settings s - LEFT JOIN model_configs m ON m.id=s.language_model_config_id AND m.deleted_at IS NULL - LEFT JOIN provider_credentials pc ON pc.id=m.provider_credential_id AND pc.deleted_at IS NULL - WHERE s.singleton=true` - ) - optSettings = fullOpt.rows[0] - if (!optSettings || !optSettings.language_model_config_id || !optSettings.language_protocol || !optSettings.credential_id || !optSettings.credential_enabled || !optSettings.api_key_encrypted) { - throw new Error('PROMPT_MODEL_NOT_CONFIGURED') - } - } - const optimizationMode = optRow.enabled ? 'enabled' : 'disabled' - const phase = optRow.enabled ? 'template_selecting' : (mediaKind === 'video' ? 'provider_submitting' : 'image_generating') - - // Generations are free: no quoting and no reservation. Insert the job - // with the immutable revision/provider/plugin identity, media kind, - // normalized request and digest for idempotent dispatch. - const jobSize = typeof normalized.parameters.size === 'string' ? normalized.parameters.size as string : null - const jobQuality = typeof normalized.parameters.quality === 'string' ? normalized.parameters.quality as string : null - const jobCount = Number(normalized.parameters.count ?? 1) - const normalizedRequestJson = JSON.stringify({ modelId: normalized.modelId, prompt: normalized.prompt, parameters: normalized.parameters, inputs: normalized.inputs, mode: normalized.mode }) - const insertSql = `INSERT INTO generation_jobs(created_by,model_id,model_name,adapter,vendor_model_id,provider_base_url,prompt,size,quality,count,watermark,idempotency_key,provider_credential_id,provider_credential_name,optimization_mode,phase,media_kind,model_revision_id,provider_id,plugin_id,plugin_version,normalized_request,request_digest) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) ON CONFLICT (created_by, idempotency_key) DO NOTHING RETURNING *` - const insertParams = [actor.id, lockedModel.id, lockedModel.display_name, lockedModel.adapter, lockedModel.vendor_model_id, providerBaseUrl, prompt, jobSize, jobQuality, jobCount, lockedModel.watermark, idempotencyKey, credId, credName, optimizationMode, phase, mediaKind, lockedModel.revision_id || null, lockedModel.provider_id || null, lockedModel.plugin_id || null, lockedModel.plugin_version || '1.0.0', normalizedRequestJson, requestDigest] - const inserted = await client.query(insertSql, insertParams) - if (inserted.rowCount === 0) { - // Concurrent create with the same idempotency key: the winner - // already committed the job, its input bindings and outbox event. - // Return the existing row and skip every write. - const replayed = await client.query('SELECT * FROM generation_jobs WHERE created_by=$1 AND idempotency_key=$2', [actor.id, idempotencyKey]) - if (replayed.rows[0]) return replayed.rows[0] - throw new Error('GENERATION_CREATE_FAILED') - } - await validateAndAttachGenerationUploads(client, actor.id, inserted.rows[0].id, normalizedInputs, attachLimits) - if (optRow.enabled) { - const optimization = await client.query(`INSERT INTO prompt_optimizations(job_id,created_by,input_prompt,input_language,language_model_config_id,language_model_name_snapshot,language_model_vendor_id_snapshot,language_model_protocol_snapshot,language_model_adapter_snapshot,language_model_base_url_snapshot,language_model_max_output_tokens_snapshot,language_model_temperature_snapshot,language_model_reasoning_effort_snapshot,provider_credential_id,provider_credential_name_snapshot) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`, [inserted.rows[0].id, actor.id, prompt, typeof input.inputLanguage === 'string' ? input.inputLanguage.slice(0, 20) : 'und', optSettings.language_model_config_id, optSettings.display_name, optSettings.vendor_model_id, optSettings.language_protocol, optSettings.adapter, optSettings.credential_base_url || optSettings.base_url, optSettings.max_output_tokens, optSettings.temperature, optSettings.reasoning_effort, optSettings.credential_id, optSettings.credential_name]) - await client.query('UPDATE generation_jobs SET prompt_optimization_id=$1 WHERE id=$2', [optimization.rows[0].id, inserted.rows[0].id]) - } - await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload,dedupe_key) VALUES('generation.requested',$1,$2,$3)", [inserted.rows[0].id, { jobId: inserted.rows[0].id }, `gen:${actor.id}:${idempotencyKey}`]) - return inserted.rows[0] - }) - } catch (error) { - if (error instanceof GenerationInputError) return fail(error.code, error.message, error.status) - if (error instanceof Error && error.message === 'MODEL_NOT_AVAILABLE') { - return fail('MODEL_NOT_AVAILABLE', '模型当前不可用', 409) - } - const code = error instanceof Error && ['PROVIDER_NOT_CONFIGURED', 'PROMPT_MODEL_NOT_CONFIGURED'].includes(error.message) ? error.message : 'GENERATION_CREATE_FAILED' - return fail(code, code === 'PROMPT_MODEL_NOT_CONFIGURED' ? '提示词优化模型配置不完整' : code === 'PROVIDER_NOT_CONFIGURED' ? '生成供应商凭据未配置' : '创建生成任务失败', 503) - } - const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [row.id, actor.id]) - const jobInputs = await loadSingleJobInputs(db(), row.id as string) - return ok(await jobDto(responseRow.rows[0] || row, [], jobInputs), { status: 202 }) - } - - const cancel = path.match(/^jobs\/([0-9a-f-]+)\/cancel$/) - if (cancel) { - if (await limited(`gen:cancel:${actor.id}`, 60, 60)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) - const outcome = await transaction(async client => { - const current = await client.query('SELECT id,status,attempt FROM generation_jobs WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL FOR UPDATE', [cancel[1], actor.id]) - const job = current.rows[0] - if (!job) return { kind: 'not_found' as const } - if (job.status === 'succeeded' || job.status === 'failed' || job.status === 'canceled') { - return { kind: 'not_cancelable' as const } - } - if (job.status === 'queued' || job.status === 'retry_wait') { - await client.query("UPDATE generation_jobs SET status='canceled',completed_at=now(),updated_at=now() WHERE id=$1", [cancel[1]]) - return { kind: 'canceled' as const } - } - // Active job: cooperative cancel. Record local intent and enqueue provider - // cancel work; never claim success on local intent alone. - await client.query('UPDATE generation_jobs SET cancel_requested_at=COALESCE(cancel_requested_at,now()),updated_at=now() WHERE id=$1', [cancel[1]]) - await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload,dedupe_key) VALUES('generation.cancel.requested',$1,$2,$3) ON CONFLICT (dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING", [cancel[1], { jobId: cancel[1] }, `cancel:${cancel[1]}:a${job.attempt}`]) - try { - await client.query("UPDATE provider_runs SET operation_state='canceling',next_action_at=now(),updated_at=now() WHERE job_id=$1 AND operation_state IN ('submitting','submission_unknown','waiting','importing')", [cancel[1]]) - } catch { - // provider_runs table may not exist on older databases; outbox carries the intent. - } - return { kind: 'cancel_requested' as const } - }) - if (outcome.kind === 'not_found' || outcome.kind === 'not_cancelable') { - return fail('JOB_NOT_CANCELABLE', '任务无法取消', 409) - } - const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [cancel[1], actor.id]) - const jobInputs = await loadSingleJobInputs(db(), cancel[1]) - const outputs = outcome.kind === 'canceled' ? [] : (await db().query(jobOutputSelect, [cancel[1]])).rows - return ok(await jobDto(responseRow.rows[0] || { id: cancel[1] }, outputs, jobInputs)) - } - - const retry = path.match(/^jobs\/([0-9a-f-]+)\/retry$/) - if (retry) { - if (await limited(`gen:retry:${actor.id}`, 30, 60)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) - const row = await transaction(async client => { - const current = await client.query(`SELECT j.id,j.prompt_optimization_id,j.optimization_mode,po.final_prompt,po.template_instruction_snapshot - FROM generation_jobs j LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id AND po.deleted_at IS NULL - WHERE j.id=$1 AND j.created_by=$2 AND j.status=$3 AND j.deleted_at IS NULL FOR UPDATE OF j`, [retry[1], actor.id, 'failed']) - const job = current.rows[0] - if (!job) return null - - const preparation = retryPreparation(job) - if (preparation.resetOptimization) await client.query("UPDATE prompt_optimizations SET status='pending',attempt=0,error_code=NULL,started_at=NULL,completed_at=NULL,updated_at=now() WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL", [job.prompt_optimization_id, actor.id]) - const updated = await client.query("UPDATE generation_jobs SET status='queued',phase=$3,attempt=0,progress=0,cancel_requested_at=NULL,error_code=NULL,provider_error=NULL,provider_reference_id=NULL,started_at=NULL,completed_at=NULL,updated_at=now() WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL RETURNING *", [retry[1], actor.id, preparation.phase]) - await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload) VALUES('generation.retry.manual',$1,$2)", [retry[1], { jobId: retry[1] }]) - return updated.rows[0] - }) - if (!row) return fail('JOB_NOT_RETRYABLE', '任务无法重试', 409) - const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [row.id, actor.id]) - const jobInputs = await loadSingleJobInputs(db(), row.id as string) - return ok(await jobDto(responseRow.rows[0] || row, [], jobInputs), { status: 202 }) - } - if (path === 'admin/invitations') { - const code = randomToken(18); const envelope = encryptForPurpose(code, 'invitation-codes'); const r = await db().query("INSERT INTO invitations(email,code_hash,code_encrypted,expires_at,created_by) VALUES(NULL,$1,$2,now()+interval '7 days',$3) RETURNING id,created_at", [hashToken(code), envelope.ciphertext, actor.id]); await audit(db(), actor, 'invitation.create', 'invitation', r.rows[0].id); return ok({ id: r.rows[0].id, code, used: false, createdAt: r.rows[0].created_at.toISOString() }) - } - if (path === 'admin/models') return upsertModel(actor, input) - if (path === 'admin/prompt-templates/import') return importPromptTemplates(actor, input) - if (path === 'admin/prompt-templates/preview') return previewPromptTemplate(input) - const promptActivate = path.match(/^admin\/prompt-templates\/sets\/([0-9a-fA-F-]+)\/activate$/) - if (promptActivate) return activatePromptTemplateSet(actor, promptActivate[1]) - const promptEntryCreate = path.match(/^admin\/prompt-templates\/sets\/([0-9a-fA-F-]+)\/entries$/) - if (promptEntryCreate) return createPromptTemplateEntry(actor, promptEntryCreate[1], input) - if (path === 'admin/provider-credentials') return createProviderCredential(actor, input) - const credTest = path.match(/^admin\/provider-credentials\/([0-9a-f-]+)\/test$/) - if (credTest) return testProviderCredential(actor, credTest[1]) - return fail('NOT_FOUND', '接口不存在', 404) + return dispatchPost(request, await cleanPath(context)) } export async function PATCH(request: NextRequest, context: Context) { - if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403); const path = await cleanPath(context); const input = await body(request); const actor = await requireActor(request, true); if (isResponse(actor)) return actor - if (path === 'admin/registration') { if (typeof input.requiresInvitation !== 'boolean') return fail('INVALID_INPUT', '注册模式无效'); const mode = input.requiresInvitation ? 'invite_only' : 'open'; await transaction(async client => { await client.query('UPDATE registration_settings SET mode=$1,updated_at=now(),updated_by=$2 WHERE singleton=true', [mode, actor.id]); await audit(client, actor, 'registration.update', 'registration', 'singleton', { requiresInvitation: input.requiresInvitation }) }); return ok({ requiresInvitation: input.requiresInvitation }) } - - if (path === 'admin/prompt-optimization-settings') return updatePromptOptimizationSettings(actor, input) - const user = path.match(/^admin\/users\/([0-9a-f-]+)(?:\/status)?$/) - if (user) { if (input.status !== 'active' && input.status !== 'disabled') return fail('INVALID_INPUT', '用户状态无效'); if (user[1] === actor.id && input.status === 'disabled') return fail('INVALID_OPERATION', '不能停用当前管理员'); const r = await transaction(async client => { const x = await client.query('UPDATE users SET status=$1,session_version=session_version+1,updated_at=now() WHERE id=$2 AND deleted_at IS NULL RETURNING *', [input.status, user[1]]); if (input.status === 'disabled') await client.query('UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL', [user[1]]); if (x.rows[0]) await audit(client, actor, 'user.status', 'user', user[1], { status: input.status }); return x.rows[0] }); return r ? ok(userDto(r)) : fail('NOT_FOUND', '用户不存在', 404) } - const model = path.match(/^admin\/models\/([0-9a-f-]+)$/); if (model) return upsertModel(actor, input, model[1]) - const cred = path.match(/^admin\/provider-credentials\/([0-9a-f-]+)$/); if (cred) return updateProviderCredential(actor, cred[1], input) - const oauthProvider = path.match(/^admin\/oauth-providers\/(github|google)$/); if (oauthProvider) return updateOAuthProvider(actor, oauthProvider[1] as OAuthProvider, input) - const promptEntryUpdate = path.match(/^admin\/prompt-templates\/entries\/([0-9a-fA-F-]+)$/); if (promptEntryUpdate) return updatePromptTemplateEntry(actor, promptEntryUpdate[1], input) - return fail('NOT_FOUND', '接口不存在', 404) + return dispatchPatch(request, await cleanPath(context)) } export async function PUT(request: NextRequest, context: Context) { - if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403); void context; const actor = await requireActor(request, true); if (isResponse(actor)) return actor - return fail('NOT_FOUND', '接口不存在', 404) + return dispatchPut(request, await cleanPath(context)) } export async function DELETE(request: NextRequest, context: Context) { - if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403); const path = await cleanPath(context); const actor = await requireActor(request, path.startsWith('admin/')); if (isResponse(actor)) return actor - const uploadMatch = path.match(/^generation-uploads\/([0-9a-f-]+)$/) - if (uploadMatch) return deleteGenerationUpload(actor, uploadMatch[1]) - const job = path.match(/^jobs\/([0-9a-f-]+)$/); if (job) { const deleted = await deleteJobWithAssets(actor.id, job[1]); return deleted ? ok({ deleted: true }) : fail('NOT_FOUND', '任务不存在', 404) } - const asset = path.match(/^library\/([0-9a-f-]+)$/); if (asset) { const ownerAsset = await db().query('SELECT job_id,deleted_at FROM assets WHERE id=$1 AND created_by=$2', [asset[1], actor.id]); if (!ownerAsset.rows[0]) return fail('NOT_FOUND', '图片不存在', 404); if (ownerAsset.rows[0].deleted_at) return ok({ deleted: true }); const deleted = await deleteJobWithAssets(actor.id, ownerAsset.rows[0].job_id); return deleted ? ok({ deleted: true }) : ok({ deleted: true }) } - const invite = path.match(/^admin\/invitations\/([0-9a-f-]+)$/); if (invite) { const r = await db().query('UPDATE invitations SET revoked_at=now() WHERE id=$1 AND consumed_at IS NULL AND revoked_at IS NULL RETURNING id', [invite[1]]); if (r.rows[0]) await audit(db(), actor, 'invitation.revoke', 'invitation', invite[1]); return r.rows[0] ? ok({ revoked: true }) : fail('NOT_FOUND', '邀请码不存在', 404) } - const user = path.match(/^admin\/users\/([0-9a-f-]+)$/); if (user) { if (user[1] === actor.id) return fail('INVALID_OPERATION', '不能删除当前管理员'); const deleted = await transaction(async client => { const r = await client.query('UPDATE users SET deleted_at=now(),deletion_requested_at=now(),session_version=session_version+1,updated_at=now() WHERE id=$1 AND deleted_at IS NULL RETURNING id', [user[1]]); if (!r.rows[0]) return false; await client.query('UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL', [user[1]]); await client.query("UPDATE generation_jobs SET status='canceled',completed_at=now() WHERE created_by=$1 AND status IN('queued','retry_wait','running')", [user[1]]); - await client.query('INSERT INTO deletion_jobs(user_id) VALUES($1) ON CONFLICT DO NOTHING', [user[1]]); - await client.query("UPDATE generation_input_images SET status='deleted',deleted_at=now() WHERE created_by=$1", [user[1]]); await audit(client, actor, 'user.delete', 'user', user[1]); return true }); return deleted ? ok({ deleted: true }) : fail('NOT_FOUND', '用户不存在', 404) } - const credDel = path.match(/^admin\/provider-credentials\/([0-9a-f-]+)$/); if (credDel) return deleteProviderCredential(actor, credDel[1]) - const modelId = modelDeleteIdFromPath(path); if (modelId) return deleteModel(actor, modelId) - const promptSetDel = path.match(/^admin\/prompt-templates\/sets\/([0-9a-fA-F-]+)$/); if (promptSetDel) return deletePromptTemplateSet(actor, promptSetDel[1]) - const promptEntryDel = path.match(/^admin\/prompt-templates\/entries\/([0-9a-fA-F-]+)$/); if (promptEntryDel) return deletePromptTemplateEntry(actor, promptEntryDel[1]) - const oauthUnlink = path.match(/^account\/oauth\/(github|google)$/) - if (oauthUnlink) { const r = await db().query('UPDATE oauth_identities SET deleted_at=now() WHERE user_id=$1 AND provider=$2 AND deleted_at IS NULL RETURNING id', [actor.id, oauthUnlink[1]]); if (r.rows[0]) await audit(db(), actor, 'oauth.unlink', 'oauth_identity', r.rows[0].id, { provider: oauthUnlink[1] }); return r.rows[0] ? ok({ unlinked: true }) : fail('NOT_FOUND', '未绑定该第三方账户', 404) } - return fail('NOT_FOUND', '接口不存在', 404) + return dispatchDelete(request, await cleanPath(context)) } diff --git a/apps/api/src/admin/model-presets.ts b/apps/api/src/admin/model-presets.ts index 9bc3454..2c7128d 100644 --- a/apps/api/src/admin/model-presets.ts +++ b/apps/api/src/admin/model-presets.ts @@ -1,28 +1,52 @@ +import { resolveCatalogPlugin } from '../modules/admin/plugin-catalog' +import type { JsonValue, MediaParameterProvenance, ModelCapabilities } from '@musecanvas/contracts' +import { validateModelCapabilities } from '@musecanvas/contracts' + export type ReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh' export type LanguageProtocol = 'openai_chat' | 'openai_responses' | 'anthropic_messages' -export type ImageModelPreset = { +/** + * A media preset is an **identity**, never a capability set. + * + * Until now these presets carried `sizes`, `qualityOptions`, `maxCount`, + * `maxInputImages`, `modes`, `parameters`, `inputSlots` and `defaults` — a + * hand-maintained transcription of what the OpenAI, Seedream, Seedance and Veo + * plugins already declare in their own manifests. Two copies of one fact is the + * bug: the preset list offered `1024x1024` to Seedream 4.5 while the vendor + * band starts at 2K, the generic video preset offered 1-60s durations while + * Seedance caps at 30, and nothing above `capabilities` could ever be trusted to + * be the plugin's answer rather than the host's guess. + * + * The contract now lives in exactly one place — the plugin manifest — and + * `resolvePresetCapabilities` is the only way to read it. A preset that cannot + * resolve to a declared model declares nothing. + */ +export type MediaModelPreset = { id: string - modelKind: 'image' + modelKind: 'image' | 'video' displayName: string - adapter: 'openai' | 'seedream' - providerId: 'openai' | 'volcengine' - pluginId: 'openai-image' | 'seedream-image' + /** + * @deprecated Mirrors the legacy `model_configs.adapter` column, which is a + * routing label from before plugins existed and has no descriptor equivalent. + * Never a capability, and no new reader should consume it. + */ + adapter?: string + providerId: string + pluginId: string pluginVersion: string vendorModelId: string baseUrl: string - sizes: string[] - qualityOptions: string[] - maxCount: number - maxInputImages: number concurrencyLimit: number - watermark: boolean } +export type ImageModelPreset = MediaModelPreset & { modelKind: 'image' } +export type VideoModelPreset = MediaModelPreset & { modelKind: 'video' } + export type LanguageModelPreset = { id: string modelKind: 'language' displayName: string + /** @deprecated Legacy `model_configs.adapter` column; language models have no plugin manifest. */ adapter: 'openai' | 'anthropic' vendorModelId: string baseUrl: string @@ -33,125 +57,40 @@ export type LanguageModelPreset = { concurrencyLimit: number } -export type VideoParameterDescriptor = - | { type: 'enum'; name: string; label?: string; options: string[]; defaultValue?: string; required?: boolean } - | { type: 'integer'; name: string; label?: string; min?: number; max?: number; defaultValue?: number; required?: boolean } - | { type: 'boolean'; name: string; label?: string; defaultValue?: boolean; required?: boolean } - | { type: 'text'; name: string; label?: string; maxLength?: number; defaultValue?: string; required?: boolean } - -export type VideoInputSlotDescriptor = { - role: 'first_frame' | 'last_frame' | 'reference_image' | 'prompt_image' | string - required: boolean - minCount: number - maxCount: number - allowedMediaKinds: ('image' | 'video')[] - label?: string -} - -export type VideoModelPreset = { - id: string - modelKind: 'video' - displayName: string - providerId: string - pluginId: string - pluginVersion: string - vendorModelId: string - baseUrl: string - modes: ('text_to_video' | 'image_to_video')[] - parameters: VideoParameterDescriptor[] - inputSlots: VideoInputSlotDescriptor[] - defaults: Record - maxCount: number - concurrencyLimit: number -} - -export type ModelPreset = ImageModelPreset | LanguageModelPreset | VideoModelPreset - -const seedream1kWay2Sizes = [ - '1024x1024', '1152x864', '864x1152', '1280x720', '720x1280', '1248x832', '832x1248', '1512x648', -] -const seedream2kWay2Sizes = [ - '2048x2048', '2304x1728', '1728x2304', '2848x1600', '1600x2848', '2496x1664', '1664x2496', '3136x1344', -] -const seedream4kWay2Sizes = [ - '4096x4096', '4704x3520', '3520x4704', '5504x3040', '3040x5504', '4992x3328', '3328x4992', '6240x2656', -] -const seedream40Way2Sizes = [...seedream1kWay2Sizes, ...seedream2kWay2Sizes, ...seedream4kWay2Sizes] -const seedream45Way2Sizes = [...seedream2kWay2Sizes, ...seedream4kWay2Sizes] - -// Seedance validates durationSeconds as a number in [1, 30]; the old generic -// 1-60 integer range contradicted the plugin, so this preset pins 1-30 here. -const seedanceDurationParameter: VideoParameterDescriptor = { - type: 'integer', name: 'durationSeconds', label: '时长(秒)', min: 1, max: 30, defaultValue: 5, required: false, -} -// Veo only accepts durations 4/6/8. Enum strings keep the descriptor -// serializable; request normalization Number-converts them before validation. -const veoDurationParameter: VideoParameterDescriptor = { - type: 'enum', name: 'durationSeconds', label: '时长(秒)', options: ['4', '6', '8'], defaultValue: '8', required: false, -} -// Veo only accepts 16:9 and 9:16; other ratios are normalized or rejected. -const veoAspectParameter: VideoParameterDescriptor = { - type: 'enum', name: 'aspectRatio', label: '宽高比', - options: ['16:9', '9:16'], defaultValue: '16:9', required: false, -} -// Veo resolutions are 720p/1080p/4k; 1080p+ requires the standard model at 8s. -const veoResolutionParameter: VideoParameterDescriptor = { - type: 'enum', name: 'resolution', label: '分辨率', - options: ['720p', '1080p', '4k'], defaultValue: '1080p', required: false, -} -const videoAspectParameter: VideoParameterDescriptor = { - type: 'enum', name: 'aspectRatio', label: '宽高比', - options: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], defaultValue: '16:9', required: false, -} -const videoResolutionParameter: VideoParameterDescriptor = { - type: 'enum', name: 'resolution', label: '分辨率', - options: ['720p', '1080p'], defaultValue: '720p', required: false, -} -const videoAudioParameter: VideoParameterDescriptor = { - type: 'boolean', name: 'audio', label: '生成音频', defaultValue: true, required: false, -} -const videoCountParameter: VideoParameterDescriptor = { - type: 'integer', name: 'count', label: '生成数量', min: 1, max: 4, defaultValue: 1, required: false, -} -const videoFrameSlots: VideoInputSlotDescriptor[] = [ - { role: 'first_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'], label: '首帧' }, - { role: 'last_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'], label: '尾帧' }, - { role: 'reference_image', required: false, minCount: 0, maxCount: 4, allowedMediaKinds: ['image'], label: '参考图' }, -] +export type ModelPreset = ImageModelPreset | VideoModelPreset | LanguageModelPreset +/** + * Host-slug preset ids are pinned by `packages/database/src/migrate.ts` + * eligibility checks (`WHERE preset_id = 'openai-gpt-image-2' …`) and by + * `presetMatchesPersistedModel`, which compares a stored `preset_id` against the + * row's plugin identity. Renaming one is a data migration, not a refactor, so + * these ids are stable API: only their *contents* became identity-only. + */ export const modelPresets: ModelPreset[] = [ { modelKind: 'image', id: 'openai-gpt-image-2', displayName: 'GPT Image 2', adapter: 'openai', providerId: 'openai', pluginId: 'openai-image', pluginVersion: '1.1.0', vendorModelId: 'gpt-image-2', baseUrl: 'https://api.openai.com', - sizes: ['1024x1024', '1280x720', '720x1280', '1536x1024', '1024x1536'], qualityOptions: ['auto', 'low', 'medium', 'high'], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false, + concurrencyLimit: 1, }, { modelKind: 'image', id: 'seedream-4-0', displayName: 'Seedream 4.0', adapter: 'seedream', providerId: 'volcengine', pluginId: 'seedream-image', pluginVersion: '1.1.0', vendorModelId: 'doubao-seedream-4-0-250828', baseUrl: 'https://ark.cn-beijing.volces.com', - sizes: seedream40Way2Sizes, qualityOptions: [], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false, + concurrencyLimit: 1, }, { modelKind: 'image', id: 'seedream-4-5', displayName: 'Seedream 4.5', adapter: 'seedream', providerId: 'volcengine', pluginId: 'seedream-image', pluginVersion: '1.1.0', vendorModelId: 'doubao-seedream-4-5-251128', baseUrl: 'https://ark.cn-beijing.volces.com', - sizes: seedream45Way2Sizes, qualityOptions: [], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false, + concurrencyLimit: 1, }, { modelKind: 'video', id: 'seedance-1-0', displayName: 'Seedance 2.0 Fast', providerId: 'volcengine', pluginId: 'seedance-video', pluginVersion: '1.0.0', vendorModelId: 'doubao-seedance-2-0-fast-260128', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - modes: ['text_to_video', 'image_to_video'], - parameters: [seedanceDurationParameter, videoAspectParameter, videoResolutionParameter, videoAudioParameter, videoCountParameter], - inputSlots: videoFrameSlots, - defaults: { durationSeconds: 5, aspectRatio: '16:9', resolution: '720p', audio: true, count: 1 }, - maxCount: 4, concurrencyLimit: 1, + concurrencyLimit: 1, }, { modelKind: 'video', id: 'veo-3-1', displayName: 'Veo 3.1', providerId: 'google', pluginId: 'veo-video', pluginVersion: '1.0.0', vendorModelId: 'veo-3.1-generate-001', baseUrl: 'https://us-central1-aiplatform.googleapis.com', - modes: ['text_to_video', 'image_to_video'], - parameters: [veoDurationParameter, veoAspectParameter, veoResolutionParameter, videoAudioParameter, videoCountParameter], - inputSlots: videoFrameSlots, - defaults: { durationSeconds: 8, aspectRatio: '16:9', resolution: '1080p', audio: true, count: 1 }, - maxCount: 4, concurrencyLimit: 1, + concurrencyLimit: 1, }, { id: 'openai-gpt-5-5', modelKind: 'language', displayName: 'GPT-5.5', adapter: 'openai', vendorModelId: 'gpt-5.5', baseUrl: 'https://api.openai.com', @@ -162,3 +101,89 @@ export const modelPresets: ModelPreset[] = [ languageProtocol: 'openai_responses', maxOutputTokens: 25000, reasoningEffort: 'medium', concurrencyLimit: 1, }, ] + +export type ResolvedPresetCapabilities = { + /** + * The manifest's own contract, rebuilt through `validateModelCapabilities` so + * nothing outside the known descriptor grammar survives into a revision. + * Empty arrays plus `declaredBy: 'undeclared'` when the model declares nothing. + */ + capabilities: ModelCapabilities + /** Starting values as declared next to the contract; never invented here. */ + defaults: Record + /** Vendor-retired but still servable. */ + deprecated: boolean + deprecationNote?: string + /** + * Why a declaration was refused. Present only when the manifest *did* declare + * something and that something was malformed: an absent contract is not an + * error, it is an absence, and the admin has to be able to tell them apart. + */ + findings?: Array<{ rule: string; message: string }> +} + +/** The one answer a model with no declaration gets: nothing, and a label saying so. */ +function undeclaredContract(): ModelCapabilities { + return { + modes: [], + parameters: [], + inputSlots: [], + maxCount: 0, + supportedMediaKinds: [], + declaredBy: 'undeclared', + } +} + +/** + * The plugin manifest is the only source of a model's parameter contract, so + * that is the only thing this lookup reads. + * + * `declaredBy` is stamped `plugin-manifest` when the model declared a contract + * without saying where it came from — the manifest *is* the source, so stating + * it is a fact rather than a guess. Anything the manifest does not say stays + * unsaid: an unknown `pluginId`, a plugin the catalog has not activated, a + * non-media manifest, a vendor model the manifest does not list, and a model + * with no `capabilities` block all resolve to `undeclared` with empty arrays. + * + * A declaration that exists but is structurally illegal (an unknown descriptor + * type, a preset outside its own geometry band, a default nobody offers) also + * resolves to `undeclared`, but carries `findings` so the write path can refuse + * the save with the plugin's own error instead of quietly persisting nothing. + */ +export async function resolvePresetCapabilities( + pluginId: string, + pluginVersion: string, + vendorModelId: string, +): Promise { + const catalog = await resolveCatalogPlugin(pluginId, pluginVersion) + if (!catalog || catalog.manifest.kind !== 'media') { + return { capabilities: undeclaredContract(), defaults: {}, deprecated: false } + } + const model = (catalog.manifest.models ?? []).find(entry => entry.id === vendorModelId) + const declared = model?.capabilities + if (!model || !declared) { + return { capabilities: undeclaredContract(), defaults: {}, deprecated: false } + } + const provenance: MediaParameterProvenance = declared.declaredBy ?? 'plugin-manifest' + const validated = validateModelCapabilities({ + ...declared, + declaredBy: provenance, + ...(typeof model.deprecated === 'boolean' ? { deprecated: model.deprecated } : {}), + ...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}), + }) + if (!validated.ok || !validated.capabilities) { + return { + capabilities: { ...undeclaredContract(), declaredBy: 'undeclared' }, + defaults: {}, + deprecated: model.deprecated === true, + ...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}), + findings: validated.findings, + } + } + return { + capabilities: validated.capabilities, + defaults: { ...(model.defaults ?? {}) }, + deprecated: model.deprecated === true, + ...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}), + } +} diff --git a/apps/api/src/admin/provider-templates.ts b/apps/api/src/admin/provider-templates.ts index 8b11aa8..9cbad21 100644 --- a/apps/api/src/admin/provider-templates.ts +++ b/apps/api/src/admin/provider-templates.ts @@ -1,10 +1,18 @@ import type { BuiltinProviderTemplate } from '@musecanvas/contracts' import { globalProviderRegistry } from '@musecanvas/providers' +import { + installedPluginBaseUrl, + presetsForCatalogPlugins, + type CatalogPlugin, +} from '../modules/admin/plugin-catalog' import { modelPresets, type ImageModelPreset, type ModelPreset, type VideoModelPreset } from './model-presets' type PluginPreset = ImageModelPreset | VideoModelPreset -// Narrows a preset to one carrying an exact plugin identity. +// Narrows a preset to one carrying an exact built-in plugin identity. Presets +// synthesized from an uploaded manifest can never collide here: uploading a key the +// static registry already owns is rejected (PLUGIN_ID_RESERVED), so an exact built-in +// spec key always belongs to a shipped preset. function isPluginPreset(preset: ModelPreset, pluginId: string, pluginVersion: string): preset is PluginPreset { return 'pluginId' in preset && preset.pluginId === pluginId && 'pluginVersion' in preset && preset.pluginVersion === pluginVersion } @@ -104,41 +112,92 @@ const BUILTIN_CATALOG_SPECS: BuiltinCatalogSpec[] = [ // exact plugin identity. Throws loudly when a listed preset references a // vendor model absent from its manifest so stale presets fail fast instead // of serving unresolvable templates. -export function buildBuiltinProviderTemplates(): BuiltinProviderTemplate[] { - return BUILTIN_CATALOG_SPECS.map((spec) => { - const plugin = globalProviderRegistry.get(spec.pluginId, spec.pluginVersion) - const models = (plugin.manifest.models ?? []).map((model) => ({ - id: model.id, - ...(model.name ? { name: model.name } : {}), - })) - const modelIds = new Set(models.map((model) => model.id)) - const presetIds = modelPresets - .filter((preset) => isPluginPreset(preset, spec.pluginId, spec.pluginVersion)) - .map((preset) => { - if (!modelIds.has(preset.vendorModelId)) { - throw new Error( - `Builtin provider template '${spec.key}' preset '${preset.id}' ` + - `vendorModelId '${preset.vendorModelId}' is absent from plugin ` + - `${spec.pluginId}@${spec.pluginVersion} manifest`, - ) - } - return preset.id - }) - return { - key: spec.key, - pluginId: spec.pluginId, - pluginVersion: spec.pluginVersion, - providerId: spec.providerId, - adapter: spec.adapter, - displayName: spec.displayName, - ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}), - modality: spec.modality, - baseUrl: spec.baseUrl, - credential: spec.credential, - presetIds, - models, - } - }) +// +// `installed` (active provider_plugins media manifests, resolved by the caller) is +// appended as extra templates; omitting it keeps the built-in listing unchanged. +export function buildBuiltinProviderTemplates(installed: CatalogPlugin[] = []): BuiltinProviderTemplate[] { + return [...BUILTIN_CATALOG_SPECS.map(builtinTemplateForSpec), ...installedProviderTemplates(installed)] +} + +function builtinTemplateForSpec(spec: BuiltinCatalogSpec): BuiltinProviderTemplate { + const plugin = globalProviderRegistry.get(spec.pluginId, spec.pluginVersion) + const models = (plugin.manifest.models ?? []).map((model) => ({ + id: model.id, + ...(model.name ? { name: model.name } : {}), + })) + const modelIds = new Set(models.map((model) => model.id)) + const presetIds = modelPresets + .filter((preset) => isPluginPreset(preset, spec.pluginId, spec.pluginVersion)) + .map((preset) => { + if (!modelIds.has(preset.vendorModelId)) { + throw new Error( + `Builtin provider template '${spec.key}' preset '${preset.id}' ` + + `vendorModelId '${preset.vendorModelId}' is absent from plugin ` + + `${spec.pluginId}@${spec.pluginVersion} manifest`, + ) + } + return preset.id + }) + return { + key: spec.key, + pluginId: spec.pluginId, + pluginVersion: spec.pluginVersion, + providerId: spec.providerId, + adapter: spec.adapter, + displayName: spec.displayName, + ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}), + modality: spec.modality, + baseUrl: spec.baseUrl, + credential: spec.credential, + presetIds, + models, + } +} + +// One credential template per active installed media plugin, so an admin can create a +// credential bound to pluginId@pluginVersion. The API never loads the artifact, so +// everything here comes from the row's whitelisted manifest: the first exact +// allowedHost is the default endpoint, and the declared credential schema only selects +// which input the admin renders — reusing the two kinds the built-in catalog has. +function installedProviderTemplates(installed: CatalogPlugin[]): BuiltinProviderTemplate[] { + const presets = presetsForCatalogPlugins(installed) + const templates: BuiltinProviderTemplate[] = [] + for (const entry of installed) { + const manifest = entry.manifest + if (manifest.kind !== 'media') continue + const schemaId = (manifest.credentialSchemas || [])[0] || 'legacy-api-key-v1' + const kind: BuiltinProviderTemplate['credential']['kind'] = schemaId === 'legacy-api-key-v1' ? 'api_key' : 'google_service_account' + templates.push({ + key: `installed:${manifest.id}@${manifest.version}`, + pluginId: manifest.id, + pluginVersion: manifest.version, + providerId: manifest.id, + // No legacy adapter exists for an uploaded plugin; the plugin id keeps the field + // populated while never colliding with openai/seedream/anthropic. + adapter: manifest.id, + displayName: manifest.displayName, + ...(manifest.description ? { description: manifest.description } : {}), + modality: manifest.modalities[0], + baseUrl: installedPluginBaseUrl(manifest.allowedHosts || []), + credential: { + schemaId, + schemaVersion: 1, + kind, + label: kind === 'api_key' ? `${manifest.displayName} API Key` : `${manifest.displayName} 凭据 JSON`, + placeholder: kind === 'api_key' ? 'API key' : '{"...":"..."}', + helpText: `${manifest.id}@${manifest.version} 声明的凭据格式(${schemaId})。`, + }, + presetIds: presets + .filter(preset => 'pluginId' in preset && preset.pluginId === manifest.id && preset.pluginVersion === manifest.version) + .map(preset => preset.id), + models: (manifest.models ?? []).map(model => ({ + id: model.id, + ...(model.name ? { name: model.name } : {}), + })), + source: 'installed', + }) + } + return templates } // Catalog lookup for credential enforcement: returns the built-in template diff --git a/apps/api/src/backend.test.ts b/apps/api/src/backend.test.ts index 8ee1a53..41150ad 100644 --- a/apps/api/src/backend.test.ts +++ b/apps/api/src/backend.test.ts @@ -1,11 +1,10 @@ -import { readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import assert from 'node:assert/strict' import test from 'node:test' -import { validateModelInput } from '../../../packages/domain/src/index' import { hashOtp, safeEqual } from './auth/security' -import { adminJobDto, jobDto, modelDto, publicModelDto } from './shared/dto' +import { adminJobDto, capabilitiesFromRow, jobDto, modelDto, publicModelDto } from './shared/dto' import { validateInputImageIdsSyntax, validateAndAttachGenerationInputs, @@ -16,11 +15,12 @@ import { } from './modules/generation-uploads' import { retryPreparation } from './generation/job-retry' import { globalProviderRegistry } from '../../../packages/providers/src/index' -import { modelPresets, type VideoModelPreset } from './admin/model-presets' +import { modelPresets, resolvePresetCapabilities, type VideoModelPreset } from './admin/model-presets' import { buildBuiltinProviderTemplates } from './admin/provider-templates' -import { ACTIVE_IMAGE_PLUGIN_VERSION, buildCanonicalImageCapabilities, imageBaseUrlAllowed, isEmptyInputOverride, manifestSupportsVendorModel, presetMatchesPersistedModel, providerCredentialMatchesPluginTarget, validateImageModelContract, validatePluginSelection, videoPresetRevisionContract } from './modules/models/handlers' +import { ACTIVE_IMAGE_PLUGIN_VERSION, imageBaseUrlAllowed, isEmptyInputOverride, manifestSupportsVendorModel, presetMatchesPersistedModel, presetRevisionContract, providerCredentialMatchesPluginTarget, validateImageModelContract, validatePluginSelection } from './modules/models/handlers' import { credentialTargetChanged, normalizeCredentialSchemaVersion, resolveCredentialPlugin, validateExplicitPluginCredential } from './modules/admin/provider-credentials' import type pg from 'pg' +import type { ModelCapabilities } from '@musecanvas/contracts' import { asValidationError, buildPromptTemplateExportPayload, @@ -50,31 +50,41 @@ test('OTP hashes are scoped to the email and compare in constant time', () => { assert.equal(safeEqual(hash, hashOtp('two@example.com', '123456')), false) }) -test('generation input accepts safe custom sizes, fixed quality values, and model-limited image counts', () => { - const model = { adapter: 'openai', sizes: ['1024x1024'], qualityOptions: ['medium'], maxCount: 4 } - const twoImageModel = { ...model, maxCount: 2 } - const seedream45 = { ...model, adapter: 'seedream', vendorModelId: 'doubao-seedream-4-5-251128' } - assert.equal(validateModelInput(model, { size: '1280x720', quality: 'auto', count: 4 }), null) - assert.equal(validateModelInput(model, { size: '2K', quality: 'medium', count: 1 }), null) - assert.equal(validateModelInput(model, { size: '3K', quality: 'medium', count: 1 }), null) - assert.equal(validateModelInput({ ...model, adapter: 'seedream' }, { size: '1024x1024', quality: 'high', count: 2 }), null) - assert.equal(validateModelInput(seedream45, { size: '2048x2048', quality: 'high', count: 2 }), null) - assert.equal(validateModelInput(seedream45, { size: '5504x3040', quality: 'high', count: 1 }), null) - assert.equal(validateModelInput(seedream45, { size: '1024x1024', quality: 'high', count: 1 }), 'INVALID_SIZE') - assert.equal(validateModelInput(seedream45, { size: '2K', quality: 'high', count: 1 }), 'INVALID_SIZE') - assert.equal(validateModelInput(model, { size: 'abc', quality: 'medium', count: 1 }), 'INVALID_SIZE') - assert.equal(validateModelInput(model, { size: '99999x99999', quality: 'medium', count: 1 }), 'INVALID_SIZE') - assert.equal(validateModelInput(model, { size: '1024x1024', quality: 'ultra', count: 1 }), 'INVALID_QUALITY') - assert.equal(validateModelInput(twoImageModel, { size: '1024x1024', quality: 'medium', count: 3 }), 'INVALID_COUNT') - assert.equal(validateModelInput(model, { size: '1024x1024', quality: 'medium', count: 5 }), 'INVALID_COUNT') -}) +// The former `generation input accepts safe custom sizes...` case exercised +// `validateModelInput`, which has been deleted from the domain package along with +// the hardcoded Seedream pixel bands it carried. Those rules are now declared by +// each plugin's `image-size` descriptor and enforced through +// `validateGenerationRequest`; the equivalent coverage lives in +// `packages/domain/src/media-capabilities.test.ts` and the per-plugin suites, +// which is where it belongs now that the adapter, the API and the browser all +// read one contract. -test('provider presets use verified model identifiers and reasoning output budgets', () => { +test('provider presets are identity-only and keep the pinned host slugs', async () => { + // `model_configs.preset_id` values are pinned by the migrate.ts eligibility + // checks and by presetMatchesPersistedModel, so the slugs are load-bearing. + assert.deepEqual( + modelPresets.map((preset) => preset.id), + ['openai-gpt-image-2', 'seedream-4-0', 'seedream-4-5', 'seedance-1-0', 'veo-3-1', 'openai-gpt-5-5', 'openai-gpt-5-4'], + ) + // A media preset carries no parameter contract at all: no sizes, no quality + // options, no modes, no descriptors, no defaults. Those live in the manifest. + const identityKeys = ['baseUrl', 'concurrencyLimit', 'displayName', 'id', 'modelKind', 'pluginId', 'pluginVersion', 'providerId', 'vendorModelId'] + for (const preset of modelPresets) { + if (preset.modelKind === 'language') continue + const expected = 'adapter' in preset ? [...identityKeys, 'adapter'] : identityKeys + assert.deepEqual(Object.keys(preset).sort(), [...expected].sort(), preset.id) + } const seedream = modelPresets.find(preset => preset.id === 'seedream-4-5') assert.equal(seedream?.vendorModelId, 'doubao-seedream-4-5-251128') - assert.deepEqual(seedream?.modelKind === 'image' ? seedream.sizes.slice(0, 7) : [], ['2048x2048', '2304x1728', '1728x2304', '2848x1600', '1600x2848', '2496x1664', '1664x2496']) - assert.equal(seedream?.modelKind === 'image' ? seedream.sizes.includes('1024x1024') : true, false) - assert.equal(seedream?.modelKind === 'image' ? seedream.sizes.includes('5504x3040') : false, true) + // The sizes the preset used to hardcode now come from the plugin it points at, + // and Seedream 4.5 still refuses the 1K grid its preset once offered. + const seedream45 = await resolvePresetCapabilities('seedream-image', '1.1.0', 'doubao-seedream-4-5-251128') + const sizeParameter = seedream45.capabilities.parameters.find(parameter => parameter.name === 'size') + assert.equal(sizeParameter?.type, 'image-size') + const offeredSizes = sizeParameter?.type === 'image-size' ? sizeParameter.presets.map(preset => preset.value) : [] + assert.deepEqual(offeredSizes.slice(0, 7), ['2048x2048', '2304x1728', '1728x2304', '2848x1600', '1600x2848', '2496x1664', '1664x2496']) + assert.equal(offeredSizes.includes('1024x1024'), false) + assert.equal(offeredSizes.includes('5504x3040'), true) for (const id of ['openai-gpt-5-5', 'openai-gpt-5-4']) { const preset = modelPresets.find(candidate => candidate.id === id) assert.equal(preset?.modelKind === 'language' ? preset.maxOutputTokens : 0, 25000) @@ -148,14 +158,45 @@ test('upload constants expose the setup-allowed absolute ceilings', () => { assert.equal(MAX_INPUT_IMAGES, 32) }) -test('publicModelDto and modelDto expose maxInputImages with 0 as default', () => { +test('publicModelDto derives the deprecated flat fields from the declared contract', () => { + // A row with no pinned snapshot declares nothing, so it offers nothing — and the + // flat `sizes` / `quality_options` / `max_input_images` columns are never read + // to fill the gap. The old DTO invented a `size` enum here from `row.sizes`. const baseRow = { id: 'm1', display_name: 'Model 1', adapter: 'openai', sizes: ['1024x1024'], enabled: true, sort_order: 1 } assert.equal(publicModelDto(baseRow).maxInputImages, 0) assert.equal(modelDto(baseRow).maxInputImages, 0) + assert.deepEqual(publicModelDto(baseRow).sizes, []) + assert.deepEqual(publicModelDto(baseRow).qualityOptions, []) + assert.equal(publicModelDto(baseRow).declaredBy, 'undeclared') + // A `max_input_images` column of 4 no longer buys a reference slot. + assert.equal(publicModelDto({ ...baseRow, max_input_images: 4 }).maxInputImages, 0) - const rowWithMax = { ...baseRow, max_input_images: 4 } - assert.equal(publicModelDto(rowWithMax).maxInputImages, 4) - assert.equal(modelDto(rowWithMax).maxInputImages, 4) + const declared = { + ...baseRow, + capabilities: { + modes: ['text_to_image', 'image_to_image'], + parameters: [ + { type: 'image-size', name: 'size', presets: [{ label: '1:1', value: '1024x1024', width: 1024, height: 1024 }] }, + { type: 'enum', name: 'quality', options: ['auto', 'high'] }, + ], + inputSlots: [ + { role: 'reference_image', required: false, minCount: 0, maxCount: 3, allowedMediaKinds: ['image'] }, + { role: 'mask', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'] }, + ], + maxCount: 2, + supportedMediaKinds: ['image'], + declaredBy: 'plugin-manifest', + }, + } + const dto = publicModelDto(declared) + assert.deepEqual(dto.sizes, ['1024x1024']) + assert.deepEqual(dto.qualityOptions, ['auto', 'high']) + assert.equal(dto.maxCount, 2) + assert.equal(dto.maxInputImages, 3) + assert.equal(dto.declaredBy, 'plugin-manifest') + // A row whose columns disagree with its snapshot follows the snapshot. + assert.deepEqual(publicModelDto({ ...declared, sizes: ['9999x9999'], quality_options: ['ultra'], max_count: 9, max_input_images: 0 }).sizes, ['1024x1024']) + assert.equal(publicModelDto({ ...declared, max_input_images: 0 }).maxInputImages, 3) }) test('jobDto exposes ordered inputImages and text-only jobs have empty inputImages', async () => { @@ -386,14 +427,50 @@ test('historical 1.0.0 revision rows stay readable through the model DTOs', () = assert.equal(publicModelDto(legacyRow).pluginVersion, '1.0.0') assert.equal(modelDto(legacyRow).pluginVersion, '1.0.0') assert.equal(modelDto(legacyRow).adapter, 'seedream') + // An immutable revision written before the contract existed carries the flat + // backfill shape, where `size` is an `enum` over bare strings. It is still an + // enum descriptor the browser can render and validate, and its provenance says + // honestly that the host — not a plugin — wrote it. + const legacySnapshot = { + ...legacyRow, + capabilities: JSON.stringify({ + modes: ['text_to_image', 'image_to_image'], + parameters: [ + { type: 'enum', name: 'size', label: '尺寸', options: ['1024x1024'] }, + { type: 'integer', name: 'count', label: '数量', min: 1, max: 4, defaultValue: 1 }, + ], + inputSlots: [{ role: 'reference_image', required: false, minCount: 0, maxCount: 4, allowedMediaKinds: ['image'] }], + maxCount: 4, + supportedMediaKinds: ['image'], + }), + defaults: JSON.stringify({}), + } + const capabilities = capabilitiesFromRow(legacySnapshot) + const size = capabilities.parameters.find(parameter => parameter.name === 'size') + assert.equal(size?.type, 'enum') + assert.deepEqual(size?.type === 'enum' ? size.options : null, ['1024x1024']) + assert.equal(capabilities.declaredBy, 'host-synthesized') + assert.deepEqual(capabilities.modes, ['text_to_image', 'image_to_image']) + assert.equal(capabilities.maxCount, 4) + assert.deepEqual(publicModelDto(legacySnapshot).sizes, ['1024x1024']) + // A snapshot that cannot be read as a contract is not repaired: it degrades to + // undeclared with nothing offered, rather than being served to a browser. + const broken = capabilitiesFromRow({ ...legacyRow, capabilities: { modes: ['text_to_image'], parameters: [{ type: 'mystery', name: 'size' }], inputSlots: [] } }) + assert.equal(broken.declaredBy, 'undeclared') + assert.deepEqual(broken.parameters, []) }) test('image 1.1.0 cutover appends immutable revisions without rewriting history', () => { const here = dirname(fileURLToPath(import.meta.url)) const source = readFileSync(join(here, '../../../packages/database/src/migrate.ts'), 'utf8') const cutoverStart = source.indexOf('10. Image plugin 1.1.0 cutover') - // Section 10 only: billing retirement lives in a dedicated later block. - const cutover = source.slice(cutoverStart, source.indexOf('-- 11. Resumable', cutoverStart)) + // Section 10 only: billing retirement lives in a dedicated later block, and so + // does 10b's plugin-declared capability backfill — which legitimately names + // every model a plugin publishes, deprecated ones included. + const cutover = source.slice( + cutoverStart, + source.indexOf('-- 10b. Plugin-declared', cutoverStart), + ) assert.ok(cutover.length > 0) assert.ok(cutover.includes("'1.1.0'")) assert.ok(cutover.includes('INSERT INTO model_config_revisions')) @@ -485,49 +562,53 @@ test('image 1.1.0 cutover appends immutable revisions without rewriting history' } }) -test('active image upsert validates fields and endpoint hosts against the plugin contract', async () => { +test('the image contract gate exercises the declared values, not the flat columns', async () => { const openai = globalProviderRegistry.get('openai-image', '1.1.0') const seedream = globalProviderRegistry.get('seedream-image', '1.1.0') - assert.deepEqual(await validateImageModelContract(openai, { - vendorModelId: 'gpt-image-2', - sizes: ['1024x1024', '1280x720', '720x1280', '1536x1024', '1024x1536'], - qualityOptions: ['auto', 'low', 'medium', 'high'], - maxCount: 4, - maxInputImages: 4, - }), { ok: true }) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'gpt-image-2', sizes: ['9999x9999'], qualityOptions: [], maxCount: 1, maxInputImages: 0, - })).ok, false) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'gpt-image-2', sizes: ['1024x1024'], qualityOptions: ['ultra'], maxCount: 1, maxInputImages: 0, - })).ok, false) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'gpt-image-2', sizes: ['1024x1024'], qualityOptions: [], maxCount: 5, maxInputImages: 0, - })).ok, false) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'gpt-image-2', sizes: ['1024x1024'], qualityOptions: [], maxCount: 1, maxInputImages: 5, - })).ok, false) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'no-such-model', sizes: [], qualityOptions: [], maxCount: 1, maxInputImages: 0, - })).ok, false) - assert.deepEqual(await validateImageModelContract(seedream, { - vendorModelId: 'doubao-seedream-4-5-251128', - sizes: ['2048x2048'], - qualityOptions: [], - maxCount: 4, - maxInputImages: 4, - }), { ok: true }) - assert.equal((await validateImageModelContract(seedream, { - vendorModelId: 'doubao-seedream-4-5-251128', sizes: ['1024x1024'], qualityOptions: [], maxCount: 1, maxInputImages: 0, - })).ok, false) - // DALL-E-3 declares maxInputImages 0: any reference image is rejected, - // while zero passes the per-model cap. - assert.deepEqual(await validateImageModelContract(openai, { - vendorModelId: 'dall-e-3', sizes: ['1024x1024'], qualityOptions: ['standard'], maxCount: 1, maxInputImages: 0, - }), { ok: true }) - assert.equal((await validateImageModelContract(openai, { - vendorModelId: 'dall-e-3', sizes: ['1024x1024'], qualityOptions: ['standard'], maxCount: 1, maxInputImages: 1, + // Every built-in image model passes its own plugin, because the gate feeds each + // declared preset, option and integer bound back through `validateRequest`. + for (const vendorModelId of ['gpt-image-2.5-sunburst', 'gpt-image-2.5-flare', 'gpt-image-2', 'gpt-image-1.5', 'dall-e-3']) { + const declared = await resolvePresetCapabilities('openai-image', '1.1.0', vendorModelId) + assert.equal(declared.findings, undefined, vendorModelId) + assert.deepEqual( + await validateImageModelContract(openai, { vendorModelId, capabilities: declared.capabilities }), + { ok: true }, + vendorModelId, + ) + } + for (const vendorModelId of ['doubao-seedream-4-0-250828', 'doubao-seedream-4-5-251128']) { + const declared = await resolvePresetCapabilities('seedream-image', '1.1.0', vendorModelId) + assert.deepEqual( + await validateImageModelContract(seedream, { vendorModelId, capabilities: declared.capabilities }), + { ok: true }, + vendorModelId, + ) + } + // A declaration the plugin contradicts is refused. These are the same shapes + // the retired column-based gate checked, now stated as a contract: an illegal + // size, an unoffered quality, a count above the declared ceiling, and a + // reference slot wider than the host can stage inputs for. + const gate = (vendorModelId: string, overrides: Partial, plugin = openai) => + validateImageModelContract(plugin, { + vendorModelId, + capabilities: { + modes: ['text_to_image'], + parameters: [], + inputSlots: [], + maxCount: 1, + supportedMediaKinds: ['image'], + ...overrides, + }, + }) + assert.equal((await gate('gpt-image-2', { parameters: [{ type: 'enum', name: 'size', options: ['9999x9999'] }] })).ok, false) + assert.equal((await gate('gpt-image-2', { parameters: [{ type: 'enum', name: 'quality', options: ['ultra'] }] })).ok, false) + assert.equal((await gate('gpt-image-2', { parameters: [{ type: 'integer', name: 'count', min: 1, max: 5 }] })).ok, false) + assert.equal((await gate('gpt-image-2', { + inputSlots: [{ role: 'reference_image', required: false, minCount: 0, maxCount: 99, allowedMediaKinds: ['image'] }], })).ok, false) + assert.equal((await gate('no-such-model', {})).ok, false) + // Seedream 4.5 has always refused the 1K grid 4.0 offered. + assert.equal((await gate('doubao-seedream-4-5-251128', { parameters: [{ type: 'enum', name: 'size', options: ['1024x1024'] }] }, seedream)).ok, false) // Official endpoint hosts only; empty means the plugin default applies. assert.equal(imageBaseUrlAllowed('openai-image', 'https://api.openai.com'), true) assert.equal(imageBaseUrlAllowed('openai-image', null), true) @@ -536,32 +617,113 @@ test('active image upsert validates fields and endpoint hosts against the plugin assert.equal(imageBaseUrlAllowed('seedream-image', 'https://api.openai.com'), false) assert.equal(imageBaseUrlAllowed('openai-image', ''), true) }) -test('active image writes persist canonical capabilities and reject overrides', () => { - const canonical = buildCanonicalImageCapabilities({ - sizes: ['1024x1024'], - qualityOptions: [], - maxCount: 2, - maxInputImages: 0, - }) - assert.deepEqual(canonical.modes, ['text_to_image']) - assert.deepEqual(canonical.supportedMediaKinds, ['image']) - assert.equal(canonical.mediaKind, 'image') - assert.equal(canonical.maxCount, 2) - const parameters = canonical.parameters as Record[] - assert.deepEqual(parameters.map((parameter) => parameter.name), ['size', 'count']) - assert.deepEqual((parameters[0] as Record).options, ['1024x1024']) - assert.deepEqual(canonical.inputSlots, []) - const withQuality = buildCanonicalImageCapabilities({ - sizes: ['1024x1024'], - qualityOptions: ['auto'], - maxCount: 4, - maxInputImages: 3, + +test('resolvePresetCapabilities reads the manifest and never fills in a contract', async () => { + const gptImage2 = await resolvePresetCapabilities('openai-image', '1.1.0', 'gpt-image-2') + assert.equal(gptImage2.capabilities.declaredBy, 'plugin-manifest') + assert.equal(gptImage2.deprecated, false) + assert.equal(gptImage2.findings, undefined) + const size = gptImage2.capabilities.parameters.find(parameter => parameter.name === 'size') + const offered = size?.type === 'image-size' ? size.presets.map(preset => preset.value) : [] + // The retired `openai-gpt-image-2` preset hardcoded this exact list. Both 720p + // sizes are still in the plugin's own preset list, so the model keeps offering + // everything it offered before — from one source instead of two. + for (const value of ['1024x1024', '1280x720', '720x1280', '1536x1024', '1024x1536']) { + assert.ok(offered.includes(value), value) + } + assert.equal(gptImage2.defaults.size, 'auto') + assert.deepEqual(gptImage2.capabilities.supportedMediaKinds, ['image']) + // A vendor model the manifest does not list declares nothing: no modes, no + // parameters, no guess at a permissive shape. + const unknownModel = await resolvePresetCapabilities('openai-image', '1.1.0', 'my-custom-model') + assert.equal(unknownModel.capabilities.declaredBy, 'undeclared') + assert.deepEqual(unknownModel.capabilities.parameters, []) + assert.deepEqual(unknownModel.capabilities.modes, []) + assert.deepEqual(unknownModel.defaults, {}) + assert.equal(unknownModel.deprecated, false) + // An unknown plugin key resolves through the catalog, which degrades to + // built-in-only membership without a database — and still declares nothing. + assert.equal((await resolvePresetCapabilities('acme-image', '1.0.0', 'acme-1')).capabilities.declaredBy, 'undeclared') + // The historical 1.0.0 manifests stayed deliberately thin; a model pinned there + // reports undeclared rather than inheriting the 1.1.0 contract. + const legacyKey = await resolvePresetCapabilities('openai-image', '1.0.0', 'gpt-image-2') + assert.equal(legacyKey.capabilities.declaredBy, 'undeclared') + // A deprecated model keeps the vendor's own note rather than being hidden. + const dalle3 = await resolvePresetCapabilities('openai-image', '1.1.0', 'dall-e-3') + assert.equal(dalle3.deprecated, true) + assert.equal(typeof dalle3.deprecationNote, 'string') + assert.ok((dalle3.deprecationNote ?? '').length > 0) + assert.equal(dalle3.capabilities.deprecated, true) + assert.deepEqual(dalle3.capabilities.inputSlots, []) + // Video resolves through the same lookup the image path uses. + const veo = await resolvePresetCapabilities('veo-video', '1.0.0', 'veo-3.1-generate-001') + assert.equal(veo.capabilities.declaredBy, 'plugin-manifest') + assert.deepEqual( + veo.capabilities.parameters.map(parameter => parameter.name), + ['durationSeconds', 'aspectRatio', 'resolution', 'audio', 'count'], + ) + const seedance = await resolvePresetCapabilities('seedance-video', '1.0.0', 'doubao-seedance-2-0-fast-260128') + const duration = seedance.capabilities.parameters.find(parameter => parameter.name === 'durationSeconds') + // The generic 1-60s fallback is gone: the plugin's own 1-30 ceiling is what the + // model offers now, together with the slider hint only the plugin can state. + assert.equal(duration?.type, 'integer') + assert.equal(duration?.type === 'integer' ? duration.min : null, 1) + assert.equal(duration?.type === 'integer' ? duration.max : null, 30) + assert.equal(duration?.type === 'integer' ? duration.defaultValue : null, 5) + assert.deepEqual(duration?.ui, { control: 'slider', unit: '秒', order: 1 }) +}) + +test('video presets resolve a manifest contract instead of carrying one', async () => { + const seedance = modelPresets.find((preset) => preset.id === 'seedance-1-0') + if (!seedance || seedance.modelKind !== 'video') throw new Error('seedance preset missing') + assert.equal(seedance.vendorModelId, 'doubao-seedance-2-0-fast-260128') + assert.equal(seedance.baseUrl, 'https://ark.cn-beijing.volces.com/api/v3') + const veo = modelPresets.find((preset) => preset.id === 'veo-3-1') + if (!veo || veo.modelKind !== 'video') throw new Error('veo preset missing') + assert.equal(veo.vendorModelId, 'veo-3.1-generate-001') + assert.equal(veo.baseUrl, 'https://us-central1-aiplatform.googleapis.com') + // Enum strings for the durations, straight from the plugin that Number-converts + // them again when the request is normalized. + const veoParameters = (await resolvePresetCapabilities('veo-video', '1.0.0', veo.vendorModelId)).capabilities.parameters + const veoDuration = veoParameters.find(parameter => parameter.name === 'durationSeconds') + assert.equal(veoDuration?.type, 'enum') + assert.deepEqual(veoDuration?.type === 'enum' ? veoDuration.options : null, ['4', '6', '8']) + // Veo only accepts 16:9 and 9:16; the generic six-ratio fallback is gone. + const veoAspect = veoParameters.find(parameter => parameter.name === 'aspectRatio') + assert.deepEqual( + veoAspect?.type === 'enum' ? { type: veoAspect.type, options: veoAspect.options, defaultValue: veoAspect.defaultValue } : null, + { type: 'enum', options: ['16:9', '9:16'], defaultValue: '16:9' }, + ) +}) + +test('media presets become complete immutable revision contracts', async () => { + const veo = modelPresets.find((preset) => preset.id === 'veo-3-1') + const contract = await presetRevisionContract(veo) + assert.ok(contract) + assert.deepEqual(contract.defaults, { + durationSeconds: '8', + aspectRatio: '16:9', + resolution: '1080p', + audio: true, + count: 1, }) - assert.deepEqual(withQuality.modes, ['text_to_image', 'image_to_image']) - assert.deepEqual(withQuality.inputSlots, [ - { role: 'reference_image', required: false, minCount: 0, maxCount: 3, allowedMediaKinds: ['image'] }, - ]) - // Omitted fields are fine; any content is a rejectable override. + assert.deepEqual( + contract.capabilities.parameters.map((parameter) => parameter.name), + ['durationSeconds', 'aspectRatio', 'resolution', 'audio', 'count'], + ) + assert.deepEqual(contract.capabilities.supportedMediaKinds, ['video']) + assert.equal(contract.capabilities.declaredBy, 'plugin-manifest') + // An image preset is the same code path now, and a language preset has no + // media contract at all. + const image = await presetRevisionContract(modelPresets.find((preset) => preset.id === 'openai-gpt-image-2')) + assert.deepEqual(image?.capabilities.parameters.map((parameter) => parameter.name).slice(0, 2), ['size', 'quality']) + assert.equal((await presetRevisionContract(modelPresets.find((preset) => preset.id === 'openai-gpt-5-5'))), null) +}) + +test('caller-supplied contracts are refused for every media kind', () => { + // Omitted fields are fine; any content is a rejectable override. The image + // write has rejected these since the hardening; the video write used to slip + // past, which is how a body could author a contract no plugin declared. for (const empty of [undefined, null, {}, [], '']) { assert.equal(isEmptyInputOverride(empty), true) } @@ -667,9 +829,7 @@ test('builtin provider templates expose exactly the four current plugins', () => const stalePreset: VideoModelPreset = { id: 'stale-probe', modelKind: 'video', displayName: 'Stale', providerId: 'google', pluginId: 'veo-video', pluginVersion: '1.0.0', vendorModelId: 'veo-retired-preview', - baseUrl: 'https://us-central1-aiplatform.googleapis.com', modes: ['text_to_video'], - parameters: [], inputSlots: [], - defaults: {}, maxCount: 1, concurrencyLimit: 1, + baseUrl: 'https://us-central1-aiplatform.googleapis.com', concurrencyLimit: 1, } modelPresets.push(stalePreset) try { @@ -679,46 +839,6 @@ test('builtin provider templates expose exactly the four current plugins', () => } }) -test('video presets use manifest-supported vendor models, official hosts, and provider-accurate parameters', () => { - const seedance = modelPresets.find((preset) => preset.id === 'seedance-1-0') - if (!seedance || seedance.modelKind !== 'video') throw new Error('seedance preset missing') - assert.equal(seedance.vendorModelId, 'doubao-seedance-2-0-fast-260128') - assert.equal(seedance.baseUrl, 'https://ark.cn-beijing.volces.com/api/v3') - const seedanceDuration = seedance.parameters.find((parameter) => parameter.name === 'durationSeconds') - assert.deepEqual(seedanceDuration, { type: 'integer', name: 'durationSeconds', label: '时长(秒)', min: 1, max: 30, defaultValue: 5, required: false }) - const veo = modelPresets.find((preset) => preset.id === 'veo-3-1') - if (!veo || veo.modelKind !== 'video') throw new Error('veo preset missing') - assert.equal(veo.vendorModelId, 'veo-3.1-generate-001') - assert.equal(veo.baseUrl, 'https://us-central1-aiplatform.googleapis.com') - // Enum strings so normalization can Number-convert them later. - assert.deepEqual( - veo.parameters.find((parameter) => parameter.name === 'durationSeconds'), - { type: 'enum', name: 'durationSeconds', label: '时长(秒)', options: ['4', '6', '8'], defaultValue: '8', required: false }, - ) - assert.deepEqual( - veo.parameters.find((parameter) => parameter.name === 'aspectRatio'), - { type: 'enum', name: 'aspectRatio', label: '宽高比', options: ['16:9', '9:16'], defaultValue: '16:9', required: false }, - ) -}) - -test('video presets become complete immutable revision contracts', () => { - const veo = modelPresets.find((preset) => preset.id === 'veo-3-1') - const contract = videoPresetRevisionContract(veo) - assert.ok(contract) - assert.deepEqual(contract.defaults, { - durationSeconds: 8, - aspectRatio: '16:9', - resolution: '1080p', - audio: true, - count: 1, - }) - assert.deepEqual( - (contract.capabilities.parameters as Array<{ name: string }>).map((parameter) => parameter.name), - ['durationSeconds', 'aspectRatio', 'resolution', 'audio', 'count'], - ) - assert.deepEqual(contract.capabilities.supportedMediaKinds, ['video']) -}) - test('model credentials require exact plugin identity with legacy provider fallback only', () => { const target = { providerId: 'google', pluginId: 'veo-video', pluginVersion: '1.0.0' } assert.equal(providerCredentialMatchesPluginTarget({ @@ -765,7 +885,10 @@ test('stored preset lookup never upgrades an explicitly pinned plugin version', test('admin provider templates route serves the registry-backed catalog', () => { const here = dirname(fileURLToPath(import.meta.url)) - const source = readFileSync(join(here, '../app/api/[...path]/route.ts'), 'utf8') + // The wiring used to live in the catch-all handler; it is now a route table, so + // that is what this reads. The behaviour under test is unchanged: the path is + // served by the catalog builder rather than a hardcoded list. + const source = readFileSync(join(here, './router/routes.ts'), 'utf8') assert.ok(source.includes("admin/provider-templates")) assert.ok(source.includes('buildBuiltinProviderTemplates')) }) @@ -1082,9 +1205,27 @@ test('prompt template validators reject null, array, and non-object inputs', () assert.equal(asValidationError(validatePromptTemplatePreview(bad))?.code, 'INVALID_INPUT') } }) +/** + * Every non-test .ts file under a directory. Test files are excluded because this + * very test spells the forbidden names out in order to forbid them. + */ +function walkTypeScript(directory: string): string[] { + const found: string[] = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.next') continue + const full = join(directory, entry.name) + if (entry.isDirectory()) found.push(...walkTypeScript(full)) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) found.push(full) + } + return found +} + test('prompt template canonical routes replace the legacy file-index surface', () => { const here = dirname(fileURLToPath(import.meta.url)) - const source = readFileSync(join(here, '../app/api/[...path]/route.ts'), 'utf8') + // Wiring moved from the catch-all handler into the route table; the legacy + // handler names are now banned across the whole source tree rather than in one + // file, which is strictly wider than what this used to check. + const source = readFileSync(join(here, './router/routes.ts'), 'utf8') for (const route of [ 'admin/prompt-templates', 'admin/prompt-templates/sets', @@ -1106,6 +1247,12 @@ test('prompt template canonical routes replace the legacy file-index surface', ( ]) { assert.ok(source.includes(route), 'missing route wiring: ' + route) } + // The routing layer is what used to hold the wiring. Banning the legacy handler + // names across every route table file is wider than the old single-file check + // while covering exactly the same concern. Note `modules/setup/handlers.ts` + // still calls `loadPromptTemplateIndex`; that predates this test and is outside + // the surface this assertion is about. + const wholeApi = walkTypeScript(join(here, 'router')).map(file => readFileSync(file, 'utf8')).join('\n') for (const legacy of [ 'createPromptTemplateEntryForSet', 'updatePromptTemplateEntryById', @@ -1116,12 +1263,12 @@ test('prompt template canonical routes replace the legacy file-index surface', ( 'importPromptTemplateSet(', 'exportPromptTemplateSet(', ]) { - assert.equal(source.includes(legacy), false, 'stale handler wiring: ' + legacy) + assert.equal(wholeApi.includes(legacy), false, 'stale handler wiring: ' + legacy) } - assert.equal(source.includes('prompt-templates/reload'), false) - assert.equal(source.includes('loadPromptTemplateIndex'), false) - assert.equal(source.includes('promptTemplateIndexDto'), false) + assert.equal(wholeApi.includes('prompt-templates/reload'), false) + assert.equal(wholeApi.includes('loadPromptTemplateIndex'), false) + assert.equal(wholeApi.includes('promptTemplateIndexDto'), false) const adminSource = readFileSync(join(here, './modules/admin/prompt-templates.ts'), 'utf8') assert.ok(adminSource.includes('Content-Disposition')) assert.ok(adminSource.includes('prompt_templates.import')) diff --git a/apps/api/src/generation/retry-validation.ts b/apps/api/src/generation/retry-validation.ts new file mode 100644 index 0000000..d3c0f96 --- /dev/null +++ b/apps/api/src/generation/retry-validation.ts @@ -0,0 +1,116 @@ +import type { PoolClient } from 'pg' + +import { validateGenerationRequest } from '@musecanvas/domain' +import { + GenerationErrorCode, + type CreateGenerationRequest, + type JsonValue, + type ModelCapabilities, + type ParameterErrorDetails, +} from '@musecanvas/contracts' +import { capabilitiesFromRow, defaultsFromRow } from '../shared/dto' + +/** + * Re-validation on a manual retry. + * + * Retrying is a resubmit, so it is checked like one: a parameter set that would + * not pass `POST /generations` must not re-enter the queue either. + * + * The contract used is the one the job is *pinned* to, falling back to the + * model's latest revision for rows created before revisions existed. That + * distinction is what makes this safe to turn on. Validating old work against a + * contract written after it would reject jobs the worker is still going to run + * correctly against their own pinned plugin — a tightened schema would silently + * turn history into undeliverable rows. Validating against the pinned revision + * catches the case that actually needs catching: stored parameters that never + * satisfied any contract, from a hand-built row or a bug since fixed. + */ +export type RetryValidation = + | { ok: true } + | { ok: false; code: string; message: string; status: number; details?: ParameterErrorDetails } + +/** The failure arm, for callers that hold a rejection across a closure boundary. */ +export type RetryRejection = Extract + +const UNDECLARED_MESSAGE = '该模型尚未声明参数契约,无法校验历史生成参数,请重新创建任务' + +export async function validateRetryRequest( + client: PoolClient, + job: { + id: string + modelId: string + /** `generation_jobs.model_revision_id`, nullable on pre-revision rows. */ + revisionId: string | null + /** The stored `normalized_request` jsonb. */ + normalizedRequest: unknown + }, +): Promise { + const stored = asRecord(job.normalizedRequest) + const parameters = asRecord(stored?.parameters) ?? {} + const inputs = Array.isArray(stored?.inputs) ? (stored.inputs as CreateGenerationRequest['inputs']) : [] + + // One query, and the revision is chosen rather than assumed: the pinned row + // when it still exists, otherwise whatever the model now publishes. + const loaded = await client.query( + `SELECT m.model_kind, m.sizes, m.quality_options, m.max_count, m.max_input_images, + COALESCE(pinned.capabilities, rev.capabilities) AS capabilities, + COALESCE(pinned.defaults, rev.defaults) AS defaults + FROM model_configs m + LEFT JOIN model_config_revisions pinned ON pinned.id = $2 + LEFT JOIN model_config_revisions rev ON rev.id = m.latest_revision_id + WHERE m.id=$1 AND m.deleted_at IS NULL AND m.enabled=true`, + [job.modelId, job.revisionId], + ).catch(() => null) + const row = loaded?.rows?.[0] as Record | undefined + if (!row) { + return { + ok: false, + code: 'MODEL_NOT_AVAILABLE', + message: '该任务绑定的模型已不可用,请重新创建任务', + status: 409, + } + } + + const capabilities = capabilitiesFromRow(row) + const declared = capabilities.declaredBy + if (declared === 'undeclared' || (declared === undefined && capabilities.parameters.length === 0)) { + return { ok: false, code: GenerationErrorCode.MODEL_CAPABILITIES_UNDECLARED, message: UNDECLARED_MESSAGE, status: 409 } + } + + const result = validateGenerationRequest( + capabilities as unknown as ModelCapabilities, + { + modelId: job.modelId, + prompt: typeof stored?.prompt === 'string' ? stored.prompt : '', + parameters: parameters as Record, + inputs, + idempotencyKey: `retry:${job.id}`, + }, + { defaults: (defaultsFromRow(row) ?? {}) as Record }, + ) + + if (result.valid) return { ok: true } + return { + ok: false, + code: result.errorCode, + message: result.errorMessage, + status: 400, + details: result.errors[0]?.details, + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} diff --git a/apps/api/src/modules/admin/credential-reads.ts b/apps/api/src/modules/admin/credential-reads.ts new file mode 100644 index 0000000..6299376 --- /dev/null +++ b/apps/api/src/modules/admin/credential-reads.ts @@ -0,0 +1,16 @@ +import { db } from '../../../../../packages/database/src/index' +import { providerCredentialDto } from '../../shared/dto' +import { ok } from '../../shared/http' + +/** + * GET /api/admin/provider-credentials + * + * Reads live in their own file because `provider-credentials.ts` holds the write + * path (create/update/delete/test) and returns responses built from decrypted + * envelopes; this is the list projection only. Newest first, soft-deleted rows + * never shown. + */ +export async function listProviderCredentials() { + const result = await db().query('SELECT * FROM provider_credentials WHERE deleted_at IS NULL ORDER BY created_at DESC') + return ok(result.rows.map(providerCredentialDto)) +} diff --git a/apps/api/src/modules/admin/dashboard.ts b/apps/api/src/modules/admin/dashboard.ts new file mode 100644 index 0000000..928f81d --- /dev/null +++ b/apps/api/src/modules/admin/dashboard.ts @@ -0,0 +1,9 @@ +import { db } from '../../../../../packages/database/src/index' +import { ok } from '../../shared/http' + +/** GET /api/admin/dashboard — four aggregates, one round-trip. */ +export async function dashboard() { + const result = await db().query(`SELECT (SELECT count(*)::int FROM users WHERE deleted_at IS NULL) total_users,(SELECT count(*)::int FROM generation_jobs WHERE deleted_at IS NULL) total_jobs,(SELECT count(*)::int FROM generation_jobs WHERE status='failed' AND created_at>now()-interval '7 days') failed_jobs_7d,(SELECT COALESCE(round(100.0*count(*) FILTER(WHERE status='succeeded')/NULLIF(count(*) FILTER(WHERE status IN('succeeded','failed')),0),1),0)::float FROM generation_jobs WHERE created_at>now()-interval '7 days') success_rate_7d`) + const row = result.rows[0] + return ok({ totalUsers: row.total_users, totalJobs: row.total_jobs, failedJobs7d: row.failed_jobs_7d, successRate7d: row.success_rate_7d }) +} diff --git a/apps/api/src/modules/admin/invitations.ts b/apps/api/src/modules/admin/invitations.ts new file mode 100644 index 0000000..66c05a4 --- /dev/null +++ b/apps/api/src/modules/admin/invitations.ts @@ -0,0 +1,49 @@ +import { decryptForPurpose, encryptForPurpose } from '../../../../../packages/providers/src/index' +import { db } from '../../../../../packages/database/src/index' +import { hashToken, randomToken } from '../../auth/security' +import { writeAudit } from '../../shared/audit' +import { fail, ok } from '../../shared/http' +import type { AuthedContext } from '../../router/types' + +/** + * Invitation codes are stored twice on purpose: `code_hash` lets a login redeem + * one without ever decrypting it, and `code_encrypted` exists solely so the admin + * list can re-display a code an operator lost. A row whose ciphertext cannot be + * decrypted (different key since it was issued) reads back with no code rather + * than failing the whole list. + */ +export async function listInvitations() { + const result = await db().query('SELECT id,consumed_at,revoked_at,created_at,code_encrypted FROM invitations ORDER BY created_at DESC LIMIT 100') + return ok({ + items: result.rows.map(row => { + let code: string | undefined + if (row.code_encrypted) { + try { + code = decryptForPurpose(row.code_encrypted as string, 'invitation-codes') + } catch { + code = undefined + } + } + return { id: row.id, code, used: !!row.consumed_at, revoked: !!row.revoked_at, createdAt: row.created_at.toISOString() } + }), + total: result.rowCount, + hasMore: false, + }) +} + +/** POST /api/admin/invitations — the plaintext code is returned exactly once. */ +export async function createInvitation(context: AuthedContext) { + const code = randomToken(18) + const envelope = encryptForPurpose(code, 'invitation-codes') + const result = await db().query("INSERT INTO invitations(email,code_hash,code_encrypted,expires_at,created_by) VALUES(NULL,$1,$2,now()+interval '7 days',$3) RETURNING id,created_at", [hashToken(code), envelope.ciphertext, context.actor.id]) + await writeAudit(db(), context.actor.id, 'invitation.create', 'invitation', result.rows[0].id) + return ok({ id: result.rows[0].id, code, used: false, createdAt: result.rows[0].created_at.toISOString() }) +} + +/** DELETE /api/admin/invitations/:id — revoke only an unconsumed, live code. */ +export async function revokeInvitation(context: AuthedContext) { + const id = context.params.id + const result = await db().query('UPDATE invitations SET revoked_at=now() WHERE id=$1 AND consumed_at IS NULL AND revoked_at IS NULL RETURNING id', [id]) + if (result.rows[0]) await writeAudit(db(), context.actor.id, 'invitation.revoke', 'invitation', id) + return result.rows[0] ? ok({ revoked: true }) : fail('NOT_FOUND', '邀请码不存在', 404) +} diff --git a/apps/api/src/modules/admin/jobs.ts b/apps/api/src/modules/admin/jobs.ts new file mode 100644 index 0000000..f4f3471 --- /dev/null +++ b/apps/api/src/modules/admin/jobs.ts @@ -0,0 +1,46 @@ +import { db } from '../../../../../packages/database/src/index' +import { adminJobDto } from '../../shared/dto' +import { ok } from '../../shared/http' +import { decodeCursor, encodeCursor, boundedLimit } from '../../shared/pagination' +import type { AuthedContext } from '../../router/types' + +/** Values `generation_jobs.status` can hold; anything else is ignored, not an error. */ +const JOB_STATUSES = ['queued', 'running', 'retry_wait', 'succeeded', 'failed', 'canceled'] +const UUID_TEXT = /^[0-9a-f-]{36}$/i + +/** + * GET /api/admin/jobs + * + * Same bare-column-then-qualify construction as `admin/users`: `values` push + * order defines the placeholders, and the count query reuses the list minus the + * cursor. Filters that fail their own sanity check (a non-uuid `userId`, an + * unparseable `from`) are dropped silently rather than rejected — that is the + * behaviour under test, so do not "fix" it into a 400 here. + */ +export async function listJobs(context: AuthedContext) { + const { request } = context + const limit = boundedLimit(request) + const cursor = decodeCursor(request.nextUrl.searchParams.get('cursor')) + const values: unknown[] = [] + const conditions = ['deleted_at IS NULL'] + const userId = request.nextUrl.searchParams.get('userId') + const status = request.nextUrl.searchParams.get('status') + const modelId = request.nextUrl.searchParams.get('modelId') + const from = request.nextUrl.searchParams.get('from') + const to = request.nextUrl.searchParams.get('to') + if (userId && UUID_TEXT.test(userId)) { values.push(userId); conditions.push(`created_by=$${values.length}::uuid`) } + if (status && JOB_STATUSES.includes(status)) { values.push(status); conditions.push(`status=$${values.length}`) } + if (modelId && UUID_TEXT.test(modelId)) { values.push(modelId); conditions.push(`model_id=$${values.length}::uuid`) } + if (from && !Number.isNaN(Date.parse(from))) { values.push(from); conditions.push(`created_at>=$${values.length}::timestamptz`) } + if (to && !Number.isNaN(Date.parse(to))) { values.push(to); conditions.push(`created_at<=$${values.length}::timestamptz`) } + if (cursor) { values.push(cursor.createdAt, cursor.id); conditions.push(`(created_at,id)<($${values.length - 1}::timestamptz,$${values.length}::uuid)`) } + const where = conditions.join(' AND ') + const totalValues = values.slice(0, cursor ? -2 : undefined) + const totalWhere = cursor ? conditions.slice(0, -1).join(' AND ') : where + values.push(limit + 1) + const page = await db().query(`SELECT j.id,j.created_by,j.model_id,j.model_name,j.status,j.phase,j.error_code,j.provider_error,j.provider_reference_id,j.created_at,j.started_at,j.completed_at,po.template_name_snapshot,po.language_model_name_snapshot,po.language_model_vendor_id_snapshot,po.language_model_protocol_snapshot FROM generation_jobs j LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id WHERE ${where.replaceAll('deleted_at', 'j.deleted_at').replaceAll('created_by', 'j.created_by').replaceAll('status=', 'j.status=').replaceAll('model_id', 'j.model_id').replaceAll('created_at', 'j.created_at').replaceAll('(j.created_at,id)', '(j.created_at,j.id)')} ORDER BY j.created_at DESC,j.id DESC LIMIT $${values.length}`, values) + const total = await db().query(`SELECT count(*)::int total FROM generation_jobs WHERE ${totalWhere}`, totalValues) + const hasMore = page.rows.length > limit + const rows = page.rows.slice(0, limit) + return ok({ items: rows.map(adminJobDto), total: total.rows[0].total, hasMore, nextCursor: hasMore && rows.length ? encodeCursor(rows[rows.length - 1]) : undefined }) +} diff --git a/apps/api/src/modules/admin/plugin-catalog.ts b/apps/api/src/modules/admin/plugin-catalog.ts new file mode 100644 index 0000000..5f94094 --- /dev/null +++ b/apps/api/src/modules/admin/plugin-catalog.ts @@ -0,0 +1,166 @@ +import { db } from '../../../../../packages/database/src/index' +import { + globalProviderRegistry, + validatePluginManifest, + type AnyProviderManifest, +} from '../../../../../packages/providers/src/index' +import { parseRevisionJsonField } from '../../shared/dto' +import { presetById } from '../../shared/model-helpers' +import { modelPresets, type ModelPreset } from '../../admin/model-presets' + +export type CatalogPluginSource = 'builtin' | 'installed' + +export type CatalogPlugin = { + source: CatalogPluginSource + manifest: AnyProviderManifest +} + +/** Prefix of a synthesized preset id; also the guard that skips the DB lookup. */ +export const INSTALLED_PRESET_PREFIX = 'installed:' + +const pluginKeyOf = (manifest: AnyProviderManifest): string => `${manifest.id}@${manifest.version}` + +/** + * THE API NEVER IMPORTS PLUGIN CODE. Loading a `.mjs` artifact requires the + * worker's sandboxed import path, so for an installed plugin the row's whitelisted + * manifest copy is the complete truth here: capabilities, hosts and models only. + * + * `model_configs.plugin_id` is NOT evidence of a plugin existing: the pre-existing + * migration backfills it for every row (`openai-language`, `-image`, …) + * with ids that are registered nowhere. Every "is this plugin usable" question must + * therefore go through this catalog — a built-in registry key or an + * `status='active'` `provider_plugins` row — and never through the column. + */ +export async function listCatalogManifests(): Promise { + const builtin = builtinCatalogPlugins() + const installed = await listInstalledCatalogPlugins() + const claimed = new Set(builtin.map(entry => pluginKeyOf(entry.manifest))) + // A built-in key always wins: the worker cannot re-register a duplicate key, so a + // colliding row could never be loaded and must not shadow the shipped plugin. + return [...builtin, ...installed.filter(entry => !claimed.has(pluginKeyOf(entry.manifest)))] +} + +export async function listInstalledCatalogPlugins(): Promise { + const r = await db().query( + `SELECT manifest FROM provider_plugins + WHERE status='active' AND deleted_at IS NULL + ORDER BY plugin_id, plugin_version`, + ) + const entries: CatalogPlugin[] = [] + for (const row of r.rows) { + const manifest = asCatalogManifest(row.manifest) + if (manifest) entries.push({ source: 'installed', manifest }) + } + return entries +} + +export async function resolveCatalogPlugin(pluginId: string, pluginVersion: string): Promise { + // Built-ins resolve synchronously so the common path stays DB-free. + if (globalProviderRegistry.has(pluginId, pluginVersion)) { + return { source: 'builtin', manifest: globalProviderRegistry.get(pluginId, pluginVersion).manifest } + } + let row: Record | undefined + try { + const r = await db().query( + `SELECT manifest FROM provider_plugins + WHERE plugin_id=$1 AND plugin_version=$2 AND status='active' AND deleted_at IS NULL LIMIT 1`, + [pluginId, pluginVersion], + ) + row = r.rows[0] + } catch (error) { + // A catalog read failure degrades to built-in-only membership, which is exactly + // the pre-upload behavior: an installed plugin must be *proven* active before + // anything may bind to it, so refusing is the safe direction. Listing endpoints + // keep throwing (see listInstalledCatalogPlugins) because an empty catalog there + // would silently hide rows. + console.error('plugin catalog read failed', error instanceof Error ? `${error.name} ${error.message}`.trim() : error) + return null + } + const manifest = row ? asCatalogManifest(row.manifest) : null + return manifest ? { source: 'installed', manifest } : null +} + +export async function isCatalogPluginActive(pluginId: string, pluginVersion: string): Promise { + return (await resolveCatalogPlugin(pluginId, pluginVersion)) !== null +} + +/** Row jsonb is re-validated rather than trusted: a drifted manifest degrades to "unusable". */ +function asCatalogManifest(value: unknown): AnyProviderManifest | null { + const parsed = parseRevisionJsonField(value) + if (!parsed) return null + const validated = validatePluginManifest(parsed) + return validated.ok ? validated.manifest : null +} + +function builtinCatalogPlugins(): CatalogPlugin[] { + return globalProviderRegistry.listManifests().map(manifest => ({ source: 'builtin' as const, manifest })) +} + +/** Exact (non-wildcard) manifest host is the only endpoint hint the host has without loading code. */ +export function installedPluginBaseUrl(allowedHosts: string[]): string { + const host = allowedHosts.find(entry => !!entry && !entry.includes('*')) + return host ? `https://${host}` : '' +} + +/** Egress policy for an installed plugin: exact host or `*.suffix`, mirroring the scanner's grammar. */ +export function manifestAllowsHost(manifest: AnyProviderManifest, host: string): boolean { + const target = host.toLowerCase() + return (manifest.allowedHosts || []).some(entry => { + const pattern = entry.trim().toLowerCase() + if (!pattern) return false + if (pattern.startsWith('*.')) return target === pattern.slice(2) || target.endsWith(`.${pattern.slice(2)}`) + return target === pattern + }) +} + +/** + * Pure so the derivation is testable without a database. + * + * An uploaded plugin publishes exactly what its manifest declares, which for a + * preset means: its identity. Parameters, modes, input slots, batch ceilings and + * defaults are read from the same manifest through `resolvePresetCapabilities` + * when the model is saved, so there is no generic fallback here to drift from it. + * A manifest that declares no contract therefore offers no contract, and the + * admin sees an undeclared model instead of a fabricated one. + */ +export function presetsForCatalogPlugins(entries: CatalogPlugin[]): ModelPreset[] { + const presets: ModelPreset[] = [] + for (const entry of entries) { + const manifest = entry.manifest + if (manifest.kind !== 'media') continue + const baseUrl = installedPluginBaseUrl(manifest.allowedHosts || []) + for (const model of manifest.models || []) { + // The per-model modality wins; the manifest list is only the inherited default. + const modality = model.modalities?.[0] || manifest.modalities[0] + if (modality !== 'image' && modality !== 'video') continue + presets.push({ + modelKind: modality, + id: `${INSTALLED_PRESET_PREFIX}${manifest.id}@${manifest.version}:${model.id}`, + displayName: `${manifest.displayName} · ${model.name || model.id}`, + providerId: manifest.id, + pluginId: manifest.id, + pluginVersion: manifest.version, + vendorModelId: model.id, + baseUrl, + concurrencyLimit: 1, + }) + } + } + return presets +} + +/** Static presets plus one synthetic preset per model of every active installed media manifest. */ +export async function listModelPresets(): Promise { + const installed = (await listCatalogManifests()).filter(entry => entry.source === 'installed') + return [...modelPresets, ...presetsForCatalogPlugins(installed)] +} + +export async function resolvePresetById(value: unknown): Promise { + const staticPreset = presetById(value) + if (staticPreset) return staticPreset + if (typeof value !== 'string' || !value.startsWith(INSTALLED_PRESET_PREFIX)) return null + // Same degradation rule as resolveCatalogPlugin: an unreadable catalog means "no + // such preset" rather than a write against an unverifiable plugin. + const installed = await listInstalledCatalogPlugins().catch(() => [] as CatalogPlugin[]) + return presetsForCatalogPlugins(installed).find(preset => preset.id === value) || null +} diff --git a/apps/api/src/modules/admin/plugins.test.ts b/apps/api/src/modules/admin/plugins.test.ts new file mode 100644 index 0000000..eef64e7 --- /dev/null +++ b/apps/api/src/modules/admin/plugins.test.ts @@ -0,0 +1,380 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Actor } from '../../auth/security' +import type { AnyProviderManifest, MediaProviderManifest } from '../../../../../packages/providers/src/index' +import { PLUGIN_ARTIFACT_MAX_BYTES, pluginObjectKey, scanPluginSource } from '../../../../../packages/providers/src/index' +import { + analyzePluginPackage, + installPlugin, + pluginArtifactIdentity, + validatePluginPackage, +} from './plugins' +import { + manifestAllowsHost, + presetsForCatalogPlugins, + resolveCatalogPlugin, + type CatalogPlugin, +} from './plugin-catalog' + +// These cases exercise only the branches that run before any I/O. That ordering is the +// point of the design — a rejected package never reaches S3 or Postgres — and it is also +// what lets this file run without a database, exactly like backend.test.ts. + +process.env.ALLOW_PLUGIN_UPLOAD = 'true' + +const ADMIN = { id: 'admin-1', role: 'admin' } as unknown as Actor + +const GOOD_MANIFEST = { + id: 'acme-image', + version: '1.0.0', + kind: 'media', + displayName: 'Acme Image', + description: 'Uploaded image plugin', + allowedHosts: ['api.acme.example'], + credentialSchemas: ['legacy-api-key-v1'], + modalities: ['image'], + models: [{ id: 'acme-1', name: 'Acme One', maxBatchSize: 3, maxInputImages: 2 }], +} + +// Zero runtime imports, no globals, `export default`: the scan yields no findings at all. +const CLEAN_ARTIFACT = 'const meta = { id: "acme-image", version: "1.0.0" }\nexport default { meta }\n' +const UNSAFE_ARTIFACT = 'import { readFile } from "node:fs"\nconst token = process.env.API_TOKEN\nexport default { token, readFile }\n' + +type PackageFields = Record + +const packageRequest = (fields: PackageFields) => ({ + formData: async () => { + const form = new FormData() + for (const [key, value] of Object.entries(fields)) { + if (typeof value === 'string') form.append(key, value) + // Copy into a plain Uint8Array so a Buffer is accepted as a BlobPart. + else form.append(key, new Blob([new Uint8Array(value.bytes)]), value.name) + } + return form + }, +}) + +const brokenRequest = { + formData: async () => { + throw new Error('not multipart') + }, +} + +const uploadFields = (manifest: unknown, source: string = CLEAN_ARTIFACT): PackageFields => ({ + manifest: JSON.stringify(manifest), + file: { bytes: new TextEncoder().encode(source), name: 'plugin.mjs' }, +}) + +type Payload = { success: boolean; data?: Record; error?: { code: string; message: string } } +const payload = async (response: Response): Promise => (await response.json()) as Payload +const errorCode = async (response: Response): Promise => (await payload(response)).error?.code +const dataOf = async (response: Response): Promise> => (await payload(response)).data ?? {} +const findingRules = (data: Record): Set => + new Set(((data.findings ?? []) as { rule: string }[]).map(finding => finding.rule)) + +test('artifact identity pairs the object key with the sha256 of the exact bytes', () => { + const bytes = Buffer.from(CLEAN_ARTIFACT, 'utf8') + const { sha256, objectKey } = pluginArtifactIdentity('acme-image', '1.0.0', bytes) + assert.equal(sha256, createHash('sha256').update(bytes).digest('hex')) + assert.equal(objectKey, pluginObjectKey('acme-image', '1.0.0', sha256)) + assert.equal(objectKey, `plugin-packages/acme-image/1.0.0/${sha256}.mjs`) + // One flipped byte moves the key, so a stored artifact can never be swapped silently. + const mutated = Buffer.from(bytes) + mutated[mutated.length - 1] = 0x20 + assert.notEqual(pluginArtifactIdentity('acme-image', '1.0.0', mutated).sha256, sha256) +}) + +test('validate reports the digest and warn-only findings without blocking', async () => { + const bytes = Buffer.from(CLEAN_ARTIFACT, 'utf8') + const data = await dataOf(await validatePluginPackage(packageRequest(uploadFields(GOOD_MANIFEST)))) + assert.equal(data.ok, true) + assert.equal(data.pluginId, 'acme-image') + assert.equal(data.pluginVersion, '1.0.0') + assert.equal(data.kind, 'media') + assert.deepEqual(data.modelIds, ['acme-1']) + assert.deepEqual(data.allowedHosts, ['api.acme.example']) + assert.equal(data.artifactDigest, createHash('sha256').update(bytes).digest('hex')) + assert.equal(data.artifactSizeBytes, bytes.byteLength) + assert.deepEqual(data.warnings, []) + // A bundle without `export default` is warn-only, so it still validates. + const noDefault = await dataOf(await validatePluginPackage(packageRequest(uploadFields(GOOD_MANIFEST, 'const meta = 1\n')))) + assert.equal(noDefault.ok, true) + assert.deepEqual(noDefault.warnings, [{ rule: 'NO_DEFAULT_EXPORT', severity: 'warn', message: 'the plugin object is expected as `export default`' }]) +}) + +test('the scanner is the shared one: error findings abort with PLUGIN_SCAN_FAILED', () => { + const bytes = Buffer.from(UNSAFE_ARTIFACT, 'utf8') + const source = bytes.toString('utf8') + const analysis = analyzePluginPackage(JSON.stringify(GOOD_MANIFEST), source, bytes) + assert.equal(analysis.ok, false) + if (analysis.ok) return + assert.equal(analysis.code, 'PLUGIN_SCAN_FAILED') + const rules = findingRules({ findings: analysis.findings }) + assert.ok(scanPluginSource(source).length > 0) + assert.equal(rules.has('FORBIDDEN_RUNTIME_IMPORT'), true) + assert.equal(rules.has('FORBIDDEN_PROCESS_ENV'), true) + assert.equal(rules.has('FORBIDDEN_NODE_BUILTIN'), true) + assert.equal(analysis.findings.some(finding => finding.severity === 'error'), true) +}) + +test('install rejects an unsafe package before any write', async () => { + const response = await installPlugin(ADMIN, packageRequest(uploadFields(GOOD_MANIFEST, UNSAFE_ARTIFACT))) + assert.equal(response.status, 422) + const data = await dataOf(response) + assert.equal(data.ok, false) + assert.equal(data.installed, false) + assert.equal(data.code, 'PLUGIN_SCAN_FAILED') + assert.ok(Array.isArray(data.findings) && data.findings.length > 0) +}) + +test('manifest validation failures abort with the manifest rule ids', async () => { + const cases: [string, Record, string][] = [ + ['empty allowlist permits no egress', { ...GOOD_MANIFEST, allowedHosts: [] }, 'EMPTY_ALLOWED_HOSTS'], + ['version must be semver', { ...GOOD_MANIFEST, version: '1.0' }, 'INVALID_PLUGIN_VERSION'], + ['wildcard root host', { ...GOOD_MANIFEST, allowedHosts: ['*'] }, 'WILDCARD_HOST_FORBIDDEN'], + ['loopback host', { ...GOOD_MANIFEST, allowedHosts: ['localhost'] }, 'PRIVATE_HOST_FORBIDDEN'], + ['ip literal host', { ...GOOD_MANIFEST, allowedHosts: ['10.0.0.8'] }, 'IP_HOST_FORBIDDEN'], + ['empty model list', { ...GOOD_MANIFEST, models: [] }, 'EMPTY_MODEL_LIST'], + ['media manifest must declare modalities', { ...GOOD_MANIFEST, modalities: [] }, 'INVALID_MODALITIES'], + ['unknown credential schema', { ...GOOD_MANIFEST, credentialSchemas: ['oauth-v9'] }, 'UNSUPPORTED_CREDENTIAL_SCHEMA'], + ] + for (const [label, manifest, rule] of cases) { + const response = await validatePluginPackage(packageRequest(uploadFields(manifest))) + assert.equal(response.status, 422, label) + const data = await dataOf(response) + assert.equal(data.code, 'PLUGIN_SCAN_FAILED', label) + assert.ok(findingRules(data).has(rule), `${label} -> ${[...findingRules(data)].join(',')}`) + } +}) + +test('malformed manifest JSON is reported instead of being scanned as a plugin', () => { + const bytes = Buffer.from(CLEAN_ARTIFACT, 'utf8') + const analysis = analyzePluginPackage('{broken', bytes.toString('utf8'), bytes) + assert.equal(analysis.ok, false) + if (analysis.ok) return + assert.equal(analysis.code, 'INVALID_PLUGIN_MANIFEST') + assert.equal(analysis.findings[0]?.severity, 'error') +}) + +test('a built-in plugin key is refused before the catalog is consulted', async () => { + // Reaching PLUGIN_ID_RESERVED proves scanning passed and that the registry guard + // runs first: the duplicate-version lookup that follows it needs a database. + const reserved = { ...GOOD_MANIFEST, id: 'openai-image', version: '1.1.0' } + const response = await installPlugin(ADMIN, packageRequest(uploadFields(reserved))) + assert.equal(response.status, 400) + assert.equal(await errorCode(response), 'PLUGIN_ID_RESERVED') +}) + +test('uploads are refused while ALLOW_PLUGIN_UPLOAD is off', async () => { + for (const value of [undefined, 'false', 'TRUE']) { + if (value === undefined) delete process.env.ALLOW_PLUGIN_UPLOAD + else process.env.ALLOW_PLUGIN_UPLOAD = value + try { + for (const handler of [ + () => installPlugin(ADMIN, packageRequest(uploadFields(GOOD_MANIFEST))), + () => validatePluginPackage(packageRequest(uploadFields(GOOD_MANIFEST))), + ]) { + const response = await handler() + assert.equal(response.status, 400) + assert.equal(await errorCode(response), 'PLUGIN_UPLOAD_DISABLED') + } + } finally { + process.env.ALLOW_PLUGIN_UPLOAD = 'true' + } + } +}) + +test('the artifact size cap is enforced before hashing or scanning', async () => { + const oversized = Buffer.alloc(PLUGIN_ARTIFACT_MAX_BYTES + 1, 0x61) + const rejected = await installPlugin(ADMIN, packageRequest({ + manifest: JSON.stringify(GOOD_MANIFEST), + file: { bytes: oversized, name: 'plugin.mjs' }, + })) + assert.equal(rejected.status, 400) + const error = (await payload(rejected)).error + assert.equal(error?.code, 'PLUGIN_ARTIFACT_TOO_LARGE') + assert.match(error?.message ?? '', new RegExp(String(PLUGIN_ARTIFACT_MAX_BYTES))) + // Exactly at the cap the gate opens and the package is analyzed normally. + const atCap = await validatePluginPackage(packageRequest({ + manifest: JSON.stringify(GOOD_MANIFEST), + file: { bytes: Buffer.alloc(PLUGIN_ARTIFACT_MAX_BYTES, 0x61), name: 'plugin.mjs' }, + })) + const data = await dataOf(atCap) + assert.equal(data.ok, true) + assert.equal(data.artifactSizeBytes, PLUGIN_ARTIFACT_MAX_BYTES) + assert.equal(data.artifactDigest, createHash('sha256').update(Buffer.alloc(PLUGIN_ARTIFACT_MAX_BYTES, 0x61)).digest('hex')) +}) + +test('the multipart envelope accepts exactly one manifest field and one .mjs file', async () => { + const fields = uploadFields(GOOD_MANIFEST) + const cases: [string, PackageFields][] = [ + ['extra field', { ...fields, payload: 'x' }], + ['missing manifest', { file: fields.file }], + ['blank manifest', { ...fields, manifest: ' ' }], + ['missing file', { manifest: fields.manifest }], + ['wrong extension', { ...fields, file: { bytes: new TextEncoder().encode(CLEAN_ARTIFACT), name: 'plugin.cjs' } }], + ['non-file field', { ...fields, file: 'not-a-file' }], + ['empty artifact', { ...fields, file: { bytes: new Uint8Array(0), name: 'plugin.mjs' } }], + ] + for (const [label, requestFields] of cases) { + const response = await installPlugin(ADMIN, packageRequest(requestFields)) + assert.equal(response.status, 400, label) + assert.equal(await errorCode(response), 'INVALID_INPUT', label) + } + // A body that cannot be read as multipart is refused with the same code. + assert.equal(await errorCode(await installPlugin(ADMIN, brokenRequest)), 'INVALID_INPUT') + assert.equal(await errorCode(await validatePluginPackage(brokenRequest)), 'INVALID_INPUT') + // Two files under `file` is ambiguous, so neither is stored. + const duplicated = { + formData: async () => { + const form = new FormData() + form.append('manifest', JSON.stringify(GOOD_MANIFEST)) + form.append('file', new Blob([CLEAN_ARTIFACT]), 'plugin.mjs') + form.append('file', new Blob([CLEAN_ARTIFACT]), 'plugin-2.mjs') + return form + }, + } + assert.equal(await errorCode(await installPlugin(ADMIN, duplicated)), 'INVALID_INPUT') + assert.equal(await errorCode(await validatePluginPackage(duplicated)), 'INVALID_INPUT') + // The empty artifact message is specific enough for the admin to act on. + const empty = await installPlugin(ADMIN, packageRequest({ ...fields, file: { bytes: new Uint8Array(0), name: 'plugin.mjs' } })) + assert.match((await payload(empty)).error?.message ?? '', /为空/) +}) + +test('synthesized presets carry the manifest identity and the first exact host as base URL', () => { + const mediaManifest: MediaProviderManifest = { + kind: 'media', + id: 'acme-video', + version: '2.1.0', + displayName: 'Acme Video', + modalities: ['video'], + allowedHosts: ['*.mirror.acme.example', 'api.acme-video.example'], + credentialSchemas: ['json-v1'], + models: [ + { id: 'acme-fast', name: 'Acme Fast', modalities: ['video'], maxBatchSize: 4 }, + { id: 'acme-draw', modalities: ['image'], maxBatchSize: 99, maxInputImages: 2 }, + ], + } + const languageManifest = { + kind: 'language', + id: 'acme-language', + version: '1.0.0', + displayName: 'Acme Language', + languageProtocols: ['openai_chat'], + allowedHosts: ['llm.acme.example'], + credentialSchemas: ['legacy-api-key-v1'], + models: [{ id: 'acme-chat' }], + } as AnyProviderManifest + const entries: CatalogPlugin[] = [ + { source: 'installed', manifest: mediaManifest }, + { source: 'installed', manifest: languageManifest }, + ] + const presets = presetsForCatalogPlugins(entries) + assert.equal(presets.length, 2) + const [fast, draw] = presets + assert.equal(fast.modelKind, 'video') + assert.equal(fast.id, 'installed:acme-video@2.1.0:acme-fast') + assert.equal(fast.displayName, 'Acme Video · Acme Fast') + assert.equal('pluginId' in fast && fast.pluginId, 'acme-video') + assert.equal('pluginVersion' in fast && fast.pluginVersion, '2.1.0') + assert.equal('providerId' in fast && fast.providerId, 'acme-video') + // The first exact allowlisted host is the only endpoint the host may assume. + assert.equal('baseUrl' in fast && fast.baseUrl, 'https://api.acme-video.example') + assert.equal('adapter' in fast, false) + // The per-model modality decides modelKind — and nothing else. A synthesized + // preset carries identity only: no `maxCount` from `maxBatchSize`, no + // `maxInputImages`, no empty `sizes`/`qualityOptions` placeholders and no + // generic video parameters. All of that comes from the manifest through + // `resolvePresetCapabilities` when the model is saved, so a manifest that + // declares nothing offers nothing instead of being padded out here. + const identityKeys = ['baseUrl', 'concurrencyLimit', 'displayName', 'id', 'modelKind', 'pluginId', 'pluginVersion', 'providerId', 'vendorModelId'] + for (const preset of presets) { + assert.deepEqual(Object.keys(preset).sort(), [...identityKeys].sort(), preset.id) + } + assert.equal(draw.modelKind, 'image') + assert.equal('maxCount' in fast, false) + assert.equal('parameters' in fast, false) + assert.equal('inputSlots' in fast, false) + assert.equal('modes' in fast, false) + assert.equal('defaults' in fast, false) + assert.equal('sizes' in draw, false) + assert.equal('qualityOptions' in draw, false) + assert.equal('maxInputImages' in draw, false) + // Language manifests never yield model presets: a model config binds media plugins only. + assert.equal(presets.some(preset => 'pluginId' in preset && preset.pluginId === 'acme-language'), false) +}) + +test('host allowlist matching covers exact and *.suffix entries only', () => { + const manifest = { + kind: 'media', + id: 'acme-image', + version: '1.0.0', + displayName: 'Acme', + modalities: ['image'], + allowedHosts: ['api.acme.example', '*.mirror.acme.example'], + credentialSchemas: ['legacy-api-key-v1'], + models: [{ id: 'm' }], + } as AnyProviderManifest + assert.equal(manifestAllowsHost(manifest, 'api.acme.example'), true) + assert.equal(manifestAllowsHost(manifest, 'API.acme.example'), true) + assert.equal(manifestAllowsHost(manifest, 'a.mirror.acme.example'), true) + assert.equal(manifestAllowsHost(manifest, 'mirror.acme.example'), true) + assert.equal(manifestAllowsHost(manifest, 'evil.acme.example'), false) + assert.equal(manifestAllowsHost(manifest, 'api.acme.example.evil.net'), false) +}) + +test('built-in plugins resolve without a database, so catalog gating stays cheap', async () => { + const builtin = await resolveCatalogPlugin('openai-image', '1.1.0') + assert.equal(builtin?.source, 'builtin') + assert.equal(builtin?.manifest.kind, 'media') + // An uploaded key is invisible until a row proves it active. This runs with no + // database at all: the built-in-only fallback is what protects existing models. + assert.equal(await resolveCatalogPlugin('acme-image', '1.0.0'), null) +}) + +test('uploads dispatch before the JSON-only body reader and never match the id route', async () => { + // The old catch-all read `await body(request)` unconditionally in POST, so a + // multipart upload would die there, and /admin/plugins/{id} must not swallow + // the static segments. Both hazards are about table order, and the table is now + // data, so assert against the real routes and the real compiled matcher rather + // than against a copy of either. + const { GET_ROUTES, POST_ROUTES } = await import('../../router/routes') + const { matchPath } = await import('../../router/match') + + const at = (routes: { path: string }[], needle: string) => { + const index = routes.findIndex(route => route.path === needle) + assert.ok(index >= 0, `no route registered for ${needle}`) + return index + } + const upload = at(POST_ROUTES, 'admin/plugins/upload') + const validate = at(POST_ROUTES, 'admin/plugins/validate') + + // Body-reading POST routes: `context.json()` is lazy, so being later in the + // table is what keeps a multipart request from ever being parsed as JSON. + for (const later of ['auth/otp/request', 'generations', 'admin/models', 'admin/provider-credentials']) { + assert.ok(upload < at(POST_ROUTES, later), `upload must be dispatched before ${later}`) + assert.ok(validate < at(POST_ROUTES, later), `validate must be dispatched before ${later}`) + } + + // Neither upload handler may reach for the JSON body. + const here = dirname(fileURLToPath(import.meta.url)) + const tableSource = readFileSync(join(here, '../../router/routes.ts'), 'utf8') + for (const path of ['admin/plugins/upload', 'admin/plugins/validate']) { + const line = tableSource.split('\n').find(entry => entry.includes(`path: '${path}'`)) + assert.ok(line, `${path} is missing from the route table source`) + assert.equal(line.includes('context.json()'), false, `${path} must not consume the JSON body`) + } + + // Static segments still win over the id route in GET, as before. + assert.ok(at(GET_ROUTES, 'admin/plugins') < at(GET_ROUTES, 'admin/models')) + + // The registered `:hexid` matcher cannot capture either static segment. This + // checks the pattern the dispatcher actually compiles. + assert.equal(matchPath('admin/plugins/:hexid', 'admin/plugins/upload'), null) + assert.equal(matchPath('admin/plugins/:hexid', 'admin/plugins/validate'), null) + assert.ok(matchPath('admin/plugins/:hexid', 'admin/plugins/123e4567-e89b-12d3-a456-426614174000')) +}) diff --git a/apps/api/src/modules/admin/plugins.ts b/apps/api/src/modules/admin/plugins.ts new file mode 100644 index 0000000..188d3f1 --- /dev/null +++ b/apps/api/src/modules/admin/plugins.ts @@ -0,0 +1,380 @@ +import { createHash } from 'node:crypto' +import type { NextResponse } from 'next/server' +import { db, transaction } from '../../../../../packages/database/src/index' +import type { + AdminPluginDto, + AdminPluginScanFinding, + AdminPluginInstallResult, + InstalledPluginStatus, + JsonObject, + MediaKind, + PluginKind, +} from '@musecanvas/contracts' +import { + PLUGIN_ARTIFACT_MAX_BYTES, + globalProviderRegistry, + pluginObjectKey, + scanPluginSource, + validatePluginManifest, + type AnyProviderManifest, + type PluginScanFinding, +} from '../../../../../packages/providers/src/index' +import { type Actor } from '../../auth/security' +import { fail, ok } from '../../shared/http' +import { writeAudit } from '../../shared/audit' +import { parseRevisionJsonField } from '../../shared/dto' +import { deleteS3Object, putPrivateS3ObjectBytes } from '../../shared/services' + +// Upload surface is opt-in: an artifact is executable code, so the endpoints stay +// dark until the operator sets ALLOW_PLUGIN_UPLOAD=true (env is read directly, +// matching auth/security.ts, because this gate must work before any DB row exists). +export function pluginUploadEnabled(): boolean { + return process.env.ALLOW_PLUGIN_UPLOAD === 'true' +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const UPLOAD_DISABLED = () => fail('PLUGIN_UPLOAD_DISABLED', '插件上传功能未启用,请联系运维设置 ALLOW_PLUGIN_UPLOAD=true') + +/** Structural instead of `NextRequest` so the DB-free branches are unit-testable. */ +export type PluginPackageRequest = { formData(): Promise } + +type UploadFile = { name: string; arrayBuffer(): Promise } + +const isUploadFile = (value: unknown): value is UploadFile => + !!value && typeof value === 'object' && + typeof (value as UploadFile).name === 'string' && + typeof (value as UploadFile).arrayBuffer === 'function' + +export type PluginAnalysis = + | { + ok: true + manifest: AnyProviderManifest + findings: PluginScanFinding[] + warnings: PluginScanFinding[] + bytes: Buffer + sha256: string + objectKey: string + } + | { ok: false; code: string; findings: PluginScanFinding[] } + +/** Artifact identity is derived from the exact bytes, so key and digest can never disagree. */ +export function pluginArtifactIdentity(pluginId: string, pluginVersion: string, bytes: Buffer): { sha256: string; objectKey: string } { + const sha256 = createHash('sha256').update(bytes).digest('hex') + return { sha256, objectKey: pluginObjectKey(pluginId, pluginVersion, sha256) } +} + +/** + * Pure package gate: scan first, then the manifest. Any error-severity finding + * aborts before a byte is stored, so a rejected upload leaves no trace in S3 or in + * `provider_plugins`. Warn-only findings (e.g. NO_DEFAULT_EXPORT) travel with the row. + */ +export function analyzePluginPackage(manifestText: string, sourceText: string, bytes: Buffer): PluginAnalysis { + let manifestInput: unknown + try { + manifestInput = JSON.parse(manifestText) + } catch { + return { ok: false, code: 'INVALID_PLUGIN_MANIFEST', findings: [{ rule: 'INVALID_PLUGIN_ID', severity: 'error', message: 'manifest 字段不是合法 JSON' }] } + } + const scanFindings = scanPluginSource(sourceText) + const validated = validatePluginManifest(manifestInput) + const findings = validated.ok ? scanFindings : [...scanFindings, ...validated.findings] + if (findings.some(finding => finding.severity === 'error')) { + return { ok: false, code: 'PLUGIN_SCAN_FAILED', findings } + } + if (!validated.ok) return { ok: false, code: 'PLUGIN_SCAN_FAILED', findings } + const manifest = validated.manifest + const { sha256, objectKey } = pluginArtifactIdentity(manifest.id, manifest.version, bytes) + return { + ok: true, + manifest, + findings, + warnings: findings.filter(finding => finding.severity === 'warn'), + bytes, + sha256, + objectKey, + } +} + +type PackageRead = + | { ok: true; manifestText: string; sourceText: string; bytes: Buffer } + | { ok: false; response: NextResponse } + +// Exactly one manifest field and one .mjs file, nothing else: extra fields would be +// an unvalidated second artifact competing with the one that gets hashed. +async function readPluginPackage(request: PluginPackageRequest): Promise { + let form: FormData + try { + form = await request.formData() + } catch { + return failResponse('INVALID_INPUT', '上传必须使用 multipart/form-data 编码') + } + const extraKeys = [...form.keys()].filter(key => key !== 'manifest' && key !== 'file') + if (extraKeys.length > 0) return failResponse('INVALID_INPUT', `上传包含不允许的字段:${extraKeys.join(', ')}`) + // getAll, so a repeated `file` cannot smuggle a second artifact that never gets hashed. + const manifestFields = form.getAll('manifest') + const fileFields = form.getAll('file') + if (manifestFields.length !== 1) return failResponse('INVALID_INPUT', 'manifest 字段必须且只能有一个') + if (fileFields.length !== 1) return failResponse('INVALID_INPUT', '插件包必须且只能有一个文件') + const manifestField = manifestFields[0] + if (typeof manifestField !== 'string' || !manifestField.trim()) { + return failResponse('INVALID_INPUT', '缺少 manifest 字段(插件清单 JSON 文本)') + } + const fileField = fileFields[0] + if (!isUploadFile(fileField)) return failResponse('INVALID_INPUT', 'file 字段必须是插件包文件') + if (!fileField.name.endsWith('.mjs')) return failResponse('INVALID_INPUT', '插件包必须是单个 .mjs 文件') + const bytes = Buffer.from(await fileField.arrayBuffer()) + if (bytes.byteLength === 0) return failResponse('INVALID_INPUT', '插件包内容为空') + // Size cap is enforced on the decoded length before any hashing or scanning work. + if (bytes.byteLength > PLUGIN_ARTIFACT_MAX_BYTES) { + return failResponse('PLUGIN_ARTIFACT_TOO_LARGE', `插件包不能超过 ${PLUGIN_ARTIFACT_MAX_BYTES} 字节`) + } + return { ok: true, manifestText: manifestField, sourceText: bytes.toString('utf8'), bytes } +} + +function failResponse(code: string, message: string, status = 400): { ok: false; response: NextResponse } { + return { ok: false, response: fail(code, message, status) } +} + +export async function listAdminPlugins(): Promise { + const r = await db().query( + `SELECT * FROM provider_plugins WHERE deleted_at IS NULL ORDER BY plugin_id, plugin_version, created_at DESC`, + ) + return ok(r.rows.map(pluginDtoFromRow)) +} + +export async function validatePluginPackage(request: PluginPackageRequest): Promise { + if (!pluginUploadEnabled()) return UPLOAD_DISABLED() + const parsed = await readPluginPackage(request) + if (!parsed.ok) return parsed.response + const analysis = analyzePluginPackage(parsed.manifestText, parsed.sourceText, parsed.bytes) + if (!analysis.ok) return rejected(analysis.code, analysis.findings) + const manifest = analysis.manifest + return ok({ + ok: true, + pluginId: manifest.id, + pluginVersion: manifest.version, + kind: manifest.kind, + displayName: manifest.displayName, + modelIds: (manifest.models || []).map(model => model.id), + allowedHosts: manifest.allowedHosts, + artifactDigest: analysis.sha256, + artifactSizeBytes: analysis.bytes.byteLength, + warnings: toFindings(analysis.warnings), + }) +} + +export async function installPlugin(actor: Actor, request: PluginPackageRequest): Promise { + if (!pluginUploadEnabled()) return UPLOAD_DISABLED() + const parsed = await readPluginPackage(request) + if (!parsed.ok) return parsed.response + const analysis = analyzePluginPackage(parsed.manifestText, parsed.sourceText, parsed.bytes) + if (!analysis.ok) return rejected(analysis.code, analysis.findings) + const manifest = analysis.manifest + // A key the static registry already owns can never be loaded: registration is + // first-write-wins, so the row would sit 'failed' forever. Fail fast instead. + if (globalProviderRegistry.has(manifest.id, manifest.version)) { + return fail('PLUGIN_ID_RESERVED', `${manifest.id}@${manifest.version} 是内置插件标识,请使用其他插件 id`) + } + // VERSION IMMUTABILITY: (plugin_id, plugin_version) is write-once, even after a + // soft delete. Both the worker's registry Map and Node's ESM module cache key on + // that identity, so a same-key hot-swap could never take effect in an already + // warmed process — it would silently keep serving the old bytes. + const duplicate = await db().query( + 'SELECT id FROM provider_plugins WHERE plugin_id=$1 AND plugin_version=$2 LIMIT 1', + [manifest.id, manifest.version], + ) + if (duplicate.rows[0]) return fail('PLUGIN_VERSION_IMMUTABLE', '请升级插件版本号后再上传', 409) + try { + await putPrivateS3ObjectBytes(analysis.objectKey, analysis.bytes, 'text/javascript') + } catch { + return fail('PLUGIN_ARTIFACT_STORE_FAILED', '插件包写入对象存储失败,请稍后重试', 503) + } + let row: Record + try { + row = await transaction(async client => { + const inserted = await client.query( + `INSERT INTO provider_plugins(plugin_id,plugin_version,kind,display_name,description,source,status,object_key,artifact_sha256,artifact_size_bytes,manifest,allowed_hosts,credential_schemas,scan_report,installed_by) + VALUES($1,$2,$3,$4,$5,'uploaded','pending',$6,$7,$8,$9::jsonb,$10::jsonb,$11::jsonb,$12::jsonb,$13) RETURNING *`, + [ + manifest.id, manifest.version, manifest.kind, manifest.displayName, + typeof manifest.description === 'string' ? manifest.description : null, + analysis.objectKey, analysis.sha256, analysis.bytes.byteLength, + JSON.stringify(manifest), JSON.stringify(manifest.allowedHosts), JSON.stringify(manifest.credentialSchemas), + JSON.stringify(analysis.findings), actor.id, + ], + ) + if (!inserted.rows[0]) throw new Error('PLUGIN_INSTALL_FAILED') + // The artifact body is never logged; identity and findings only. + await writeAudit(client, actor.id, 'plugin.install', 'provider_plugin', inserted.rows[0].id as string, { + pluginId: manifest.id, + pluginVersion: manifest.version, + sha256: analysis.sha256, + findings: toFindings(analysis.findings), + }) + return inserted.rows[0] + }) + } catch { + // Chosen failure mode: best-effort compensating delete. If that delete also + // fails the object is left orphaned — an unreachable private-bucket key is an + // acceptable cost, whereas a DB row without a verified artifact is not. + try { + await deleteS3Object(analysis.objectKey) + } catch { + // orphan accepted + } + return fail('PLUGIN_INSTALL_FAILED', '插件元数据写入失败,制品已回滚删除', 503) + } + const result: AdminPluginInstallResult = { + installed: true, + plugin: pluginDtoFromRow(row), + warnings: toFindings(analysis.warnings), + } + return ok(result, { status: 201 }) +} + +export async function updatePluginStatus(actor: Actor, id: string, input: Record): Promise { + const status = input.status + if (status !== 'active' && status !== 'disabled') return fail('INVALID_INPUT', '只能切换插件的启用或停用状态') + if (!UUID_PATTERN.test(id)) return fail('NOT_FOUND', '插件不存在', 404) + const current = await db().query('SELECT * FROM provider_plugins WHERE id=$1 AND deleted_at IS NULL', [id]) + const row = current.rows[0] + if (!row) return fail('NOT_FOUND', '插件不存在', 404) + // 'failed' is terminal: the loader rejected those bytes, so re-enabling requires a + // re-upload under a new version (see the version-immutability rule). + if (row.status === 'failed') return fail('PLUGIN_FAILED_IMMUTABLE', '加载失败的插件不能重新启用,请以新版本重新上传', 409) + // 'pending' belongs to the worker: until it has imported the artifact there is + // nothing to enable, and flipping the row early would fake an activation. + if (row.status === 'pending') return fail('PLUGIN_NOT_LOADED', '插件尚未由 Worker 加载完成,暂时不能调整状态', 409) + if (row.status === status) return ok(pluginDtoFromRow(row)) + const updated = await transaction(async client => { + const r = await client.query( + "UPDATE provider_plugins SET status=$2, updated_at=now() WHERE id=$1 AND deleted_at IS NULL AND status IN('active','disabled') RETURNING *", + [id, status], + ) + if (!r.rows[0]) return null + await writeAudit(client, actor.id, 'plugin.status', 'provider_plugin', id, { + pluginId: r.rows[0].plugin_id, + pluginVersion: r.rows[0].plugin_version, + sha256: r.rows[0].artifact_sha256, + status, + }) + return r.rows[0] + }) + if (!updated) return fail('PLUGIN_NOT_LOADED', '插件尚未由 Worker 加载完成,暂时不能调整状态', 409) + return ok(pluginDtoFromRow(updated)) +} + +export async function deletePlugin(actor: Actor, id: string): Promise { + if (!UUID_PATTERN.test(id)) return fail('NOT_FOUND', '插件不存在', 404) + const current = await db().query('SELECT * FROM provider_plugins WHERE id=$1 AND deleted_at IS NULL', [id]) + const row = current.rows[0] + if (!row) return fail('NOT_FOUND', '插件不存在', 404) + const inUse = await db().query( + 'SELECT id FROM model_configs WHERE plugin_id=$1 AND plugin_version=$2 AND deleted_at IS NULL LIMIT 1', + [row.plugin_id, row.plugin_version], + ) + if (inUse.rows[0]) return fail('PLUGIN_IN_USE', '该插件版本仍被模型配置引用,请先调整模型后再删除', 409) + const deleted = await transaction(async client => { + const r = await client.query( + "UPDATE provider_plugins SET deleted_at=now(), status='disabled', updated_at=now() WHERE id=$1 AND deleted_at IS NULL RETURNING *", + [id], + ) + if (!r.rows[0]) return null + await writeAudit(client, actor.id, 'plugin.delete', 'provider_plugin', id, { + pluginId: r.rows[0].plugin_id, + pluginVersion: r.rows[0].plugin_version, + sha256: r.rows[0].artifact_sha256, + findings: [], + }) + return r.rows[0] + }) + if (!deleted) return fail('NOT_FOUND', '插件不存在', 404) + try { + await deleteS3Object(deleted.object_key as string) + } catch { + // The row is already gone; a lingering object is harmless (unlisted, private bucket). + } + return ok({ + deleted: true, + pinnedRevisionsRetainArtifact: true, + note: '历史 model_config_revisions 仍以内容摘要固定并指向该插件标识,已生成的任务记录不受影响;如需再次使用该插件请重新上传新版本。', + }) +} + +/** Scan/validation rejection carries the findings verbatim — the UI renders them, it never guesses. */ +function rejected(code: string, findings: PluginScanFinding[]): NextResponse { + return ok({ installed: false, ok: false, code, findings: toFindings(findings) }, { status: 422 }) +} + +function toStringArray(value: unknown): string[] { + const parsed = typeof value === 'string' ? safeJson(value) : value + return Array.isArray(parsed) ? parsed.map(String) : [] +} + +function safeJson(value: string): unknown { + try { + return JSON.parse(value) + } catch { + return null + } +} + +function toFindings(findings: PluginScanFinding[]): AdminPluginScanFinding[] { + return findings.map(finding => ({ + rule: finding.rule, + severity: finding.severity, + ...(finding.line !== undefined ? { line: finding.line } : {}), + ...(finding.column !== undefined ? { column: finding.column } : {}), + message: finding.message, + })) +} + +/** `object_key` is deliberately absent: the artifact is worker-only. */ +function pluginDtoFromRow(row: Record): AdminPluginDto { + const kind = (row.kind as PluginKind) || 'media' + const manifest = (parseRevisionJsonField(row.manifest) || {}) as JsonObject + const modalities = kind === 'media' ? (toStringArray(manifest.modalities) as MediaKind[]) : [] + const languageProtocols = kind === 'language' ? toStringArray(manifest.languageProtocols) : [] + return { + id: row.id as string, + pluginId: row.plugin_id as string, + pluginVersion: row.plugin_version as string, + kind, + displayName: row.display_name as string, + description: (row.description as string) ?? null, + status: (row.status as InstalledPluginStatus) || 'pending', + source: (row.source as 'builtin' | 'uploaded') || 'uploaded', + allowedHosts: toStringArray(row.allowed_hosts), + credentialSchemas: toStringArray(row.credential_schemas), + modalities, + languageProtocols, + manifest, + artifactDigest: row.artifact_sha256 as string, + artifactSizeBytes: Number(row.artifact_size_bytes || 0), + scanReport: findingsFromRow(row.scan_report), + errorCode: (row.error_code as string) ?? null, + errorMessage: (row.error_message as string) ?? null, + createdAt: isoString(row.created_at), + updatedAt: isoString(row.updated_at), + } +} + +function findingsFromRow(value: unknown): AdminPluginScanFinding[] { + const parsed = typeof value === 'string' ? safeJson(value) : value + if (!Array.isArray(parsed)) return [] + return parsed.reduce((acc, entry) => { + const finding = entry as Partial | null + if (!finding || typeof finding !== 'object' || typeof finding.rule !== 'string' || typeof finding.message !== 'string') return acc + acc.push({ + rule: finding.rule, + severity: finding.severity === 'warn' ? 'warn' : 'error', + ...(typeof finding.line === 'number' ? { line: finding.line } : {}), + ...(typeof finding.column === 'number' ? { column: finding.column } : {}), + message: finding.message, + }) + return acc + }, []) +} + +const isoString = (value: unknown): string => new Date(value as string | number | Date).toISOString() diff --git a/apps/api/src/modules/admin/prompt-optimization.ts b/apps/api/src/modules/admin/prompt-optimization.ts index 7f69fc0..1399b94 100644 --- a/apps/api/src/modules/admin/prompt-optimization.ts +++ b/apps/api/src/modules/admin/prompt-optimization.ts @@ -13,6 +13,30 @@ const optimizationSettingsDto = (row: any) => ({ updatedAt: row.updated_at.toISOString(), }) +/** + * GET projection for the same row. + * + * This is deliberately *not* the `optimizationSettingsDto` used by the PATCH + * response above. The read path coerces (`Boolean`/`Number`), falls back to the + * canonical 600000ms timeout, and tolerates a missing `updated_at` by stamping + * now, because it has to answer for a row that may predate the column. The write + * path returns the driver's raw values for a row it just read back. Merging them + * would change the payload of `GET /api/admin/prompt-optimization-settings`. + */ +const optimizationSettingsReadDto = (row: Record) => ({ + enabled: Boolean(row.enabled), + allowUserReadFinalPrompt: Boolean(row.allow_user_read_final_prompt), + languageModelConfigId: (row.language_model_config_id as string) || null, + timeoutMs: Number(row.timeout_ms || 600000), + updatedAt: row.updated_at ? new Date(row.updated_at as string | number | Date).toISOString() : new Date().toISOString(), +}) + +/** GET /api/admin/prompt-optimization-settings */ +export async function readPromptOptimizationSettings() { + const result = await db().query('SELECT * FROM prompt_optimization_settings WHERE singleton=true') + return ok(optimizationSettingsReadDto(result.rows[0])) +} + export async function updatePromptOptimizationSettings( actor: Actor, input: Record, diff --git a/apps/api/src/modules/admin/provider-credentials.ts b/apps/api/src/modules/admin/provider-credentials.ts index 24ead78..75fa20c 100644 --- a/apps/api/src/modules/admin/provider-credentials.ts +++ b/apps/api/src/modules/admin/provider-credentials.ts @@ -6,7 +6,8 @@ import { writeAudit } from '../../shared/audit' import { providerCredentialDto } from '../../shared/dto' import { normalizedProviderBaseUrl } from '../../shared/model-helpers' import { builtinProviderTemplateForPlugin } from '../../admin/provider-templates' -import { callLanguageModel, decodeCredential, globalProviderRegistry } from '../../../../../packages/providers/src/index' +import { manifestAllowsHost, resolveCatalogPlugin } from './plugin-catalog' +import { callLanguageModel, decodeCredential, globalPluginRegistry, globalProviderRegistry, urlHostOf } from '../../../../../packages/providers/src/index' import { decryptProviderCredential, encryptProviderCredential, fingerprintApiKey } from '../../auth/security' const LEGACY_ADAPTERS = ['openai', 'seedream', 'anthropic'] as const @@ -129,9 +130,48 @@ export async function validateExplicitPluginCredential(options: { secretPayload?: unknown }): Promise { const { pluginId, pluginVersion } = options - if (!globalProviderRegistry.has(pluginId, pluginVersion)) { + // Catalog membership, not the static registry: an uploaded plugin must be able to + // hold a credential once its row is active. Built-ins keep every rule below. + const catalog = await resolveCatalogPlugin(pluginId, pluginVersion) + if (!catalog) { return { ok: false, code: 'INVALID_PLUGIN', message: '供应商插件不存在或版本不受支持' } } + const declaredSchemas = (catalog.manifest.credentialSchemas ?? []) as string[] + if (catalog.source === 'installed') { + const installedSchemaId = + typeof options.schemaId === 'string' && options.schemaId.trim() ? options.schemaId.trim() : (declaredSchemas[0] ?? 'legacy-api-key-v1') + if (!declaredSchemas.includes(installedSchemaId)) { + return { ok: false, code: 'INVALID_INPUT', message: '该插件不支持此凭据 schema' } + } + const installedVersion = normalizeCredentialSchemaVersion(options.schemaVersion, 1) + if (!installedVersion.ok || installedVersion.version === undefined) { + return { ok: false, code: 'INVALID_INPUT', message: '凭据 schema 版本无效' } + } + const installedBaseUrl = options.baseUrl ?? undefined + // The manifest allowlist is the plugin's egress policy, which the worker's + // SafeHttpClient enforces at call time; a base URL outside it could never work. + if (typeof installedBaseUrl === 'string' && installedBaseUrl) { + const host = urlHostOf(installedBaseUrl) + if (!host || !manifestAllowsHost(catalog.manifest, host)) { + return { ok: false, code: 'INVALID_BASE_URL', message: 'Base URL 不在插件清单允许的域名内' } + } + } + if (options.secretPayload !== undefined) { + const raw = options.secretPayload + if (typeof raw === 'string' && !raw.trim()) { + return { ok: false, code: 'INVALID_CREDENTIAL', message: '凭据内容不能为空' } + } + try { + // Only the pure decoder runs here: the API never imports plugin code, so the + // plugin's own validateConfig executes in the worker at load time instead. + decodeCredential(raw, installedSchemaId, pluginId, pluginVersion) + } catch (error) { + console.error('credential decode failed', error instanceof Error ? error.message : error) + return { ok: false, code: 'INVALID_CREDENTIAL', message: '凭据内容无法解析' } + } + } + return { ok: true, pluginId, pluginVersion, schemaId: installedSchemaId, schemaVersion: installedVersion.version, baseUrl: installedBaseUrl } + } const plugin = globalProviderRegistry.get(pluginId, pluginVersion) const template = builtinProviderTemplateForPlugin(pluginId, pluginVersion) const schemaId = @@ -205,7 +245,7 @@ export async function createProviderCredential(actor: Actor, input: Record limit + const rows = page.rows.slice(0, limit) + return ok({ + items: rows.map(row => userDto(row)), + total: total.rows[0].total, + hasMore, + nextCursor: hasMore && rows.length ? encodeCursor(rows[rows.length - 1]) : undefined, + }) +} + +/** + * PATCH /api/admin/users/:id (and the identical `/:id/status` alias). + * + * Disabling a user revokes their sessions in the same transaction and bumps + * `session_version`, so an in-flight request cannot keep an old token alive. + */ +export async function setUserStatus(context: AuthedContext) { + const { actor } = context + const input = await context.json() + const id = context.params.id + if (input.status !== 'active' && input.status !== 'disabled') return fail('INVALID_INPUT', '用户状态无效') + if (id === actor.id && input.status === 'disabled') return fail('INVALID_OPERATION', '不能停用当前管理员') + const updated = await transaction(async client => { + const result = await client.query('UPDATE users SET status=$1,session_version=session_version+1,updated_at=now() WHERE id=$2 AND deleted_at IS NULL RETURNING *', [input.status, id]) + if (input.status === 'disabled') await client.query('UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL', [id]) + if (result.rows[0]) await writeAudit(client, actor.id, 'user.status', 'user', id, { status: input.status }) + return result.rows[0] + }) + return updated ? ok(userDto(updated)) : fail('NOT_FOUND', '用户不存在', 404) +} + +/** + * DELETE /api/admin/users/:id + * + * Soft-deletes and hands the byte cleanup to the worker via `deletion_jobs`; + * the request never touches object storage itself. + */ +export async function deleteUser(context: AuthedContext) { + const { actor } = context + const id = context.params.id + if (id === actor.id) return fail('INVALID_OPERATION', '不能删除当前管理员') + const deleted = await transaction(async client => { + const result = await client.query('UPDATE users SET deleted_at=now(),deletion_requested_at=now(),session_version=session_version+1,updated_at=now() WHERE id=$1 AND deleted_at IS NULL RETURNING id', [id]) + if (!result.rows[0]) return false + await client.query('UPDATE sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL', [id]) + await client.query("UPDATE generation_jobs SET status='canceled',completed_at=now() WHERE created_by=$1 AND status IN('queued','retry_wait','running')", [id]) + await client.query('INSERT INTO deletion_jobs(user_id) VALUES($1) ON CONFLICT DO NOTHING', [id]) + await client.query("UPDATE generation_input_images SET status='deleted',deleted_at=now() WHERE created_by=$1", [id]) + await writeAudit(client, actor.id, 'user.delete', 'user', id) + return true + }) + return deleted ? ok({ deleted: true }) : fail('NOT_FOUND', '用户不存在', 404) +} diff --git a/apps/api/src/modules/auth/account.ts b/apps/api/src/modules/auth/account.ts new file mode 100644 index 0000000..1311fac --- /dev/null +++ b/apps/api/src/modules/auth/account.ts @@ -0,0 +1,31 @@ +import { db } from '../../../../../packages/database/src/index' +import { writeAudit } from '../../shared/audit' +import { oauthIdentityDto } from '../../shared/dto' +import { fail, ok } from '../../shared/http' +import type { OAuthProvider } from '../../auth/oauth' +import type { AuthedContext } from '../../router/types' +import { startOAuth } from './oauth-flow' + +/** GET /api/account/oauth — linked identities, oldest link first. */ +export async function listLinkedIdentities(context: AuthedContext) { + const result = await db().query('SELECT * FROM oauth_identities WHERE user_id=$1 AND deleted_at IS NULL ORDER BY linked_at', [context.actor.id]) + return ok(result.rows.map(oauthIdentityDto)) +} + +/** GET /api/account/oauth/:oauth/link/start */ +export function startLink(context: AuthedContext) { + return startOAuth(context.params.oauth as OAuthProvider, 'link', context.actor.id) +} + +/** + * DELETE /api/account/oauth/:oauth + * + * Soft-deletes the link only; the user row and its sessions are untouched, since + * password/OTP login remains available. + */ +export async function unlinkIdentity(context: AuthedContext) { + const provider = context.params.oauth as OAuthProvider + const result = await db().query('UPDATE oauth_identities SET deleted_at=now() WHERE user_id=$1 AND provider=$2 AND deleted_at IS NULL RETURNING id', [context.actor.id, provider]) + if (result.rows[0]) await writeAudit(db(), context.actor.id, 'oauth.unlink', 'oauth_identity', result.rows[0].id, { provider }) + return result.rows[0] ? ok({ unlinked: true }) : fail('NOT_FOUND', '未绑定该第三方账户', 404) +} diff --git a/apps/api/src/modules/auth/handlers.ts b/apps/api/src/modules/auth/handlers.ts new file mode 100644 index 0000000..6810b3e --- /dev/null +++ b/apps/api/src/modules/auth/handlers.ts @@ -0,0 +1,107 @@ +import { randomInt } from 'node:crypto' +import { db, transaction } from '../../../../../packages/database/src/index' +import { actorFrom, hashOtp, hashToken, randomToken, shouldUseSecureCookie, verifyOtpHash } from '../../auth/security' +import { findActiveInvitationHash } from '../../auth/invitations' +import { writeAudit } from '../../shared/audit' +import { clientIpFromRequest, emailValid, fail, ok } from '../../shared/http' +import { userDto } from '../../shared/dto' +import { sendMail } from '../../shared/services' +import { limited } from '../../shared/redis' +import { resolvePublicOrigin } from '../settings/runtime' +import type { PublicContext } from '../../router/types' + +/** + * POST /api/auth/otp/request. + * + * The body is read by the dispatcher and handed in, because this route runs + * before the session gate: an anonymous visitor must be able to ask for a code. + */ +export async function requestOtp(context: PublicContext) { + const input = await context.json() + if (!emailValid(input.email)) return fail('INVALID_INPUT', '邮箱格式不正确') + const email = input.email.trim().toLowerCase() + const ip = clientIpFromRequest(context.request) + if (await limited(`otp:${email}:${ip}`, 5, 600)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) + const existing = await db().query('SELECT id,status,deleted_at FROM users WHERE lower(email)=$1 ORDER BY deleted_at NULLS FIRST LIMIT 1', [email]) + const account = existing.rows[0] + if (account && (account.deleted_at || account.status !== 'active')) return fail('ACCOUNT_UNAVAILABLE', '账户当前不可用', 403) + const setting = await db().query('SELECT mode FROM registration_settings WHERE singleton=true') + const requiresInvitation = !account && setting.rows[0]?.mode === 'invite_only' + let invitationHash: string | null = null + if (requiresInvitation) { + if (typeof input.invitationCode !== 'string' || !input.invitationCode.trim()) return ok({ accepted: false, nextStep: 'invitation' as const }) + invitationHash = await findActiveInvitationHash(db(), input.invitationCode) + if (!invitationHash) return fail('INVALID_INVITATION', '邀请码无效或已过期') + } + const code = randomInt(100000, 1000000).toString() + await db().query('UPDATE otp_challenges SET consumed_at=now() WHERE lower(email)=$1 AND consumed_at IS NULL', [email]) + const challenge = await db().query("INSERT INTO otp_challenges(email,code_hash,invitation_code_hash,expires_at) VALUES($1,$2,$3,now()+interval '10 minutes') RETURNING id", [email, hashOtp(email, code), invitationHash]) + try { + await sendMail(email, 'MuseCanvas 登录验证码', `你的 MuseCanvas 验证码是:${code}。10 分钟内有效。`) + } catch (error) { + // A code that was never delivered must not stay redeemable. + await db().query('UPDATE otp_challenges SET consumed_at=now() WHERE id=$1', [challenge.rows[0].id]) + console.error('otp delivery failed', { code: error instanceof Error ? error.message : 'SMTP_ERROR' }) + return fail('EMAIL_DELIVERY_FAILED', '验证码发送失败,请稍后重试', 503) + } + return ok({ accepted: true, nextStep: 'otp' as const }) +} + +/** POST /api/auth/otp/verify — creates the account on first login when open. */ +export async function verifyOtp(context: PublicContext) { + const input = await context.json() + if (!emailValid(input.email) || typeof input.code !== 'string' || !/^\d{6}$/.test(input.code)) return fail('INVALID_OTP', '验证码无效') + const email = input.email.trim().toLowerCase() + if (await limited(`verify:${email}`, 10, 600)) return fail('RATE_LIMITED', '验证尝试过多,请稍后再试', 429) + const result = await transaction(async client => { + const challengeResult = await client.query('SELECT * FROM otp_challenges WHERE lower(email)=$1 AND consumed_at IS NULL AND expires_at>now() ORDER BY created_at DESC LIMIT 1 FOR UPDATE', [email]) + const challenge = challengeResult.rows[0] + if (!challenge || challenge.attempts >= 5 || !verifyOtpHash(challenge.code_hash, email, input.code as string)) { + if (challenge) await client.query('UPDATE otp_challenges SET attempts=attempts+1 WHERE id=$1', [challenge.id]) + return null + } + let userResult = await client.query('SELECT * FROM users WHERE lower(email)=$1 AND deleted_at IS NULL FOR UPDATE', [email]) + let user = userResult.rows[0] + if (!user) { + const setting = await client.query('SELECT mode FROM registration_settings WHERE singleton=true FOR UPDATE') + if (setting.rows[0].mode === 'invite_only') { + if (!challenge.invitation_code_hash) return null + const invite = await client.query('UPDATE invitations SET consumed_at=now() WHERE code_hash=$1 AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>now() RETURNING id', [challenge.invitation_code_hash]) + if (!invite.rows[0]) return null + } + userResult = await client.query('INSERT INTO users(email) VALUES($1) RETURNING *', [email]) + user = userResult.rows[0] + } + if (user.status !== 'active') return null + await client.query('UPDATE otp_challenges SET consumed_at=now() WHERE id=$1', [challenge.id]) + const token = randomToken() + await client.query("INSERT INTO sessions(user_id,token_hash,expires_at) VALUES($1,$2,now()+interval '30 days')", [user.id, hashToken(token)]) + await writeAudit(client, user.id, 'auth.otp.login', 'user', user.id) + return { user, token } + }) + if (!result) return fail('INVALID_OTP', '验证码无效或已过期', 401) + const response = ok({ user: userDto(result.user) }) + response.cookies.set('muse_session', result.token, { httpOnly: true, secure: shouldUseSecureCookie(await resolvePublicOrigin()), sameSite: 'lax', path: '/', maxAge: 30 * 86400 }) + return response +} + +/** + * POST /api/auth/logout — succeeds for an anonymous caller, and clears the setup + * cookie as well, so a half-finished wizard cannot leave a usable token behind. + */ +export async function logout(context: PublicContext) { + const logoutActor = await actorFrom(context.request) + const token = context.request.cookies.get('muse_session')?.value + if (token) await db().query('UPDATE sessions SET revoked_at=now() WHERE token_hash=$1', [hashToken(token)]) + if (logoutActor) { + try { + await writeAudit(db(), logoutActor.id, 'auth.logout', 'user', logoutActor.id) + } catch (error) { + console.error('audit write failed', error) + } + } + const response = ok({ loggedOut: true }) + response.cookies.delete('muse_session') + response.cookies.delete('muse_setup') + return response +} diff --git a/apps/api/src/modules/generation-uploads/input-limits.test.ts b/apps/api/src/modules/generation-uploads/input-limits.test.ts index 1cc92ec..b4d449f 100644 --- a/apps/api/src/modules/generation-uploads/input-limits.test.ts +++ b/apps/api/src/modules/generation-uploads/input-limits.test.ts @@ -2,6 +2,9 @@ import assert from 'node:assert/strict' import test from 'node:test' import { GenerationInputError, + attachGenerationInputs, + normalizeGenerationInputs, + validateAndAttachGenerationAssets, validateAndAttachGenerationUploads, validateInputsAgainstSlots, } from './validation' @@ -68,3 +71,145 @@ test('attach enforces resolved total and per-image limits', async () => { maxInputs: 32, }) }) + +const ASSET_1 = '00000000-0000-4000-8000-000000000001' +const UPLOAD_1 = '00000000-0000-4000-8000-0000000000e1' +const READY_UPLOAD = { + id: UPLOAD_1, + status: 'ready', + size_bytes: 60, + expires_at: new Date(Date.now() + 60000), + deleted_at: null, + attached_job_id: null, + media_kind: 'image', +} + +function assetRow(overrides: Record = {}) { + return { + id: ASSET_1, + media_kind: 'image', + mime_type: 'image/png', + width: 1024, + height: 1024, + size_bytes: 60, + ...overrides, + } +} + +/** Records every statement so a test can assert what was *not* written. */ +function recordingClient(rows: Record[], uploadRows: Record[] = []) { + const queries: string[] = [] + return { + queries, + query: async (sql: string) => { + queries.push(sql) + if (sql.includes('FROM assets')) return { rows } + if (sql.includes('FROM media_uploads')) return { rows: uploadRows } + return { rows: [] } + }, + } +} + +const assetInput = (assetId: string, position = 0) => ({ assetId, role: 'reference_image', position }) + +test('normalize accepts assetId references and keeps the two id kinds apart', () => { + assert.deepEqual(normalizeGenerationInputs([assetInput(ASSET_1)]), [ + { assetId: ASSET_1, role: 'reference_image', position: 0 }, + ]) + + // One uuid used as an upload and as an asset is two different images. + assert.equal( + normalizeGenerationInputs([{ uploadId: ASSET_1, role: 'reference_image', position: 0 }, assetInput(ASSET_1, 1)]).length, + 2, + ) + + assert.throws(() => normalizeGenerationInputs([assetInput(ASSET_1), assetInput(ASSET_1, 1)]), /assetId 重复/) + assert.throws( + () => normalizeGenerationInputs([{ uploadId: UPLOAD_1, assetId: ASSET_1, role: 'reference_image', position: 0 }]), + /不能同时携带/, + ) + assert.throws(() => normalizeGenerationInputs([{ role: 'reference_image', position: 0 }]), /必须提供/) + assert.throws(() => normalizeGenerationInputs([assetInput('not-a-uuid')]), /assetId 格式无效/) +}) + +test('a gallery-only input binds the asset and touches nothing else', async () => { + const client = recordingClient([assetRow()]) + + await validateAndAttachGenerationAssets(client as never, 'actor', 'job', [assetInput(ASSET_1)]) + + const inserts = client.queries.filter((sql) => sql.includes('INSERT INTO generation_job_inputs')) + assert.equal(inserts.length, 1) + assert.match(inserts[0], /asset_id/) + assert.ok( + !client.queries.some((sql) => /UPDATE assets|UPDATE media_uploads|UPDATE generation_input_images|FROM media_uploads/.test(sql)), + 'a gallery image is referenced, never claimed, mutated, or re-uploaded', + ) + // input_image_id stays NULL so the UNIQUE legacy column is not occupied. + assert.match(inserts[0], /VALUES\(\$1, NULL, NULL, \$2, \$3, \$4\)/) +}) + +test('asset ownership is enforced by the query, not by hiding rows in the UI', async () => { + const client = recordingClient([assetRow()]) + await validateAndAttachGenerationAssets(client as never, 'actor-7', 'job', [assetInput(ASSET_1)]) + const select = client.queries.find((sql) => sql.includes('FROM assets')) ?? '' + assert.match(select, /created_by = \$2/) + assert.match(select, /deleted_at IS NULL/) + + // Another user's image simply is not returned, so the attach fails. + await assert.rejects( + validateAndAttachGenerationAssets(recordingClient([]) as never, 'actor-7', 'job', [assetInput(ASSET_1)]), + (err: unknown) => err instanceof GenerationInputError && err.code === 'INPUT_IMAGE_UNAVAILABLE', + ) +}) + +test('gallery images the worker could not decode are refused at submit time', async () => { + const rejects = async (overrides: Record, pattern: RegExp) => { + await assert.rejects( + validateAndAttachGenerationAssets( + recordingClient([assetRow(overrides)]) as never, + 'actor', + 'job', + [assetInput(ASSET_1)], + ), + pattern, + ) + } + + // inspectImageBytes only understands PNG and JPEG magic bytes, so a WebP artifact + // can never be a generation input no matter what the gallery shows. + await rejects({ mime_type: 'image/webp' }, /格式不支持/) + await rejects({ media_kind: 'video' }, /格式不支持/) + await rejects({ width: 16 }, /分辨率/) + await rejects({ width: 8000 }, /分辨率/) + await rejects({ width: 6000, height: 100 }, /宽高比/) +}) + +test('uploads and gallery picks share one pooled total-size budget', async () => { + const mixed = [{ uploadId: UPLOAD_1, role: 'reference_image', position: 0 }, assetInput(ASSET_1, 1)] + const limits = { maxImageBytes: 100, maxTotalBytes: 100, maxInputs: 32 } + + // 60 bytes from the upload plus 60 from the asset: each branch alone fits. + await assert.rejects( + attachGenerationInputs(recordingClient([assetRow()], [READY_UPLOAD]) as never, 'actor', 'job', mixed, limits), + /总大小超出限制/, + ) + + // With room for both, the same request succeeds and both linkage rows appear. + const roomy = recordingClient([assetRow()], [READY_UPLOAD]) + await attachGenerationInputs(roomy as never, 'actor', 'job', mixed, { ...limits, maxTotalBytes: 200 }) + const inserts = roomy.queries.filter((sql) => sql.includes('INSERT INTO generation_job_inputs')) + assert.equal(inserts.length, 2, 'one row per input, regardless of provenance') + assert.ok(roomy.queries.some((sql) => sql.includes('asset_id')), 'the gallery pick was attached') + assert.ok(roomy.queries.some((sql) => sql.includes("SET status='attached'")), 'the upload was claimed') +}) + +test('a gallery-only request never queries the upload tables', async () => { + const client = recordingClient([assetRow()]) + await attachGenerationInputs(client as never, 'actor', 'job', [assetInput(ASSET_1)], { + maxImageBytes: 100, + maxTotalBytes: 1000, + maxInputs: 32, + }) + assert.ok(!client.queries.some((sql) => sql.includes('FROM media_uploads')), 'no empty ANY() probe for upload-less requests') + assert.equal(client.queries.filter((sql) => sql.includes('INSERT INTO generation_job_inputs')).length, 1) +}) diff --git a/apps/api/src/modules/generation-uploads/validation.ts b/apps/api/src/modules/generation-uploads/validation.ts index d7386ba..f040365 100644 --- a/apps/api/src/modules/generation-uploads/validation.ts +++ b/apps/api/src/modules/generation-uploads/validation.ts @@ -1,4 +1,10 @@ -import { GENERATION_UPLOAD_ID_PATTERN, MAX_INPUT_IMAGES, MAX_UPLOAD_IMAGE_BYTES, MAX_UPLOAD_TOTAL_BYTES } from './constants' +import { GENERATION_UPLOAD_ID_PATTERN, ALLOWED_MIME_TYPES, MAX_INPUT_IMAGES, MAX_UPLOAD_IMAGE_BYTES, MAX_UPLOAD_TOTAL_BYTES } from './constants' +import { MASK_INPUT_ROLE } from '@musecanvas/contracts' +import { + MAX_INPUT_IMAGE_ASPECT_RATIO, + MAX_INPUT_IMAGE_DIMENSION, + MIN_INPUT_IMAGE_DIMENSION, +} from '../../../../../packages/providers/src/index' export class GenerationInputError extends Error { code: string @@ -13,7 +19,14 @@ export class GenerationInputError extends Error { export type GenerationInputRole = 'prompt_image' | 'reference_image' | 'first_frame' | 'last_frame' | 'source_video' | string export interface NormalizedGenerationInput { - uploadId: string + /** Set when the input is a locally uploaded file (`media_uploads` row). */ + uploadId?: string + /** + * Set when the input references an image that is already in the user's gallery + * (`assets` row). No upload row and no second object are created for those — see + * `validateAndAttachGenerationAssets`. + */ + assetId?: string role: GenerationInputRole position: number } @@ -24,11 +37,15 @@ const KNOWN_INPUT_ROLES: Record = { first_frame: true, last_frame: true, source_video: true, + // The 局部修改 mask. Only reachable when a model declares no slots at all (a + // slot-bearing contract is gated by its own slots instead), but leaving it out + // would make `packages/contracts`' `MASK_INPUT_ROLE` a lie about this list. + [MASK_INPUT_ROLE]: true, } /** - * Normalize the unified `inputs` payload (`[{uploadId, role, position}]`) while - * accepting the legacy `inputImageIds` string array as a compatibility path. + * Normalize the unified `inputs` payload (`[{uploadId | assetId, role, position}]`) + * while accepting the legacy `inputImageIds` string array as a compatibility path. * Legacy ids are mapped to `reference_image` roles in array order. */ export function normalizeGenerationInputs( @@ -53,20 +70,35 @@ export function normalizeGenerationInputs( throw new GenerationInputError('INVALID_INPUT', '输入项格式无效') } const record = item as Record - const uploadId = record.uploadId - if (typeof uploadId !== 'string' || !GENERATION_UPLOAD_ID_PATTERN.test(uploadId)) { - throw new GenerationInputError('INVALID_INPUT', '输入 uploadId 格式无效') + // An input carries exactly one reference: an upload (bytes the browser streamed + // into object storage, owned by this job) or a gallery asset (referenced in + // place, reusable by later jobs). Both is ambiguous, neither is unfillable. + const uploadId = typeof record.uploadId === 'string' ? record.uploadId.trim() : '' + const assetId = typeof record.assetId === 'string' ? record.assetId.trim() : '' + if (uploadId && assetId) { + throw new GenerationInputError('INVALID_INPUT', '输入不能同时携带 uploadId 与 assetId') } - if (seen[uploadId]) { - throw new GenerationInputError('INVALID_INPUT', '输入 uploadId 重复') + if (!uploadId && !assetId) { + throw new GenerationInputError('INVALID_INPUT', '输入必须提供 uploadId 或 assetId') } - seen[uploadId] = true + const ref = uploadId || assetId + // Both kinds are uuids (`gen_random_uuid()`), so one pattern covers them. + if (!GENERATION_UPLOAD_ID_PATTERN.test(ref)) { + throw new GenerationInputError('INVALID_INPUT', uploadId ? '输入 uploadId 格式无效' : '输入 assetId 格式无效') + } + // Kind-prefixed key: the same string used as an upload id and as an asset id + // refers to two different images, so it is not a duplicate. + const refKey = uploadId ? `u:${ref}` : `a:${ref}` + if (seen[refKey]) { + throw new GenerationInputError('INVALID_INPUT', uploadId ? '输入 uploadId 重复' : '输入 assetId 重复') + } + seen[refKey] = true const role = typeof record.role === 'string' && record.role.trim() ? record.role.trim() : fallbackRole const position = record.position === undefined || record.position === null ? index : Number(record.position) if (!Number.isInteger(position) || position < 0 || position >= 32) { throw new GenerationInputError('INVALID_INPUT', '输入 position 无效') } - return { uploadId, role, position } + return { ...(uploadId ? { uploadId } : { assetId }), role, position } }) normalized.sort((a, b) => a.position - b.position) return normalized @@ -237,6 +269,10 @@ export async function validateAndAttachGenerationInputs( * backfill). Persists `upload_id` + `role` linkage; keeps the legacy * `input_image_id` column populated for image uploads so older readers keep * working. Never stores provider secrets or signed URLs. + * + * Asset-referenced inputs are ignored here (they have no upload row) and handled by + * `validateAndAttachGenerationAssets`. Returns the bytes this branch counted, so the + * caller can enforce one pooled total across both kinds. */ export async function validateAndAttachGenerationUploads( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, @@ -244,12 +280,18 @@ export async function validateAndAttachGenerationUploads( jobId: string, normalized: NormalizedGenerationInput[], limits?: UploadAttachLimits, -): Promise { +): Promise { const maxInputs = limits?.maxInputs ?? MAX_INPUT_IMAGES if (normalized.length > maxInputs) { throw new GenerationInputError('INVALID_INPUT', '参考图数量超出上限') } - const ids = normalized.map(item => item.uploadId) + const items = normalized.filter((item): item is NormalizedGenerationInput & { uploadId: string } => + Boolean(item.uploadId), + ) + // A gallery-only request must never reach `= ANY($1)` with an empty array: pg + // cannot infer the element type of `{}` and errors out instead of matching nothing. + if (items.length === 0) return 0 + const ids = items.map(item => item.uploadId) let rows: Record[] = [] try { const result = await client.query( @@ -285,7 +327,7 @@ export async function validateAndAttachGenerationUploads( } let totalBytes = 0 const now = Date.now() - for (const item of normalized) { + for (const item of items) { const row = rowsById[item.uploadId] if (!row || row.deleted_at !== null || row.status === 'deleted') { throw new GenerationInputError('INPUT_IMAGE_UNAVAILABLE', '参考图已被删除') @@ -310,7 +352,7 @@ export async function validateAndAttachGenerationUploads( if (totalBytes > (limits?.maxTotalBytes ?? MAX_UPLOAD_TOTAL_BYTES)) { throw new GenerationInputError('INVALID_INPUT_IMAGE_SIZE', '参考图总大小超出限制') } - for (const item of normalized) { + for (const item of items) { const mediaKind = String(rowsById[item.uploadId]?.media_kind || 'image') await client.query( `INSERT INTO generation_job_inputs(job_id, input_image_id, upload_id, position, role) VALUES($1, $2, $3, $4, $5)`, @@ -331,4 +373,129 @@ export async function validateAndAttachGenerationUploads( ) } } + return totalBytes +} + +/** + * Attach inputs that reference an image already in the user's gallery. + * + * These rows store only `asset_id`: no `media_uploads` row, no second object, and no + * `attached_job_id` claim — a gallery image is not consumed by being used, so the + * same one may feed any number of later jobs. That is also what keeps + * `deleteGenerationUpload` and the worker's upload TTL / orphan sweeps (which act on + * `media_uploads` and `generation_input_images` object keys) structurally unable to + * reach a gallery object. + * + * Deliberately no `FOR UPDATE` on `assets`: nothing here writes to that table, so + * there is no write skew to guard, while a row lock would serialize every job that + * reuses a popular image and invert the lock order against account deletion + * (which updates `assets` before touching `generation_job_inputs`). The race that + * remains — the user deletes the image after this check but before the worker reads + * it — resolves in the worker as a retryable `INPUT_IMAGE_UNAVAILABLE`. + * + * @param usedBytes bytes already counted by the upload branch, so a single pooled + * total-size cap applies across both kinds. + * @returns the bytes this branch counted. + */ +export async function validateAndAttachGenerationAssets( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + actorId: string, + jobId: string, + normalized: NormalizedGenerationInput[], + limits?: UploadAttachLimits, + usedBytes = 0, +): Promise { + const maxInputs = limits?.maxInputs ?? MAX_INPUT_IMAGES + if (normalized.length > maxInputs) { + throw new GenerationInputError('INVALID_INPUT', '参考图数量超出上限') + } + const items = normalized.filter((item): item is NormalizedGenerationInput & { assetId: string } => + Boolean(item.assetId), + ) + if (items.length === 0) return 0 + + const ids = items.map(item => item.assetId) + // Ownership is exactly this predicate — the same `created_by` rule the library list + // uses, so the picker can never hand out another user's image. + const result = await client.query( + `SELECT id, media_kind, mime_type, width, height, size_bytes + FROM assets + WHERE id = ANY($1::uuid[]) AND created_by = $2 AND deleted_at IS NULL`, + [ids, actorId], + ) + if (result.rows.length !== ids.length) { + throw new GenerationInputError('INPUT_IMAGE_UNAVAILABLE', '参考图不存在或无权访问') + } + const rowsById: Record> = {} + for (const row of result.rows) { + rowsById[row.id as string] = row + } + + const maxSingle = limits?.maxImageBytes ?? MAX_UPLOAD_IMAGE_BYTES + const maxTotal = limits?.maxTotalBytes ?? MAX_UPLOAD_TOTAL_BYTES + let totalBytes = usedBytes + for (const item of items) { + const row = rowsById[item.assetId] + if (!row) { + throw new GenerationInputError('INPUT_IMAGE_UNAVAILABLE', '参考图不存在或无权访问') + } + // Only PNG/JPEG bytes survive `inspectImageBytes` in the worker, so a WebP + // artifact (or a video poster) is rejected here with a readable message instead + // of failing the job minutes later. + if (String(row.media_kind || 'image') !== 'image' || !ALLOWED_MIME_TYPES[String(row.mime_type)]) { + throw new GenerationInputError('INVALID_INPUT_IMAGE', '图库作品格式不支持作为参考图,仅支持 PNG 或 JPEG 图片') + } + // Same geometry gate the worker applies to the bytes it loads. + const width = Number(row.width || 0) + const height = Number(row.height || 0) + if ( + width < MIN_INPUT_IMAGE_DIMENSION || + width > MAX_INPUT_IMAGE_DIMENSION || + height < MIN_INPUT_IMAGE_DIMENSION || + height > MAX_INPUT_IMAGE_DIMENSION + ) { + throw new GenerationInputError( + 'INVALID_INPUT_IMAGE', + `参考图分辨率须在 ${MIN_INPUT_IMAGE_DIMENSION}~${MAX_INPUT_IMAGE_DIMENSION} 像素之间`, + ) + } + const aspectRatio = Math.max(width / height, height / width) + if (aspectRatio > MAX_INPUT_IMAGE_ASPECT_RATIO) { + throw new GenerationInputError('INVALID_INPUT_IMAGE', `参考图宽高比不能超过 ${MAX_INPUT_IMAGE_ASPECT_RATIO}:1`) + } + const sizeBytes = Number(row.size_bytes || 0) + if (sizeBytes > maxSingle) { + throw new GenerationInputError('INVALID_INPUT_IMAGE_SIZE', '参考图大小超出限制') + } + totalBytes += sizeBytes + if (totalBytes > maxTotal) { + throw new GenerationInputError('INVALID_INPUT_IMAGE_SIZE', '参考图总大小超出限制') + } + } + + for (const item of items) { + // `input_image_id` must stay NULL: it is UNIQUE and references + // `generation_input_images`, which asset-sourced inputs never occupy. + await client.query( + `INSERT INTO generation_job_inputs(job_id, input_image_id, upload_id, asset_id, position, role) VALUES($1, NULL, NULL, $2, $3, $4)`, + [jobId, item.assetId, item.position, item.role], + ) + } + return totalBytes - usedBytes +} + +/** + * Attach a normalized input list of mixed provenance. Uploads are validated and + * claimed first; gallery assets then consume whatever is left of the pooled + * total-size budget. + */ +export async function attachGenerationInputs( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + actorId: string, + jobId: string, + normalized: NormalizedGenerationInput[], + limits?: UploadAttachLimits, +): Promise { + const usedByUploads = await validateAndAttachGenerationUploads(client, actorId, jobId, normalized, limits) + await validateAndAttachGenerationAssets(client, actorId, jobId, normalized, limits, usedByUploads) } diff --git a/apps/api/src/modules/generations/create-job.ts b/apps/api/src/modules/generations/create-job.ts new file mode 100644 index 0000000..d47c623 --- /dev/null +++ b/apps/api/src/modules/generations/create-job.ts @@ -0,0 +1,264 @@ +import { createHash } from 'node:crypto' +import type { NextResponse } from 'next/server' +import { db, transaction } from '../../../../../packages/database/src/index' +import { validateGenerationRequest, prepareRequestDigestInput } from '@musecanvas/domain' +import { + RUNTIME_SETTINGS_DEFAULTS, + GenerationErrorCode, + type CreateGenerationRequest, + type ModelCapabilities, +} from '@musecanvas/contracts' +import type { Actor } from '../../auth/security' +import { fail, ok } from '../../shared/http' +import { + capabilitiesFromRow, + defaultsFromRow, + jobDto, +} from '../../shared/dto' +import { loadSingleJobInputs, userJobSelect } from '../../shared/pagination' +import { resolveRuntimeSettings } from '../settings/runtime' +import { + GenerationInputError, + attachGenerationInputs, + validateInputsAgainstSlots, +} from '../generation-uploads' + +export interface CreateGenerationJobCommand { + actor: Actor + modelId: string + prompt: string + /** + * Unified `parameters` object, when the caller sent one. Left `undefined` to + * select the legacy flat-field path below. + */ + parameters?: Record + /** + * Legacy flat image fields, honoured only when `parameters` is absent. An image + * model still owes a `size` on this path while a video model does not, so the + * rule is applied here — after the model row is known — rather than by the + * caller, exactly as it was while this code lived inline in the route. + */ + legacyParameters?: { size?: unknown; quality?: unknown; count?: unknown } + /** Inputs already normalised to `{uploadId? | assetId?, role, position}`. */ + normalizedInputs: Array<{ uploadId?: string; assetId?: string; role: string; position: number }> + idempotencyKey: string + inputLanguage?: string +} + +/** + * The single path that turns a validated request into a queued generation. + * + * It used to live inline in `POST /api/generations`. `POST /api/images/edit` + * needs the same guarantees — model revision snapshot, credential check, + * prompt-optimization snapshot, per-user idempotency, input attach and outbox + * enqueue in one transaction — and re-implementing any of them in a second place + * is how an edit job ends up bypassing one. Everything that is *specific* to the + * JSON body shape (field allowlist, legacy `size`/`quality`/`count` promotion, + * `inputImageIds` compatibility) stays at the route; what lives here is the + * creation contract, shared verbatim by both entry points. + * + * Rate limiting is deliberately the caller's job: `POST /api/generations` checks + * the budget before it parses anything, and that position is part of the + * behaviour. A check here would run second and count every request twice. + */ +export async function createGenerationJob(cmd: CreateGenerationJobCommand): Promise { + const { actor, modelId, normalizedInputs, idempotencyKey } = cmd + + const modelResult = await db().query( + `SELECT m.*, rev.capabilities, rev.defaults, rev.revision FROM model_configs m + LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id + WHERE m.id=$1 AND m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL`, + [modelId], + ) + const model = modelResult.rows[0]; if (!model) return fail('MODEL_NOT_AVAILABLE', '模型当前不可用') + const mediaKind = ((model.model_kind as string) || 'image') as 'image' | 'video' + const capabilities = capabilitiesFromRow(model) + const defaults = defaultsFromRow(model) as Record + // Shared parameters: the unified `parameters` object is primary; legacy image + // fields (size/quality/count) are only a normalized compatibility path. + let rawParameters: Record + if (cmd.parameters !== undefined) { + rawParameters = cmd.parameters + } else { + rawParameters = {} + if (typeof cmd.legacyParameters?.size === 'string') rawParameters.size = cmd.legacyParameters.size + else if (mediaKind === 'image') return fail('INVALID_INPUT', '生成参数无效') + if (typeof cmd.legacyParameters?.quality === 'string') rawParameters.quality = cmd.legacyParameters.quality + if (cmd.legacyParameters?.count !== undefined) rawParameters.count = Number(cmd.legacyParameters.count) + } + + // Resolved runtime input limits (DB first) enforce both raised and + // lowered settings; canonical defaults are the safe fallback. + let runtimeLimits: { maxImageBytes: number; maxTotalBytes: number; maxInputs: number } = { + maxImageBytes: RUNTIME_SETTINGS_DEFAULTS.maxImageBytes, + maxTotalBytes: RUNTIME_SETTINGS_DEFAULTS.maxTotalBytes, + maxInputs: RUNTIME_SETTINGS_DEFAULTS.maxInputs, + } + try { + const resolved = await resolveRuntimeSettings() + runtimeLimits = { + maxImageBytes: resolved.maxImageBytes, + maxTotalBytes: resolved.maxTotalBytes, + maxInputs: resolved.maxInputs, + } + } catch { + runtimeLimits = { + maxImageBytes: RUNTIME_SETTINGS_DEFAULTS.maxImageBytes, + maxTotalBytes: RUNTIME_SETTINGS_DEFAULTS.maxTotalBytes, + maxInputs: RUNTIME_SETTINGS_DEFAULTS.maxInputs, + } + } + // Role-aware generic inputs; each item references either an upload (`uploadId`) + // or a gallery image (`assetId`). + try { + validateInputsAgainstSlots( + normalizedInputs, + (capabilities.inputSlots as { role: string; required?: boolean; minCount?: number; maxCount?: number }[]) || [], + runtimeLimits, + ) + } catch (err) { + if (err instanceof GenerationInputError) return fail(err.code, err.message, err.status) + return fail('INVALID_INPUT', '参考图参数无效', 400) + } + // Descriptor-driven validation via domain, for every media kind alike. + // + // Image models used to be checked here against a `{ type: 'text', maxLength: 32 }` + // stub, on the reasoning that the provider plugin owns shape enforcement + // downstream. What that actually bought was: `size: "9999x9999"` passed this + // endpoint, a job row was created and queued, the plugin rejected it there, + // and the user saw a failed task with a vendor error instead of a form error + // naming the field they got wrong. The model's real descriptors are now the + // single contract at this boundary too; the plugin's `validateRequest` stays + // as the last gate before the network call, running the same code. + // + // An undeclared contract is refused rather than tolerated. Falling back to a + // permissive shape is the behaviour being removed, and a model nobody has + // described is an admin problem, not a licence to guess. + const undeclared = capabilities.declaredBy === 'undeclared' + || (capabilities.declaredBy === undefined && capabilities.parameters.length === 0) + if (undeclared) { + return fail( + GenerationErrorCode.MODEL_CAPABILITIES_UNDECLARED, + '该模型尚未声明参数契约,无法校验生成参数,请管理员在模型设置中重新保存该模型', + 409, + { parameter: 'modelId', value: modelId }, + ) + } + const validationCaps = { + modes: capabilities.modes, + parameters: capabilities.parameters, + // Only the runtime ceiling is applied to a slot. The old code also forced + // `required: false` and `minCount: 0`, which silently disabled every + // mandatory-input rule — for video as well as image. + inputSlots: capabilities.inputSlots.map(slot => ({ + ...slot, + maxCount: Math.min(slot.maxCount, runtimeLimits.maxInputs), + })), + maxCount: capabilities.maxCount, + supportedMediaKinds: capabilities.supportedMediaKinds, + flags: capabilities.flags, + crossFieldConstraints: capabilities.crossFieldConstraints, + declaredBy: capabilities.declaredBy, + } + const createRequest = { + modelId, + prompt: cmd.prompt.trim(), + parameters: rawParameters, + inputs: normalizedInputs, + idempotencyKey, + inputLanguage: cmd.inputLanguage, + } as CreateGenerationRequest + const domainValidation = validateGenerationRequest(validationCaps as ModelCapabilities, createRequest, { defaults: defaults as Record }) + if (!domainValidation.valid) { + // Forward the structured detail so the console can grey out the exact + // control, not just print a sentence about a form the user already filled. + return fail(domainValidation.errorCode, domainValidation.errorMessage, 400, domainValidation.errors[0]?.details) + } + const normalized = domainValidation.value + const prompt = normalized.prompt + const requestDigest = createHash('sha256').update(prepareRequestDigestInput(normalized)).digest('hex') + const attachLimits = runtimeLimits + let row: Record + try { + row = await transaction(async client => { + const existing = await client.query('SELECT * FROM generation_jobs WHERE created_by=$1 AND idempotency_key=$2', [actor.id, idempotencyKey]) + if (existing.rows[0]) return existing.rows[0] + + // Lock model config and prompt optimization settings in generation transaction + const lockedModelRes = await client.query( + `SELECT m.*, rev.id AS revision_id, rev.capabilities AS revision_capabilities, rev.defaults AS revision_defaults, rev.revision AS revision_number FROM model_configs m + LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id + WHERE m.id=$1 AND m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL FOR SHARE`, + [modelId] + ) + const lockedModel = lockedModelRes.rows[0] + if (!lockedModel) throw new Error('MODEL_NOT_AVAILABLE') + + const optRes = await client.query('SELECT * FROM prompt_optimization_settings WHERE singleton=true FOR SHARE') + const optRow = optRes.rows[0] || { singleton: true, enabled: false } + + let credId: string | null = null; let credName: string | null = null; let providerBaseUrl = lockedModel.base_url + if (lockedModel.provider_credential_id) { + const cred = await client.query('SELECT id, display_name, enabled, api_key_encrypted, payload_encrypted, base_url FROM provider_credentials WHERE id=$1 AND deleted_at IS NULL', [lockedModel.provider_credential_id]) + if (!cred.rows[0] || !cred.rows[0].enabled || (!cred.rows[0].api_key_encrypted && !cred.rows[0].payload_encrypted)) throw new Error('PROVIDER_NOT_CONFIGURED') + credId = cred.rows[0].id + credName = cred.rows[0].display_name + providerBaseUrl = cred.rows[0].base_url || lockedModel.base_url + } + + let optSettings = optRow + if (optRow.enabled) { + const fullOpt = await client.query( + `SELECT s.*,m.display_name,m.vendor_model_id,m.adapter,m.language_protocol,m.max_output_tokens,m.temperature,m.reasoning_effort,m.base_url,pc.id credential_id,pc.display_name credential_name,pc.base_url credential_base_url,pc.enabled credential_enabled,COALESCE(NULLIF(pc.payload_encrypted,''),pc.api_key_encrypted) api_key_encrypted + FROM prompt_optimization_settings s + LEFT JOIN model_configs m ON m.id=s.language_model_config_id AND m.deleted_at IS NULL + LEFT JOIN provider_credentials pc ON pc.id=m.provider_credential_id AND pc.deleted_at IS NULL + WHERE s.singleton=true` + ) + optSettings = fullOpt.rows[0] + if (!optSettings || !optSettings.language_model_config_id || !optSettings.language_protocol || !optSettings.credential_id || !optSettings.credential_enabled || !optSettings.api_key_encrypted) { + throw new Error('PROMPT_MODEL_NOT_CONFIGURED') + } + } + const optimizationMode = optRow.enabled ? 'enabled' : 'disabled' + const phase = optRow.enabled ? 'template_selecting' : (mediaKind === 'video' ? 'provider_submitting' : 'image_generating') + + // Generations are free: no quoting and no reservation. Insert the job + // with the immutable revision/provider/plugin identity, media kind, + // normalized request and digest for idempotent dispatch. + const jobSize = typeof normalized.parameters.size === 'string' ? normalized.parameters.size as string : null + const jobQuality = typeof normalized.parameters.quality === 'string' ? normalized.parameters.quality as string : null + const jobCount = Number(normalized.parameters.count ?? 1) + const normalizedRequestJson = JSON.stringify({ modelId: normalized.modelId, prompt: normalized.prompt, parameters: normalized.parameters, inputs: normalized.inputs, mode: normalized.mode }) + const insertSql = `INSERT INTO generation_jobs(created_by,model_id,model_name,adapter,vendor_model_id,provider_base_url,prompt,size,quality,count,watermark,idempotency_key,provider_credential_id,provider_credential_name,optimization_mode,phase,media_kind,model_revision_id,provider_id,plugin_id,plugin_version,normalized_request,request_digest) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) ON CONFLICT (created_by, idempotency_key) DO NOTHING RETURNING *` + const insertParams = [actor.id, lockedModel.id, lockedModel.display_name, lockedModel.adapter, lockedModel.vendor_model_id, providerBaseUrl, prompt, jobSize, jobQuality, jobCount, lockedModel.watermark, idempotencyKey, credId, credName, optimizationMode, phase, mediaKind, lockedModel.revision_id || null, lockedModel.provider_id || null, lockedModel.plugin_id || null, lockedModel.plugin_version || '1.0.0', normalizedRequestJson, requestDigest] + const inserted = await client.query(insertSql, insertParams) + if (inserted.rowCount === 0) { + // Concurrent create with the same idempotency key: the winner + // already committed the job, its input bindings and outbox event. + // Return the existing row and skip every write. + const replayed = await client.query('SELECT * FROM generation_jobs WHERE created_by=$1 AND idempotency_key=$2', [actor.id, idempotencyKey]) + if (replayed.rows[0]) return replayed.rows[0] + throw new Error('GENERATION_CREATE_FAILED') + } + await attachGenerationInputs(client, actor.id, inserted.rows[0].id, normalizedInputs, attachLimits) + if (optRow.enabled) { + const optimization = await client.query(`INSERT INTO prompt_optimizations(job_id,created_by,input_prompt,input_language,language_model_config_id,language_model_name_snapshot,language_model_vendor_id_snapshot,language_model_protocol_snapshot,language_model_adapter_snapshot,language_model_base_url_snapshot,language_model_max_output_tokens_snapshot,language_model_temperature_snapshot,language_model_reasoning_effort_snapshot,provider_credential_id,provider_credential_name_snapshot) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`, [inserted.rows[0].id, actor.id, prompt, cmd.inputLanguage ?? 'und', optSettings.language_model_config_id, optSettings.display_name, optSettings.vendor_model_id, optSettings.language_protocol, optSettings.adapter, optSettings.credential_base_url || optSettings.base_url, optSettings.max_output_tokens, optSettings.temperature, optSettings.reasoning_effort, optSettings.credential_id, optSettings.credential_name]) + await client.query('UPDATE generation_jobs SET prompt_optimization_id=$1 WHERE id=$2', [optimization.rows[0].id, inserted.rows[0].id]) + } + await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload,dedupe_key) VALUES('generation.requested',$1,$2,$3)", [inserted.rows[0].id, { jobId: inserted.rows[0].id }, `gen:${actor.id}:${idempotencyKey}`]) + return inserted.rows[0] + }) + } catch (error) { + if (error instanceof GenerationInputError) return fail(error.code, error.message, error.status) + if (error instanceof Error && error.message === 'MODEL_NOT_AVAILABLE') { + return fail('MODEL_NOT_AVAILABLE', '模型当前不可用', 409) + } + const code = error instanceof Error && ['PROVIDER_NOT_CONFIGURED', 'PROMPT_MODEL_NOT_CONFIGURED'].includes(error.message) ? error.message : 'GENERATION_CREATE_FAILED' + return fail(code, code === 'PROMPT_MODEL_NOT_CONFIGURED' ? '提示词优化模型配置不完整' : code === 'PROVIDER_NOT_CONFIGURED' ? '生成供应商凭据未配置' : '创建生成任务失败', 503) + } + const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [row.id, actor.id]) + const jobInputs = await loadSingleJobInputs(db(), row.id as string) + return ok(await jobDto(responseRow.rows[0] || row, [], jobInputs), { status: 202 }) +} diff --git a/apps/api/src/modules/generations/create.ts b/apps/api/src/modules/generations/create.ts new file mode 100644 index 0000000..3796d84 --- /dev/null +++ b/apps/api/src/modules/generations/create.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto' +import { fail } from '../../shared/http' +import { limited } from '../../shared/redis' +import { GenerationInputError, normalizeGenerationInputs } from '../generation-uploads' +import type { AuthedContext } from '../../router/types' +import { createGenerationJob } from './create-job' + +/** + * `POST /api/generations` — the JSON boundary. + * + * Everything here is about the *shape of the request body*, which only this + * endpoint has: the field allowlist, the prompt sanity checks, the + * `parameters`-versus-legacy-flat-fields choice, the `inputImageIds` + * compatibility path, and the idempotency key resolution from header or body. + * + * What happens once those are settled — model snapshot, credential check, + * descriptor validation, the job row, input attach and the outbox event — is the + * shared creation contract in `create-job.ts`, because `POST /api/images/edit` + * must not get a second, slightly different copy of those guarantees. + */ + +const ALLOWED_GENERATION_FIELDS = new Set(['prompt', 'modelId', 'parameters', 'inputs', 'idempotencyKey', 'inputLanguage', 'size', 'quality', 'count', 'inputImageIds']) + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const hasControlChars = (value: string): boolean => { + for (const ch of value) { + const code = ch.codePointAt(0) || 0 + if (code < 32 && code !== 9 && code !== 10 && code !== 13) return true + } + return false +} + +export async function createGeneration(context: AuthedContext) { + const { actor, request } = context + // First, before any parsing: this is the position it has always held, and a + // request over budget is refused without being inspected. + if (await limited(`gen:create:${actor.id}`, 20, 300)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) + const input = await context.json() + + if (Object.keys(input).some(key => !ALLOWED_GENERATION_FIELDS.has(key))) return fail('INVALID_INPUT', '生成请求包含不允许的字段') + if (typeof input.prompt !== 'string' || input.prompt.trim().length < 1 || input.prompt.length > 4000 || hasControlChars(input.prompt) || typeof input.modelId !== 'string') return fail('INVALID_INPUT', '生成参数无效') + if (!UUID_PATTERN.test(input.modelId)) return fail('INVALID_INPUT', '模型参数无效') + // The unified `parameters` object must be an object; the legacy path is only + // reached when it is absent altogether. + if (input.parameters !== undefined && (typeof input.parameters !== 'object' || input.parameters === null || Array.isArray(input.parameters))) { + return fail('INVALID_INPUT', '生成参数无效') + } + + // Role-aware generic inputs with legacy inputImageIds compatibility. Each item + // references either an upload (`uploadId`) or a gallery image (`assetId`). + // Slot validation itself happens in the contract, once the model's declared + // input slots and the runtime limits are known. + let normalizedInputs + try { + normalizedInputs = normalizeGenerationInputs(input.inputs, input.inputImageIds) + } catch (err) { + if (err instanceof GenerationInputError) return fail(err.code, err.message, err.status) + return fail('INVALID_INPUT', '参考图参数无效', 400) + } + + return createGenerationJob({ + actor, + modelId: input.modelId as string, + prompt: input.prompt as string, + parameters: input.parameters as Record | undefined, + legacyParameters: { size: input.size, quality: input.quality, count: input.count }, + normalizedInputs, + idempotencyKey: request.headers.get('idempotency-key') || (typeof input.idempotencyKey === 'string' ? input.idempotencyKey : randomUUID()), + inputLanguage: typeof input.inputLanguage === 'string' ? (input.inputLanguage as string).slice(0, 20) : undefined, + }) +} diff --git a/apps/api/src/modules/generations/handlers.ts b/apps/api/src/modules/generations/handlers.ts index 30d2ed2..1bf14e9 100644 --- a/apps/api/src/modules/generations/handlers.ts +++ b/apps/api/src/modules/generations/handlers.ts @@ -40,18 +40,21 @@ export async function deleteJobWithAssets(userId: string, jobId: string) { // in-flight provider/output state markers so no signed output URL survives // privacy deletion in durable state. const assets = await client.query( - 'UPDATE assets SET deleted_at=now(),updated_at=now() WHERE job_id=$1 AND created_by=$2 AND deleted_at IS NULL RETURNING id,object_key,poster_object_key', + 'UPDATE assets SET deleted_at=now(),updated_at=now() WHERE job_id=$1 AND created_by=$2 AND deleted_at IS NULL RETURNING id,object_key,poster_object_key,thumbnail_object_key', [jobId, userId], ) for (const asset of assets.rows) { - await client.query( - 'INSERT INTO asset_deletion_jobs(asset_id,object_key) VALUES($1,$2) ON CONFLICT DO NOTHING', - [asset.id, asset.object_key], - ) - if (asset.poster_object_key) { + // One asset can own two objects now (original + derived preview; a video + // points poster_object_key and thumbnail_object_key at the SAME preview + // object), so enqueue every distinct key exactly once. Dedupe keeps the + // (asset_id, object_key) active key from swallowing a duplicate insert. + const objectKeys = [asset.object_key, asset.poster_object_key, asset.thumbnail_object_key] + .filter((key: unknown): key is string => Boolean(key)) + const distinctKeys = new Set(objectKeys) + for (const objectKey of distinctKeys) { await client.query( 'INSERT INTO asset_deletion_jobs(asset_id,object_key) VALUES($1,$2) ON CONFLICT DO NOTHING', - [asset.id, asset.poster_object_key], + [asset.id, objectKey], ) } } diff --git a/apps/api/src/modules/health/handlers.ts b/apps/api/src/modules/health/handlers.ts new file mode 100644 index 0000000..63b7873 --- /dev/null +++ b/apps/api/src/modules/health/handlers.ts @@ -0,0 +1,30 @@ +import { db, getOnboardingState } from '../../../../../packages/database/src/index' +import { derivePurposeKey } from '../../../../../packages/providers/src/index' +import { fail, ok } from '../../shared/http' + +/** + * GET /api/health/ready. + * + * `deploy/compose.yaml` polls this as the api service healthcheck, so the three + * distinct 503 conditions (database unreachable, key derivation unavailable) and + * the `setupComplete` flag are all operator-visible contract, not incidental. + */ +export async function readiness() { + try { + await db().query('SELECT 1') + } catch { + return fail('DEPENDENCY_UNAVAILABLE', '服务尚未就绪', 503) + } + try { + derivePurposeKey('session-hmac') + } catch { + return fail('DEPENDENCY_UNAVAILABLE', '服务尚未就绪', 503) + } + let setupComplete = false + try { + setupComplete = (await getOnboardingState(db()))?.status === 'complete' + } catch { + setupComplete = false + } + return ok({ status: 'ready', setupComplete }) +} diff --git a/apps/api/src/modules/image-edit/handlers.test.ts b/apps/api/src/modules/image-edit/handlers.test.ts new file mode 100644 index 0000000..1d98eeb --- /dev/null +++ b/apps/api/src/modules/image-edit/handlers.test.ts @@ -0,0 +1,688 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { ok } from '../../shared/http' +import { buildInpaintPrompt, MASK_INPUT_ROLE, MAX_MASK_BYTES, MIN_EDIT_SELECTION_PX } from '@musecanvas/contracts' +import { createEditMask } from '../../../../../packages/providers/src/index' +import { resolvePresetCapabilities } from '../../admin/model-presets' +import type { CreateGenerationJobCommand } from '../generations/create-job' +import type { Actor } from '../../auth/security' +import type { AuthedContext } from '../../router/types' +import { editImage, storeReadyInputImage, type ImageEditPorts, type SqlClient } from './handlers' + +/** + * `POST /api/images/edit`, against stubbed collaborators. + * + * No network, no Postgres, no S3: the route's interesting claims are all of the + * form "this never touched storage" or "this row describes exactly those bytes", + * and a recording `ImageEditPorts` is the only way to see them. The images + * themselves are real — rasterised by the shipped `createEditMask` — because + * dimension and checksum behaviour has to be measured on decodable PNG bytes + * rather than asserted on a fixture that lies about its own size. + */ + +const MODEL_ID = '11111111-1111-4111-8111-111111111111' +const ASSET_ID = '22222222-2222-4222-8222-222222222222' +const ACTOR_ID = '33333333-3333-4333-8333-333333333333' +const OTHER_USER = '44444444-4444-4444-8444-444444444444' +const ASSET_KEY = 'outputs/other-user/asset-1.png' +/** Non-square on purpose: the picked size has to follow the source's aspect ratio. */ +const SOURCE_WIDTH = 1200 +const SOURCE_HEIGHT = 800 + +const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') + +const sourcePng = await createEditMask({ + imageWidth: SOURCE_WIDTH, + imageHeight: SOURCE_HEIGHT, + selection: { x: 0, y: 0, width: MIN_EDIT_SELECTION_PX, height: MIN_EDIT_SELECTION_PX }, +}) + +/** The capability snapshot a real enabled `openai-gpt-image-2` row carries. */ +const maskContract = (await resolvePresetCapabilities('openai-image', '1.1.0', 'gpt-image-2')).capabilities +/** A real image model that declares no `mask` slot: the refusal must name that. */ +const noMaskContract = (await resolvePresetCapabilities('openai-image', '1.1.0', 'gpt-image-1.5')).capabilities +/** A model pinned before any plugin authored its contract. */ +const undeclaredContract = (await resolvePresetCapabilities('openai-image', '1.0.0', 'gpt-image-2')).capabilities + +const modelRow = (capabilities: unknown, overrides: Record = {}) => ({ + id: MODEL_ID, + display_name: 'GPT Image 2', + model_kind: 'image', + enabled: true, + deleted_at: null, + capabilities, + defaults: {}, + revision: 3, + ...overrides, +}) + +const assetRow = (overrides: Record = {}) => ({ + id: ASSET_ID, + object_key: ASSET_KEY, + media_kind: 'image', + mime_type: 'image/png', + width: SOURCE_WIDTH, + height: SOURCE_HEIGHT, + created_by: ACTOR_ID, + ...overrides, +}) + +type FileField = { bytes: Uint8Array; type?: string; name?: string } +type FormFields = Record + +const editForm = (fields: FormFields): FormData => { + const form = new FormData() + for (const [key, value] of Object.entries(fields)) { + if (typeof value === 'string') form.append(key, value) + else form.append(key, new Blob([new Uint8Array(value.bytes)], { type: value.type ?? 'image/png' }), value.name ?? `${key}.png`) + } + return form +} + +/** The minimum the route needs from a `NextRequest`: the body and one header. */ +const editContext = (form: FormData, headers: Record = {}) => ({ + actor: { id: ACTOR_ID, role: 'user' } as Actor, + request: { + formData: async () => form, + headers: new Headers(headers), + }, +}) as unknown as AuthedContext + +interface HarnessOptions { + model?: Record | null + asset?: Record | null + /** Object keys the bucket actually holds. Anything else throws like S3 would. */ + objects?: Record + limited?: boolean + putFails?: boolean + transactionFails?: boolean +} + +/** + * A recording stand-in for the whole outside world. The `db` fake answers the two + * reads this route issues *with their own predicates applied*, so "another user's + * asset" is simulated by the ownership clause rather than by a hand-written empty. + */ +function harness(options: HarnessOptions = {}) { + const queries: Array<{ sql: string; params: unknown[] }> = [] + const puts: Array<{ objectKey: string; bytes: Buffer; contentType: string }> = [] + const reads: string[] = [] + const deletes: string[] = [] + const commands: CreateGenerationJobCommand[] = [] + const asset = options.asset === undefined ? assetRow() : options.asset + const objects = options.objects ?? { [ASSET_KEY]: sourcePng } + + const client: SqlClient = { + query: async (sql, params = []) => { + queries.push({ sql, params }) + if (options.transactionFails) throw new Error('DB_WRITE_FAILED') + return { rows: [] } + }, + } + + const ports: ImageEditPorts = { + db: () => ({ + query: async (sql, params = []) => { + queries.push({ sql, params }) + if (sql.includes('FROM model_configs')) { + const row = options.model === undefined ? modelRow(maskContract) : options.model + return { rows: row && String(row.id) === String(params[0]) ? [row] : [] } + } + if (sql.includes('FROM assets')) { + // `WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL`, honoured. + if (!asset) return { rows: [] } + const owned = String(asset.id) === String(params[0]) && String(asset.created_by) === String(params[1]) + return { rows: owned ? [asset] : [] } + } + return { rows: [] } + }, + }), + runTransaction: async fn => { await fn(client) }, + limited: async () => options.limited ?? false, + readObjectBytes: async objectKey => { + reads.push(objectKey) + const bytes = objects[objectKey] + if (!bytes) throw new Error('S3_NOT_FOUND') + return bytes + }, + putObjectBytes: async (objectKey, bytes, contentType) => { + if (options.putFails) throw new Error('S3_PUT_FAILED') + puts.push({ objectKey, bytes, contentType }) + }, + deleteObject: async objectKey => { deletes.push(objectKey) }, + uploadLimits: async () => ({ maxImageBytes: 10_000_000, uploadTtlSeconds: 86400 }), + createJob: async command => { + commands.push(command) + return ok({ id: 'job-1', marker: 'from-create-generation-job' }, { status: 202 }) + }, + } + + const insertsFor = (table: string) => queries.filter(entry => entry.sql.includes(`INSERT INTO ${table}`)) + const sqlFor = (table: string) => queries.filter(entry => entry.sql.includes(table)).map(entry => entry.sql).join('\n') + return { ports, queries, puts, reads, deletes, commands, insertsFor, sqlFor } +} + +const errorOf = async (response: Response): Promise<{ status: number; code: string; message: string }> => { + const payload = await response.json() as { success: boolean; error?: { code?: string; message?: string } } + assert.equal(payload.success, false, 'a refused edit must keep the {success:false,error} envelope') + const error = payload.error + assert.ok(error && typeof error.code === 'string' && typeof error.message === 'string', 'a refusal must carry a code and a readable message') + return { status: response.status, code: error.code, message: error.message } +} + +/** `parameters` is the optional half of the command; every assertion here wants an object. */ +const parametersOf = (command: CreateGenerationJobCommand): Record => command.parameters ?? {} + +/** Text fields only, with the source image already resolved from the gallery. */ +const assetFields = (extra: Record = {}): FormFields => ({ + modelId: MODEL_ID, + prompt: '把选区里的猫换成一只柴犬', + assetId: ASSET_ID, + region: JSON.stringify({ x: 100, y: 100, width: 400, height: 300 }), + ...extra, +}) + +test('a request over the generation budget is refused before the body is read', async () => { + const h = harness({ limited: true }) + const response = await editImage(editContext(editForm(assetFields())), h.ports) + const error = await errorOf(response) + assert.equal(error.status, 429) + assert.equal(error.code, 'RATE_LIMITED') + // The bucket is the generation bucket: an edit is a generation, and a second + // budget on this path would be a second allowance. + const keys: string[] = [] + const ports = { ...h.ports, limited: async (key: string) => { keys.push(key); return false } } + await editImage(editContext(editForm(assetFields())), ports as ImageEditPorts) + assert.deepEqual(keys, [`gen:create:${ACTOR_ID}`]) + assert.equal(h.commands.length, 1) +}) + +test('an unexpected form key is refused without touching the database or the bucket', async () => { + const form = editForm(assetFields()) + form.append('count', '4') + const h = harness() + const error = await errorOf(await editImage(editContext(form), h.ports)) + assert.equal(error.code, 'INVALID_INPUT') + assert.match(error.message, /count/) + assert.deepEqual(h.queries, []) + assert.deepEqual(h.puts, []) +}) + +test('a duplicated image or mask part is refused', async () => { + for (const field of ['image', 'mask']) { + const form = new FormData() + form.append('modelId', MODEL_ID) + form.append('prompt', '换掉选区里的内容') + form.append('region', JSON.stringify({ x: 0, y: 0, width: 100, height: 100 })) + form.append('assetId', ASSET_ID) + form.append(field, new Blob([new Uint8Array(sourcePng)], { type: 'image/png' }), `${field}-a.png`) + form.append(field, new Blob([new Uint8Array(sourcePng)], { type: 'image/png' }), `${field}-b.png`) + const h = harness() + const error = await errorOf(await editImage(editContext(form), h.ports)) + assert.equal(error.code, 'INVALID_INPUT', field) + assert.match(error.message, new RegExp(field), field) + assert.deepEqual(h.queries, [], `a ${field} duplicate must be refused before any query`) + } +}) + +test('exactly one source is required: assetId and image together, or neither, both fail', async () => { + const both = editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + assetId: ASSET_ID, + image: { bytes: new Uint8Array(sourcePng) }, + region: JSON.stringify({ x: 0, y: 0, width: 100, height: 100 }), + }) + const bothError = await errorOf(await editImage(editContext(both), harness().ports)) + assert.equal(bothError.code, 'INVALID_INPUT') + assert.match(bothError.message, /源图/) + + const neither = editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + region: JSON.stringify({ x: 0, y: 0, width: 100, height: 100 }), + }) + const neitherError = await errorOf(await editImage(editContext(neither), harness().ports)) + assert.equal(neitherError.code, 'INVALID_INPUT') + assert.match(neitherError.message, /缺少源图/) +}) + +test('exactly one region kind is required: region and mask together, or neither, both fail', async () => { + const both = editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + assetId: ASSET_ID, + region: JSON.stringify({ x: 0, y: 0, width: 100, height: 100 }), + mask: { bytes: new Uint8Array(sourcePng) }, + }) + const bothError = await errorOf(await editImage(editContext(both), harness().ports)) + assert.equal(bothError.code, 'INVALID_INPUT') + assert.match(bothError.message, /选区/) + + const neither = editForm({ modelId: MODEL_ID, prompt: '换掉选区里的内容', assetId: ASSET_ID }) + const neitherError = await errorOf(await editImage(editContext(neither), harness().ports)) + assert.equal(neitherError.code, 'INVALID_INPUT') + assert.match(neitherError.message, /缺少选区/) +}) + +test('a region outside the image or below the minimum is refused with a readable message', async () => { + const cases: Array<[string, { x: number; y: number; width: number; height: number }]> = [ + ['off the right edge', { x: SOURCE_WIDTH - 4, y: 10, width: 200, height: 200 }], + ['below the minimum', { x: 10, y: 10, width: MIN_EDIT_SELECTION_PX - 1, height: 400 }], + ['entirely outside', { x: 5000, y: 5000, width: 100, height: 100 }], + ['not a rectangle', { x: 10, y: 10, width: 0, height: 100 }], + ] + for (const [label, region] of cases) { + const h = harness() + const error = await errorOf(await editImage( + editContext(editForm(assetFields({ region: JSON.stringify(region) }))), + h.ports, + )) + assert.equal(error.code, 'INVALID_INPUT', label) + assert.match(error.message, /框选区域无效/, label) + // The message has to be actionable on its own: what the user drew, in whose units. + assert.ok(error.message.includes(`${SOURCE_WIDTH}×${SOURCE_HEIGHT}`), `${label}: ${error.message}`) + assert.ok(error.message.includes(String(MIN_EDIT_SELECTION_PX)), `${label}: ${error.message}`) + assert.deepEqual(h.puts, [], `${label}: a rejected region must not reach object storage`) + assert.equal(h.commands.length, 0, label) + } + for (const region of ['not json', '{"x":1,"y":1,"width":100,"height":100', '{"type":"mask","x":1,"y":1}']) { + const error = await errorOf(await editImage( + editContext(editForm(assetFields({ region }))), + harness().ports, + )) + assert.equal(error.code, 'INVALID_INPUT', region) + assert.match(error.message, /region 必须是/, region) + } +}) + +test('a model without a mask slot is refused before any storage call', async () => { + for (const [label, capabilities] of [ + ['no mask slot', noMaskContract], + ['undeclared contract', undeclaredContract], + ] as const) { + const h = harness({ model: modelRow(capabilities) }) + const error = await errorOf(await editImage(editContext(editForm(assetFields())), h.ports)) + assert.equal(error.code, 'MODEL_MASK_NOT_SUPPORTED', label) + assert.match(error.message, /局部修改/, label) + assert.deepEqual(h.puts, [], `${label}: nothing may be written first`) + assert.deepEqual(h.reads, [], `${label}: not even the gallery source may be fetched`) + assert.equal(h.sqlFor('generation_input_images'), '', `${label}: no input row may exist`) + assert.equal(h.commands.length, 0, label) + } +}) + +test('a non-image or missing model is refused, and the asset is never fetched for it', async () => { + const missing = harness({ model: null }) + const missingError = await errorOf(await editImage(editContext(editForm(assetFields())), missing.ports)) + assert.equal(missingError.code, 'MODEL_NOT_AVAILABLE') + assert.equal(missingError.status, 404) + assert.deepEqual(missing.reads, []) + + const video = harness({ model: modelRow(maskContract, { model_kind: 'video' }) }) + const videoError = await errorOf(await editImage(editContext(editForm(assetFields())), video.ports)) + assert.equal(videoError.code, 'MODEL_NOT_AVAILABLE') + assert.match(videoError.message, /图片生成模型/) + assert.deepEqual(video.reads, []) +}) + +test('an asset owned by someone else is refused through the ownership predicate', async () => { + const h = harness({ asset: assetRow({ created_by: OTHER_USER }) }) + const error = await errorOf(await editImage(editContext(editForm(assetFields())), h.ports)) + assert.equal(error.code, 'NOT_FOUND') + assert.equal(error.status, 404) + const select = h.queries.find(entry => entry.sql.includes('FROM assets')) + assert.ok(select, 'the asset read must happen at all') + assert.match(select.sql, /created_by\s*=\s*\$2/) + assert.match(select.sql, /deleted_at IS NULL/) + assert.deepEqual(select.params, [ASSET_ID, ACTOR_ID]) + assert.deepEqual(h.reads, [], 'a foreign asset must not even be fetched from the bucket') + assert.equal(h.commands.length, 0) +}) + +test('a soft-deleted or non-PNG/JPEG asset is refused', async () => { + const deleted = harness({ asset: null }) + assert.equal((await errorOf(await editImage(editContext(editForm(assetFields())), deleted.ports))).code, 'NOT_FOUND') + for (const overrides of [{ mime_type: 'image/webp' }, { media_kind: 'video' }]) { + const h = harness({ asset: assetRow(overrides) }) + const error = await errorOf(await editImage(editContext(editForm(assetFields())), h.ports)) + assert.equal(error.code, 'INVALID_INPUT_IMAGE') + assert.deepEqual(h.puts, [], JSON.stringify(overrides)) + } +}) + +test('a gallery source is referenced by assetId and never mirrored into an upload row', async () => { + const h = harness() + const response = await editImage(editContext(editForm(assetFields())), h.ports) + assert.equal(response.status, 202) + assert.deepEqual(h.reads, [ASSET_KEY]) + // The failure this prevents: an upload row carrying the asset's own key would be + // deleted by the input TTL sweep, taking the user's original artwork with it. + const rows = [...h.insertsFor('generation_input_images'), ...h.insertsFor('media_uploads')] + assert.equal(rows.length, 2, 'exactly one input pair: the mask') + for (const row of rows) { + assert.equal(row.params.includes(ASSET_KEY), false, 'the asset object key must never enter an upload row') + assert.equal(row.params.includes(ASSET_ID), false, 'the asset id must never enter an upload row') + } + const [source, mask] = h.commands[0].normalizedInputs + assert.deepEqual(source, { assetId: ASSET_ID, role: 'reference_image', position: 0 }) + assert.equal(mask.role, MASK_INPUT_ROLE) + assert.equal(mask.position, 1) + assert.ok(mask.uploadId) +}) + +test('the mask row is ready, and its checksum, size and mime describe exactly the stored bytes', async () => { + const h = harness() + await editImage(editContext(editForm(assetFields())), h.ports) + assert.equal(h.puts.length, 1, 'a gallery source adds no object of its own') + const stored = h.puts[0] + assert.match(stored.objectKey, new RegExp(`^inputs/${ACTOR_ID}/[0-9a-f-]+\\.png$`)) + assert.equal(stored.contentType, 'image/png') + + for (const [table, inserts] of [ + ['generation_input_images', h.insertsFor('generation_input_images')], + ['media_uploads', h.insertsFor('media_uploads')], + ] as const) { + assert.equal(inserts.length, 1, table) + const [row] = inserts + assert.match(row.sql, /'ready'/, `${table}: the worker requires a ready row`) + assert.equal(row.params[0], stored.objectKey.match(/[0-9a-f-]+(?=\.png$)/)?.[0], `${table}: upload id`) + assert.equal(row.params[1], ACTOR_ID, table) + assert.equal(row.params[2], stored.objectKey, table) + assert.equal(row.params[3], 'image/png', table) + assert.deepEqual(row.params.slice(4, 6), [SOURCE_WIDTH, SOURCE_HEIGHT], `${table}: mask size is the image size`) + assert.equal(row.params[6], stored.bytes.length, `${table}: size_bytes is the stored length`) + assert.equal(row.params[7], sha256(stored.bytes), `${table}: checksum is the stored bytes`) + assert.match(row.sql, /interval '1 second'/, `${table}: the row carries a TTL`) + } + // The vendor rule the whole route exists to satisfy. + assert.ok(stored.bytes.length <= MAX_MASK_BYTES) +}) + +test('a browser-posted source image is staged as a ready upload row and passed by uploadId', async () => { + const h = harness({ asset: null }) + const response = await editImage(editContext(editForm({ + modelId: MODEL_ID, + prompt: '把选区里的猫换成一只柴犬', + image: { bytes: new Uint8Array(sourcePng), name: 'photo.png' }, + region: JSON.stringify({ x: 50, y: 50, width: 300, height: 200 }), + })), h.ports) + assert.equal(response.status, 202) + assert.deepEqual(h.reads, [], 'an uploaded source is stored, not fetched back') + assert.equal(h.puts.length, 2) + const [sourcePut, maskPut] = h.puts + assert.match(sourcePut.objectKey, new RegExp(`^inputs/${ACTOR_ID}/[0-9a-f-]+\\.png$`)) + assert.equal(sourcePut.bytes.length, sourcePng.length) + assert.notEqual(sourcePut.objectKey, maskPut.objectKey, 'the source and the mask are distinct objects') + + const [sourceRow, maskRow] = h.insertsFor('generation_input_images') + assert.equal(sourceRow.params[2], sourcePut.objectKey) + assert.equal(sourceRow.params[6], sourcePng.length) + assert.equal(sourceRow.params[7], sha256(sourcePng)) + assert.equal(maskRow.params[2], maskPut.objectKey) + assert.equal(maskRow.params[7], sha256(Buffer.from(maskPut.bytes))) + + const [source, mask] = h.commands[0].normalizedInputs + assert.equal(source.role, 'reference_image') + assert.equal(source.position, 0) + assert.equal(source.uploadId, sourceRow.params[0]) + assert.equal(mask.role, MASK_INPUT_ROLE) + assert.equal(mask.position, 1) + assert.equal(mask.uploadId, maskRow.params[0]) +}) + +test('an uploaded source that is not a decodable image is refused before anything is stored', async () => { + for (const bytes of [new Uint8Array(0), new Uint8Array(Buffer.from('not an image at all'))]) { + const h = harness({ asset: null }) + const error = await errorOf(await editImage(editContext(editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + image: { bytes, name: 'broken.png' }, + region: JSON.stringify({ x: 0, y: 0, width: 100, height: 100 }), + })), h.ports)) + assert.equal(error.code, 'INVALID_INPUT_IMAGE') + assert.match(error.message, /源图/) + assert.deepEqual(h.puts, []) + } +}) + +test('a mask without an alpha channel is refused, and a correct one is resized to the image', async () => { + // A genuinely decodable 64x64 PNG whose IHDR colour type is 2 (truecolour, no + // alpha band). `normalizeAlphaMask` rejects it rather than converting it into a + // mask that would silently mean "edit nothing". + const opaquePng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAoUlEQVR4nO2SQQkAQRDDqqRKTkn8C1kR9wgDhQpIQtOP04tO0AmgV+wuxN1FJ+gE0Ct2F+LuohN0AugVuwtxd9EJOgH0it2FuLvoBJ0AesXuQtxddIJOAL1idyHuLjpBJ4BesbsQdxedoBNAr9hdiLuLTtAJoFfsLsTdRSfoBNArdhfi7qITdALoFbsLcXfRCToB9Irdhbi76ASdAHrFPxd6WAxApm1NYrAAAAAASUVORK5CYII=', + 'base64', + ) + assert.equal(opaquePng.readUInt8(25), 2, 'the fixture must stay an alpha-less PNG') + const h = harness() + const error = await errorOf(await editImage(editContext(editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + assetId: ASSET_ID, + mask: { bytes: new Uint8Array(opaquePng), name: 'mask.png' }, + })), h.ports)) + assert.equal(error.code, 'INVALID_INPUT_IMAGE') + assert.match(error.message, /alpha/) + assert.deepEqual(h.puts, []) + + // A mask drawn at display scale still lands on the right pixels: it is resized to + // exactly the source dimensions, and that is what the row records. + const smallMask = await createEditMask({ + imageWidth: 120, + imageHeight: 80, + selection: { x: 10, y: 10, width: 60, height: 40 }, + }) + const okHarness = harness() + const response = await editImage(editContext(editForm({ + modelId: MODEL_ID, + prompt: '换掉选区里的内容', + assetId: ASSET_ID, + mask: { bytes: new Uint8Array(smallMask), name: 'mask.png' }, + })), okHarness.ports) + assert.equal(response.status, 202) + const [row] = okHarness.insertsFor('generation_input_images') + assert.deepEqual(row.params.slice(4, 6), [SOURCE_WIDTH, SOURCE_HEIGHT], 'the stored mask matches the image exactly') +}) + +test('the job is created through the shared contract with a wrapped prompt, one picture and a legal size', async () => { + const h = harness() + const response = await editImage(editContext(editForm(assetFields({ quality: 'high' }))), h.ports) + // The creation contract's own response, untouched: 202 plus the job DTO. + assert.equal(response.status, 202) + assert.deepEqual(await response.json(), { success: true, data: { id: 'job-1', marker: 'from-create-generation-job' } }) + + assert.equal(h.commands.length, 1) + const command = h.commands[0] + assert.equal(command.modelId, MODEL_ID) + assert.equal(command.actor.id, ACTOR_ID) + // The user's words survive verbatim at the end of the standing instruction, and + // this route is the only place that composition happens. + assert.equal(command.prompt, buildInpaintPrompt('把选区里的猫换成一只柴犬')) + assert.match(command.prompt, /User request:\n把选区里的猫换成一只柴犬$/) + assert.ok(command.prompt.length > '把选区里的猫换成一只柴犬'.length) + // 1200x800 is 3:2 — the model's own nearest legal size, not its `auto` default. + assert.equal(parametersOf(command).size, '1536x1024') + assert.equal(parametersOf(command).quality, 'high') + assert.equal(parametersOf(command).count, 1) + assert.equal(typeof command.idempotencyKey, 'string') + assert.match(command.idempotencyKey, /^[0-9a-f-]{36}$/) +}) + +test('the output size follows the source image, not the model default', async () => { + // Portrait: the same picker on the same model answers a different literal, and + // 1000x1500 has exactly one legal match — so this can only pass if the source's + // own aspect ratio drove the choice rather than the model's `auto` default. + const tall = await createEditMask({ + imageWidth: 1000, + imageHeight: 1500, + selection: { x: 0, y: 0, width: MIN_EDIT_SELECTION_PX, height: MIN_EDIT_SELECTION_PX }, + }) + const h = harness({ asset: assetRow({ width: 1000, height: 1500 }), objects: { [ASSET_KEY]: tall } }) + await editImage(editContext(editForm(assetFields())), h.ports) + assert.equal(parametersOf(h.commands[0]).size, '1024x1536') +}) + +test('a model that declares no usable size options fails instead of guessing', async () => { + // Structurally a valid contract — an enumerated `size` offering only `auto`, as a + // host-synthesized legacy revision might — with no `WIDTHxHEIGHT` to pick. + // The GPT Image cross-field rule is dropped with the parameters it references. + const noSizes = { + ...maskContract, + parameters: [{ type: 'enum', name: 'size', label: '尺寸', options: ['auto'] }], + crossFieldConstraints: [], + } + const h = harness({ model: modelRow(noSizes) }) + const error = await errorOf(await editImage(editContext(editForm(assetFields())), h.ports)) + assert.equal(error.code, 'INVALID_INPUT') + assert.match(error.message, /输出尺寸/) + // The refusal lands before the mask is rasterised and stored, so an unanswerable + // model leaves nothing behind in the bucket. + assert.equal(h.commands.length, 0) + assert.deepEqual(h.puts, []) + assert.equal(h.sqlFor('generation_input_images'), '') +}) + +test('the idempotency key comes from the header, then the form field, then a fresh uuid', async () => { + const header = harness() + await editImage(editContext(editForm(assetFields()), { 'idempotency-key': 'shared-key-1' }), header.ports) + assert.equal(header.commands[0].idempotencyKey, 'shared-key-1') + + const field = harness() + await editImage( + editContext(editForm(assetFields({ idempotencyKey: ' shared-key-2 ' }))), + field.ports, + ) + assert.equal(field.commands[0].idempotencyKey, 'shared-key-2') + + const generated = harness() + await editImage(editContext(editForm(assetFields())), generated.ports) + await editImage(editContext(editForm(assetFields())), generated.ports) + assert.match(generated.commands[0].idempotencyKey, /^[0-9a-f-]{36}$/) + assert.notEqual(generated.commands[0].idempotencyKey, generated.commands[1].idempotencyKey) +}) + +test('a body that is not multipart is refused with a readable message', async () => { + const context = { + actor: { id: ACTOR_ID, role: 'user' } as Actor, + request: { + formData: async () => { throw new TypeError('no multipart boundary') }, + headers: new Headers(), + }, + } as unknown as AuthedContext + const error = await errorOf(await editImage(context, harness().ports)) + assert.equal(error.code, 'INVALID_INPUT') + assert.match(error.message, /multipart\/form-data/) +}) + +test('an invalid model id or prompt is refused on the same rules the generation route uses', async () => { + const cases: Array<[string, FormFields]> = [ + ['not a uuid', assetFields({ modelId: 'gpt-image-2' })], + ['empty prompt', assetFields({ prompt: ' ' })], + ['over-long prompt', assetFields({ prompt: 'x'.repeat(4001) })], + ['prompt with control chars', assetFields({ prompt: '换掉\u0007它' })], + ['a file where prompt belongs', { ...assetFields(), prompt: { bytes: new Uint8Array(sourcePng) } }], + ] + for (const [label, fields] of cases) { + const h = harness() + const error = await errorOf(await editImage(editContext(editForm(fields)), h.ports)) + assert.equal(error.code, 'INVALID_INPUT', label) + assert.deepEqual(h.queries, [], `${label}: refused before the model lookup`) + } +}) + +test('a storage failure on the mask leaves a compensating delete and no job', async () => { + const h = harness({ putFails: true }) + const error = await errorOf(await editImage(editContext(editForm(assetFields())), h.ports)) + assert.equal(error.code, 'GENERATION_CREATE_FAILED') + assert.equal(error.status, 503) + assert.equal(h.commands.length, 0) + + const lostWrite = harness() + // Object stored, row never written: the object is the orphan, not the row. + const ports: ImageEditPorts = { + ...lostWrite.ports, + putObjectBytes: async () => undefined, + runTransaction: async () => { throw new Error('DB_UNREACHABLE') }, + } + const second = await editImage(editContext(editForm(assetFields())), ports) + assert.equal(second.status, 503) + assert.equal(lostWrite.deletes.length, 1) + assert.match(lostWrite.deletes[0], /^inputs\/33333333/) +}) + +test('storeReadyInputImage writes both rows in one shape and survives a missing mirror table', async () => { + const queries: Array<{ sql: string; params: unknown[] }> = [] + let mirrorThrows = false + const client: SqlClient = { + query: async (sql, params = []) => { + queries.push({ sql, params }) + if (sql.includes('INSERT INTO media_uploads') && mirrorThrows) throw new Error('42P01 no relation media_uploads') + return { rows: [] } + }, + } + const bytes = Buffer.from(sourcePng) + const row = { + uploadId: '55555555-5555-4555-8555-555555555555', + actorId: ACTOR_ID, + objectKey: `inputs/${ACTOR_ID}/55555555-5555-4555-8555-555555555555.png`, + mimeType: 'image/png', + width: SOURCE_WIDTH, + height: SOURCE_HEIGHT, + sizeBytes: bytes.length, + checksum: sha256(bytes), + ttlSeconds: 86400, + } + await storeReadyInputImage(client, row) + assert.deepEqual(queries.map(entry => entry.sql.match(/INSERT INTO (\w+)/)?.[1]), ['generation_input_images', 'media_uploads']) + assert.equal(queries[0].params[3], 'image/png') + // Both rows carry the same nine values in the same order: id, owner, key, mime, + // geometry, byte count, digest, TTL. + assert.deepEqual(queries[0].params, queries[1].params) + assert.equal(queries[1].params[6], bytes.length) + assert.equal(queries[1].params[8], 86400) + // An older database without the generic table keeps working on the legacy row. + queries.length = 0 + mirrorThrows = true + await storeReadyInputImage(client, row) + assert.equal(queries.length, 2) +}) + +test('every declared control rides in `parameters` and reaches the job', async () => { + const h = harness() + await editImage(editContext(editForm(assetFields({ + parameters: JSON.stringify({ background: 'transparent', output_format: 'png', input_fidelity: 'high' }), + }))), h.ports) + const parameters = parametersOf(h.commands[0]) + // A control the console still shows must never be silently dropped on this path. + assert.equal(parameters.background, 'transparent') + assert.equal(parameters.output_format, 'png') + assert.equal(parameters.input_fidelity, 'high') + // The two the route owns stay its own. + assert.equal(parameters.size, '1536x1024') + assert.equal(parameters.count, 1) +}) + +test('`parameters` can never override the size or count this route derives', async () => { + const h = harness() + await editImage(editContext(editForm(assetFields({ + parameters: JSON.stringify({ size: '1024x1024', count: 4 }), + quality: 'low', + }))), h.ports) + const parameters = parametersOf(h.commands[0]) + assert.equal(parameters.size, '1536x1024', 'the output size follows the source geometry') + assert.equal(parameters.count, 1, 'one edit returns one picture') + assert.equal(parameters.quality, 'low', 'the scalar field wins over a packed object') +}) + +test('a malformed `parameters` body is refused before anything is stored', async () => { + for (const value of ['{', '[]', '"transparent"', 'null']) { + const h = harness() + const error = await errorOf(await editImage(editContext(editForm(assetFields({ parameters: value }))), h.ports)) + assert.equal(error.code, 'INVALID_INPUT') + assert.match(error.message, /parameters/) + assert.equal(h.puts.length, 0, `${value} must not reach the bucket`) + assert.equal(h.commands.length, 0, `${value} must not create a job`) + } +}) diff --git a/apps/api/src/modules/image-edit/handlers.ts b/apps/api/src/modules/image-edit/handlers.ts new file mode 100644 index 0000000..62b8da4 --- /dev/null +++ b/apps/api/src/modules/image-edit/handlers.ts @@ -0,0 +1,534 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { NextResponse } from 'next/server' +import { + buildInpaintPrompt, + clampEditSelection, + MASK_INPUT_ROLE, + MAX_MASK_BYTES, + MIN_EDIT_SELECTION_PX, + parseRectangleRegion, + RUNTIME_SETTINGS_DEFAULTS, + type EditSelection, + type GenerationInputItem, +} from '@musecanvas/contracts' +import { + createEditMask, + inspectInputImage, + normalizeAlphaMask, + pickClosestAllowedSize, +} from '../../../../../packages/providers/src/index' +import { db, transaction } from '../../../../../packages/database/src/index' +import type { AuthedContext } from '../../router/types' +import { capabilitiesFromRow, legacyColumnsFromCapabilities } from '../../shared/dto' +import { fail } from '../../shared/http' +import { limited } from '../../shared/redis' +import { + deleteS3Object, + getPrivateS3ObjectBytes, + putPrivateS3ObjectBytes, +} from '../../shared/services' +import { ALLOWED_MIME_TYPES, GENERATION_UPLOAD_TTL_SECONDS } from '../generation-uploads' +import { resolveRuntimeSettings } from '../settings/runtime' +import { createGenerationJob, type CreateGenerationJobCommand } from '../generations/create-job' + +/** + * `POST /api/images/edit` — 局部框选修改图片 (region-select inpainting). + * + * Everything on this route is about the *shape of a multipart body*, which no + * other endpoint has: the source image arrives either as a gallery `assetId` or as + * raw PNG/JPEG bytes, and the edit region either as a pixel-space `region` + * rectangle or as an alpha `mask` PNG. What happens once those are settled — the + * model snapshot, the credential check, the descriptor validation, the job row, + * the input attach and the outbox event — is the shared creation contract in + * `generations/create-job.ts`, so an edit can never drift from a plain generation + * on any of those guarantees. The response is the same 202 plus job DTO, which is + * why polling, history and the library need no changes for this feature. + * + * Two vendor rules shape the order of the code below; both are owned by + * `packages/providers/src/core/image-edit.ts`, so this route only *decides*, it + * never re-implements them: + * + * 1. the mask is a PNG **with alpha** whose dimensions are **exactly** the source + * image's — so the mask is built *after* the real pixel size is known, from the + * decoded bytes, never from a declared width and height; + * 2. a transparent pixel marks what may be regenerated — so a mask without an + * alpha channel is refused instead of being resized into a guess that would + * silently mean "edit nothing". + * + * And the one data-integrity rule this route owns: a gallery source is + * *referenced*, never mirrored. Writing a `media_uploads` row carrying the asset's + * own object key would hand that key to the TTL sweep in + * `apps/worker/src/maintenance/index.ts`, which deletes objects by + * `media_uploads.object_key` — destroying the user's original artwork because an + * unrelated temporary input aged out. + */ + +/** The body's whole vocabulary. Any other key is one this route has no meaning for. */ +const ALLOWED_EDIT_FIELDS = [ + 'modelId', + 'prompt', + 'assetId', + 'image', + 'region', + 'mask', + 'quality', + 'parameters', + 'idempotencyKey', +] as const + +const ALLOWED_EDIT_FIELD_SET = new Set(ALLOWED_EDIT_FIELDS) + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const hasControlChars = (value: string): boolean => { + for (const ch of value) { + const code = ch.codePointAt(0) || 0 + if (code < 32 && code !== 9 && code !== 10 && code !== 13) return true + } + return false +} + +/** A part rather than a text field. Structural, like the plugin upload reader's. */ +const isFilePart = (value: unknown): value is File => + !!value && typeof value === 'object' && + typeof (value as File).name === 'string' && + typeof (value as File).arrayBuffer === 'function' + +/** Minimal pg surface this route touches, so every branch is testable against a stub. */ +export interface SqlClient { + query(sql: string, params?: unknown[]): Promise<{ rows: Record[] }> +} + +export interface ImageEditUploadLimits { + maxImageBytes: number + uploadTtlSeconds: number +} + +/** + * The collaborators `editImage` needs. Production wiring is `defaultImageEditPorts`; + * a test passes stubs. The seam exists because the promises worth asserting here — + * "no object was written", "the row says `ready` with *this* checksum", "the count + * is 1" — are only observable against an injected boundary, and this repo's runner + * has no module mocking. + */ +export interface ImageEditPorts { + db: () => SqlClient + runTransaction: (fn: (client: SqlClient) => Promise) => Promise + limited: (key: string, max: number, seconds: number) => Promise + readObjectBytes: (objectKey: string) => Promise + putObjectBytes: (objectKey: string, bytes: Buffer, contentType: string) => Promise + deleteObject: (objectKey: string) => Promise + uploadLimits: () => Promise + createJob: (cmd: CreateGenerationJobCommand) => Promise +} + +/** Runtime settings first, canonical defaults last — the same resolution order the upload routes use. */ +export async function resolveImageEditUploadLimits(): Promise { + try { + const runtime = await resolveRuntimeSettings() + return { maxImageBytes: runtime.maxImageBytes, uploadTtlSeconds: runtime.uploadTtlSeconds } + } catch { + return { + maxImageBytes: RUNTIME_SETTINGS_DEFAULTS.maxImageBytes, + uploadTtlSeconds: GENERATION_UPLOAD_TTL_SECONDS, + } + } +} + +export const defaultImageEditPorts: ImageEditPorts = { + db: () => db(), + runTransaction: fn => transaction(async client => { await fn(client) }), + limited, + readObjectBytes: objectKey => getPrivateS3ObjectBytes(objectKey), + putObjectBytes: (objectKey, bytes, contentType) => putPrivateS3ObjectBytes(objectKey, bytes, contentType), + deleteObject: objectKey => deleteS3Object(objectKey), + uploadLimits: resolveImageEditUploadLimits, + createJob: cmd => createGenerationJob(cmd), +} + +/** A row created already in `ready` state: geometry, digest and length of the exact bytes in the bucket. */ +export interface ReadyInputWrite { + uploadId: string + actorId: string + objectKey: string + mimeType: string + width: number + height: number + sizeBytes: number + checksum: string + ttlSeconds: number +} + +/** + * Write the dual input row (`generation_input_images` plus its `media_uploads` + * mirror) for bytes that are already stored, straight into `ready`. + * + * The presigned flow creates `pending` first and completes it after the browser's + * direct PUT; here the server holds the bytes itself, so there is no un-uploaded + * window to model. `ready` is what `attachGenerationInputs` demands, and + * `size_bytes`, `mime_type` and `checksum` must describe the exact stored object + * because `apps/worker/src/jobs/index.ts` re-derives all three after fetching it + * and fails the job when they disagree. The mirror keeps the same best-effort + * `try` every other writer here uses, for databases that predate the table. + */ +export async function storeReadyInputImage(client: SqlClient, row: ReadyInputWrite): Promise { + await client.query( + `INSERT INTO generation_input_images(id, created_by, status, object_key, mime_type, width, height, size_bytes, checksum, expires_at) + VALUES($1, $2, 'ready', $3, $4, $5, $6, $7, $8, now() + ($9 * interval '1 second'))`, + [row.uploadId, row.actorId, row.objectKey, row.mimeType, row.width, row.height, row.sizeBytes, row.checksum, row.ttlSeconds] + ) + try { + await client.query( + `INSERT INTO media_uploads(id, created_by, media_kind, status, object_key, mime_type, width, height, size_bytes, checksum, expires_at) + VALUES($1, $2, 'image', 'ready', $3, $4, $5, $6, $7, $8, now() + ($9 * interval '1 second')) ON CONFLICT (id) DO NOTHING`, + [row.uploadId, row.actorId, row.objectKey, row.mimeType, row.width, row.height, row.sizeBytes, row.checksum, row.ttlSeconds] + ) + } catch { + // media_uploads table may not exist on older databases; the legacy row stays source of truth. + } +} + +/** Object key for a server-staged input: the same `inputs/…` namespace the presigned path uses. */ +function stagedObjectKey(actorId: string, uploadId: string, mimeType: string): string { + return `inputs/${actorId}/${uploadId}.${mimeType === 'image/png' ? 'png' : 'jpg'}` +} + +/** Byte-length and dimension failures from `inspectInputImage`, coded the way the upload route codes them. */ +function sourceImageFailure(error: unknown): NextResponse { + const message = error instanceof Error ? error.message : '' + if (message === 'INVALID_INPUT_IMAGE_SIZE') return fail('INVALID_INPUT_IMAGE_SIZE', '图片尺寸或大小超出限制', 400) + return fail('INVALID_INPUT_IMAGE', '源图无效或不受支持,仅支持 PNG 与 JPEG 图片', 400) +} + +/** A mask failure from `normalizeAlphaMask` / `createEditMask`: readable and Chinese, never a raw vendor dump. */ +function maskFailure(error: unknown): NextResponse { + const message = error instanceof Error ? error.message : '' + const code = message.split(':')[0] + if (code === 'MASK_BYTE_LIMIT_EXCEEDED') { + return fail('INVALID_INPUT_IMAGE_SIZE', `遮罩文件过大,上限为 ${MAX_MASK_BYTES} 字节`, 400) + } + if (code === 'MASK_ALPHA_MISSING' || code === 'MASK_FORMAT_INVALID') { + return fail('INVALID_INPUT_IMAGE', '遮罩必须是带 alpha 通道的 PNG 图片(透明处即要修改的区域)', 400) + } + if (code === 'MASK_TARGET_DIMENSIONS_EXCEEDED') { + return fail('INVALID_INPUT_IMAGE_SIZE', '图片尺寸过大,无法生成遮罩', 400) + } + return fail('INVALID_INPUT_IMAGE', '遮罩无效,请重新框选或重新绘制选区', 400) +} + +/** The body, read as exactly one value per allowed field — or the response that refused it. */ +type EditForm = + | { ok: true; fields: Record } + | { ok: false; response: NextResponse } + +/** + * `getAll` on every field: a repeated `image` or `mask` part would otherwise + * smuggle a second artifact that never gets decoded, hashed or attached, and a + * repeated text field would leave the winning value up to form ordering. + */ +function readEditForm(form: FormData): EditForm { + const unexpected = [...form.keys()].filter(key => !ALLOWED_EDIT_FIELD_SET.has(key)) + if (unexpected.length > 0) { + return { ok: false, response: fail('INVALID_INPUT', `局部修改请求包含不允许的字段:${unexpected.join(', ')}`) } + } + const fields: Record = {} + for (const key of ALLOWED_EDIT_FIELDS) { + const values = form.getAll(key) + if (values.length > 1) { + return { ok: false, response: fail('INVALID_INPUT', `${key} 字段只能出现一次`) } + } + fields[key] = values[0] + } + return { ok: true, fields } +} + +const textOf = (fields: Record, name: string): string => + typeof fields[name] === 'string' ? (fields[name] as string).trim() : '' + +export async function editImage( + context: AuthedContext, + ports: ImageEditPorts = defaultImageEditPorts, +): Promise { + const { actor, request } = context + + // First, before any parsing: the same bucket, budget and position as + // `POST /api/generations`, because this *is* a generation. An edit that skipped + // the check would simply be a way to buy extra rate with a different URL. + if (await ports.limited(`gen:create:${actor.id}`, 20, 300)) { + return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) + } + + let form: FormData + try { + form = await request.formData() + } catch { + return fail('INVALID_INPUT', '局部修改请求必须使用 multipart/form-data 编码') + } + const parsed = readEditForm(form) + if (!parsed.ok) return parsed.response + const { fields } = parsed + + const modelId = textOf(fields, 'modelId') + const prompt = typeof fields.prompt === 'string' ? fields.prompt : '' + if (!UUID_PATTERN.test(modelId)) return fail('INVALID_INPUT', '模型参数无效') + // `create.ts`'s sanity rules, verbatim: same prompt field of the same job, + // arriving in a different container. + if (prompt.trim().length < 1 || prompt.length > 4000 || hasControlChars(prompt)) { + return fail('INVALID_INPUT', '生成参数无效') + } + + const assetId = textOf(fields, 'assetId') + const regionText = textOf(fields, 'region') + const quality = textOf(fields, 'quality') + // The unified `parameters` object, mirroring `POST /api/generations`. Every + // control the model declares — `background`, `output_format`, `input_fidelity`, + // whatever a future plugin adds — rides here, so an edit never silently drops a + // choice the console is still showing. Anything the model did not declare is + // refused by `createGenerationJob`'s descriptor gate, so widening this field + // does not widen what is accepted. Parsed before any storage work for the same + // reason the model is read early: a malformed body must not leave an object in + // the bucket. `size` and `count` are overwritten further down regardless. + let clientParameters: Record = {} + const parametersText = textOf(fields, 'parameters') + if (parametersText) { + let parsedParameters: unknown + try { + parsedParameters = JSON.parse(parametersText) + } catch { + return fail('INVALID_INPUT', 'parameters 必须是合法的 JSON 对象') + } + if (typeof parsedParameters !== 'object' || parsedParameters === null || Array.isArray(parsedParameters)) { + return fail('INVALID_INPUT', 'parameters 必须是 JSON 对象') + } + clientParameters = parsedParameters as Record + } + const imagePart = isFilePart(fields.image) ? fields.image : undefined + const maskPart = isFilePart(fields.mask) ? fields.mask : undefined + if (assetId && imagePart) return fail('INVALID_INPUT', '源图只能提供 assetId 或 image 文件其中一种') + if (!assetId && !imagePart) return fail('INVALID_INPUT', '缺少源图:请提供 assetId 或 image 文件') + if (regionText && maskPart) return fail('INVALID_INPUT', '选区只能提供 region 矩形或 mask 遮罩文件其中一种') + if (!regionText && !maskPart) return fail('INVALID_INPUT', '缺少选区:请提供 region 矩形或 mask 遮罩文件') + if (assetId && !UUID_PATTERN.test(assetId)) return fail('INVALID_INPUT', 'assetId 格式无效') + + // The model decides whether a mask is even accepted and which `size` is legal, so + // it is read ahead of any storage work: a refusal here must not leave an object + // in the bucket or an input row behind. + const modelResult = await ports.db().query( + `SELECT m.*, rev.capabilities, rev.defaults, rev.revision FROM model_configs m + LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id + WHERE m.id=$1 AND m.enabled=true AND m.deleted_at IS NULL`, + [modelId], + ) + const model = modelResult.rows[0] + if (!model) return fail('MODEL_NOT_AVAILABLE', '模型当前不可用', 404) + if (String(model.model_kind || '') !== 'image') { + return fail('MODEL_NOT_AVAILABLE', '局部修改仅支持图片生成模型', 400) + } + const capabilities = capabilitiesFromRow(model) + // The server-side twin of a greyed-out button. An undeclared contract is not a + // permissive one, and a model with no `mask` slot has never been able to take an + // edit region — leaving it to `createGenerationJob` would answer minutes later + // with an opaque `UNKNOWN_INPUT_ROLE` on a job the user already trusted. + const maskCapable = capabilities.declaredBy !== 'undeclared' + && capabilities.inputSlots.some(slot => slot.role === MASK_INPUT_ROLE) + if (!maskCapable) { + return fail( + 'MODEL_MASK_NOT_SUPPORTED', + '当前模型不支持局部修改(未声明选区遮罩输入),请更换支持局部修改的图片模型', + 409, + { parameter: 'modelId', value: modelId }, + ) + } + // `size` is derived from the source image's geometry, so the declared options have + // to exist for the request to be answerable at all. The helper is the same one + // the model DTOs use: an `image-size` descriptor answers with its presets, an + // `enum` one with its option values, and both keep this route off the deprecated + // flat `model_configs.sizes` column. + const declaredSizes = legacyColumnsFromCapabilities(capabilities).sizes + const limits = await ports.uploadLimits() + + // --- the source image ----------------------------------------------------- + let source: { width: number; height: number } + let sourceInput: GenerationInputItem + if (assetId) { + // Ownership is the library predicate, so a picker can never hand over another + // user's image, and a soft-deleted one is no longer a source. + const owned = await ports.db().query( + `SELECT id,object_key,media_kind,mime_type,width,height FROM assets + WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL`, + [assetId, actor.id], + ) + const asset = owned.rows[0] + if (!asset) return fail('NOT_FOUND', '图库作品不存在或无权访问', 404) + // Only PNG and JPEG bytes survive the worker's `inspectInputImage`, so only + // those can be sent to a vendor at all — say it here, in a form error. + if (String(asset.media_kind || 'image') !== 'image' || !ALLOWED_MIME_TYPES[String(asset.mime_type || '')]) { + return fail('INVALID_INPUT_IMAGE', '局部修改仅支持 PNG 或 JPEG 图片', 400) + } + let assetBytes: Buffer + try { + assetBytes = await ports.readObjectBytes(String(asset.object_key)) + } catch { + return fail('INPUT_IMAGE_UNAVAILABLE', '图库作品文件读取失败,请稍后重试', 400) + } + let inspectedSource: { width: number; height: number; mimeType: 'image/png' | 'image/jpeg' } + try { + // The *decoded* dimensions, not `assets.width`: the mask is built from these, + // and a stored value that ever disagreed with the bytes would produce a mask + // the vendor rejects — worse, one that edits the wrong pixels. + inspectedSource = inspectInputImage(assetBytes, { maxImageBytes: limits.maxImageBytes }) + } catch (error) { + return sourceImageFailure(error) + } + // The worker re-checks the object it fetches against the row it joins on, so a + // mismatch here is a job that fails minutes later on a vendor error nobody read. + if (inspectedSource.mimeType !== String(asset.mime_type || '')) { + return fail('INVALID_INPUT_IMAGE', '图库作品的实际格式与其记录不符,无法用于局部修改', 400) + } + source = { width: inspectedSource.width, height: inspectedSource.height } + // Referenced, not mirrored: no upload row, and never the asset's object key in + // one — see the module comment. + sourceInput = { assetId: String(asset.id), role: 'reference_image', position: 0 } + } else { + const bytes = Buffer.from(await imagePart!.arrayBuffer()) + if (bytes.length === 0) return fail('INVALID_INPUT_IMAGE', '源图内容为空', 400) + if (bytes.length > limits.maxImageBytes) { + return fail('INVALID_INPUT_IMAGE_SIZE', `源图不能超过 ${limits.maxImageBytes} 字节`, 400) + } + // The mime type is what the bytes decode as, not what the part declared: the + // row and the worker's re-check must agree, and the browser is not authoritative. + let inspectedUpload: { width: number; height: number; mimeType: 'image/png' | 'image/jpeg' } + try { + inspectedUpload = inspectInputImage(bytes, { maxImageBytes: limits.maxImageBytes }) + } catch (error) { + return sourceImageFailure(error) + } + source = { width: inspectedUpload.width, height: inspectedUpload.height } + const uploadId = randomUUID() + const objectKey = stagedObjectKey(actor.id, uploadId, inspectedUpload.mimeType) + try { + await ports.putObjectBytes(objectKey, bytes, inspectedUpload.mimeType) + await ports.runTransaction(async client => { + await storeReadyInputImage(client, { + uploadId, + actorId: actor.id, + objectKey, + mimeType: inspectedUpload.mimeType, + width: inspectedUpload.width, + height: inspectedUpload.height, + sizeBytes: bytes.length, + checksum: createHash('sha256').update(bytes).digest('hex'), + ttlSeconds: limits.uploadTtlSeconds, + }) + }) + } catch { + // Chosen failure mode, same as the plugin install path: best-effort + // compensating delete. An orphaned key in a private bucket is an acceptable + // cost; a `ready` row pointing at bytes that never landed is not. + await ports.deleteObject(objectKey).catch(() => { /* orphan accepted */ }) + return fail('GENERATION_CREATE_FAILED', '源图存储失败,请稍后重试', 503) + } + sourceInput = { uploadId, role: 'reference_image', position: 0 } + } + + // An edit of a full-size photo almost never lands on one of the model's fixed + // sizes, so the declared option nearest the source's aspect ratio is chosen — + // instead of the model's default, and instead of an illegal literal. Derived + // before the mask is built: a model that offers nothing legal here is a refusal + // the user should get *before* the server paid for a rasterised mask. + const size = pickClosestAllowedSize(declaredSizes, source.width, source.height) + if (!size) { + return fail('INVALID_INPUT', '该模型未声明可用的输出尺寸,无法为局部修改确定尺寸', 400) + } + + // --- the edit region ------------------------------------------------------ + let maskBytes: Buffer + if (regionText) { + let regionJson: unknown + try { + regionJson = JSON.parse(regionText) + } catch { + return fail('INVALID_INPUT', 'region 必须是 {x,y,width,height} 形式的 JSON 对象') + } + const region = parseRectangleRegion(regionJson) + if (!region || region.type !== 'rectangle') { + return fail('INVALID_INPUT', 'region 必须是 {x,y,width,height} 形式的 JSON 对象') + } + // Clamped rather than trusted: a rectangle hanging off the edge is a visible + // mistake, and a sub-`MIN_EDIT_SELECTION_PX` slip of the mouse would otherwise + // quietly regenerate almost nothing while reporting a completed edit. + const selection: EditSelection | null = clampEditSelection( + { x: region.x, y: region.y, width: region.width, height: region.height }, + source.width, + source.height, + ) + if (!selection) { + return fail( + 'INVALID_INPUT', + `框选区域无效:必须落在图片(${source.width}×${source.height} 像素)范围内,且不小于 ${MIN_EDIT_SELECTION_PX}×${MIN_EDIT_SELECTION_PX} 像素`, + 400, + { parameter: 'region', value: regionText.slice(0, 200) }, + ) + } + try { + maskBytes = await createEditMask({ imageWidth: source.width, imageHeight: source.height, selection }) + } catch (error) { + return maskFailure(error) + } + } else { + const bytes = Buffer.from(await maskPart!.arrayBuffer()) + try { + // Enforces the alpha channel, resizes to exactly the source image's own + // dimensions, and caps at the vendor limit — so a brush mask drawn at display + // scale still lines up pixel for pixel. + maskBytes = await normalizeAlphaMask(bytes, source.width, source.height) + } catch (error) { + return maskFailure(error) + } + } + + const maskUploadId = randomUUID() + const maskObjectKey = stagedObjectKey(actor.id, maskUploadId, 'image/png') + try { + await ports.putObjectBytes(maskObjectKey, maskBytes, 'image/png') + await ports.runTransaction(async client => { + await storeReadyInputImage(client, { + uploadId: maskUploadId, + actorId: actor.id, + objectKey: maskObjectKey, + mimeType: 'image/png', + width: source.width, + height: source.height, + sizeBytes: maskBytes.length, + checksum: createHash('sha256').update(maskBytes).digest('hex'), + ttlSeconds: limits.uploadTtlSeconds, + }) + }) + } catch { + await ports.deleteObject(maskObjectKey).catch(() => { /* orphan accepted */ }) + return fail('GENERATION_CREATE_FAILED', '遮罩存储失败,请稍后重试', 503) + } + const maskInput: GenerationInputItem = { uploadId: maskUploadId, role: MASK_INPUT_ROLE, position: 1 } + + // --- the job -------------------------------------------------------------- + // Client-chosen parameters first, then the two this route owns: the output size + // follows the source image's geometry and one edit returns one picture, so a + // caller-supplied `size` or `count` is overridden rather than trusted. + const parameters: Record = { ...clientParameters, size } + if (quality) parameters.quality = quality + // One edit, one picture: `count > 1` would spend the same mask several times over, + // which is not a choice this endpoint offers. + parameters.count = 1 + + return ports.createJob({ + actor, + modelId, + // The user's own words, wrapped by the shared standing instruction — the only + // place that composition happens, so the wording cannot drift from the contract. + prompt: buildInpaintPrompt(prompt), + parameters, + normalizedInputs: [sourceInput, maskInput], + idempotencyKey: request.headers.get('idempotency-key') + || (textOf(fields, 'idempotencyKey') || randomUUID()), + }) +} diff --git a/apps/api/src/modules/jobs/handlers.ts b/apps/api/src/modules/jobs/handlers.ts new file mode 100644 index 0000000..30ab2d8 --- /dev/null +++ b/apps/api/src/modules/jobs/handlers.ts @@ -0,0 +1,130 @@ +import { db, transaction } from '../../../../../packages/database/src/index' +import { fail, ok } from '../../shared/http' +import { jobDto } from '../../shared/dto' +import { limited } from '../../shared/redis' +import { loadJobInputs, loadSingleJobInputs, userJobSelect } from '../../shared/pagination' +import { retryPreparation } from '../../generation/job-retry' +import { validateRetryRequest, type RetryRejection } from '../../generation/retry-validation' +import type { AuthedContext } from '../../router/types' +import { deleteJobWithAssets } from '../generations/handlers' +import { jobOutputSelect } from './queries' + +/** GET /api/jobs — the owning user's most recent fifty jobs. */ +export async function listJobs(context: AuthedContext) { + const result = await db().query(`${userJobSelect} WHERE j.created_by=$1 AND j.deleted_at IS NULL ORDER BY j.created_at DESC LIMIT 50`, [context.actor.id]) + const jobIds = result.rows.map(row => row.id) + const inputsByJobId = await loadJobInputs(db(), jobIds) + return ok({ + items: await Promise.all(result.rows.map(async row => jobDto(row, (await db().query(jobOutputSelect, [row.id])).rows, inputsByJobId[row.id as string] || []))), + total: result.rowCount, + hasMore: false, + }) +} + +/** GET /api/jobs/:id */ +export async function getJob(context: AuthedContext) { + const id = context.params.id + const result = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2 AND j.deleted_at IS NULL`, [id, context.actor.id]) + if (!result.rows[0]) return fail('NOT_FOUND', '任务不存在', 404) + const outputs = await db().query(jobOutputSelect, [id]) + const inputs = await loadSingleJobInputs(db(), id) + return ok(await jobDto(result.rows[0], outputs.rows, inputs)) +} + +/** + * POST /api/jobs/:id/cancel — cooperative. + * + * A queued or waiting job is cancelled outright. An active one only records local + * intent plus an outbox event: the worker owns the provider call, and claiming + * success here would be a lie the UI would have to walk back. + */ +export async function cancelJob(context: AuthedContext) { + const id = context.params.id + const { actor } = context + if (await limited(`gen:cancel:${actor.id}`, 60, 60)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) + const outcome = await transaction(async client => { + const current = await client.query('SELECT id,status,attempt FROM generation_jobs WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL FOR UPDATE', [id, actor.id]) + const job = current.rows[0] + if (!job) return { kind: 'not_found' as const } + if (job.status === 'succeeded' || job.status === 'failed' || job.status === 'canceled') { + return { kind: 'not_cancelable' as const } + } + if (job.status === 'queued' || job.status === 'retry_wait') { + await client.query("UPDATE generation_jobs SET status='canceled',completed_at=now(),updated_at=now() WHERE id=$1", [id]) + return { kind: 'canceled' as const } + } + // Active job: cooperative cancel. Record local intent and enqueue provider + // cancel work; never claim success on local intent alone. + await client.query('UPDATE generation_jobs SET cancel_requested_at=COALESCE(cancel_requested_at,now()),updated_at=now() WHERE id=$1', [id]) + await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload,dedupe_key) VALUES('generation.cancel.requested',$1,$2,$3) ON CONFLICT (dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING", [id, { jobId: id }, `cancel:${id}:a${job.attempt}`]) + try { + await client.query("UPDATE provider_runs SET operation_state='canceling',next_action_at=now(),updated_at=now() WHERE job_id=$1 AND operation_state IN ('submitting','submission_unknown','waiting','importing')", [id]) + } catch { + // provider_runs table may not exist on older databases; outbox carries the intent. + } + return { kind: 'cancel_requested' as const } + }) + if (outcome.kind === 'not_found' || outcome.kind === 'not_cancelable') { + return fail('JOB_NOT_CANCELABLE', '任务无法取消', 409) + } + const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [id, actor.id]) + const jobInputs = await loadSingleJobInputs(db(), id) + const outputs = outcome.kind === 'canceled' ? [] : (await db().query(jobOutputSelect, [id])).rows + return ok(await jobDto(responseRow.rows[0] || { id }, outputs, jobInputs)) +} + +type RetryOutcome = + | { kind: 'not-found' } + | { kind: 'rejected'; rejection: RetryRejection } + | { kind: 'retried'; row: Record } + +/** POST /api/jobs/:id/retry — requeues the same job row, never creates a new one. */ +export async function retryJob(context: AuthedContext) { + const id = context.params.id + const { actor } = context + if (await limited(`gen:retry:${actor.id}`, 30, 60)) return fail('RATE_LIMITED', '请求过于频繁,请稍后再试', 429) + // The transaction reports *why* it produced no row rather than writing to a + // captured outer variable: an outer `let` narrowed to `null` at its + // declaration is invisible to control-flow analysis across the callback, and + // "no row" would otherwise be reported as a plain not-retryable for a job + // that was actually refused on validation. + const outcome = await transaction(async client => { + const current = await client.query(`SELECT j.id,j.model_id,j.model_revision_id,j.normalized_request,j.prompt_optimization_id,j.optimization_mode,po.final_prompt,po.template_instruction_snapshot + FROM generation_jobs j LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id AND po.deleted_at IS NULL + WHERE j.id=$1 AND j.created_by=$2 AND j.status=$3 AND j.deleted_at IS NULL FOR UPDATE OF j`, [id, actor.id, 'failed']) + const job = current.rows[0] + if (!job) return { kind: 'not-found' } + + // A retry is a resubmit, so the stored parameters are checked again rather + // than waved through because they once passed. Bailing out before any + // UPDATE leaves the rejected job exactly as it was. + const validation = await validateRetryRequest(client, { + id: job.id as string, + modelId: job.model_id as string, + revisionId: (job.model_revision_id as string) ?? null, + normalizedRequest: job.normalized_request, + }) + if (!validation.ok) return { kind: 'rejected', rejection: validation } + + const preparation = retryPreparation(job) + if (preparation.resetOptimization) await client.query("UPDATE prompt_optimizations SET status='pending',attempt=0,error_code=NULL,started_at=NULL,completed_at=NULL,updated_at=now() WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL", [job.prompt_optimization_id, actor.id]) + const updated = await client.query("UPDATE generation_jobs SET status='queued',phase=$3,attempt=0,progress=0,cancel_requested_at=NULL,error_code=NULL,provider_error=NULL,provider_reference_id=NULL,started_at=NULL,completed_at=NULL,updated_at=now() WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL RETURNING *", [id, actor.id, preparation.phase]) + await client.query("INSERT INTO outbox_events(event_type,aggregate_id,payload) VALUES('generation.retry.manual',$1,$2)", [id, { jobId: id }]) + return { kind: 'retried', row: updated.rows[0] as Record } + }) + if (!outcome || outcome.kind === 'not-found') return fail('JOB_NOT_RETRYABLE', '任务无法重试', 409) + if (outcome.kind === 'rejected') { + const { code, message, status, details } = outcome.rejection + return fail(code, message, status, details) + } + const row = outcome.row + const responseRow = await db().query(`${userJobSelect} WHERE j.id=$1 AND j.created_by=$2`, [row.id, actor.id]) + const jobInputs = await loadSingleJobInputs(db(), row.id as string) + return ok(await jobDto(responseRow.rows[0] || row, [], jobInputs), { status: 202 }) +} + +/** DELETE /api/jobs/:id */ +export async function deleteJob(context: AuthedContext) { + const deleted = await deleteJobWithAssets(context.actor.id, context.params.id) + return deleted ? ok({ deleted: true }) : fail('NOT_FOUND', '任务不存在', 404) +} diff --git a/apps/api/src/modules/jobs/queries.ts b/apps/api/src/modules/jobs/queries.ts new file mode 100644 index 0000000..fb2268b --- /dev/null +++ b/apps/api/src/modules/jobs/queries.ts @@ -0,0 +1,10 @@ +/** SQL shared by the job read and write paths. */ + +/** + * Output rows for one job, excluding soft-deleted assets. + * + * `generation_outputs` is the join; the media columns come off `assets`, which is + * why a deleted asset disappears from a job's result grid without erasing the job. + */ +export const jobOutputSelect = `SELECT go.asset_id,a.object_key,a.media_kind,a.mime_type,a.width,a.height,a.duration_seconds,a.fps,a.codec,a.has_audio,a.size_bytes,a.poster_asset_id,a.poster_object_key + FROM generation_outputs go JOIN assets a ON a.id=go.asset_id WHERE go.job_id=$1 AND a.deleted_at IS NULL` diff --git a/apps/api/src/modules/library/dto.ts b/apps/api/src/modules/library/dto.ts new file mode 100644 index 0000000..9b7ad31 --- /dev/null +++ b/apps/api/src/modules/library/dto.ts @@ -0,0 +1,45 @@ +import { signedAssetUrl } from '../../shared/services' + +/** + * Gallery row → the shape `apps/web-next` renders. + * + * This projection deliberately lives here rather than in `shared/dto.ts`: it is + * the only place that presigns three objects per row, and the reuse below is a + * cost decision, not an aesthetic one. + */ +export async function libraryAssetDto(row: Record) { + const mediaKind = (row.media_kind as string) || 'image' + const url = await signedAssetUrl(row.object_key as string) + const thumbnailKey = (row.thumbnail_object_key as string) || undefined + const posterKey = (row.poster_object_key as string) || undefined + const thumbnailUrl = thumbnailKey ? await signedAssetUrl(thumbnailKey) : undefined + // A video's poster and its gallery preview ARE the same object, so reuse + // the signature instead of presigning a second time. + const posterUrl = posterKey ? (posterKey === thumbnailKey ? thumbnailUrl : await signedAssetUrl(posterKey)) : undefined + return { + id: row.id, + mediaKind, + prompt: row.input_prompt, + inputPrompt: row.input_prompt, + finalPrompt: row.allow_user_read_final_prompt ? row.final_prompt || null : null, + canReadFinalPrompt: !!row.allow_user_read_final_prompt, + url, + downloadUrl: url, + imageUrl: url, + posterUrl, + posterAssetId: (row.poster_asset_id as string) || undefined, + thumbnailUrl, + thumbnailMimeType: (row.thumbnail_mime_type as string) || undefined, + thumbnailWidth: row.thumbnail_width !== null && row.thumbnail_width !== undefined ? Number(row.thumbnail_width) : undefined, + thumbnailHeight: row.thumbnail_height !== null && row.thumbnail_height !== undefined ? Number(row.thumbnail_height) : undefined, + mimeType: row.mime_type, + width: row.width !== null && row.width !== undefined ? Number(row.width) : undefined, + height: row.height !== null && row.height !== undefined ? Number(row.height) : undefined, + durationSeconds: row.duration_seconds !== null && row.duration_seconds !== undefined ? Number(row.duration_seconds) : undefined, + fps: row.fps !== null && row.fps !== undefined ? Number(row.fps) : undefined, + codec: (row.codec as string) || undefined, + hasAudio: typeof row.has_audio === 'boolean' ? row.has_audio as boolean : undefined, + sizeBytes: row.size_bytes !== undefined ? Number(row.size_bytes) : undefined, + createdAt: (row.created_at as Date).toISOString(), + } +} diff --git a/apps/api/src/modules/library/handlers.ts b/apps/api/src/modules/library/handlers.ts new file mode 100644 index 0000000..2945586 --- /dev/null +++ b/apps/api/src/modules/library/handlers.ts @@ -0,0 +1,79 @@ +import { db } from '../../../../../packages/database/src/index' +import { decodeCursor, encodeCursor, boundedLimit } from '../../shared/pagination' +import { fail, ok } from '../../shared/http' +import { signedAssetUrl } from '../../shared/services' +import type { AuthedContext } from '../../router/types' +import { deleteJobWithAssets } from '../generations/handlers' +import { libraryAssetDto } from './dto' +import { LIBRARY_FROM_CLAUSE, LIBRARY_SELECT_COLUMNS } from './queries' + +/** GET /api/library — keyset-paginated, owner-scoped, filterable gallery. */ +export async function listLibrary(context: AuthedContext) { + const { request, actor } = context + const limit = boundedLimit(request) + const cursor = decodeCursor(request.nextUrl.searchParams.get('cursor')) + const search = (request.nextUrl.searchParams.get('q') || '').trim().slice(0, 100) + const kind = request.nextUrl.searchParams.get('kind') + const eligibleOnly = request.nextUrl.searchParams.get('eligible') === 'true' + + // One owner-scoped predicate list, reused verbatim by the page query and the + // count, so a filter can never be applied to one and forgotten in the other. + const values: unknown[] = [actor.id] + const conditions = ['a.created_by=$1', 'a.deleted_at IS NULL', 'j.deleted_at IS NULL'] + if (kind === 'image' || kind === 'video') { values.push(kind); conditions.push(`a.media_kind=$${values.length}`) } + if (eligibleOnly) { + // Only PNG/JPEG bytes can become a generation input (`inspectImageBytes`), so + // the picker can ask for what it is actually able to use. Without this a page + // of 30 could hold two selectable rows and read as a broken gallery. + conditions.push(`a.media_kind='image' AND a.mime_type IN ('image/png','image/jpeg')`) + } + if (search) { + // `assets` has no filename or tag columns — the prompt is the only label that + // exists, so search means the prompt and nothing else. + values.push(`%${search}%`) + conditions.push(`(COALESCE(po.input_prompt,a.prompt) ILIKE $${values.length} OR po.final_prompt ILIKE $${values.length})`) + } + // Keyset on (created_at,id): `id` breaks ties, which a plain created_at cursor + // cannot, and a batch of same-instant inserts would skip or repeat rows. + if (cursor) { values.push(cursor.createdAt, cursor.id); conditions.push(`(a.created_at,a.id)<($${values.length - 1}::timestamptz,$${values.length}::uuid)`) } + const where = conditions.join(' AND ') + // The cursor says where we are, not how many match: drop it from the total. + const totalValues = cursor ? values.slice(0, -2) : values + const totalWhere = cursor ? conditions.slice(0, -1).join(' AND ') : where + values.push(limit + 1) + const page = await db().query(`${LIBRARY_SELECT_COLUMNS} + ${LIBRARY_FROM_CLAUSE} WHERE ${where} ORDER BY a.created_at DESC,a.id DESC LIMIT $${values.length}`, values) + const total = await db().query(`SELECT count(*)::int total ${LIBRARY_FROM_CLAUSE} WHERE ${totalWhere}`, totalValues) + const hasMore = page.rows.length > limit + const rows = page.rows.slice(0, limit) + return ok({ + items: await Promise.all(rows.map(libraryAssetDto)), + total: total.rows[0]?.total ?? rows.length, + hasMore, + nextCursor: hasMore && rows.length ? encodeCursor(rows[rows.length - 1]) : undefined, + }) +} + +/** GET /api/library/:id/download */ +export async function downloadAsset(context: AuthedContext) { + const result = await db().query('SELECT id,object_key,media_kind,mime_type,duration_seconds FROM assets WHERE id=$1 AND created_by=$2 AND deleted_at IS NULL', [context.params.id, context.actor.id]) + if (!result.rows[0]) return fail('NOT_FOUND', '资源不存在', 404) + const row = result.rows[0] + const url = await signedAssetUrl(row.object_key as string) + return ok({ url, downloadUrl: url, mediaKind: (row.media_kind as string) || 'image', mimeType: row.mime_type }) +} + +/** + * DELETE /api/library/:id + * + * Deleting an asset deletes its whole job, because outputs are only ever + * reachable through one. An already-deleted row answers the same as a freshly + * deleted one so a double click is not an error. + */ +export async function deleteAsset(context: AuthedContext) { + const owned = await db().query('SELECT job_id,deleted_at FROM assets WHERE id=$1 AND created_by=$2', [context.params.id, context.actor.id]) + if (!owned.rows[0]) return fail('NOT_FOUND', '图片不存在', 404) + if (owned.rows[0].deleted_at) return ok({ deleted: true }) + await deleteJobWithAssets(context.actor.id, owned.rows[0].job_id) + return ok({ deleted: true }) +} diff --git a/apps/api/src/modules/library/queries.ts b/apps/api/src/modules/library/queries.ts new file mode 100644 index 0000000..b384688 --- /dev/null +++ b/apps/api/src/modules/library/queries.ts @@ -0,0 +1,9 @@ +/** Owner-scoped asset queries. */ + +export const LIBRARY_SELECT_COLUMNS = `SELECT a.id,a.object_key,a.media_kind,a.mime_type,a.width,a.height,a.duration_seconds,a.fps,a.codec,a.has_audio,a.size_bytes,a.poster_asset_id,a.poster_object_key,a.thumbnail_object_key,a.thumbnail_mime_type,a.thumbnail_width,a.thumbnail_height,a.created_at,COALESCE(po.input_prompt,a.prompt) input_prompt,po.final_prompt,s.allow_user_read_final_prompt` + +/** + * Shared by the page query and the count query, so a filter can never be applied + * to one and quietly forgotten by the other. + */ +export const LIBRARY_FROM_CLAUSE = `FROM assets a JOIN generation_jobs j ON j.id=a.job_id LEFT JOIN prompt_optimizations po ON po.id=j.prompt_optimization_id AND po.deleted_at IS NULL CROSS JOIN prompt_optimization_settings s` diff --git a/apps/api/src/modules/models/handlers.ts b/apps/api/src/modules/models/handlers.ts index c449d91..efb504d 100644 --- a/apps/api/src/modules/models/handlers.ts +++ b/apps/api/src/modules/models/handlers.ts @@ -1,19 +1,24 @@ import { createHash } from 'node:crypto' import { db, transaction } from '../../../../../packages/database/src/index' import { createModelConfigRevision } from '@musecanvas/database' +import type { JsonValue, ModelCapabilities } from '@musecanvas/contracts' +import { enumOptionValues } from '@musecanvas/contracts' import { type Actor } from '../../auth/security' import { fail, ok } from '../../shared/http' -import { capabilitiesFromRow, defaultsFromRow, modelDto } from '../../shared/dto' -import { normalizedProviderBaseUrl, presetById, sanitizeReasoningEffort } from '../../shared/model-helpers' +import { capabilitiesFromRow, defaultsFromRow, legacyColumnsFromCapabilities, modelDto } from '../../shared/dto' +import { normalizedProviderBaseUrl, sanitizeReasoningEffort } from '../../shared/model-helpers' +import { resolveCatalogPlugin, resolvePresetById } from '../admin/plugin-catalog' import { globalProviderRegistry, MAX_INPUT_IMAGES } from '../../../../../packages/providers/src/index' +import type { AnyProviderManifest } from '../../../../../packages/providers/src/index' import type { MediaProviderPlugin } from '../../../../../packages/providers/src/index' -import type { ModelPreset } from '../../admin/model-presets' +import { resolvePresetCapabilities, type ModelPreset } from '../../admin/model-presets' // Plugin-first validation. New image configuration targets the hardened active // keys (openai-image@1.1.0, seedream-image@1.1.0); exact registered 1.0.0 keys // remain accepted so already-pinned historical revisions stay readable. // Runtime selection never maps adapter/provider strings to a plugin — the only -// authority is the static registry plus the manifest modality. +// authority is the catalog (static registry plus active provider_plugins rows) +// and the manifest modality. export const ACTIVE_IMAGE_PLUGIN_VERSION = '1.1.0' const IMAGE_PLUGIN_IDS: Record = { 'openai-image': true, 'seedream-image': true } @@ -21,16 +26,28 @@ export function modelDeleteIdFromPath(path: string): string | null { return path.match(/^admin\/models\/([0-9a-f-]+)$/)?.[1] ?? null } +/** + * Modality gate over a manifest. Language manifests have no `modalities`, so they + * are rejected here: a model config may only bind to a media plugin. + */ +export function manifestMediaSelection( + manifest: AnyProviderManifest, + modelKind: string, +): { ok: true; mediaKind: 'image' | 'video' } | { ok: false } { + const modalities: string[] = manifest.kind === 'media' ? manifest.modalities : [] + if (!modalities.includes(modelKind)) return { ok: false } + return { ok: true, mediaKind: modelKind as 'image' | 'video' } +} + export function validatePluginSelection( pluginId: string, pluginVersion: string, modelKind: string, ): { ok: true; mediaKind: 'image' | 'video' } | { ok: false; error: 'INVALID_PLUGIN' | 'INVALID_MODALITY' } { if (!globalProviderRegistry.has(pluginId, pluginVersion)) return { ok: false, error: 'INVALID_PLUGIN' } - const plugin = globalProviderRegistry.get(pluginId, pluginVersion) - const modalities = (plugin.manifest.modalities || []) as string[] - if (!modalities.includes(modelKind)) return { ok: false, error: 'INVALID_MODALITY' } - return { ok: true, mediaKind: modelKind as 'image' | 'video' } + const selection = manifestMediaSelection(globalProviderRegistry.get(pluginId, pluginVersion).manifest, modelKind) + if (!selection.ok) return { ok: false, error: 'INVALID_MODALITY' } + return selection } // Manifest vendor-model gate for the hardened image keys. An empty model list @@ -64,21 +81,29 @@ export function imageBaseUrlAllowed(pluginId: string, baseUrl: string | null | u export type ImageModelContract = { vendorModelId: string - sizes: string[] - qualityOptions: string[] - maxCount: number - maxInputImages: number + /** The contract the shipped plugin itself declared for this vendor model. */ + capabilities: ModelCapabilities } -// Validates persisted image model fields against the selected plugin contract -// by exercising the plugin's own validateRequest: each configured size and -// quality must be accepted, maxCount must be a valid request count, and -// maxInputImages must fit the manifest per-model cap (or the shared cap). +/** + * The smoke gate for a *built-in* image plugin: does the shipped plugin actually + * accept its own declaration? + * + * The values exercised here are no longer read off `model_configs` columns — they + * are the declared image-size presets, the declared enum options and the declared + * integer bounds, fed back into the plugin's own `validateRequest`. That makes + * this a check that the manifest and the adapter agree, which is the only thing + * the host can still verify without the plugin's source in front of it: an + * installed plugin's code is never imported here, so its manifest is taken at its + * word and only exercised at generation time. + */ export async function validateImageModelContract( plugin: Pick, contract: ImageModelContract, ): Promise<{ ok: true } | { ok: false; message: string }> { - const check = async (extra: { size?: string; quality?: string; count?: number }): Promise => { + const send = async ( + extra: { size?: string; quality?: string; count?: number; parameters?: Record }, + ): Promise => { await plugin.validateRequest({ modality: 'image', vendorModelId: contract.vendorModelId, @@ -86,58 +111,53 @@ export async function validateImageModelContract( ...extra, }, {}) } + try { - await check({}) - for (const size of contract.sizes) await check({ size }) - for (const quality of contract.qualityOptions) await check({ quality }) - await check({ count: contract.maxCount }) + await send({}) + for (const descriptor of contract.capabilities.parameters) { + if (descriptor.type === 'image-size') { + for (const preset of descriptor.presets) await send({ size: preset.value }) + continue + } + if (descriptor.type === 'enum') { + for (const option of enumOptionValues(descriptor.options)) { + if (descriptor.name === 'size') await send({ size: option }) + else if (descriptor.name === 'quality') await send({ quality: option }) + else await send({ parameters: { [descriptor.name]: option } }) + } + continue + } + // Only the declared bounds are exercised: they are the two values a caller + // can get wrong by one, and the plugin owns the arithmetic in between. + if (descriptor.type === 'integer' || descriptor.type === 'number') { + for (const bound of [descriptor.min, descriptor.max]) { + if (typeof bound !== 'number') continue + if (descriptor.name === 'count') await send({ count: bound }) + else await send({ parameters: { [descriptor.name]: bound } }) + } + } + } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : '模型配置与插件契约不符' } } - // Per-model manifest cap wins when present (e.g. dall-e-3 supports no input - // images); otherwise the shared global input-image cap applies. - const entry = plugin.manifest.models?.find((model) => model.id === contract.vendorModelId) as - | { maxInputImages?: unknown } - | undefined - const configuredCap = entry?.maxInputImages - const cap = Number.isSafeInteger(configuredCap) && (configuredCap as number) >= 0 - ? Math.min(configuredCap as number, MAX_INPUT_IMAGES) - : MAX_INPUT_IMAGES - if (!Number.isInteger(contract.maxInputImages) || contract.maxInputImages < 0 || contract.maxInputImages > cap) { - return { ok: false, message: `maxInputImages must be an integer between 0 and ${cap}` } + // The declared reference slot may not promise more input images than the host + // can actually stage, and a model that declares no slot accepts none. + const declaredReferences = contract.capabilities.inputSlots + .find(slot => slot.role === 'reference_image')?.maxCount ?? 0 + if (!Number.isInteger(declaredReferences) || declaredReferences < 0 || declaredReferences > MAX_INPUT_IMAGES) { + return { ok: false, message: `reference_image slot must declare an integer count between 0 and ${MAX_INPUT_IMAGES}` } } return { ok: true } } -// Canonical image capabilities derived solely from validated top-level -// fields. Active image writes persist exactly this shape — never caller -// supplied input.capabilities. -export function buildCanonicalImageCapabilities(input: { - sizes: string[] - qualityOptions: string[] - maxCount: number - maxInputImages: number -}): Record { - return { - modes: input.maxInputImages > 0 ? ['text_to_image', 'image_to_image'] : ['text_to_image'], - parameters: [ - { type: 'enum', name: 'size', label: '尺寸', options: input.sizes }, - ...(input.qualityOptions.length > 0 - ? [{ type: 'enum', name: 'quality', label: '质量', options: input.qualityOptions }] - : []), - { type: 'integer', name: 'count', label: '数量', min: 1, max: input.maxCount, defaultValue: 1 }, - ], - inputSlots: input.maxInputImages > 0 - ? [{ role: 'reference_image', required: false, minCount: 0, maxCount: input.maxInputImages, allowedMediaKinds: ['image'] }] - : [], - maxCount: input.maxCount, - supportedMediaKinds: ['image'], - mediaKind: 'image', - } -} -// True when a caller-supplied capabilities/defaults override carries no -// content (absent, null, empty object/array/string). Anything else must be -// rejected on active image writes rather than persisted or silently dropped. + +/** + * True when a caller-supplied capabilities/defaults override carries no content + * (absent, null, empty object/array/string). Anything else is rejected on + * **every** media write, image and video alike: the contract is the plugin's + * declaration, so a body that brings its own `capabilities` is not configuring a + * model, it is authoring one from outside the manifest. + */ export function isEmptyInputOverride(value: unknown): boolean { if (value === undefined || value === null) return true if (typeof value === 'string') return value.trim().length === 0 @@ -162,6 +182,7 @@ function asRecord(value: unknown): Record | null { if (typeof value === 'object' && value !== null && !Array.isArray(value)) return value as Record return null } + function configuredPluginIdentity(row: Record): { pluginId?: string pluginVersion?: string @@ -222,91 +243,74 @@ export function presetMatchesPersistedModel( ) } -export function videoPresetRevisionContract( +/** The immutable contract a media revision stores, taken from the plugin manifest. */ +export type ModelRevisionContract = { + capabilities: ModelCapabilities + defaults: Record +} + +/** + * What a preset contributes to a saved model: its identity. Everything else is + * read back from the manifest the preset points at, so an image preset and a + * video preset are the same code path and a preset can never smuggle a parameter + * the plugin does not accept. + * + * A language preset has no media manifest and therefore no contract, which is + * reported as `null` rather than as an empty one. + */ +export async function presetRevisionContract( preset: ModelPreset | null | undefined, -): { - capabilities: Record - defaults: Record -} | null { - if (!preset || preset.modelKind !== 'video') return null - return { - capabilities: { - modes: preset.modes, - parameters: preset.parameters, - inputSlots: preset.inputSlots, - maxCount: preset.maxCount, - supportedMediaKinds: ['video'], - }, - defaults: preset.defaults, - } +): Promise { + if (!preset || preset.modelKind === 'language') return null + const resolved = await resolvePresetCapabilities(preset.pluginId, preset.pluginVersion, preset.vendorModelId) + return { capabilities: resolved.capabilities, defaults: resolved.defaults } } +/** + * `model_configs.max_input_images` still carries a `CHECK (… <= 4)` from before + * role-aware input slots existed. The declared slot is the truth the API serves; + * this only caps the deprecated mirror column so a wide declaration cannot fail + * the row write. + */ +const LEGACY_MAX_INPUT_IMAGES_CEILING = 4 -function buildPluginCapabilities( - pluginId: string, - mediaKind: 'image' | 'video', - input: Record, - fallbackRow?: Record | null, -): Record { - const provided = asRecord(input.capabilities) - if (provided && (Array.isArray(provided.modes) || Array.isArray(provided.parameters))) { - return { - modes: provided.modes ?? [], - parameters: provided.parameters ?? [], - inputSlots: provided.inputSlots ?? [], - maxCount: provided.maxCount ?? 1, - supportedMediaKinds: provided.supportedMediaKinds ?? [mediaKind], - mediaKind, - } - } - if (mediaKind === 'video') { - return { - modes: provided?.modes ?? ['text_to_video', 'image_to_video'], - parameters: provided?.parameters ?? [ - { type: 'integer', name: 'durationSeconds', label: '时长(秒)', min: 1, max: 60, defaultValue: 5 }, - { type: 'enum', name: 'aspectRatio', label: '宽高比', options: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], defaultValue: '16:9' }, - { type: 'enum', name: 'resolution', label: '分辨率', options: ['720p', '1080p'], defaultValue: '720p' }, - { type: 'boolean', name: 'audio', label: '生成音频', defaultValue: true }, - { type: 'integer', name: 'count', label: '生成数量', min: 1, max: 4, defaultValue: 1 }, - ], - inputSlots: provided?.inputSlots ?? [ - { role: 'first_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'] }, - { role: 'last_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'] }, - { role: 'reference_image', required: false, minCount: 0, maxCount: 4, allowedMediaKinds: ['image'] }, - ], - maxCount: 4, - supportedMediaKinds: ['video'], - mediaKind, - pluginId, - } - } - if (fallbackRow) { - const legacy = capabilitiesFromRow(fallbackRow) - return { - modes: legacy.modes, - parameters: legacy.parameters, - inputSlots: legacy.inputSlots, - maxCount: legacy.maxCount, - supportedMediaKinds: legacy.supportedMediaKinds, - mediaKind, - } +/** + * The deprecated flat columns, always derived from the resolved contract. + * + * These used to be an input: `input.sizes`, `input.qualityOptions`, + * `input.maxCount` and `input.maxInputImages` were validated against the plugin + * and stored, which gave the same fact two authors. They now have exactly one + * author — the manifest — and the columns are a projection of it, because the + * columns are NOT NULL in the base schema and `jobDto` still echoes them. + */ +function legacyColumnValues( + capabilities: ModelCapabilities | null, + mediaKind: 'image' | 'video' | null, +): { sizes: string | null; qualityOptions: string; maxCount: number | null; maxInputImages: number } { + const derived = capabilities ? legacyColumnsFromCapabilities(capabilities) : null + const maxCount = derived?.maxCount + return { + sizes: mediaKind === 'image' ? JSON.stringify(derived?.sizes ?? []) : null, + qualityOptions: mediaKind === 'image' ? JSON.stringify(derived?.qualityOptions ?? []) : '[]', + maxCount: Number.isInteger(maxCount) && (maxCount as number) >= 1 && (maxCount as number) <= 10 + ? (maxCount as number) + : null, + maxInputImages: derived && mediaKind + ? Math.min(Math.max(derived.maxInputImages, 0), LEGACY_MAX_INPUT_IMAGES_CEILING) + : 0, } - return { modes: [], parameters: [], inputSlots: [], maxCount: 1, supportedMediaKinds: [mediaKind], mediaKind } } async function snapshotRevisionForRow( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, row: Record, actorId: string, - contract?: { - capabilities: Record - defaults: Record - } | null, + contract?: ModelRevisionContract | null, ): Promise> { const providerId = (row.provider_id as string) || 'legacy' const pluginId = (row.plugin_id as string) || 'legacy-image' const pluginVersion = (row.plugin_version as string) || '1.0.0' - const capabilities = contract?.capabilities ?? capabilitiesFromRow(row) as unknown as Record + const capabilities = (contract?.capabilities ?? capabilitiesFromRow(row)) as unknown as Record const defaults = contract?.defaults ?? { ...(defaultsFromRow(row)), ...(asRecord(row.defaults) || {}) } const digest = snapshotDigest({ modelId: row.id, providerId, pluginId, pluginVersion, capabilities, defaults }) const existing = await client.query( @@ -356,11 +360,24 @@ export async function upsertModel( : null if (id && !existing) return fail('NOT_FOUND', '模型不存在', 404) + // Uniform across every media kind and both write paths: the parameter contract + // belongs to the plugin manifest, so a body carrying its own capabilities or + // defaults is refused instead of persisted. Previously only the hardened image + // write rejected them, so a video write could author a contract the plugin had + // never declared. An omitted or empty override stays accepted. + if (!isEmptyInputOverride(input.capabilities)) { + return fail('INVALID_INPUT', 'capabilities 由插件 manifest 声明,不接受自定义覆盖') + } + if (!isEmptyInputOverride(input.defaults)) { + return fail('INVALID_INPUT', 'defaults 由插件 manifest 声明,不接受自定义覆盖') + } + // Plugin-driven path: explicit provider/plugin identity (image or video). // The static registry plus the manifest modality is the only authority: // no adapter/provider-string mapping. New image configuration targets the - // hardened 1.1.0 keys and is validated against the plugin contract - // (vendor model, sizes, qualities, counts, input images, endpoint host). + // hardened 1.1.0 keys, whose declared contract is then exercised by the + // plugin's own validateRequest (vendor model, presets, options, integer + // bounds, reference slot ceiling, endpoint host). // Exact 1.0.0 image keys are accepted only when updating an existing row // already pinned to that exact key; all other image writes use 1.1.0. if (typeof input.pluginId === 'string' && input.pluginId.trim()) { @@ -371,19 +388,23 @@ export async function upsertModel( const requestedKind = typeof input.modelKind === 'string' && ['image', 'video'].includes(input.modelKind) ? input.modelKind : (existing?.model_kind as string) || null - if (!globalProviderRegistry.has(pluginId, pluginVersion)) { - return fail('INVALID_PLUGIN', '供应商插件不存在或版本不受支持') - } - const provisionalKind = requestedKind || globalProviderRegistry.get(pluginId, pluginVersion).manifest.modalities[0] + // Catalog membership — not `model_configs.plugin_id` and not the static + // registry alone — decides whether a plugin exists: an uploaded plugin is + // bindable once the worker has activated its row. + const catalog = await resolveCatalogPlugin(pluginId, pluginVersion) + if (!catalog) return fail('INVALID_PLUGIN', '供应商插件不存在或版本不受支持') + const provisionalKind = requestedKind || (catalog.manifest.kind === 'media' ? catalog.manifest.modalities[0] : undefined) if (!provisionalKind) return fail('INVALID_PLUGIN', '供应商插件不存在或版本不受支持') - const selection = validatePluginSelection(pluginId, pluginVersion, provisionalKind) + const selection = manifestMediaSelection(catalog.manifest, provisionalKind) if (!selection.ok) { - return selection.error === 'INVALID_MODALITY' - ? fail('INVALID_PLUGIN', '供应商插件不支持该媒体类型') - : fail('INVALID_PLUGIN', '供应商插件不存在或版本不受支持') + return fail('INVALID_PLUGIN', '供应商插件不支持该媒体类型') } const mediaKind = selection.mediaKind - if (mediaKind === 'image' && pluginVersion !== ACTIVE_IMAGE_PLUGIN_VERSION) { + // The hardened 1.1.0 rules are built-in image keys only. An uploaded image + // plugin publishes whatever version its manifest declares, so forcing 1.1.0 + // for every image write would make a 1.0.0 upload permanently unbindable. + const hardenedImageWrite = mediaKind === 'image' && catalog.source === 'builtin' && Boolean(IMAGE_PLUGIN_IDS[pluginId]) + if (hardenedImageWrite && pluginVersion !== ACTIVE_IMAGE_PLUGIN_VERSION) { const pinned = id && existing?.plugin_id === pluginId && existing?.plugin_version === pluginVersion if (!pinned) return fail('INVALID_INPUT', '新的图片模型配置必须使用插件版本 1.1.0') } @@ -402,7 +423,7 @@ export async function upsertModel( // Hardened image keys carry an exhaustive manifest model list: unknown or // custom vendor IDs are rejected here so they never reach strict plugin // validation. Historical 1.0.0 revisions stay permissive. - if (mediaKind === 'image' && pluginVersion === ACTIVE_IMAGE_PLUGIN_VERSION) { + if (hardenedImageWrite) { const supported = manifestSupportsVendorModel( globalProviderRegistry.get(pluginId, pluginVersion).manifest.models, vendorModelId, @@ -413,7 +434,7 @@ export async function upsertModel( ? normalizedProviderBaseUrl(input.baseUrl) : (existing?.base_url ?? undefined) if (baseUrl === null) return fail('INVALID_BASE_URL', 'Base URL 必须是安全的 HTTPS 地址') - if (mediaKind === 'image' && pluginVersion === ACTIVE_IMAGE_PLUGIN_VERSION) { + if (hardenedImageWrite) { const effectiveBase = (baseUrl === undefined ? existing?.base_url : baseUrl) as string | null | undefined if (!imageBaseUrlAllowed(pluginId, effectiveBase)) { return fail('INVALID_BASE_URL', '图片插件 1.1.0 仅支持官方服务端点') @@ -433,7 +454,7 @@ export async function upsertModel( // A credential base URL overrides the model base URL at runtime, so a // custom-host credential must be rejected for hardened image keys even // when the model itself points at the official endpoint. - if (mediaKind === 'image' && pluginVersion === ACTIVE_IMAGE_PLUGIN_VERSION) { + if (hardenedImageWrite) { const credBase = cred.rows[0]?.base_url as string | null | undefined if (!imageBaseUrlAllowed(pluginId, credBase)) { return fail('INVALID_BASE_URL', '该供应商凭据的 Base URL 非官方服务端点,不能用于图片插件 1.1.0') @@ -447,67 +468,29 @@ export async function upsertModel( if (!Number.isInteger(concurrencyLimit) || concurrencyLimit < 1 || concurrencyLimit > 50 || !Number.isInteger(sortOrder)) { return fail('INVALID_INPUT', '并发或排序配置无效') } - let capabilities: Record = buildPluginCapabilities(pluginId, mediaKind, input, existing) - let defaults: Record = { ...(asRecord(input.defaults) || {}) } + // One contract source for both media kinds. Whatever the plugin declares is + // what the model offers; a model the manifest does not describe (an uploaded + // plugin that shipped no `capabilities`, or a historical key whose manifest + // predates the contract) resolves to `undeclared` and offers nothing, which + // the submit path then refuses. Re-resolving on every save is what keeps the + // persisted snapshot a copy of the manifest rather than a second opinion. + const resolved = await resolvePresetCapabilities(pluginId, pluginVersion, vendorModelId) + if (resolved.findings && resolved.findings.length > 0) { + return fail('INVALID_MODEL_CAPABILITIES', `插件声明的参数契约无效:${resolved.findings[0].message}`) + } + const capabilities = resolved.capabilities + const defaults = resolved.defaults const watermark = typeof input.watermark === 'boolean' ? input.watermark : Boolean(existing?.watermark ?? false) const enabled = typeof input.enabled === 'boolean' ? input.enabled : Boolean(existing?.enabled ?? false) - const sizes = mediaKind === 'image' - ? (Array.isArray(input.sizes) ? JSON.stringify((input.sizes as unknown[]).map(String)) : existing?.sizes ? JSON.stringify(existing.sizes) : JSON.stringify([])) - : null - const qualityOptions = mediaKind === 'image' - ? (Array.isArray(input.qualityOptions) ? JSON.stringify((input.qualityOptions as unknown[]).map(String)) : existing?.quality_options ? JSON.stringify(existing.quality_options) : JSON.stringify([])) - : JSON.stringify([]) - const maxCount = mediaKind === 'image' - ? (input.maxCount !== undefined ? Number(input.maxCount) : Number(existing?.max_count ?? 1)) - : (input.maxCount !== undefined ? Number(input.maxCount) : 1) - if (!Number.isInteger(maxCount) || maxCount < 1 || maxCount > 10) return fail('INVALID_INPUT', '模型配置无效') - const maxInputImages = input.maxInputImages !== undefined - ? Number(input.maxInputImages) - : Number(existing?.max_input_images ?? (mediaKind === 'video' ? 4 : 0)) - if (!Number.isInteger(maxInputImages) || maxInputImages < 0 || maxInputImages > 32) { - return fail('INVALID_INPUT', '模型输入配置无效') - } - if (mediaKind === 'image' && pluginVersion === ACTIVE_IMAGE_PLUGIN_VERSION) { - // Non-empty caller overrides are rejected: the persisted snapshot is - // always the canonical contract below, and omitted fields stay fine. - if (!isEmptyInputOverride(input.capabilities)) { - return fail('INVALID_INPUT', '图片插件 capabilities 由模型配置派生,不接受自定义覆盖') - } - if (!isEmptyInputOverride(input.defaults)) { - return fail('INVALID_INPUT', '图片插件 defaults 暂不支持自定义') - } - const sizeList = ((): string[] => { - try { - const parsed = JSON.parse(sizes as string) as unknown - return Array.isArray(parsed) ? parsed.map(String) : [] - } catch { - return [] - } - })() - const qualityList = ((): string[] => { - try { - const parsed = JSON.parse(qualityOptions) as unknown - return Array.isArray(parsed) ? parsed.map(String) : [] - } catch { - return [] - } - })() + const columns = legacyColumnValues(capabilities, mediaKind) + if (hardenedImageWrite) { const contract = await validateImageModelContract(globalProviderRegistry.get(pluginId, pluginVersion), { vendorModelId, - sizes: sizeList, - qualityOptions: qualityList, - maxCount, - maxInputImages, + capabilities, }) if (!contract.ok) return fail('INVALID_INPUT', contract.message) - capabilities = buildCanonicalImageCapabilities({ - sizes: sizeList, - qualityOptions: qualityList, - maxCount, - maxInputImages, - }) - defaults = {} } + const pluginSource = catalog.source === 'installed' ? 'installed' : 'builtin' const row = await transaction(async (client) => { let record: Record if (id) { @@ -515,24 +498,24 @@ export async function upsertModel( `UPDATE model_configs SET display_name=$1,vendor_model_id=$2,base_url=$3,sizes=$4::jsonb,quality_options=$5::jsonb,max_count=$6, concurrency_limit=$7,enabled=$8,watermark=$9,sort_order=$10, provider_credential_id=CASE WHEN $11::text IS NULL THEN provider_credential_id WHEN $11::text = '' THEN NULL ELSE $11::uuid END, - model_kind=$12,provider_id=$13,plugin_id=$14,plugin_version=$15,max_input_images=$16,updated_at=now() + model_kind=$12,provider_id=$13,plugin_id=$14,plugin_version=$15,max_input_images=$16,plugin_source=$18,updated_at=now() WHERE id=$17 AND deleted_at IS NULL RETURNING *`, [displayName, vendorModelId, baseUrl === undefined ? existing?.base_url || null : baseUrl || null, - sizes, qualityOptions, maxCount, concurrencyLimit, enabled, watermark, sortOrder, + columns.sizes, columns.qualityOptions, columns.maxCount, concurrencyLimit, enabled, watermark, sortOrder, credId === undefined ? null : credId, mediaKind, providerId, pluginId, pluginVersion, - maxInputImages, id], + columns.maxInputImages, id, pluginSource], ) if (!updated.rows[0]) throw new Error('NOT_FOUND') record = updated.rows[0] } else { const inserted = await client.query( `INSERT INTO model_configs(display_name,vendor_model_id,base_url,sizes,quality_options,max_count,concurrency_limit,enabled, - watermark,sort_order,created_by,provider_credential_id,model_kind,provider_id,plugin_id,plugin_version,max_input_images) - VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`, - [displayName, vendorModelId, baseUrl || null, sizes, qualityOptions, maxCount, concurrencyLimit, enabled, + watermark,sort_order,created_by,provider_credential_id,model_kind,provider_id,plugin_id,plugin_version,max_input_images,plugin_source) + VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING *`, + [displayName, vendorModelId, baseUrl || null, columns.sizes, columns.qualityOptions, columns.maxCount, concurrencyLimit, enabled, watermark, sortOrder, actor.id, typeof effectiveCredId === 'string' && effectiveCredId ? effectiveCredId : null, - mediaKind, providerId, pluginId, pluginVersion, maxInputImages], + mediaKind, providerId, pluginId, pluginVersion, columns.maxInputImages, pluginSource], ) record = inserted.rows[0] } @@ -546,7 +529,7 @@ export async function upsertModel( baseUrl: (baseUrl === undefined ? existing?.base_url : baseUrl) as string | null, credentialId: (typeof effectiveCredId === 'string' && effectiveCredId ? effectiveCredId : null) as string | null, credentialSchemaVersion: 1, - capabilities, + capabilities: capabilities as unknown as Record, normalizedConfig: { vendorModelId, concurrencyLimit, watermark, modelKind: mediaKind }, defaults, snapshotDigest: digest, @@ -567,13 +550,15 @@ export async function upsertModel( ] if (forbiddenManualFields.some((field) => input[field] !== undefined)) return fail('INVALID_INPUT', '模型参数只能通过预设选择') - const storedPreset = existing?.preset_id ? presetById(existing.preset_id) : null + // Preset resolution is catalog-aware so a model can be re-saved from a preset + // synthesized from an active installed manifest. + const storedPreset = existing?.preset_id ? await resolvePresetById(existing.preset_id) : null const preset = input.presetId === undefined ? storedPreset && presetMatchesPersistedModel(storedPreset, existing) ? storedPreset : null - : presetById(input.presetId) + : await resolvePresetById(input.presetId) if (!id && !preset) return fail('INVALID_PRESET', '请选择模型预设') if (input.presetId !== undefined && !preset) return fail('INVALID_PRESET', '模型预设不存在') const targetPreset = preset @@ -624,6 +609,26 @@ export async function upsertModel( if (reasoningEffort === undefined && targetKind === 'language') return fail('INVALID_INPUT', '思考等级无效') + // Which resolution path this write pins: 'installed' only when the preset's exact + // key resolves to an active provider_plugins row. Built-in and language presets + // keep 'builtin'. Membership is probed in the catalog, never inferred from the + // backfilled model_configs.plugin_id column. + const presetPluginId = targetPreset && 'pluginId' in targetPreset ? targetPreset.pluginId : null + const presetPluginVersion = targetPreset && 'pluginVersion' in targetPreset ? String(targetPreset.pluginVersion) : '1.0.0' + const presetPluginSource = (presetPluginId && (await resolveCatalogPlugin(presetPluginId, presetPluginVersion))?.source === 'installed') + ? 'installed' + : 'builtin' + + // A preset carries identity only, so the contract is read from the manifest it + // points at — the same lookup the plugin-selected write uses — and the + // deprecated flat columns are a projection of it rather than a preset field. + const revisionContract = await presetRevisionContract(targetPreset) + const presetMediaKind = targetPreset && targetPreset.modelKind !== 'language' ? targetPreset.modelKind : null + const presetColumns = legacyColumnValues(revisionContract?.capabilities ?? null, presetMediaKind) + // Seedream declares `watermark` as a parameter; the column is the request + // default, not a capability, so it stays the admin's choice and off otherwise. + const presetWatermark = presetMediaKind !== null && input.watermark === true + let result if (id && !targetPreset) { result = await db().query( @@ -640,20 +645,19 @@ export async function upsertModel( ) } else if (id && targetPreset) { result = await db().query( - `UPDATE model_configs SET preset_id=$1,display_name=$2,adapter=$3,vendor_model_id=$4,base_url=$5,sizes=$6,quality_options=$7,max_count=$8,concurrency_limit=$9,enabled=COALESCE($10,enabled),watermark=$11,sort_order=$12,provider_credential_id=CASE WHEN $13::text IS NULL THEN provider_credential_id WHEN $13::text = '' THEN NULL ELSE $13::uuid END,model_kind=$14,language_protocol=$15,max_output_tokens=$16,temperature=$17,reasoning_effort=$18,max_input_images=$19,provider_id=$20,plugin_id=$21,plugin_version=$22,updated_at=now() WHERE id=$23 AND deleted_at IS NULL RETURNING *`, + `UPDATE model_configs SET preset_id=$1,display_name=$2,adapter=$3,vendor_model_id=$4,base_url=$5,sizes=$6,quality_options=$7,max_count=$8,concurrency_limit=$9,enabled=COALESCE($10,enabled),watermark=$11,sort_order=$12,provider_credential_id=CASE WHEN $13::text IS NULL THEN provider_credential_id WHEN $13::text = '' THEN NULL ELSE $13::uuid END,model_kind=$14,language_protocol=$15,max_output_tokens=$16,temperature=$17,reasoning_effort=$18,max_input_images=$19,provider_id=$20,plugin_id=$21,plugin_version=$22,plugin_source=$24,updated_at=now() WHERE id=$23 AND deleted_at IS NULL RETURNING *`, [ targetPreset.id, targetPreset.displayName, 'adapter' in targetPreset ? targetPreset.adapter : existing?.adapter, targetPreset.vendorModelId, targetPreset.baseUrl, - targetPreset.modelKind === 'image' ? JSON.stringify(targetPreset.sizes) : null, - targetPreset.modelKind === 'image' ? JSON.stringify(targetPreset.qualityOptions) : '[]', - targetPreset.modelKind === 'image' ? targetPreset.maxCount : targetPreset.modelKind === 'video' ? targetPreset.maxCount : null, + presetColumns.sizes, + presetColumns.qualityOptions, + presetColumns.maxCount, concurrencyLimit, typeof input.enabled === 'boolean' ? input.enabled : null, - (targetPreset.modelKind === 'image' || targetPreset.modelKind === 'video') && - (typeof input.watermark === 'boolean' ? input.watermark : 'watermark' in targetPreset ? targetPreset.watermark : false), + presetWatermark, sortOrder, credId === undefined ? null : credId, targetPreset.modelKind, @@ -663,29 +667,29 @@ export async function upsertModel( ? targetPreset.temperature : null, targetPreset.modelKind === 'language' ? reasoningEffort ?? null : null, - targetPreset.modelKind === 'image' ? (targetPreset.maxInputImages ?? 0) : targetPreset.modelKind === 'video' ? 4 : 0, + presetColumns.maxInputImages, 'providerId' in targetPreset ? targetPreset.providerId : existing?.provider_id || null, 'pluginId' in targetPreset ? targetPreset.pluginId : existing?.plugin_id || null, 'pluginVersion' in targetPreset ? targetPreset.pluginVersion : existing?.plugin_version || '1.0.0', id, + presetPluginSource, ], ) } else if (targetPreset) { result = await db().query( - 'INSERT INTO model_configs(preset_id,display_name,adapter,vendor_model_id,base_url,sizes,quality_options,max_count,concurrency_limit,enabled,watermark,sort_order,created_by,provider_credential_id,model_kind,language_protocol,max_output_tokens,temperature,reasoning_effort,max_input_images,provider_id,plugin_id,plugin_version) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) RETURNING *', + 'INSERT INTO model_configs(preset_id,display_name,adapter,vendor_model_id,base_url,sizes,quality_options,max_count,concurrency_limit,enabled,watermark,sort_order,created_by,provider_credential_id,model_kind,language_protocol,max_output_tokens,temperature,reasoning_effort,max_input_images,provider_id,plugin_id,plugin_version,plugin_source) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING *', [ targetPreset.id, targetPreset.displayName, 'adapter' in targetPreset ? targetPreset.adapter : null, targetPreset.vendorModelId, targetPreset.baseUrl, - targetPreset.modelKind === 'image' ? JSON.stringify(targetPreset.sizes) : null, - targetPreset.modelKind === 'image' ? JSON.stringify(targetPreset.qualityOptions) : '[]', - targetPreset.modelKind === 'image' ? targetPreset.maxCount : targetPreset.modelKind === 'video' ? targetPreset.maxCount : null, + presetColumns.sizes, + presetColumns.qualityOptions, + presetColumns.maxCount, concurrencyLimit, input.enabled === true, - (targetPreset.modelKind === 'image' || targetPreset.modelKind === 'video') && - (typeof input.watermark === 'boolean' ? input.watermark : 'watermark' in targetPreset ? targetPreset.watermark : false), + presetWatermark, sortOrder, actor.id, typeof credId === 'string' && credId ? credId : null, @@ -696,10 +700,11 @@ export async function upsertModel( ? targetPreset.temperature : null, targetPreset.modelKind === 'language' ? reasoningEffort ?? null : null, - targetPreset.modelKind === 'image' ? (targetPreset.maxInputImages ?? 0) : targetPreset.modelKind === 'video' ? 4 : 0, + presetColumns.maxInputImages, 'providerId' in targetPreset ? targetPreset.providerId : null, 'pluginId' in targetPreset ? targetPreset.pluginId : null, 'pluginVersion' in targetPreset ? targetPreset.pluginVersion : '1.0.0', + presetPluginSource, ], ) } else { @@ -712,7 +717,7 @@ export async function upsertModel( db(), result.rows[0], actor.id, - videoPresetRevisionContract(targetPreset), + revisionContract, ) return ok(modelDto(withRevision)) } catch { diff --git a/apps/api/src/modules/models/queries.ts b/apps/api/src/modules/models/queries.ts new file mode 100644 index 0000000..d7b5c6d --- /dev/null +++ b/apps/api/src/modules/models/queries.ts @@ -0,0 +1,26 @@ +import { db } from '../../../../../packages/database/src/index' +import { modelDto, publicModelDto } from '../../shared/dto' +import { ok } from '../../shared/http' + +/** + * Model catalog reads. + * + * `handlers.ts` beside this file owns the admin write path; reads live here so + * the SELECT that feeds every picker in the console has one home. Both queries + * bind to `latest_revision_id`, which is what makes the immutable revision the + * authority for capabilities instead of the mutable `model_configs` columns. + */ + +export async function listPublicModels() { + const result = await db().query( + `SELECT m.*, rev.capabilities, rev.defaults, rev.revision, rev.id AS revision_id + FROM model_configs m LEFT JOIN model_config_revisions rev ON rev.id = m.latest_revision_id + WHERE m.model_kind IN ('image','video') AND m.enabled=true AND m.deleted_at IS NULL ORDER BY m.sort_order,m.created_at`, + ) + return ok(result.rows.map(publicModelDto)) +} + +export async function listAdminModels() { + const result = await db().query('SELECT m.*, pc.display_name AS provider_credential_name, rev.capabilities, rev.defaults, rev.revision FROM model_configs m LEFT JOIN provider_credentials pc ON pc.id=m.provider_credential_id AND pc.deleted_at IS NULL LEFT JOIN model_config_revisions rev ON rev.id=m.latest_revision_id WHERE m.deleted_at IS NULL ORDER BY m.sort_order,m.created_at') + return ok(result.rows.map(modelDto)) +} diff --git a/apps/api/src/modules/session/handlers.ts b/apps/api/src/modules/session/handlers.ts new file mode 100644 index 0000000..61e9e44 --- /dev/null +++ b/apps/api/src/modules/session/handlers.ts @@ -0,0 +1,36 @@ +import { db, transaction } from '../../../../../packages/database/src/index' +import { writeAudit } from '../../shared/audit' +import type { AuthedContext, PublicContext } from '../../router/types' +import { fail, ok } from '../../shared/http' + +/** + * `GET /api/registration` and `GET /api/admin/registration` answer from the same + * query with the same payload; only the access gate differs, so the query lives + * here once and the route table carries the two access levels. + */ +export async function registrationMode(_context: PublicContext | AuthedContext) { + const result = await db().query('SELECT mode FROM registration_settings WHERE singleton=true') + return ok({ requiresInvitation: result.rows[0]?.mode === 'invite_only' }) +} + +/** GET /api/session — the real session endpoint; `/auth/me` has never existed. */ +export function readSession(context: AuthedContext) { + return ok({ user: context.actor }) +} + +/** + * PATCH /api/admin/registration + * + * Echoes back exactly the boolean it was given rather than re-reading the row, so + * the response is the caller's own value and not a race with another writer. + */ +export async function setRegistrationMode(context: AuthedContext) { + const input = await context.json() + if (typeof input.requiresInvitation !== 'boolean') return fail('INVALID_INPUT', '注册模式无效') + const mode = input.requiresInvitation ? 'invite_only' : 'open' + await transaction(async client => { + await client.query('UPDATE registration_settings SET mode=$1,updated_at=now(),updated_by=$2 WHERE singleton=true', [mode, context.actor.id]) + await writeAudit(client, context.actor.id, 'registration.update', 'registration', 'singleton', { requiresInvitation: input.requiresInvitation }) + }) + return ok({ requiresInvitation: input.requiresInvitation }) +} diff --git a/apps/api/src/router/guard.ts b/apps/api/src/router/guard.ts new file mode 100644 index 0000000..ac06ac5 --- /dev/null +++ b/apps/api/src/router/guard.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { actorFrom, type Actor } from '../auth/security' +import { fail } from '../shared/http' + +/** + * The single session gate. Extracted verbatim from the catch-all handler, so the + * status codes, error codes and Chinese message strings stay exactly where they + * were; every route table entry resolves through here. + */ +export async function requireActor(request: NextRequest, admin = false): Promise { + const actor = await actorFrom(request) + if (!actor) return fail('UNAUTHORIZED', '请先登录', 401) + if (admin && actor.role !== 'admin') return fail('FORBIDDEN', '无权执行该操作', 403) + return actor +} + +export function requireAdmin(request: NextRequest): Promise { + return requireActor(request, true) +} + +/** Narrow the `Actor | NextResponse` union: a response means "already answered". */ +export function isResponse(value: Actor | NextResponse): value is NextResponse { + return value instanceof NextResponse +} diff --git a/apps/api/src/router/index.ts b/apps/api/src/router/index.ts new file mode 100644 index 0000000..244a7b6 --- /dev/null +++ b/apps/api/src/router/index.ts @@ -0,0 +1,4 @@ +export { dispatchDelete, dispatchGet, dispatchPatch, dispatchPost, dispatchPut, notFound } from './pipeline' +export { isResponse, requireActor, requireAdmin } from './guard' +export { matchPath, matchRoute } from './match' +export type { AuthedContext, Handler, HandlerContext, Method, PublicContext, Route } from './types' diff --git a/apps/api/src/router/match.ts b/apps/api/src/router/match.ts new file mode 100644 index 0000000..83e6555 --- /dev/null +++ b/apps/api/src/router/match.ts @@ -0,0 +1,73 @@ +import type { MatchedRoute, Route, RouteParams } from './types' + +/** + * Path pattern compilation. + * + * Every placeholder reproduces the character class of the `path.match()` it + * replaces. That is on purpose: these widths are existing behaviour. Several + * paths accept a loose lowercase id while the prompt-template and plugin ids + * accept upper-case hex, and narrowing any of them would turn a working request + * into a 404. + */ +const SEGMENT_PATTERNS: Record = { + // /^jobs\/([0-9a-f-]+)$/, /^library\/([0-9a-f-]+)\/download$/ and friends + id: '[0-9a-f-]+', + // /^admin\/prompt-templates\/sets\/([0-9a-fA-F-]+)$/ and the plugin id routes + hexid: '[0-9a-fA-F-]+', + // /^auth\/oauth\/(github|google)\/start$/ — the OAUTH_PROVIDERS whitelist + oauth: 'github|google', +} + +const ESCAPE = /[.*+?^${}()|[\]\\]/g + +function compile(pattern: string): { regex: RegExp; keys: string[] } { + const keys: string[] = [] + const source = pattern + .split('/') + .map(segment => { + if (!segment.startsWith(':')) return segment.replace(ESCAPE, '\\$&') + const name = segment.slice(1) + const body = SEGMENT_PATTERNS[name] + if (!body) throw new Error(`unknown route parameter :${name} in pattern "${pattern}"`) + keys.push(name) + return `(${body})` + }) + .join('/') + return { regex: new RegExp(`^${source}$`), keys } +} + +const cache = new Map() + +function compiled(pattern: string) { + let entry = cache.get(pattern) + if (!entry) { + entry = compile(pattern) + cache.set(pattern, entry) + } + return entry +} + +export function matchPath(pattern: string, path: string): RouteParams | null { + const { regex, keys } = compiled(pattern) + const found = regex.exec(path) + if (!found) return null + const params: RouteParams = {} + keys.forEach((key, index) => { + params[key] = found[index + 1] + }) + return params +} + +/** + * First match wins, so table order *is* precedence. The historical handler relied + * on this in several places (`library` before `library/:id/download`, + * `admin/prompt-templates` before `/sets` before `/export`), and `router.test.ts` + * pins those orderings. + */ +export function matchRoute(routes: Route[], path: string): MatchedRoute | null { + for (const route of routes) { + const params = matchPath(route.path, path) + if (params) return { route, params } + } + return null +} diff --git a/apps/api/src/router/pipeline.ts b/apps/api/src/router/pipeline.ts new file mode 100644 index 0000000..49322f2 --- /dev/null +++ b/apps/api/src/router/pipeline.ts @@ -0,0 +1,88 @@ +import { body, fail, mutationOriginValid } from '../shared/http' +import type { NextRequest, NextResponse } from 'next/server' +import { isResponse, requireActor } from './guard' +import { matchRoute } from './match' +import { DELETE_ROUTES, GET_ROUTES, PATCH_ROUTES, POST_ROUTES } from './routes' +import type { Route } from './types' + +/** + * The dispatcher: one ordered route table and one pipeline per method. + * + * Table order is precedence, and `access` is declared per route. That is safe + * because `router.test.ts` asserts the invariant the old inline handler relied + * on — a path is administratively gated exactly when it begins with `admin/`. + * + * The part worth understanding is the *fallthrough*. The old code ran one global + * gate before any protected route and only then reached its `404`, so an + * unmatched `admin/...` path answered 403 to a signed-in non-admin and 404 to an + * admin. Reproducing that means the no-match path has to run the same gate + * rather than answering 404 directly. + */ + +export const notFound = () => fail('NOT_FOUND', '接口不存在', 404) + +type Context = { + request: NextRequest + path: string + params: Record + json: () => Promise> +} + +function makeContext(request: NextRequest, path: string, params: Record): Context { + let parsed: Record | undefined + // Lazy and memoized: `body()` is JSON-only and consumes the stream, so a + // multipart route must be able to answer without ever triggering it. + return { request, path, params, json: async () => (parsed ??= await body(request)) } +} + +async function answer(route: Route, context: Context): Promise { + if (route.access === 'public') { + return route.handler({ ...context, actor: undefined }) + } + const actor = await requireActor(context.request, route.access === 'admin') + if (isResponse(actor)) return actor + return route.handler({ ...context, actor }) +} + +/** The gate the old handler applied before its trailing 404. */ +async function unmatchedGate(request: NextRequest, path: string, alwaysAdmin: boolean) { + const actor = await requireActor(request, alwaysAdmin || path.startsWith('admin/')) + return isResponse(actor) ? actor : null +} + +export async function dispatchGet(request: NextRequest, path: string) { + const matched = matchRoute(GET_ROUTES, path) + if (matched) return answer(matched.route, makeContext(request, path, matched.params)) + return (await unmatchedGate(request, path, false)) ?? notFound() +} + +export async function dispatchPost(request: NextRequest, path: string) { + if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403) + const matched = matchRoute(POST_ROUTES, path) + if (matched) return answer(matched.route, makeContext(request, path, matched.params)) + return (await unmatchedGate(request, path, false)) ?? notFound() +} + +export async function dispatchPatch(request: NextRequest, path: string) { + if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403) + const matched = matchRoute(PATCH_ROUTES, path) + if (matched) return answer(matched.route, makeContext(request, path, matched.params)) + // Every PATCH route is administrative, and so is the 404 for an unknown one. + return (await unmatchedGate(request, path, true)) ?? notFound() +} + +/** + * PUT is accepted but never handled: it exists so a PUT still passes the admin + * gate before 404ing. The 401 and 403 for an unauthorized PUT are observable. + */ +export async function dispatchPut(request: NextRequest, path: string) { + if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403) + return (await unmatchedGate(request, path, true)) ?? notFound() +} + +export async function dispatchDelete(request: NextRequest, path: string) { + if (!mutationOriginValid(request)) return fail('CSRF_REJECTED', '请求来源无效', 403) + const matched = matchRoute(DELETE_ROUTES, path) + if (matched) return answer(matched.route, makeContext(request, path, matched.params)) + return (await unmatchedGate(request, path, false)) ?? notFound() +} diff --git a/apps/api/src/router/router.test.ts b/apps/api/src/router/router.test.ts new file mode 100644 index 0000000..b281239 --- /dev/null +++ b/apps/api/src/router/router.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { NextRequest } from 'next/server' + +import { API_ENDPOINTS } from '@musecanvas/contracts' +import { DELETE_ROUTES, GET_ROUTES, PATCH_ROUTES, POST_ROUTES } from './routes' +import { dispatchDelete, dispatchGet, dispatchPatch, dispatchPost, dispatchPut } from './pipeline' +import { matchPath, matchRoute } from './match' +import type { Route } from './types' + +/** + * Regression gates for the route table. + * + * These exist because the routing behaviour that matters is not "does a path + * resolve" but the *consequences of the order things resolve in*: who is refused + * before the 404, which segment wins, and how wide a matcher is. All of that used + * to be implicit in the fall-through chain of one big handler. + */ + +const ALL_TABLES: Array<[string, Route[]]> = [ + ['GET', GET_ROUTES], + ['POST', POST_ROUTES], + ['PATCH', PATCH_ROUTES], + ['DELETE', DELETE_ROUTES], +] + +const SAMPLE_UUID = '123e4567-e89b-12d3-a456-426614174000' + +/** A pattern is only comparable to a declared URL once its parameters are filled. */ +function concrete(path: string): string { + return path + .replaceAll(':hexid', SAMPLE_UUID) + .replaceAll(':id', SAMPLE_UUID) + .replaceAll(':oauth', 'github') +} + +/** + * A declared endpoint may be produced by a helper taking either a resource id or + * an oauth provider name, so one endpoint can have several legitimate concrete + * spellings. + */ +function declaredCandidates(value: (argument: string) => string): string[] { + return [value(SAMPLE_UUID), ...['github', 'google'].map(provider => value(provider))] +} + +/** Every URL the registry declares, as its possible concrete paths. */ +function declaredEndpoints(): Array<{ endpoint: string; candidates: string[] }> { + const found: Array<{ endpoint: string; candidates: string[] }> = [] + const visit = (node: unknown) => { + for (const value of Object.values(node as Record)) { + if (typeof value === 'string') found.push({ endpoint: value, candidates: [value] }) + else if (typeof value === 'function') { + const candidates = declaredCandidates(value as (argument: string) => string) + found.push({ endpoint: candidates[0], candidates }) + } else if (value && typeof value === 'object') visit(value) + } + } + visit(API_ENDPOINTS) + return found +} + +const ALL_ROUTES = ALL_TABLES.flatMap(([, routes]) => routes) + +/** + * Endpoints the registry declares but no route serves yet. Listing them keeps the + * gap visible and falsifiable: the assertion below fails if a path is routed while + * still listed here, so this cannot quietly accumulate. It is empty today — and a + * new entry needs a reason, not just an unfinished feature. + */ +const DECLARED_WITHOUT_HANDLER: string[] = [] + +test('every endpoint declared in contracts has a handler', () => { + const unrouted = declaredEndpoints() + .filter(({ candidates }) => !candidates.some(candidate => matchRoute(ALL_ROUTES, candidate.replace(/^\/api\//, '')) !== null)) + .map(({ endpoint }) => endpoint) + .filter(endpoint => !DECLARED_WITHOUT_HANDLER.includes(endpoint)) + assert.deepEqual(unrouted, [], 'API_ENDPOINTS declares paths with no backend handler') +}) + +test('the declared-but-unrouted list never goes stale', () => { + for (const endpoint of DECLARED_WITHOUT_HANDLER) { + const path = endpoint.replace(/^\/api\//, '') + assert.equal( + matchRoute(ALL_ROUTES, path) === null, + true, + `${endpoint} now has a handler — remove it from DECLARED_WITHOUT_HANDLER`, + ) + } +}) + +test('every handler is reachable through a declared endpoint', () => { + const declared = new Set(declaredEndpoints().flatMap(entry => entry.candidates)) + for (const [method, routes] of ALL_TABLES) { + for (const route of routes) { + assert.ok( + declared.has(`/api/${concrete(route.path)}`), + `${method} ${route.path} is not declared in API_ENDPOINTS`, + ) + } + } +}) + +test('a path is administratively gated exactly when it begins with admin/', () => { + // This invariant is what makes per-route `access` equivalent to the old global + // `requireActor(request, path.startsWith('admin/'))`, including for paths that + // match nothing at all. + for (const [method, routes] of ALL_TABLES) { + for (const route of routes) { + const isAdmin = route.path.startsWith('admin/') + assert.equal(route.access === 'admin', isAdmin, `${method} ${route.path}: access=${route.access} but admin-prefix=${isAdmin}`) + } + } +}) + +test('no two routes in one table share a pattern', () => { + for (const [method, routes] of ALL_TABLES) { + const seen = new Set() + for (const route of routes) { + assert.equal(seen.has(route.path), false, `${method} registers ${route.path} twice`) + seen.add(route.path) + } + } +}) + +test('order-of-registration precedence is preserved from the inline handler', () => { + const index = (routes: Route[], path: string) => { + const found = routes.findIndex(route => route.path === path) + assert.ok(found >= 0, `no route with pattern ${path}`) + return found + } + // A literal collection must beat the parameterized child, or the list endpoint + // becomes unreachable. + assert.ok(index(GET_ROUTES, 'library') < index(GET_ROUTES, 'library/:id/download')) + assert.ok(index(GET_ROUTES, 'admin/prompt-templates') < index(GET_ROUTES, 'admin/prompt-templates/sets')) + assert.ok(index(GET_ROUTES, 'admin/prompt-templates/sets') < index(GET_ROUTES, 'admin/prompt-templates/sets/:hexid')) + assert.ok(index(GET_ROUTES, 'admin/prompt-templates/export') < index(GET_ROUTES, 'admin/prompt-templates/sets/:hexid')) + assert.ok(index(GET_ROUTES, 'admin/plugins') < index(GET_ROUTES, 'admin/models')) + // Public and wizard paths precede the session gate, which precedes the rest. + assert.ok(index(GET_ROUTES, 'registration') < index(GET_ROUTES, 'session')) + assert.ok(index(GET_ROUTES, 'auth/oauth/providers') < index(GET_ROUTES, 'jobs')) + assert.ok(index(POST_ROUTES, 'setup/complete') < index(POST_ROUTES, 'auth/otp/request')) + assert.ok(index(POST_ROUTES, 'auth/logout') < index(POST_ROUTES, 'generations')) + // The alias and the bare form of the user status route must both resolve. + assert.ok(index(PATCH_ROUTES, 'admin/users/:id/status') < index(PATCH_ROUTES, 'admin/users/:id')) +}) + +test('parameter widths still accept and reject what the old regexes did', () => { + // Each case is [pattern, path, matchedBefore]. The patterns replace specific + // literals in the old handler; these are the exact acceptance sets. + const cases: Array<[string, string, boolean]> = [ + ['jobs/:id', `jobs/${SAMPLE_UUID}`, true], + // The old `/^jobs\/([0-9a-f-]+)$/` accepted a non-v4, even non-hex shape. + ['jobs/:id', 'jobs/zzz', false], + ['jobs/:id', 'jobs/deadbeef', true], + ['jobs/:id', 'jobs/123e4567-e89b-12d3-a456-426614174000/extra', false], + ['jobs/:id', 'jobs/', false], + // Prompt template and plugin ids were case-insensitive hex; jobs were not. + ['admin/prompt-templates/sets/:hexid', 'admin/prompt-templates/sets/ABCDEF', true], + ['admin/prompt-templates/sets/:hexid', 'admin/prompt-templates/sets/zzzz', false], + ['admin/plugins/:hexid', 'admin/plugins/upload', false], + ['admin/plugins/:hexid', 'admin/plugins/validate', false], + // The oauth enum was spelled out inline and must not widen to any provider. + ['auth/oauth/:oauth/start', 'auth/oauth/github/start', true], + ['auth/oauth/:oauth/start', 'auth/oauth/facebook/start', false], + // PATCH used `(?:/status)?`, so both forms matched one regex; two patterns + // must cover exactly the same pair and nothing else. + ['admin/users/:id', `admin/users/${SAMPLE_UUID}`, true], + ['admin/users/:id', `admin/users/${SAMPLE_UUID}/status`, false], + ['admin/users/:id/status', `admin/users/${SAMPLE_UUID}/status`, true], + ['admin/users/:id/status', `admin/users/${SAMPLE_UUID}/status/extra`, false], + ] + for (const [pattern, path, expected] of cases) { + assert.equal(matchPath(pattern, path) !== null, expected, `matchPath('${pattern}', '${path}')`) + } +}) + +/** + * A minimal stand-in: the gate under test only reads headers and cookies. + * + * `host` is always present because `mutationOriginValid` compares the Origin + * against `x-forwarded-host` then `host`; without it every write would be + * rejected as CSRF and the session gate would never be reached. nginx sets both. + */ +function fakeRequest(headers: Record = {}): NextRequest { + return { + headers: new Headers({ host: 'musecanvas.test', ...headers }), + cookies: { get: () => undefined }, + nextUrl: new URL('http://musecanvas.test/api/x'), + } as unknown as NextRequest +} + +async function statusAndCode(response: Response): Promise<[number, string]> { + const payload = await response.json() as { success: boolean; error?: { code: string } } + assert.equal(payload.success, false, 'a refused request must keep the {success:false,error} envelope') + return [response.status, payload.error?.code ?? ''] +} + +test('an anonymous request is refused with 401 before any 404', async () => { + // This is the fallthrough behaviour: the old handler ran its global gate ahead + // of the trailing `NOT_FOUND`, so a typo while signed out never looked like a + // missing route. + const cases: Array<[string, Promise]> = [ + ['GET unknown', dispatchGet(fakeRequest(), 'does/not/exist')], + ['GET admin typo', dispatchGet(fakeRequest(), 'admin/does-not-exist')], + ['POST unknown', dispatchPost(fakeRequest({ origin: 'http://musecanvas.test' }), 'does/not/exist')], + ['PATCH unknown', dispatchPatch(fakeRequest({ origin: 'http://musecanvas.test' }), 'does/not/exist')], + ['PUT anything', dispatchPut(fakeRequest({ origin: 'http://musecanvas.test' }), 'anything')], + ['DELETE unknown', dispatchDelete(fakeRequest({ origin: 'http://musecanvas.test' }), 'does/not/exist')], + // Protected but real paths must not leak their existence to an anonymous caller. + ['GET admin/users', dispatchGet(fakeRequest(), 'admin/users')], + ['GET jobs', dispatchGet(fakeRequest(), 'jobs')], + ] + for (const [label, pending] of cases) { + const [status, code] = await statusAndCode(await pending) + assert.equal(status, 401, label) + assert.equal(code, 'UNAUTHORIZED', label) + } +}) + +test('mutation origin is checked before authentication on every write', async () => { + // Order matters: a cross-origin request is rejected as CSRF even when the + // caller would also have failed the session gate. + const foreign = { origin: 'http://evil.test' } + const cases: Array<[string, Promise]> = [ + ['POST', dispatchPost(fakeRequest(foreign), 'auth/otp/request')], + ['PATCH', dispatchPatch(fakeRequest(foreign), 'admin/users/1')], + ['PUT', dispatchPut(fakeRequest(foreign), 'anything')], + ['DELETE', dispatchDelete(fakeRequest(foreign), 'library/1')], + ] + for (const [label, pending] of cases) { + const [status, code] = await statusAndCode(await pending) + assert.equal(status, 403, label) + assert.equal(code, 'CSRF_REJECTED', label) + } +}) + +test('GET is never CSRF-gated', async () => { + // Reads carry no side effect; the old handler had no origin check in GET, and + // adding one now would break image URLs the browser fetches cross-site. + const [status] = await statusAndCode(await dispatchGet(fakeRequest({ origin: 'http://evil.test' }), 'does/not/exist')) + assert.equal(status, 401) +}) diff --git a/apps/api/src/router/routes.ts b/apps/api/src/router/routes.ts new file mode 100644 index 0000000..1f40802 --- /dev/null +++ b/apps/api/src/router/routes.ts @@ -0,0 +1,196 @@ +import { fail, ok } from '../shared/http' +import type { OAuthProvider } from '../auth/oauth' +import { adminOAuthSettings, oauthProviderList } from '../modules/auth/oauth-settings' +import { completeOAuthInvitation, handleOAuthCallback, startOAuth } from '../modules/auth/oauth-flow' +import { handleSetupPost, setupConfig, setupStatus } from '../modules/setup/handlers' +import { readiness } from '../modules/health/handlers' +import { readSession, registrationMode, setRegistrationMode } from '../modules/session/handlers' +import { listLinkedIdentities, startLink, unlinkIdentity } from '../modules/auth/account' +import { logout, requestOtp, verifyOtp } from '../modules/auth/handlers' +import { listAdminModels, listPublicModels } from '../modules/models/queries' +import { deleteModel, upsertModel } from '../modules/models/handlers' +import { cancelJob, deleteJob, getJob, listJobs, retryJob } from '../modules/jobs/handlers' +import { deleteAsset, downloadAsset, listLibrary } from '../modules/library/handlers' +import { createGeneration } from '../modules/generations/create' +import { editImage } from '../modules/image-edit/handlers' +import { + completeGenerationUpload, + createGenerationUpload, + deleteGenerationUpload, +} from '../modules/generation-uploads' +import { dashboard } from '../modules/admin/dashboard' +import { deleteUser, listUsers, setUserStatus } from '../modules/admin/users' +import { listJobs as listAdminJobs } from '../modules/admin/jobs' +import { createInvitation, listInvitations, revokeInvitation } from '../modules/admin/invitations' +import { readPromptOptimizationSettings, updatePromptOptimizationSettings } from '../modules/admin/prompt-optimization' +import { listInstalledCatalogPlugins, listModelPresets } from '../modules/admin/plugin-catalog' +import { buildBuiltinProviderTemplates } from '../admin/provider-templates' +import { + deletePlugin, + installPlugin, + listAdminPlugins, + updatePluginStatus, + validatePluginPackage, +} from '../modules/admin/plugins' +import { listProviderCredentials } from '../modules/admin/credential-reads' +import { + createProviderCredential, + deleteProviderCredential, + testProviderCredential, + updateProviderCredential, +} from '../modules/admin/provider-credentials' +import { updateOAuthProvider } from '../modules/admin/oauth' +import { + activatePromptTemplateSet, + createPromptTemplateEntry, + deletePromptTemplateEntry, + deletePromptTemplateSet, + exportPromptTemplates, + getAdminPromptTemplates, + getPromptTemplateSetDetail, + importPromptTemplates, + listPromptTemplateSets, + previewPromptTemplate, + updatePromptTemplateEntry, +} from '../modules/admin/prompt-templates' +import type { Route } from './types' + +/** + * The API surface, as data. + * + * Order is precedence, preserved from the old inline handler. Paths mirror + * `API_ENDPOINTS` in `@musecanvas/contracts` — the single source of truth for + * URLs — and `router.test.ts` fails if the two diverge in either direction. + * + * Handlers take the whole context. Anything needing the JSON body awaits + * `context.json()`, which is lazy, so the multipart upload routes below can + * answer without ever consuming the request stream. + */ + +/** The setup wizard posts to these eleven paths; all public, all pre-body. */ +const SETUP_POST_PATHS = [ + 'setup/complete', + 'setup/claim', + 'setup/site', + 'setup/smtp', + 'setup/smtp/test', + 'setup/storage', + 'setup/storage/test', + 'setup/runtime', + 'setup/prompt-templates/import', + 'setup/admin/request', + 'setup/admin/verify', +] as const + +const setupRoute = (path: (typeof SETUP_POST_PATHS)[number]): Route => ({ + path, + access: 'public', + // `handleSetupPost` returns null for a path it does not own, which the old + // handler turned into a 404; that distinction is preserved. + handler: context => handleSetupPost(context.request, path).then(response => response ?? fail('NOT_FOUND', '接口不存在', 404)), +}) + +export const GET_ROUTES: Route[] = [ + // --- public ------------------------------------------------------------- + { path: 'health/ready', access: 'public', handler: () => readiness() }, + { path: 'setup/status', access: 'public', handler: () => setupStatus() }, + { path: 'setup/config', access: 'public', handler: context => setupConfig(context.request) }, + { path: 'registration', access: 'public', handler: context => registrationMode(context) }, + { path: 'auth/oauth/providers', access: 'public', handler: async () => ok({ providers: await oauthProviderList() }) }, + { path: 'auth/oauth/:oauth/start', access: 'public', handler: context => startOAuth(context.params.oauth as OAuthProvider, 'login') }, + { path: 'auth/oauth/:oauth/callback', access: 'public', handler: context => handleOAuthCallback(context.request, context.params.oauth as OAuthProvider) }, + + // --- the caller's own account ------------------------------------------- + { path: 'session', access: 'actor', handler: context => readSession(context) }, + { path: 'account/oauth', access: 'actor', handler: context => listLinkedIdentities(context) }, + { path: 'account/oauth/:oauth/link/start', access: 'actor', handler: context => startLink(context) }, + // The model catalog is behind the session gate: it was in the old handler too, + // after the global gate and before any admin route. + { path: 'models', access: 'actor', handler: () => listPublicModels() }, + { path: 'jobs', access: 'actor', handler: context => listJobs(context) }, + { path: 'jobs/:id', access: 'actor', handler: context => getJob(context) }, + { path: 'library', access: 'actor', handler: context => listLibrary(context) }, + { path: 'library/:id/download', access: 'actor', handler: context => downloadAsset(context) }, + + // --- administration ----------------------------------------------------- + { path: 'admin/dashboard', access: 'admin', handler: () => dashboard() }, + { path: 'admin/registration', access: 'admin', handler: context => registrationMode(context) }, + { path: 'admin/users', access: 'admin', handler: context => listUsers(context) }, + { path: 'admin/model-presets', access: 'admin', handler: async () => ok(await listModelPresets()) }, + { path: 'admin/provider-templates', access: 'admin', handler: async () => ok({ templates: buildBuiltinProviderTemplates(await listInstalledCatalogPlugins()) }) }, + { path: 'admin/plugins', access: 'admin', handler: () => listAdminPlugins() }, + { path: 'admin/models', access: 'admin', handler: () => listAdminModels() }, + { path: 'admin/prompt-templates', access: 'admin', handler: () => getAdminPromptTemplates() }, + { path: 'admin/prompt-templates/sets', access: 'admin', handler: () => listPromptTemplateSets() }, + { path: 'admin/prompt-templates/export', access: 'admin', handler: context => exportPromptTemplates(context.request.nextUrl.searchParams.get('setId') || undefined) }, + { path: 'admin/prompt-templates/sets/:hexid', access: 'admin', handler: context => getPromptTemplateSetDetail(context.params.hexid) }, + { path: 'admin/prompt-optimization-settings', access: 'admin', handler: () => readPromptOptimizationSettings() }, + { path: 'admin/jobs', access: 'admin', handler: context => listAdminJobs(context) }, + { path: 'admin/invitations', access: 'admin', handler: () => listInvitations() }, + { path: 'admin/oauth-providers', access: 'admin', handler: async () => ok(await adminOAuthSettings()) }, + { path: 'admin/provider-credentials', access: 'admin', handler: () => listProviderCredentials() }, +] + +export const POST_ROUTES: Route[] = [ + // The wizard never reaches the session gate. + ...SETUP_POST_PATHS.map(setupRoute), + // Plugin uploads are multipart, so they are dispatched before anything that + // reads the JSON body, and neither handler calls context.json(). + { path: 'admin/plugins/upload', access: 'admin', handler: context => installPlugin(context.actor, context.request) }, + { path: 'admin/plugins/validate', access: 'admin', handler: context => validatePluginPackage(context.request) }, + + // The login flow, still anonymous. + { path: 'auth/otp/request', access: 'public', handler: context => requestOtp(context) }, + { path: 'auth/otp/verify', access: 'public', handler: context => verifyOtp(context) }, + { path: 'auth/logout', access: 'public', handler: context => logout(context) }, + { path: 'auth/oauth/invitation', access: 'public', handler: async context => completeOAuthInvitation(context.request, await context.json()) }, + + // --- authenticated ------------------------------------------------------ + { path: 'generation-uploads', access: 'actor', handler: async context => createGenerationUpload(context.actor, await context.json()) }, + { path: 'generation-uploads/:id/complete', access: 'actor', handler: context => completeGenerationUpload(context.actor, context.params.id) }, + { path: 'generations', access: 'actor', handler: context => createGeneration(context) }, + // Multipart like the plugin uploads above, and it never calls `context.json()`: + // the source image and the mask arrive as file parts. + { path: 'images/edit', access: 'actor', handler: context => editImage(context) }, + { path: 'jobs/:id/cancel', access: 'actor', handler: context => cancelJob(context) }, + { path: 'jobs/:id/retry', access: 'actor', handler: context => retryJob(context) }, + + // --- administration ----------------------------------------------------- + { path: 'admin/invitations', access: 'admin', handler: context => createInvitation(context) }, + { path: 'admin/models', access: 'admin', handler: async context => upsertModel(context.actor, await context.json()) }, + { path: 'admin/prompt-templates/import', access: 'admin', handler: async context => importPromptTemplates(context.actor, await context.json()) }, + { path: 'admin/prompt-templates/preview', access: 'admin', handler: async context => previewPromptTemplate(await context.json()) }, + { path: 'admin/prompt-templates/sets/:hexid/activate', access: 'admin', handler: context => activatePromptTemplateSet(context.actor, context.params.hexid) }, + { path: 'admin/prompt-templates/sets/:hexid/entries', access: 'admin', handler: async context => createPromptTemplateEntry(context.actor, context.params.hexid, await context.json()) }, + { path: 'admin/provider-credentials', access: 'admin', handler: async context => createProviderCredential(context.actor, await context.json()) }, + { path: 'admin/provider-credentials/:id/test', access: 'admin', handler: context => testProviderCredential(context.actor, context.params.id) }, +] + +export const PATCH_ROUTES: Route[] = [ + { path: 'admin/registration', access: 'admin', handler: context => setRegistrationMode(context) }, + { path: 'admin/prompt-optimization-settings', access: 'admin', handler: async context => updatePromptOptimizationSettings(context.actor, await context.json()) }, + // The old single regex accepted both the bare id and the `/status` alias. Two + // entries say the same thing, and `:id` cannot span a slash, so they never + // overlap. + { path: 'admin/users/:id/status', access: 'admin', handler: context => setUserStatus(context) }, + { path: 'admin/users/:id', access: 'admin', handler: context => setUserStatus(context) }, + { path: 'admin/models/:id', access: 'admin', handler: async context => upsertModel(context.actor, await context.json(), context.params.id) }, + { path: 'admin/plugins/:hexid', access: 'admin', handler: async context => updatePluginStatus(context.actor, context.params.hexid, await context.json()) }, + { path: 'admin/provider-credentials/:id', access: 'admin', handler: async context => updateProviderCredential(context.actor, context.params.id, await context.json()) }, + { path: 'admin/oauth-providers/:oauth', access: 'admin', handler: async context => updateOAuthProvider(context.actor, context.params.oauth as OAuthProvider, await context.json()) }, + { path: 'admin/prompt-templates/entries/:hexid', access: 'admin', handler: async context => updatePromptTemplateEntry(context.actor, context.params.hexid, await context.json()) }, +] + +export const DELETE_ROUTES: Route[] = [ + { path: 'generation-uploads/:id', access: 'actor', handler: context => deleteGenerationUpload(context.actor, context.params.id) }, + { path: 'jobs/:id', access: 'actor', handler: context => deleteJob(context) }, + { path: 'library/:id', access: 'actor', handler: context => deleteAsset(context) }, + { path: 'admin/invitations/:id', access: 'admin', handler: context => revokeInvitation(context) }, + { path: 'admin/users/:id', access: 'admin', handler: context => deleteUser(context) }, + { path: 'admin/provider-credentials/:id', access: 'admin', handler: context => deleteProviderCredential(context.actor, context.params.id) }, + { path: 'admin/plugins/:hexid', access: 'admin', handler: context => deletePlugin(context.actor, context.params.hexid) }, + { path: 'admin/models/:id', access: 'admin', handler: context => deleteModel(context.actor, context.params.id) }, + { path: 'admin/prompt-templates/sets/:hexid', access: 'admin', handler: context => deletePromptTemplateSet(context.actor, context.params.hexid) }, + { path: 'admin/prompt-templates/entries/:hexid', access: 'admin', handler: context => deletePromptTemplateEntry(context.actor, context.params.hexid) }, + { path: 'account/oauth/:oauth', access: 'actor', handler: context => unlinkIdentity(context) }, +] diff --git a/apps/api/src/router/types.ts b/apps/api/src/router/types.ts new file mode 100644 index 0000000..910b2f0 --- /dev/null +++ b/apps/api/src/router/types.ts @@ -0,0 +1,44 @@ +import type { NextRequest, NextResponse } from 'next/server' +import type { Actor } from '../auth/security' + +/** + * Types for the declarative route table. + * + * `access` is deliberately explicit per route rather than derived from the path, + * because the behaviour being preserved is the *order* of the old handler: an + * unauthenticated request to anything outside the public prefix answers 401, and + * an authenticated non-admin hitting an unmatched `admin/` path answers 403, + * not 404. `dispatch.ts` asserts the two agree. + */ + +export type Access = 'public' | 'actor' | 'admin' + +export type RouteParams = Record + +type Common = { + request: NextRequest + /** Catch-all remainder, without the leading `/api/`. */ + path: string + params: RouteParams + /** + * Parsed JSON body, memoized. Lazy so a multipart route can never consume the + * request stream by accident: `body()` is JSON-only and was previously read + * unconditionally in POST, with the plugin upload paths hoisted above it. + */ + json: () => Promise> +} + +export type PublicContext = Common & { actor: undefined } +export type AuthedContext = Common & { actor: Actor } +export type HandlerContext = PublicContext | AuthedContext + +export type HandlerResult = NextResponse | Promise +export type Handler = (context: HandlerContext) => HandlerResult + +export type Route = + | { path: string; access: 'public'; handler: (context: PublicContext) => HandlerResult } + | { path: string; access: 'actor' | 'admin'; handler: (context: AuthedContext) => HandlerResult } + +export type MatchedRoute = { route: Route; params: RouteParams } + +export type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' diff --git a/apps/api/src/shared/dto.ts b/apps/api/src/shared/dto.ts index b2a9f9d..74a5751 100644 --- a/apps/api/src/shared/dto.ts +++ b/apps/api/src/shared/dto.ts @@ -1,4 +1,17 @@ import { signedAssetUrl } from './services' +import { enumOptionValues, validateModelCapabilities } from '@musecanvas/contracts' +import type { + GenerationMode, + InputSlotDescriptor, + JsonValue, + MediaKind, + MediaParameterProvenance, + ModelCapabilities, + ModelCapabilityFlags, + ParameterCrossFieldConstraint, + ParameterDescriptor, + PublicModelDto, +} from '@musecanvas/contracts' export const userDto = (row: Record) => ({ id: row.id as string, @@ -24,19 +37,6 @@ function parseJsonField(value: unknown): Record | null { return null } -function parseJsonArray(value: unknown): string[] { - if (Array.isArray(value)) return value.map(String) - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) as unknown - return Array.isArray(parsed) ? parsed.map(String) : [] - } catch { - return [] - } - } - return [] -} - function parseDescriptorArray(value: unknown): Record[] { if (Array.isArray(value)) return value as Record[] if (typeof value === 'string') { @@ -50,58 +50,111 @@ function parseDescriptorArray(value: unknown): Record[] { return [] } -export function capabilitiesFromRow(row: Record): { - modes: string[] - parameters: Record[] - inputSlots: Record[] +/** + * The capability contract a model row carries, already rebuilt through + * `validateModelCapabilities` and therefore safe to hand to a browser. + * + * `declaredBy` is never inferred from convenience: the only three answers are + * what the pinned revision said, what the host wrote into a legacy revision, and + * `undeclared` for a row that says nothing. + */ +export type ModelCapabilitySnapshot = { + modes: GenerationMode[] + parameters: ParameterDescriptor[] + inputSlots: InputSlotDescriptor[] maxCount: number - supportedMediaKinds: string[] -} { - const snapshot = parseJsonField(row.capabilities) - const mediaKind = (row.media_kind as string) || (row.model_kind as string) || 'image' - if (snapshot && (Array.isArray(snapshot.modes) || Array.isArray(snapshot.parameters) || Array.isArray(snapshot.inputSlots))) { - return { - modes: Array.isArray(snapshot.modes) ? (snapshot.modes as unknown[]).map(String) : [], - parameters: Array.isArray(snapshot.parameters) ? (snapshot.parameters as Record[]) : [], - inputSlots: Array.isArray(snapshot.inputSlots) ? (snapshot.inputSlots as Record[]) : [], - maxCount: typeof snapshot.maxCount === 'number' ? snapshot.maxCount : Number(row.max_count || 1), - supportedMediaKinds: Array.isArray(snapshot.supportedMediaKinds) - ? (snapshot.supportedMediaKinds as unknown[]).map(String) - : [mediaKind], - } + supportedMediaKinds: MediaKind[] + flags?: ModelCapabilityFlags + crossFieldConstraints?: ParameterCrossFieldConstraint[] + declaredBy: MediaParameterProvenance + deprecated?: boolean + deprecationNote?: string +} + +/** Fresh arrays every call: the empty contract is never a shared mutable object. */ +function undeclaredCapabilities(): ModelCapabilitySnapshot { + return { + modes: [], + parameters: [], + inputSlots: [], + maxCount: 0, + supportedMediaKinds: [], + declaredBy: 'undeclared', } - // Legacy image-shaped capability snapshot (backfill format) or raw columns. - const sizes = (row.sizes as string[] | string | null | undefined) !== undefined && row.sizes !== null - ? parseJsonArray(row.sizes) - : [] - const qualityOptions = row.quality_options !== undefined && row.quality_options !== null - ? parseJsonArray(row.quality_options) - : [] - const maxCount = Number(row.max_count || snapshot?.maxCount || 1) - const maxInputImages = row.max_input_images !== undefined && row.max_input_images !== null - ? Number(row.max_input_images) - : Number((snapshot as Record | null)?.maxInputImages || 0) - const parameters: Record[] = [] - if (sizes.length > 0 || mediaKind === 'image') { - parameters.push({ type: 'enum', name: 'size', label: '尺寸', options: sizes }) +} + +/** + * Reads the model's contract out of the pinned revision snapshot. + * + * There is no second source any more. The former legacy branch here rebuilt + * `size` / `quality` / `count` descriptors from the flat `sizes`, + * `quality_options`, `max_count` and `max_input_images` columns, so a row could + * advertise a parameter no plugin ever accepted — `quality: 'ultra'` on a model + * with no quality token, a `size` the vendor had dropped, an input slot the + * plugin's own endpoint has never supported — and nothing downstream could tell + * an advertised value from a guessed one. Those columns are now *derived* from + * this function's result, never the other way round. + * + * A snapshot that fails structural validation degrades to `undeclared` rather + * than being served: the alternative is shipping a descriptor the browser cannot + * validate against, which is how illegal values reached a provider and failed + * there with an opaque error. + */ +export function capabilitiesFromRow(row: Record): ModelCapabilitySnapshot { + const snapshot = parseJsonField(row.capabilities) + if (!snapshot) return undeclaredCapabilities() + const validated = validateModelCapabilities(snapshot) + if (!validated.ok || !validated.capabilities) return undeclaredCapabilities() + const capabilities = validated.capabilities + return { + modes: capabilities.modes, + parameters: capabilities.parameters, + inputSlots: capabilities.inputSlots, + maxCount: typeof capabilities.maxCount === 'number' ? capabilities.maxCount : 0, + supportedMediaKinds: capabilities.supportedMediaKinds ?? [], + ...(capabilities.flags ? { flags: capabilities.flags } : {}), + ...(capabilities.crossFieldConstraints + ? { crossFieldConstraints: capabilities.crossFieldConstraints } + : {}), + // A revision written before provenance existed was assembled by the host from + // its own columns, so `host-synthesized` is the truthful label for it. It is + // still a declared contract, which is what the submit gate keys on. + declaredBy: capabilities.declaredBy ?? 'host-synthesized', + ...(typeof capabilities.deprecated === 'boolean' ? { deprecated: capabilities.deprecated } : {}), + ...(typeof capabilities.deprecationNote === 'string' ? { deprecationNote: capabilities.deprecationNote } : {}), } - if (qualityOptions.length > 0) { - parameters.push({ type: 'enum', name: 'quality', label: '质量', options: qualityOptions }) +} + +/** + * @deprecated These are the flat `model_configs` columns, kept only because the + * columns exist and `jobDto` still echoes the per-job copies. Every one of them is + * *derived* from the descriptors above rather than read from the row, so they can + * never disagree with what the model declares. No new reader may consume them: + * `sizes` is the preset/option list of the `size` descriptor, `qualityOptions` the + * `quality` descriptor's options, and `maxInputImages` the widest image-accepting + * input slot. + */ +export function legacyColumnsFromCapabilities(capabilities: Pick): { + sizes: string[] + qualityOptions: string[] + maxCount: number + maxInputImages: number +} { + const valuesFor = (parameterName: string): string[] => { + const descriptor = capabilities.parameters.find((entry) => entry.name === parameterName) + if (!descriptor) return [] + if (descriptor.type === 'image-size') return descriptor.presets.map((preset) => preset.value) + if (descriptor.type === 'enum') return enumOptionValues(descriptor.options) + return [] } - parameters.push({ type: 'integer', name: 'count', label: '数量', min: 1, max: maxCount || 10, defaultValue: 1 }) - const inputSlots: Record[] = maxInputImages > 0 - ? [{ role: 'reference_image', required: false, minCount: 0, maxCount: maxInputImages, allowedMediaKinds: ['image'] }] - : [] + const maxInputImages = capabilities.inputSlots.reduce((widest, slot) => ( + slot.allowedMediaKinds.includes('image') ? Math.max(widest, slot.maxCount) : widest + ), 0) return { - modes: mediaKind === 'video' - ? ['text_to_video', 'image_to_video'] - : mediaKind === 'image' - ? ['text_to_image', 'image_to_image'] - : [], - parameters, - inputSlots, - maxCount: maxCount || 1, - supportedMediaKinds: [mediaKind], + sizes: valuesFor('size'), + qualityOptions: valuesFor('quality'), + maxCount: typeof capabilities.maxCount === 'number' ? capabilities.maxCount : 0, + maxInputImages, } } @@ -109,10 +162,13 @@ export function defaultsFromRow(row: Record): Record) => { - const modelKind = (row.media_kind as string) || (row.model_kind as string) || 'image' +export const publicModelDto = (row: Record): PublicModelDto => { + // `model_configs.model_kind` also carries 'language'; this DTO is only ever + // served for the media kinds `GET /api/models` selects. + const modelKind = ((row.media_kind as string) || (row.model_kind as string) || 'image') as MediaKind const capabilities = capabilitiesFromRow(row) - const defaults = defaultsFromRow(row) + const defaults = defaultsFromRow(row) as Record + const legacy = legacyColumnsFromCapabilities(capabilities) return { id: row.id as string, displayName: row.display_name as string, @@ -124,12 +180,20 @@ export const publicModelDto = (row: Record) => { parameters: capabilities.parameters, inputSlots: capabilities.inputSlots, defaults, - // Legacy image fields for compatibility. - adapter: row.adapter as string, - sizes: Array.isArray(row.sizes) ? (row.sizes as string[]).map(String) : parseJsonArray(row.sizes), - qualityOptions: Array.isArray(row.quality_options) ? (row.quality_options as string[]).map(String) : parseJsonArray(row.quality_options), - maxCount: Number(row.max_count || capabilities.maxCount || 0), - maxInputImages: row.max_input_images !== undefined && row.max_input_images !== null ? Number(row.max_input_images) : 0, + maxCount: capabilities.maxCount, + maxInputImages: legacy.maxInputImages, + supportedMediaKinds: capabilities.supportedMediaKinds, + ...(capabilities.flags ? { flags: capabilities.flags } : {}), + ...(capabilities.crossFieldConstraints ? { crossFieldConstraints: capabilities.crossFieldConstraints } : {}), + declaredBy: capabilities.declaredBy, + ...(capabilities.deprecated !== undefined ? { deprecated: capabilities.deprecated } : {}), + ...(capabilities.deprecationNote !== undefined ? { deprecationNote: capabilities.deprecationNote } : {}), + /** @deprecated Legacy `model_configs.adapter` routing column. */ + adapter: (row.adapter as string) || '', + /** @deprecated Mirror of the `size` descriptor; read `parameters`. */ + sizes: legacy.sizes, + /** @deprecated Mirror of the `quality` descriptor; read `parameters`. */ + qualityOptions: legacy.qualityOptions, enabled: Boolean(row.enabled), sortOrder: Number(row.sort_order || 0), } @@ -250,18 +314,27 @@ export async function jobDto(row: Record, outputs: Record, index: number) => ({ - id: input.id as string, - uploadId: (input.id as string) || (input.upload_id as string), - role: (input.role as string) || 'reference_image', - position: input.position !== undefined ? Number(input.position) : index, - imageUrl: (input.imageUrl as string) || (input.object_key ? await signedAssetUrl(input.object_key as string) : ''), - url: (input.imageUrl as string) || (input.object_key ? await signedAssetUrl(input.object_key as string) : ''), - mimeType: (input.mime_type as string) || (input.mimeType as string), - width: (input.width as number) || 0, - height: (input.height as number) || 0, - sizeBytes: Number(input.size_bytes ?? input.sizeBytes ?? 0), - })) + rawInputs.map(async (input: Record, index: number) => { + // A gallery-sourced input owns no upload row: report it by `assetId` and + // leave `uploadId` undefined, so no reader can mistake the reference for a + // file this job uploaded (and try to delete or re-attach it). + const assetId = (input.assetId as string) || (input.asset_id as string) || undefined + const source = assetId ? 'gallery' : 'upload' + return { + id: input.id as string, + uploadId: assetId ? undefined : ((input.upload_id as string) || (input.id as string)), + assetId, + source, + role: (input.role as string) || 'reference_image', + position: input.position !== undefined ? Number(input.position) : index, + imageUrl: (input.imageUrl as string) || (input.object_key ? await signedAssetUrl(input.object_key as string) : ''), + url: (input.imageUrl as string) || (input.object_key ? await signedAssetUrl(input.object_key as string) : ''), + mimeType: (input.mime_type as string) || (input.mimeType as string), + width: (input.width as number) || 0, + height: (input.height as number) || 0, + sizeBytes: Number(input.size_bytes ?? input.sizeBytes ?? 0), + } + }) ), // Legacy alias preserved for existing image clients. inputImages: await Promise.all( diff --git a/apps/api/src/shared/http.ts b/apps/api/src/shared/http.ts index d0d34cd..d7edb49 100644 --- a/apps/api/src/shared/http.ts +++ b/apps/api/src/shared/http.ts @@ -1,7 +1,23 @@ import { NextResponse, type NextRequest } from 'next/server' +import type { ParameterErrorDetails } from '@musecanvas/contracts' export const ok = (data: T, init?: ResponseInit) => NextResponse.json({ success: true, data }, init) -export const fail = (code: string, message: string, status = 400) => NextResponse.json({ success: false, error: { code, message } }, { status }) +/** + * `details` carries the machine-readable part of a validation failure — which + * parameter, with what value, broke which rule — so the console can mark the + * offending control instead of making the user parse a sentence. Additive: every + * existing caller passes three arguments and every existing client reads only + * `code` and `message`. + */ +export const fail = ( + code: string, + message: string, + status = 400, + details?: ParameterErrorDetails, +) => NextResponse.json( + { success: false, error: details ? { code, message, details } : { code, message } }, + { status }, +) export async function body(request: NextRequest): Promise> { try { return await request.json() } catch { return {} } } export const emailValid = (value: unknown): value is string => typeof value === 'string' && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) export function clientIpFromRequest(request: NextRequest): string { diff --git a/apps/api/src/shared/model-helpers.ts b/apps/api/src/shared/model-helpers.ts index 22f0cbe..221fb8d 100644 --- a/apps/api/src/shared/model-helpers.ts +++ b/apps/api/src/shared/model-helpers.ts @@ -1,4 +1,5 @@ import { modelPresets, type ModelPreset, type ReasoningEffort } from '../admin/model-presets' +import { isPrivateProviderHost } from '../../../../packages/providers/src/index' export const reasoningEfforts: ReasoningEffort[] = ['none', 'low', 'medium', 'high', 'xhigh'] @@ -11,17 +12,9 @@ export function normalizedProviderBaseUrl(value: unknown): string | null | undef const insecureAllowed = process.env.ALLOW_INSECURE_PROVIDER_BASE_URL === 'true' if (url.protocol !== 'https:' && !(insecureAllowed && url.protocol === 'http:')) return null if (url.username || url.password || url.search || url.hash) return null - const host = url.hostname.toLowerCase() - const privateHost = - host === 'localhost' || - host === '0.0.0.0' || - host === '::1' || - /^127\./.test(host) || - /^10\./.test(host) || - /^192\.168\./.test(host) || - /^169\.254\./.test(host) || - /^172\.(1[6-9]|2\d|3[01])\./.test(host) - if (privateHost && process.env.ALLOW_PRIVATE_PROVIDER_BASE_URL !== 'true') return null + // Single private-address definition (shared with the plugin scanner and the + // worker's SSRF guard) so a host can never be "private" for one layer only. + if (isPrivateProviderHost(url.hostname) && process.env.ALLOW_PRIVATE_PROVIDER_BASE_URL !== 'true') return null return url.toString().replace(/\/$/, '') } catch { return null diff --git a/apps/api/src/shared/pagination.ts b/apps/api/src/shared/pagination.ts index 4bc668e..8860488 100644 --- a/apps/api/src/shared/pagination.ts +++ b/apps/api/src/shared/pagination.ts @@ -36,6 +36,9 @@ export type JobInputRecord = { position: number role: string upload_id: string + /** 'gallery' when the input references an `assets` row instead of an upload. */ + source: 'upload' | 'gallery' + asset_id?: string } export async function loadJobInputs( @@ -46,24 +49,29 @@ export async function loadJobInputs( let res try { res = await dbClient.query( - `SELECT gji.job_id, COALESCE(mu.id, gi.id) AS id, - COALESCE(mu.object_key, gi.object_key) AS object_key, - COALESCE(mu.mime_type, gi.mime_type) AS mime_type, - COALESCE(mu.width, gi.width) AS width, COALESCE(mu.height, gi.height) AS height, - COALESCE(mu.size_bytes, gi.size_bytes) AS size_bytes, + `SELECT gji.job_id, COALESCE(mu.id, gi.id, at.id) AS id, + COALESCE(mu.object_key, gi.object_key, at.object_key) AS object_key, + COALESCE(mu.mime_type, gi.mime_type, at.mime_type) AS mime_type, + COALESCE(mu.width, gi.width, at.width) AS width, COALESCE(mu.height, gi.height, at.height) AS height, + COALESCE(mu.size_bytes, gi.size_bytes, at.size_bytes) AS size_bytes, gji.position, COALESCE(gji.role, 'reference_image') AS role, - COALESCE(gji.upload_id::text, gi.id::text) AS upload_id + COALESCE(gji.upload_id::text, gi.id::text) AS upload_id, + gji.asset_id::text AS asset_id, + CASE WHEN gji.asset_id IS NOT NULL THEN 'gallery' ELSE 'upload' END AS source FROM generation_job_inputs gji LEFT JOIN media_uploads mu ON mu.id = gji.upload_id LEFT JOIN generation_input_images gi ON gi.id = gji.input_image_id OR gi.id = gji.upload_id + LEFT JOIN assets at ON at.id = gji.asset_id AND at.deleted_at IS NULL WHERE gji.job_id = ANY($1) ORDER BY gji.job_id, gji.position ASC`, [jobIds] ) } catch { + // Pre-asset databases have neither the column nor asset-sourced rows, so this + // legacy shape stays upload-only. res = await dbClient.query( `SELECT gji.job_id, gi.id, gi.object_key, gi.mime_type, gi.width, gi.height, gi.size_bytes, gji.position, - 'reference_image' AS role, gi.id::text AS upload_id + 'reference_image' AS role, gi.id::text AS upload_id, NULL::text AS asset_id, 'upload' AS source FROM generation_job_inputs gji JOIN generation_input_images gi ON gi.id=gji.input_image_id WHERE gji.job_id = ANY($1) @@ -75,8 +83,11 @@ export async function loadJobInputs( for (const row of res.rows) { const jobId = row.job_id as string if (!map[jobId]) map[jobId] = [] + const assetId = (row.asset_id as string) || undefined map[jobId].push({ - id: row.id as string, + // An asset-sourced row has no upload, so `id` falls back to the asset: without + // it every gallery input would carry a NULL key and collide in the UI. + id: (row.id as string) || assetId || '', object_key: row.object_key as string, mime_type: row.mime_type as string, width: Number(row.width || 0), @@ -84,7 +95,9 @@ export async function loadJobInputs( size_bytes: Number(row.size_bytes || 0), position: Number(row.position || 0), role: (row.role as string) || 'reference_image', - upload_id: (row.upload_id as string) || (row.id as string), + upload_id: (row.upload_id as string) || (row.id as string) || assetId || '', + source: row.source === 'gallery' ? 'gallery' : 'upload', + ...(assetId ? { asset_id: assetId } : {}), }) } return map diff --git a/apps/api/src/shared/services.ts b/apps/api/src/shared/services.ts index 7735ab9..57e5d05 100644 --- a/apps/api/src/shared/services.ts +++ b/apps/api/src/shared/services.ts @@ -195,6 +195,29 @@ export async function createUploadPresignedPost( } } +/** + * Writes server-held bytes into the private bucket: an administrator-uploaded + * plugin artifact, and the two inputs of a masked edit (`POST /api/images/edit`) + * that arrive in its own request body — the source image the browser posted and + * the alpha mask rasterised from the selection. + * + * Never presigned, and a plugin artifact is never read back through this helper: + * clients must not be able to pull executable plugin code from storage, so only + * the worker fetches those bytes. A staged input is an ordinary image, visible to + * its owner through the same short-lived signed URL every other generation input + * uses, and to nobody else. + */ +export async function putPrivateS3ObjectBytes( + objectKey: string, + bytes: Buffer, + contentType = 'application/octet-stream', + explicit?: StorageExplicitConfig, +): Promise { + const cfg = await effectiveStorage(explicit) + const client = storageClient(cfg.endpoint || cfg.publicEndpoint, cfg.region, cfg.accessKeyId, cfg.secretAccessKey) + await client.send(new PutObjectCommand({ Bucket: cfg.bucket, Key: objectKey, Body: bytes, ContentType: contentType })) +} + export async function getPrivateS3ObjectBytes( objectKey: string, explicit?: StorageExplicitConfig, diff --git a/apps/web-next/next.config.mjs b/apps/web-next/next.config.mjs index a67efff..42a9de2 100644 --- a/apps/web-next/next.config.mjs +++ b/apps/web-next/next.config.mjs @@ -29,6 +29,21 @@ const nextConfig = { destination: '/admin/users', permanent: false, }, + { + source: '/admin/models', + destination: '/admin/language-models', + permanent: false, + }, + { + source: '/admin/plugins', + destination: '/admin/media-models', + permanent: false, + }, + { + source: '/admin/providers', + destination: '/admin/media-models', + permanent: false, + }, ] }, async rewrites() { diff --git a/apps/web-next/src/app/(workspace)/generate/page.tsx b/apps/web-next/src/app/(workspace)/generate/page.tsx index e8dabb2..c5063fd 100644 --- a/apps/web-next/src/app/(workspace)/generate/page.tsx +++ b/apps/web-next/src/app/(workspace)/generate/page.tsx @@ -9,8 +9,10 @@ export const metadata = { } export default function GeneratePage() { - // The console mirrors its 图像/视频 tab into `?tab=`, so it reads search params - // and needs a boundary like every other `useSearchParams` consumer. + // The console still reads `?tab=` as a one-shot deep link into the creation + // mode, so it remains a `useSearchParams` consumer and needs a boundary like + // every other one. The mode itself is owned by `useGenerateUiStore.activeTab`, + // which is what the workspace header's 作图 / 生视频 entries write. return ( 正在加载创作台...}> diff --git a/apps/web-next/src/app/admin/language-models/page.tsx b/apps/web-next/src/app/admin/language-models/page.tsx new file mode 100644 index 0000000..757a983 --- /dev/null +++ b/apps/web-next/src/app/admin/language-models/page.tsx @@ -0,0 +1,11 @@ +import { AdminLanguageModelsView } from '@/features/admin/components/admin-language-models-view' + +export const dynamic = 'force-dynamic' + +export const metadata = { + title: '语言模型 - MuseCanvas 管理后台', +} + +export default function AdminLanguageModelsPage() { + return +} diff --git a/apps/web-next/src/app/admin/media-models/page.tsx b/apps/web-next/src/app/admin/media-models/page.tsx new file mode 100644 index 0000000..c5f12df --- /dev/null +++ b/apps/web-next/src/app/admin/media-models/page.tsx @@ -0,0 +1,11 @@ +import { AdminMediaModelsView } from '@/features/admin/components/admin-media-models-view' + +export const dynamic = 'force-dynamic' + +export const metadata = { + title: '媒体模型 - MuseCanvas 管理后台', +} + +export default function AdminMediaModelsPage() { + return +} diff --git a/apps/web-next/src/app/admin/models/page.tsx b/apps/web-next/src/app/admin/models/page.tsx deleted file mode 100644 index 103cdf8..0000000 --- a/apps/web-next/src/app/admin/models/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { AdminModelsView } from '@/features/admin/components/admin-models-view' - -export const dynamic = 'force-dynamic' - -export const metadata = { - title: '模型管理 - MuseCanvas 管理后台', -} - -export default function AdminModelsPage() { - return -} diff --git a/apps/web-next/src/app/admin/plugins/page.tsx b/apps/web-next/src/app/admin/plugins/page.tsx deleted file mode 100644 index 9ca4d22..0000000 --- a/apps/web-next/src/app/admin/plugins/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { AdminPluginsView } from '@/features/admin/components/admin-plugins-view' - -export const dynamic = 'force-dynamic' - -export const metadata = { - title: '媒体插件 - MuseCanvas 管理后台', -} - -export default function AdminPluginsPage() { - return -} diff --git a/apps/web-next/src/app/admin/providers/page.tsx b/apps/web-next/src/app/admin/providers/page.tsx deleted file mode 100644 index 5ff32d2..0000000 --- a/apps/web-next/src/app/admin/providers/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { AdminProvidersView } from '@/features/admin/components/admin-providers-view' - -export const dynamic = 'force-dynamic' - -export const metadata = { - title: '供应商凭据 - MuseCanvas 管理后台', -} - -export default function AdminProvidersPage() { - return -} diff --git a/apps/web-next/src/app/globals.css b/apps/web-next/src/app/globals.css index e56310f..6774e6f 100644 --- a/apps/web-next/src/app/globals.css +++ b/apps/web-next/src/app/globals.css @@ -3,14 +3,19 @@ /* * Design tokens — single source of truth for the whole web app. * - * Usage rules (enforced by scripts/check-design-tokens.mjs): + * Usage rules: * main action -> primary (ink solid) * selected/active/progress -> accent / accent-strong / accent-soft * neutral hover -> surface-subtle * pressed -> surface-subtle-strong * semantic status -> success|warning|danger|info (+ *-soft) - * control outlines -> border-control (>= 3:1 against surface) - * card/panel -> border, never a shadow + * control outlines -> border-control (>= 3:1 against surface) + * card/panel -> bg-surface + shadow, no rule + * sections and rows -> whitespace and a surface step, no rule + * + * Cards and panels lost their rules; controls keep theirs, and a state that + * used to be drawn as a box outline (selected row, upload error) is now drawn + * as a fill plus an accent bar. */ @theme { /* Surfaces */ @@ -85,7 +90,8 @@ --radius-panel: 18px; --radius-pill: 9999px; - /* Shadows: popovers and overlays only — cards and panels use borders */ + /* Shadows: the elevation ladder. `sm` marks small surfaces and sticky bars, + `md` is the default card, `lg` is a lifted card or popover. */ --shadow-sm: 0 1px 2px 0 rgb(26 26 24 / 0.04); --shadow-md: 0 10px 24px -16px rgb(26 26 24 / 0.20); --shadow-lg: 0 18px 44px -24px rgb(26 26 24 / 0.26); @@ -94,7 +100,13 @@ --motion-fast: 120ms; --motion-base: 180ms; --motion-slow: 240ms; + /* Overlay-scale entrances (scrim + dialog panel) sit between `base` and `slow`: + long enough to read as deliberate, short enough to never block interaction. */ + --motion-overlay: 200ms; --ease-standard: cubic-bezier(0.2, 0, 0, 1); + /* Per-item offset for `.motion-stagger` lists. Clamped to 12 steps in the + utility so a 50-row page never ends up waiting on the last cell. */ + --stagger-step: 40ms; } html, @@ -122,18 +134,102 @@ body { } /* Reduced motion: no spins, flashes, shifts or shimmer. State stays legible - through static shape, color and text only. */ + through static shape, color and text only. + The delays are zeroed alongside the durations on purpose: `.motion-stagger` + offsets items with `animation-delay`, and a 12-step list would otherwise keep + the last cell invisible for ~480ms even though nothing animates any more. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; + animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; + transition-delay: 0s !important; scroll-behavior: auto !important; } } +/* + * Keyframes — top level (never inside `@layer`, so a layer order change cannot + * hide them) and paired with the `.motion-*` utilities below. + * + * FAIL-VISIBLE RULE (read before adding an animation): the base declaration of + * every animated element must be its FINAL VISIBLE state, and the keyframes must + * carry the temporary invisible starting point. Because the block above rewrites + * durations to 0.01ms globally, an element whose *base* rule is `opacity: 0` and + * that relies on an animation to become visible would stay permanently invisible + * for anyone with reduced motion on. Entrances therefore use + * `animation-fill-mode: backwards` (the from-frame paints during the stagger + * delay and nothing after the run); exits use `forwards` only because the dialog + * hook (`src/shared/hooks/useDialog.ts`) also unmounts on a timer, so a missed + * `animationend` can never leave a faded-out element stuck on screen. + */ +@keyframes mc-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Grid/list entrance: 8px rise + fade. Geometry literals are fine here — the + token budget covers durations and easing only. */ +@keyframes mc-reveal-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes mc-pop-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Skeleton sweep. With `background-size: 200% 100%` the highlight crosses two + container widths per pass, and the loop restarts off-screen: seamless. */ +@keyframes mc-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +@keyframes mc-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes mc-scale-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} + @layer utilities { /* Functional scrim for text legibility on top of photos/videos. */ .media-scrim { @@ -144,6 +240,98 @@ body { color-mix(in srgb, var(--color-overlay) 0%, transparent) 100% ); } + + /* Entrances. `animation` is written as longhands on purpose: the shorthand + resets `animation-delay`, which would silently kill `.motion-stagger`. */ + .motion-fade-in { + animation-name: mc-fade-in; + animation-duration: var(--motion-overlay); + animation-timing-function: var(--ease-standard); + animation-fill-mode: backwards; + } + + .motion-reveal { + animation-name: mc-reveal-up; + animation-duration: var(--motion-slow); + animation-timing-function: var(--ease-standard); + animation-fill-mode: backwards; + } + + .motion-pop { + animation-name: mc-pop-in; + animation-duration: var(--motion-base); + animation-timing-function: var(--ease-standard); + animation-fill-mode: backwards; + } + + /* Transition-only hover lift for cards and tiles. It rides `transform` on the + container, so a child image keeping `group-hover:scale-105` never fights + it — the two transforms live on different elements. */ + .motion-lift { + transition-property: transform, box-shadow, border-color; + transition-duration: var(--motion-base); + transition-timing-function: var(--ease-standard); + } + + .motion-lift:hover, + .motion-lift:focus-within { + transform: translateY(-2px); + } + + /* Hover reveal for overlays and row actions: this owns the timing, while the + `opacity-0 group-hover:opacity-100` pair (plus its `sm:` gate) owns the value. */ + .motion-hover-fade { + transition-property: opacity, transform; + transition-duration: var(--motion-fast); + transition-timing-function: var(--ease-standard); + } + + /* Loading skeleton. Base state is the static, visible placeholder; the + animation only moves the highlight, so reduced motion keeps a legible box. */ + .motion-shimmer { + background-color: var(--color-surface-subtle); + background-image: linear-gradient( + to right, + transparent 0%, + color-mix(in srgb, var(--color-surface) 70%, transparent) 45%, + transparent 90% + ); + background-repeat: no-repeat; + background-size: 200% 100%; + animation-name: mc-shimmer; + /* One sweep scaled off the slowest token instead of a fresh magic number. */ + animation-duration: calc(var(--motion-slow) * 3); + animation-timing-function: linear; + animation-iteration-count: infinite; + } + + /* Dialog lifecycle: `.motion-fade-in` / `.motion-fade-out` go on the scrim, + the pair below on the panel. */ + .motion-dialog-in { + animation-name: mc-pop-in; + animation-duration: var(--motion-overlay); + animation-timing-function: var(--ease-standard); + animation-fill-mode: backwards; + } + + .motion-fade-out { + animation-name: mc-fade-out; + animation-duration: var(--motion-overlay); + animation-timing-function: var(--ease-standard); + animation-fill-mode: forwards; + } + + .motion-dialog-out { + animation-name: mc-scale-out; + animation-duration: var(--motion-fast); + animation-timing-function: var(--ease-standard); + animation-fill-mode: forwards; + } + + /* Entrance offset: callers set `--stagger-index` (unitless, 0-based) inline. */ + .motion-stagger { + animation-delay: calc(min(var(--stagger-index, 0), 12) * var(--stagger-step)); + } } /* Custom Scrollbar */ diff --git a/apps/web-next/src/app/page.tsx b/apps/web-next/src/app/page.tsx index 5febd45..069126e 100644 --- a/apps/web-next/src/app/page.tsx +++ b/apps/web-next/src/app/page.tsx @@ -73,7 +73,7 @@ export default function HomePage() { return (
{/* Top navigation */} -