diff --git a/.env.example b/.env.example index 43c21d2b2..507e76952 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ AUTH_SECRET=replace-with-random-secret # Public application URL. Required for production and self-hosted Auth.js. AUTH_URL=https://your-domain.com +NEXTAUTH_URL=https://your-domain.com NEXT_PUBLIC_APP_URL=https://your-domain.com # Only enable behind a trusted reverse proxy with validated Host headers. @@ -81,6 +82,14 @@ FAL_BASE_URL=https://fal.run FAL_MODEL= FAL_REALTIME_MODEL= +# Realtime Canvas fallback providers. +REPLICATE_API_TOKEN= +BFL_API_KEY= +BFL_KEY= +BFL_BASE_URL= +BFL_MODEL= +RUNWARE_MODEL= + # 3D / CAD providers. TRIPO_API_KEY= TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi @@ -96,6 +105,7 @@ ZOO_BASE_URL=https://api.zoo.dev # RUXA / video callback. RUXA_CALLBACK_URL= +RUXA_VIDEO_CALLBACK_URL= # ----------------------------------------------------------------------------- # Product Planning Integrations @@ -111,6 +121,7 @@ TAOBAO_SALE_API_TOKEN= PRODUCT_PLANNING_DB_HOST= PRODUCT_PLANNING_DB_PORT=3306 +PRODUCT_PLANNING_DB_DATABASE= PRODUCT_PLANNING_DB_NAME= PRODUCT_PLANNING_DB_USER= PRODUCT_PLANNING_DB_PASSWORD= @@ -143,6 +154,9 @@ AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES= AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS= AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL=6 AGENT_PSD_RENDER_TASK_CONCURRENCY=2 +NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR=true +NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE=0.01 +NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER=true # ----------------------------------------------------------------------------- # Logging / Observability @@ -171,3 +185,6 @@ PORT=3001 PLAYWRIGHT=false CI_PLAYWRIGHT=false PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:3001 +SMOKE_BASE_URL=http://127.0.0.1:3001 +SMOKE_TIMEOUT_MS=20000 +DISABLE_PERF_GUARD_AUTO_INSTALL=false diff --git a/README.md b/README.md index 269e93c42..8799a0d1a 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ pnpm lint pnpm check:logs pnpm check:brand pnpm check:ci +pnpm check:env pnpm check:artifacts pnpm check:quality pnpm clean:artifacts diff --git a/docs/operations.md b/docs/operations.md index f651d2a60..b929b1480 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -9,6 +9,7 @@ pnpm lint pnpm check:logs pnpm check:brand pnpm check:ci +pnpm check:env pnpm exec tsc --noEmit --pretty false git diff --check pnpm check:artifacts @@ -27,7 +28,7 @@ pnpm check:quality ## CI -`.github/workflows/lint.yml` 是基础质量门禁,运行 `pnpm check:quality`,负责 lint、日志治理、公开交付面品牌治理、CI hygiene、类型检查、diff hygiene 和 tracked 污染物检查。 +`.github/workflows/lint.yml` 是基础质量门禁,运行 `pnpm check:quality`,负责 lint、日志治理、公开交付面品牌治理、CI hygiene、环境变量示例卫生、类型检查、diff hygiene 和 tracked 污染物检查。 CI 和本地提交前检查复用同一个入口,避免规则漂移。 @@ -67,6 +68,14 @@ pnpm check:ci `pnpm check:ci` 会检查 workflow 是否保留只读权限、concurrency、固定 Ubuntu runner、固定 pnpm 版本、frozen install、cache v4,并禁止 `@latest`、`ubuntu-latest`、`--no-lint` 或构建 fallback 这类不稳定入口回流。 +## 环境变量示例治理 + +```bash +pnpm check:env +``` + +`pnpm check:env` 会检查 `.env.example` 是否包含关键部署变量、是否存在重复 key、是否把 token/password/API key 这类字段填成疑似真实密钥。 + ## 首页性能烟测 ```bash diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 54feb4b87..9a40317f9 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -13,7 +13,7 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes 预期: -- `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、类型检查、diff hygiene 和 tracked 污染物检查。 +- `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、环境变量示例卫生、类型检查、diff hygiene 和 tracked 污染物检查。 - `pnpm smoke:routes` 验证 `/` 返回 200。 - `/classic` 和 `/chat/test-id` 重定向到 `/`。 - `/settings` 未登录重定向到 `/login?callbackUrl=%2Fsettings`。 diff --git a/package.json b/package.json index e1575d225..edc6c3506 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "check:artifacts": "node scripts/check-tracked-artifacts.mjs", "check:brand": "node scripts/check-brand-hygiene.mjs", "check:ci": "node scripts/check-ci-hygiene.mjs", - "check:quality": "pnpm lint && pnpm check:logs && pnpm check:brand && pnpm check:ci && pnpm exec tsc --noEmit --pretty false && git diff --check && pnpm check:artifacts", + "check:env": "node scripts/check-env-example.mjs", + "check:quality": "pnpm lint && pnpm check:logs && pnpm check:brand && pnpm check:ci && pnpm check:env && pnpm exec tsc --noEmit --pretty false && git diff --check && pnpm check:artifacts", "clean:artifacts": "node scripts/clean-local-artifacts.mjs", "smoke:routes": "node scripts/smoke-routes.mjs", "format": "pnpm exec ultracite fix", diff --git a/scripts/check-env-example.mjs b/scripts/check-env-example.mjs new file mode 100644 index 000000000..6c548ecc2 --- /dev/null +++ b/scripts/check-env-example.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +const envExamplePath = ".env.example"; +const source = readFileSync(envExamplePath, "utf8"); +const lines = source.split(/\r?\n/); + +const requiredKeys = [ + "AUTH_SECRET", + "AUTH_URL", + "NEXTAUTH_URL", + "NEXT_PUBLIC_APP_URL", + "AUTH_TRUST_HOST", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "ADMIN_EMAIL", + "ADMIN_PASSWORD", + "RESEND_API_KEY", + "RESEND_FROM_EMAIL", + "EMAIL_VERIFICATION_SECRET", + "CRON_SECRET", + "POSTGRES_URL", + "POSTGRES_MAX_CONNECTIONS", + "POSTGRES_IDLE_TIMEOUT_SECONDS", + "POSTGRES_CONNECT_TIMEOUT_SECONDS", + "BLOB_READ_WRITE_TOKEN", + "S3_ENDPOINT", + "S3_REGION", + "S3_BUCKET", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "S3_PUBLIC_URL", + "AI_GATEWAY_API_KEY", + "NEXT_PUBLIC_DEFAULT_CHAT_MODEL", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_API_KEY", + "GTP5_API_URL", + "GTP5_API_KEY", + "GTP5_MODEL", + "FAL_KEY", + "FAL_API_KEY", + "FAL_BASE_URL", + "FAL_MODEL", + "FAL_REALTIME_MODEL", + "REPLICATE_API_TOKEN", + "BFL_API_KEY", + "BFL_KEY", + "BFL_BASE_URL", + "BFL_MODEL", + "RUNWARE_MODEL", + "TRIPO_API_KEY", + "TRIPO_BASE_URL", + "WORLDLABS_API_KEY", + "WORLDLABS_BASE_URL", + "WORLDLABS_IMPORT_TOKEN", + "WORLDLABS_KEY_POOL_IMPORT_TOKEN", + "KITTYCAD_API_TOKEN", + "KITTYCAD_API_KEY", + "KITTYCAD_BASE_URL", + "ZOO_API_TOKEN", + "ZOO_BASE_URL", + "RUXA_CALLBACK_URL", + "RUXA_VIDEO_CALLBACK_URL", + "PRODUCT_PLANNING_DETAIL_PLUS_BASE_URL", + "PRODUCT_PLANNING_DETAIL_PLUS_INTERNAL_BASE_URL", + "PRODUCT_PLANNING_DETAIL_PLUS_API_KEY", + "PRODUCT_PLANNING_QA_TOKEN", + "TAOBAO_API_KEY", + "TAOBAO_API_SECRET", + "TAOBAO_SALE_API_TOKEN", + "PRODUCT_PLANNING_DB_HOST", + "PRODUCT_PLANNING_DB_PORT", + "PRODUCT_PLANNING_DB_DATABASE", + "PRODUCT_PLANNING_DB_NAME", + "PRODUCT_PLANNING_DB_USER", + "PRODUCT_PLANNING_DB_PASSWORD", + "PRODUCT_PLANNING_DB_CHARSET", + "PRODUCT_PLANNING_DB_SSL", + "CANVAS_IMAGE_TASK_CONCURRENCY", + "CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS", + "EXTERNAL_IMAGE_API_MAX_CONCURRENT", + "EXTERNAL_IMAGE_API_MAX_QUEUE", + "EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS", + "AGENT_FORCE_LLM_PLANNER", + "AGENT_FORCE_QWEN3VL_FALLBACK", + "AGENT_IMAGE_GEN_LLM_OPTIMIZER_ENABLED", + "AGENT_LLM_PROMPT_OPTIMIZER_ENABLED", + "AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT", + "AGENT_PSD_LOCAL_CACHE_DIR", + "AGENT_PSD_REMOTE_ASSET_CACHE_DISABLED", + "AGENT_PSD_REMOTE_ASSET_CACHE_MAX_BYTES", + "AGENT_PSD_REMOTE_ASSET_CACHE_MAX_ENTRY_BYTES", + "AGENT_PSD_REMOTE_ASSET_CACHE_TTL_MS", + "AGENT_PSD_RENDER_PNG_COMPRESSION_LEVEL", + "AGENT_PSD_RENDER_TASK_CONCURRENCY", + "NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR", + "NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE", + "NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER", + "LOG_LEVEL", + "LOG_INCLUDE_STACK", + "NEXT_PUBLIC_ENABLE_CLIENT_LOGS", + "VIDEO_ARCHIVE_DELAY_HOURS", + "PORT", + "PLAYWRIGHT", + "CI_PLAYWRIGHT", + "PLAYWRIGHT_TEST_BASE_URL", + "SMOKE_BASE_URL", + "SMOKE_TIMEOUT_MS", + "DISABLE_PERF_GUARD_AUTO_INSTALL", +]; + +const secretLikeKey = /(SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY|CLIENT_SECRET|FAL_KEY|BFL_KEY|ZOO_API_TOKEN|KITTYCAD_API_TOKEN)$/; +const suspiciousSecretValue = + /(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,})/; +const allowedSecretPlaceholders = /^(|replace-with-[a-z0-9-]+|change-me[a-z0-9-]*|placeholder|example|your-[a-z0-9-]+)$/i; +const parsed = new Map(); +const violations = []; + +lines.forEach((line, index) => { + if (!line || line.trim().startsWith("#")) { + return; + } + + const match = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) { + violations.push(`${envExamplePath}:${index + 1}: invalid env assignment: ${line}`); + return; + } + + const [, key, rawValue] = match; + const value = rawValue.trim(); + + if (parsed.has(key)) { + violations.push(`${envExamplePath}:${index + 1}: duplicate key: ${key}`); + } + parsed.set(key, { value, line: index + 1 }); + + if (secretLikeKey.test(key) && value && !allowedSecretPlaceholders.test(value)) { + violations.push(`${envExamplePath}:${index + 1}: secret-like key must use an empty value or placeholder: ${key}`); + } + + if (suspiciousSecretValue.test(value)) { + violations.push(`${envExamplePath}:${index + 1}: suspicious real secret value detected: ${key}`); + } +}); + +for (const key of requiredKeys) { + if (!parsed.has(key)) { + violations.push(`${envExamplePath}: missing documented env key: ${key}`); + } +} + +if (violations.length > 0) { + console.error("Environment example hygiene check failed."); + console.error(violations.join("\n")); + process.exit(1); +} + +console.log(`Environment example hygiene check passed for ${parsed.size} keys.`);