From 1c8cb8e26d88e95ff030ddd1de13284d7e9d5825 Mon Sep 17 00:00:00 2001 From: Forrest Brown Date: Sun, 20 Sep 2026 14:44:00 +0800 Subject: [PATCH 1/3] feat(media): declare model capabilities in plugins, drive the console from them Media parameters were invented by the host instead of by the plugins. The API synthesized size/quality descriptors from flat model_configs columns, route.ts validated image requests against a `{type:'text',maxLength:32}` stub, and the console hardcoded five size options -- two of which no configured model accepts. `size:"9999x9999"` therefore passed the boundary, queued a job, and failed at the vendor, so users saw a dead task instead of a form error naming the field. Provider manifests are now the single source of truth. Each media model declares its parameter descriptors, geometry limits, dependencies and edit capabilities, and one validator in @musecanvas/contracts is shared by the browser, the API, the domain normalizer and the plugin adapters -- so a rendered control and an acceptable value are the same set by construction rather than three lists that can disagree. - image-size descriptors carry presets plus a custom-size band; the picker validates live and blocks submit before a 400 comes back - dependsOn and cross-field rules hide output_compression for lossless formats and reject background=transparent with jpeg, in the API and adapter too - model switches reconcile to the new contract instead of sending stale values - retries re-validate against the revision the job is pinned to - a model with no declared contract is refused, never guessed at - migrate.ts appends manifest-declared revisions; historical jobs keep rendering the parameters they were created with apps/web-next also stopped hand-copying the descriptor types from contracts, and CI's integration step now takes a glob instead of one filename, so new tests under tests/integration/ actually run. This commit bundles in-flight work from concurrent sessions (region-select image editing, the plugin-upload catalog, and the route.ts -> modules/router split). Their hunks are interleaved with these changes inside shared files and this tree does not compile without their untracked modules, so the threads could not be separated; that work is not verified by me beyond the suite passing as a whole. Co-Authored-By: Qoder --- .env.example | 11 + .github/workflows/media-quality.yml | 6 +- apps/api/app/api/[...path]/route.ts | 571 +---------- apps/api/src/admin/model-presets.ts | 241 +++-- apps/api/src/admin/provider-templates.ts | 131 ++- apps/api/src/backend.test.ts | 441 +++++--- apps/api/src/generation/retry-validation.ts | 116 +++ .../api/src/modules/admin/credential-reads.ts | 16 + apps/api/src/modules/admin/dashboard.ts | 9 + apps/api/src/modules/admin/invitations.ts | 49 + apps/api/src/modules/admin/jobs.ts | 46 + apps/api/src/modules/admin/plugin-catalog.ts | 166 +++ apps/api/src/modules/admin/plugins.test.ts | 380 +++++++ apps/api/src/modules/admin/plugins.ts | 380 +++++++ .../src/modules/admin/prompt-optimization.ts | 24 + .../src/modules/admin/provider-credentials.ts | 75 +- apps/api/src/modules/admin/users.ts | 86 ++ apps/api/src/modules/auth/account.ts | 31 + apps/api/src/modules/auth/handlers.ts | 107 ++ .../generation-uploads/input-limits.test.ts | 145 +++ .../modules/generation-uploads/validation.ts | 197 +++- .../api/src/modules/generations/create-job.ts | 264 +++++ apps/api/src/modules/generations/create.ts | 72 ++ apps/api/src/modules/generations/handlers.ts | 17 +- apps/api/src/modules/health/handlers.ts | 30 + .../src/modules/image-edit/handlers.test.ts | 688 +++++++++++++ apps/api/src/modules/image-edit/handlers.ts | 534 ++++++++++ apps/api/src/modules/jobs/handlers.ts | 130 +++ apps/api/src/modules/jobs/queries.ts | 10 + apps/api/src/modules/library/dto.ts | 45 + apps/api/src/modules/library/handlers.ts | 79 ++ apps/api/src/modules/library/queries.ts | 9 + apps/api/src/modules/models/handlers.ts | 453 ++++---- apps/api/src/modules/models/queries.ts | 26 + apps/api/src/modules/session/handlers.ts | 36 + apps/api/src/router/guard.ts | 24 + apps/api/src/router/index.ts | 4 + apps/api/src/router/match.ts | 73 ++ apps/api/src/router/pipeline.ts | 88 ++ apps/api/src/router/router.test.ts | 242 +++++ apps/api/src/router/routes.ts | 196 ++++ apps/api/src/router/types.ts | 44 + apps/api/src/shared/dto.ts | 235 +++-- apps/api/src/shared/http.ts | 18 +- apps/api/src/shared/model-helpers.ts | 15 +- apps/api/src/shared/pagination.ts | 31 +- apps/api/src/shared/services.ts | 23 + apps/web-next/next.config.mjs | 15 + .../src/app/(workspace)/generate/page.tsx | 6 +- .../src/app/admin/language-models/page.tsx | 11 + .../src/app/admin/media-models/page.tsx | 11 + apps/web-next/src/app/admin/models/page.tsx | 11 - apps/web-next/src/app/admin/plugins/page.tsx | 11 - .../web-next/src/app/admin/providers/page.tsx | 11 - apps/web-next/src/app/globals.css | 198 +++- apps/web-next/src/app/page.tsx | 30 +- .../components/admin-credential-table.tsx | 234 +++++ .../components/admin-installed-plugins.tsx | 376 +++++++ .../components/admin-language-models-view.tsx | 451 ++++++++ .../components/admin-media-models-view.tsx | 570 +++++++++++ .../admin/components/admin-models-view.tsx | 317 ------ .../components/admin-plugin-upload-dialog.tsx | 372 +++++++ .../admin/components/admin-plugins-view.tsx | 190 ---- .../admin-provider-credential-dialog.tsx | 59 +- .../admin/components/admin-providers-view.tsx | 217 ---- .../features/admin/components/admin-shell.tsx | 6 +- .../src/features/admin/lib/plugin-upload.ts | 80 ++ .../features/admin/lib/provider-templates.ts | 57 +- .../generate/components/active-jobs-board.tsx | 25 +- .../generate/components/edit-region-stage.tsx | 377 +++++++ .../components/gallery-image-picker.tsx | 312 ++++++ .../generate/components/generate-console.tsx | 552 +++++++--- .../components/generate-mode-tabs.tsx | 86 -- .../components/media-parameter-controls.tsx | 329 ++++++ .../components/size-picker-control.tsx | 180 ++++ .../components/video-parameter-controls.tsx | 210 ---- .../features/generate/lib/edit-image-api.ts | 97 ++ .../features/generate/lib/use-image-edit.ts | 39 + .../library/components/asset-lightbox.tsx | 196 ++++ .../library/components/library-view.tsx | 137 ++- .../workspace/components/workspace-header.tsx | 109 +- .../features/workspace/lib/workspace-nav.ts | 55 + .../src/shared/components/media-frame.tsx | 189 +++- apps/web-next/src/shared/hooks/useDialog.ts | 222 ++++ .../src/shared/hooks/useGenerationMode.ts | 40 + apps/web-next/src/shared/hooks/useLibrary.ts | 57 +- apps/web-next/src/shared/lib/app-routes.ts | 16 + .../src/shared/lib/generation-params.ts | 262 +---- .../src/shared/lib/media-parameters.ts | 479 +++++++++ .../src/shared/lib/model-capabilities.ts | 71 ++ .../src/shared/lib/reference-upload.ts | 147 ++- apps/web-next/src/shared/services/api.ts | 9 +- .../src/shared/stores/admin-ui-store.ts | 38 - .../src/shared/stores/generate-ui-store.ts | 109 +- apps/web-next/src/shared/types/index.ts | 243 +++-- apps/worker/src/index.ts | 24 +- apps/worker/src/jobs/index.test.ts | 54 +- apps/worker/src/jobs/index.ts | 178 +++- apps/worker/src/jobs/media-request.test.ts | 118 +++ apps/worker/src/plugins/availability.test.ts | 192 ++++ apps/worker/src/plugins/availability.ts | 171 ++++ apps/worker/src/plugins/loader.test.ts | 425 ++++++++ apps/worker/src/plugins/loader.ts | 519 ++++++++++ apps/worker/src/preprocessing/index.ts | 17 +- apps/worker/src/shared/storage.ts | 44 +- deploy/README.md | 15 +- deploy/compose.dev.yaml | 5 + deploy/compose.images.yaml | 5 + deploy/compose.prod.yaml | 5 + deploy/compose.yaml | 5 + deploy/docker/worker.Dockerfile | 6 + deploy/nginx/default.conf | 23 + packages/contracts/package.json | 2 +- packages/contracts/src/endpoints.test.ts | 5 + packages/contracts/src/endpoints.ts | 10 + packages/contracts/src/image-edit.test.ts | 178 ++++ packages/contracts/src/image-edit.ts | 288 ++++++ packages/contracts/src/index.ts | 166 ++- .../src/media-parameter-validation.test.ts | 355 +++++++ .../src/media-parameter-validation.ts | 968 ++++++++++++++++++ packages/contracts/src/media-parameters.ts | 350 +++++++ packages/database/src/migrate.ts | 226 ++++ packages/domain/src/index.test.ts | 138 ++- packages/domain/src/index.ts | 346 ++----- .../domain/src/media-capabilities.test.ts | 209 ++++ packages/providers/src/core/core.test.ts | 1 + .../providers/src/core/image-edit.test.ts | 239 +++++ packages/providers/src/core/image-edit.ts | 243 +++++ packages/providers/src/core/index.ts | 7 + .../src/core/language-errors.test.ts | 14 + .../providers/src/core/language-errors.ts | 53 + .../src/core/language-registry.test.ts | 121 +++ .../providers/src/core/language-registry.ts | 95 ++ .../src/core/plugin-registry.test.ts | 134 +++ .../providers/src/core/plugin-registry.ts | 98 ++ .../providers/src/core/plugin-scan.test.ts | 270 +++++ packages/providers/src/core/plugin-scan.ts | 554 ++++++++++ packages/providers/src/core/thumbnail.test.ts | 172 ++++ packages/providers/src/core/thumbnail.ts | 265 +++++ packages/providers/src/core/types.ts | 154 ++- packages/providers/src/core/url-guard.test.ts | 20 + packages/providers/src/core/url-guard.ts | 21 + .../src/language-model-plugin.test.ts | 210 ++++ packages/providers/src/language-model.ts | 92 +- .../src/plugins/openai-image/index.ts | 371 +++++-- .../src/plugins/openai-image/models.test.ts | 309 ++++++ .../src/plugins/openai-image/models.ts | 434 ++++++++ .../plugins/openai-image/openai-image.test.ts | 231 ++++- .../src/plugins/seedance-video/index.ts | 439 ++++++-- .../src/plugins/seedream-image/index.ts | 265 ++++- .../seedream-image/seedream-image.test.ts | 162 +++ .../providers/src/plugins/veo-video/index.ts | 287 +++++- readme.md | 5 + scripts/backfill-asset-thumbnails.ts | 136 +++ .../integration/capabilities-backfill.test.ts | 103 ++ .../media-provider-contract.test.ts | 134 +++ 156 files changed, 21573 insertions(+), 3546 deletions(-) create mode 100644 apps/api/src/generation/retry-validation.ts create mode 100644 apps/api/src/modules/admin/credential-reads.ts create mode 100644 apps/api/src/modules/admin/dashboard.ts create mode 100644 apps/api/src/modules/admin/invitations.ts create mode 100644 apps/api/src/modules/admin/jobs.ts create mode 100644 apps/api/src/modules/admin/plugin-catalog.ts create mode 100644 apps/api/src/modules/admin/plugins.test.ts create mode 100644 apps/api/src/modules/admin/plugins.ts create mode 100644 apps/api/src/modules/admin/users.ts create mode 100644 apps/api/src/modules/auth/account.ts create mode 100644 apps/api/src/modules/auth/handlers.ts create mode 100644 apps/api/src/modules/generations/create-job.ts create mode 100644 apps/api/src/modules/generations/create.ts create mode 100644 apps/api/src/modules/health/handlers.ts create mode 100644 apps/api/src/modules/image-edit/handlers.test.ts create mode 100644 apps/api/src/modules/image-edit/handlers.ts create mode 100644 apps/api/src/modules/jobs/handlers.ts create mode 100644 apps/api/src/modules/jobs/queries.ts create mode 100644 apps/api/src/modules/library/dto.ts create mode 100644 apps/api/src/modules/library/handlers.ts create mode 100644 apps/api/src/modules/library/queries.ts create mode 100644 apps/api/src/modules/models/queries.ts create mode 100644 apps/api/src/modules/session/handlers.ts create mode 100644 apps/api/src/router/guard.ts create mode 100644 apps/api/src/router/index.ts create mode 100644 apps/api/src/router/match.ts create mode 100644 apps/api/src/router/pipeline.ts create mode 100644 apps/api/src/router/router.test.ts create mode 100644 apps/api/src/router/routes.ts create mode 100644 apps/api/src/router/types.ts create mode 100644 apps/web-next/src/app/admin/language-models/page.tsx create mode 100644 apps/web-next/src/app/admin/media-models/page.tsx delete mode 100644 apps/web-next/src/app/admin/models/page.tsx delete mode 100644 apps/web-next/src/app/admin/plugins/page.tsx delete mode 100644 apps/web-next/src/app/admin/providers/page.tsx create mode 100644 apps/web-next/src/features/admin/components/admin-credential-table.tsx create mode 100644 apps/web-next/src/features/admin/components/admin-installed-plugins.tsx create mode 100644 apps/web-next/src/features/admin/components/admin-language-models-view.tsx create mode 100644 apps/web-next/src/features/admin/components/admin-media-models-view.tsx delete mode 100644 apps/web-next/src/features/admin/components/admin-models-view.tsx create mode 100644 apps/web-next/src/features/admin/components/admin-plugin-upload-dialog.tsx delete mode 100644 apps/web-next/src/features/admin/components/admin-plugins-view.tsx delete mode 100644 apps/web-next/src/features/admin/components/admin-providers-view.tsx create mode 100644 apps/web-next/src/features/admin/lib/plugin-upload.ts create mode 100644 apps/web-next/src/features/generate/components/edit-region-stage.tsx create mode 100644 apps/web-next/src/features/generate/components/gallery-image-picker.tsx delete mode 100644 apps/web-next/src/features/generate/components/generate-mode-tabs.tsx create mode 100644 apps/web-next/src/features/generate/components/media-parameter-controls.tsx create mode 100644 apps/web-next/src/features/generate/components/size-picker-control.tsx delete mode 100644 apps/web-next/src/features/generate/components/video-parameter-controls.tsx create mode 100644 apps/web-next/src/features/generate/lib/edit-image-api.ts create mode 100644 apps/web-next/src/features/generate/lib/use-image-edit.ts create mode 100644 apps/web-next/src/features/library/components/asset-lightbox.tsx create mode 100644 apps/web-next/src/features/workspace/lib/workspace-nav.ts create mode 100644 apps/web-next/src/shared/hooks/useDialog.ts create mode 100644 apps/web-next/src/shared/hooks/useGenerationMode.ts create mode 100644 apps/web-next/src/shared/lib/app-routes.ts create mode 100644 apps/web-next/src/shared/lib/media-parameters.ts create mode 100644 apps/web-next/src/shared/lib/model-capabilities.ts delete mode 100644 apps/web-next/src/shared/stores/admin-ui-store.ts create mode 100644 apps/worker/src/jobs/media-request.test.ts create mode 100644 apps/worker/src/plugins/availability.test.ts create mode 100644 apps/worker/src/plugins/availability.ts create mode 100644 apps/worker/src/plugins/loader.test.ts create mode 100644 apps/worker/src/plugins/loader.ts create mode 100644 packages/contracts/src/image-edit.test.ts create mode 100644 packages/contracts/src/image-edit.ts create mode 100644 packages/contracts/src/media-parameter-validation.test.ts create mode 100644 packages/contracts/src/media-parameter-validation.ts create mode 100644 packages/contracts/src/media-parameters.ts create mode 100644 packages/domain/src/media-capabilities.test.ts create mode 100644 packages/providers/src/core/image-edit.test.ts create mode 100644 packages/providers/src/core/image-edit.ts create mode 100644 packages/providers/src/core/language-errors.test.ts create mode 100644 packages/providers/src/core/language-errors.ts create mode 100644 packages/providers/src/core/language-registry.test.ts create mode 100644 packages/providers/src/core/language-registry.ts create mode 100644 packages/providers/src/core/plugin-registry.test.ts create mode 100644 packages/providers/src/core/plugin-registry.ts create mode 100644 packages/providers/src/core/plugin-scan.test.ts create mode 100644 packages/providers/src/core/plugin-scan.ts create mode 100644 packages/providers/src/core/thumbnail.test.ts create mode 100644 packages/providers/src/core/thumbnail.ts create mode 100644 packages/providers/src/core/url-guard.test.ts create mode 100644 packages/providers/src/core/url-guard.ts create mode 100644 packages/providers/src/language-model-plugin.test.ts create mode 100644 packages/providers/src/plugins/openai-image/models.test.ts create mode 100644 packages/providers/src/plugins/openai-image/models.ts create mode 100644 scripts/backfill-asset-thumbnails.ts create mode 100644 tests/integration/capabilities-backfill.test.ts 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 */} -