diff --git a/.env.example b/.env.example index 44008b1..4ad78fa 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ -# Dev proxy target — the backend API server -VITE_DEV_PROXY=http://47.110.225.76:3600 - -# Key server URL for auth/profile endpoints -VITE_KEY_SERVER_URL= - -# Main API base URL (used when not served from omniai.net.cn) -VITE_API_BASE_URL= \ No newline at end of file +# Frontend environment variables are intentionally unsupported. +# +# API traffic must go through same-origin /api. +# Public runtime settings must come from application APIs. +# Provider keys and OSS credentials must stay on the server. diff --git a/.gitignore b/.gitignore index 39ebdc3..ddc64a0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ node_modules/ Thumbs.db .vscode/ .idea/ +.claude/ +tmp/ *.swp *.swo -coverage/ \ No newline at end of file +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4f0e1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Project Rules + +## Asset, Key, And Runtime Data Governance + +These rules are mandatory for all frontend, backend, deployment, and agent-generated changes. + +1. Image and media assets must be stored in OSS. + - Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders. + - Code may reference media only by OSS URL or by data returned from an API. + - Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification. + +2. Frontend code must not contain API keys or secrets. + - Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs. + - Browser-delivered code must treat every visible value as public. + +3. Provider keys are owned by the server key pool. + - AI provider credentials are stored and managed server-side. + - The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client. + - Do not add direct browser-to-provider calls that require provider credentials. + +4. Application data must come through APIs. + - Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend. + - Use typed API clients and server-provided payloads for runtime data. + - Static constants are allowed only for presentation defaults that are not business-authoritative. + +5. Do not use fixed environment configuration in application code. + - Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code. + - Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints. + - Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets. + - If the browser needs runtime configuration, it must request that data from an application API. + +6. Deployment configuration must follow the same rules. + - Nginx and process manager configs must not embed provider API keys or long-lived credentials. + - Reverse proxies should route application traffic to the backend, not expose third-party credentials. + - Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs. + +7. Reviews must reject violations. + - Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue. + - Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config. diff --git a/package.json b/package.json index efb8eb3..9cdec5e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview --host 127.0.0.1", "type-check": "tsc -p tsconfig.json --noEmit", + "governance:check": "node scripts/check-governance.mjs", "style:check": "node scripts/check-style-governance.mjs", "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" }, diff --git a/scripts/check-governance.mjs b/scripts/check-governance.mjs new file mode 100644 index 0000000..efd2246 --- /dev/null +++ b/scripts/check-governance.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]); +const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]); + +const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"]; +const allowedFiles = new Set([ + normalizePath("src/data/ossAssets.ts"), + normalizePath("src/utils/ossImageOptimize.ts"), +]); + +const forbiddenPatterns = [ + { label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ }, + { label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i }, + { label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i }, + { label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i }, + { label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i }, +]; + +const failures = []; + +function normalizePath(value) { + return value.replace(/\\/g, "/"); +} + +function walk(targetPath, visitor) { + if (!fs.existsSync(targetPath)) return; + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(targetPath)) { + if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; + walk(path.join(targetPath, entry), visitor); + } + return; + } + visitor(targetPath, stat); +} + +function report(file, message) { + failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`); +} + +walk(path.join(repoRoot, "src", "assets"), (file) => { + if (mediaExtensions.has(path.extname(file).toLowerCase())) { + report(file, "media files must live in OSS, not src/assets"); + } +}); + +for (const root of scanRoots) { + walk(path.join(repoRoot, root), (file) => { + const relative = normalizePath(path.relative(repoRoot, file)); + const ext = path.extname(file).toLowerCase(); + if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return; + if (relative.startsWith("src/assets/")) return; + + const content = fs.readFileSync(file, "utf8"); + const isAllowed = allowedFiles.has(relative); + for (const rule of forbiddenPatterns) { + if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) { + continue; + } + if (rule.pattern.test(content)) { + report(file, `forbidden ${rule.label}`); + } + } + }); +} + +if (failures.length) { + console.error("Governance check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Governance check passed."); diff --git a/scripts/check-style-governance.mjs b/scripts/check-style-governance.mjs new file mode 100644 index 0000000..3f5d301 --- /dev/null +++ b/scripts/check-style-governance.mjs @@ -0,0 +1 @@ +import "./check-governance.mjs"; diff --git a/server-patches/patch-email-verification.js b/server-patches/patch-email-verification.js deleted file mode 100644 index bfc1967..0000000 --- a/server-patches/patch-email-verification.js +++ /dev/null @@ -1,291 +0,0 @@ -const fs = require("fs"); - -// ── Patch 1: context.js ────────────────────────────────────── -const ctxPath = "/opt/omniai-server/src/routes/context.js"; -let ctx = fs.readFileSync(ctxPath, "utf8"); - -const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);"; -const emailConsts = ` -const EMAIL_PURPOSES = new Set(["register", "login", "reset"]); -const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10); -const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60); -const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`; - -if (!ctx.includes("EMAIL_PURPOSES")) { - ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts); - console.log("[ctx] added EMAIL_PURPOSES"); -} - -const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}'; -const emailFuncs = ` -function hashEmailCode(email, code) { - const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret"; - return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); -} - -async function sendEmailCode(email, code, purpose) { - const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase(); - - if (provider === "smtp") { - const nodemailer = require("nodemailer"); - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT) || 587, - secure: process.env.SMTP_SECURE === "1", - auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, - }); - - const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801"; - await transporter.sendMail({ - from: process.env.SMTP_FROM || process.env.SMTP_USER, - to: email, - subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801", - text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002", - html: '
\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a
' + code + '
\u7528\u9014\uff1a' + purposeText + '
\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f
\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002