diff --git a/admin-pages-flow.test.js b/admin-pages-flow.test.js index 8faba99..c0d1718 100644 --- a/admin-pages-flow.test.js +++ b/admin-pages-flow.test.js @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) { test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/auth.test.js b/auth.test.js index 48f9d80..70c2ed2 100644 --- a/auth.test.js +++ b/auth.test.js @@ -37,7 +37,7 @@ let child; let cwd; test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } child = spawn(process.execPath, ["server.js"], { diff --git a/dance-channel.test.js b/dance-channel.test.js index 1ae4fbf..f0f443e 100644 --- a/dance-channel.test.js +++ b/dance-channel.test.js @@ -91,7 +91,7 @@ test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); diff --git a/dressup-crud.test.js b/dressup-crud.test.js index 16e374a..9648a94 100644 --- a/dressup-crud.test.js +++ b/dressup-crud.test.js @@ -41,7 +41,7 @@ test.before(async () => { // empty and this test can't collide with real seed data or the live // pm2 instance. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/dressup-generate.test.js b/dressup-generate.test.js index 29d03c1..6226cef 100644 --- a/dressup-generate.test.js +++ b/dressup-generate.test.js @@ -104,7 +104,7 @@ test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } // ensureH3AssetUrl reads these off disk (path.join(__dirname, record.imageUrl)) @@ -331,7 +331,7 @@ test("generate: poll loop honors GENERATION_TIMEOUT_MS wall-clock, not a fixed p const port2 = 22934 + Math.floor(Math.random() * 1000); const base2 = `http://localhost:${port2}`; const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-timeout-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); } fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); @@ -451,7 +451,7 @@ test("generate: a transient non-JSON poll response doesn't fail the job, it just const port2 = 23934 + Math.floor(Math.random() * 1000); const base2 = `http://localhost:${port2}`; const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-flaky-poll-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); } fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); diff --git a/dressup-store.js b/dressup-store.js index e130b27..42b0321 100644 --- a/dressup-store.js +++ b/dressup-store.js @@ -13,4 +13,9 @@ const danceRefs = createStore(path.join(__dirname, "dance-refs.json")); // The finished *video* itself lands in library.json (M.7), not here. const generationJobs = createStore(path.join(__dirname, "generation-jobs.json")); -module.exports = { characters, garments, danceRefs, generationJobs }; +// Auto-moderation results (dev-plan-4.md T26; schema M.9) — one row per +// moderateGarmentImage() call, approved or rejected, so a human can audit +// what the automated gate did even before a real vendor is picked. +const moderationLog = createStore(path.join(__dirname, "moderation-log.json")); + +module.exports = { characters, garments, danceRefs, generationJobs, moderationLog }; diff --git a/server.js b/server.js index df36cc4..c575fa0 100644 --- a/server.js +++ b/server.js @@ -53,9 +53,10 @@ const { saveVideoUpload, forwardToH3Assets, } = require("./uploads.js"); -const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js"); +const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); const { buildStage2Prompt } = require("./prompt-template.js"); -const { users: userStore, sessions: userSessions } = require("./db.js"); +const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); +const { pricing } = require("./pricing.js"); const LIBRARY_PATH = path.join(__dirname, "library.json"); function getLibrary() { @@ -167,6 +168,44 @@ function sendVerificationEmail(email, token) { return verifyUrl; } +// T25: no vision-model vendor has been picked yet (dev-plan-4.md §1.2 item +// 2 — needed because mode 2's uploader has no admin around to hand-write +// garments.appearanceDescriptor the way admin_seed garments do). Same seam +// pattern as sendVerificationEmail above: today it's a fixed placeholder, +// swapping in a real vendor later is a one-function change, callers (T27) +// don't need to know the difference. +const VISION_API_KEY = process.env.VISION_API_KEY || ""; +async function describeGarmentImage(absImagePath) { + if (!VISION_API_KEY) return "用户上传的服装"; + throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); +} + +// T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 +// item 1 — the requirements doc names a vendor as "TBD" and never actually +// lists it as its own task, so this is that gap filled in). Every call logs +// to moderation_log (M.9) regardless of which branch runs, so the audit +// trail exists before a real vendor does. `assetId` is the caller's own +// stable id for the upload (T27 passes the just-saved file's relPath, since +// at moderation time there's no garments.id yet — moderation runs before +// the garment record is created, per dev-plan-4.md T27's ordering). +const MODERATION_API_KEY = process.env.MODERATION_API_KEY || ""; +async function moderateGarmentImage(absImagePath, assetId) { + let verdict, reason; + if (!MODERATION_API_KEY) { + // No vendor configured: default-permissive placeholder pass (T2's + // upload endpoint already enforces the real trust-boundary checks — + // file type by magic bytes, 8MB cap, EXIF-stripped re-encode — this is + // only the *content* check, which nothing here can actually perform + // without a vendor). + verdict = "auto_pass"; + reason = null; + } else { + throw new Error("moderation vendor not implemented — set MODERATION_API_KEY only once a provider is wired up"); + } + moderationLog.create({ assetType: "garment_upload", assetId, verdict, reason, reviewedBy: "system" }); + return { approved: verdict === "auto_pass", verdict, reason }; +} + // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are // the same viewer_uid/viewer_nickname cookies those pages already read diff --git a/uploads-route.test.js b/uploads-route.test.js index 68af903..c354dc9 100644 --- a/uploads-route.test.js +++ b/uploads-route.test.js @@ -48,7 +48,7 @@ test.before(async () => { // pattern as dressup-generate.test.js / dance-channel.test.js, so this // test can never touch the real repo's uploads/. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "uploads-route-test-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); }