Commit bf8317726d82e3632f48117d2a657cc3362373e4
1 parent
9ea1cfce
Add T25 (garment auto-tagging) + T26 (garment image moderation)
Both are pluggable seams (dev-plan-4.md §1.2 items 1-2), same shape as T20's sendVerificationEmail: a fixed env var gates a real vendor call that isn't implemented yet (none has been picked), and the no-vendor path is what actually runs today. describeGarmentImage() — mode 2 has no admin around to hand-write garments.appearanceDescriptor the way admin_seed garments do, so it needs *something* non-empty to fill that field; today that's a fixed placeholder string, swapped for a real vision-model call once a vendor's picked. moderateGarmentImage() — the requirements doc flags image-content moderation as vendor-TBD but never actually lists it as its own task; this fills that gap. Every call writes a moderation_log row (new dressup-store.js export, M.9 schema) regardless of which branch runs, so the audit trail exists before a real vendor does. Default-permissive when unconfigured — T2's upload endpoint already enforces the real trust-boundary checks (magic-byte type check, 8MB cap, EXIF-stripped re-encode); this only covers the *content* check, which nothing here can perform without a vendor. Neither is unit-tested standalone (server.js doesn't export internal functions, matching sendVerificationEmail's existing precedent) — T27 integration-tests both through the actual upload endpoint. Also adds pricing.js to the tmpdir file-copy list in every test that spawns server.js in isolation — server.js now requires it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
8 changed files
with
55 additions
and
11 deletions
admin-pages-flow.test.js
| ... | ... | @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) { |
| 41 | 41 | |
| 42 | 42 | test.before(async () => { |
| 43 | 43 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); |
| 44 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 44 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 45 | 45 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 46 | 46 | } |
| 47 | 47 | ... | ... |
auth.test.js
| ... | ... | @@ -37,7 +37,7 @@ let child; |
| 37 | 37 | let cwd; |
| 38 | 38 | test.before(async () => { |
| 39 | 39 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "auth-test-")); |
| 40 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 40 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 41 | 41 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 42 | 42 | } |
| 43 | 43 | child = spawn(process.execPath, ["server.js"], { | ... | ... |
dance-channel.test.js
| ... | ... | @@ -91,7 +91,7 @@ test.before(async () => { |
| 91 | 91 | await startMockH3(); |
| 92 | 92 | |
| 93 | 93 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-")); |
| 94 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 94 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 95 | 95 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 96 | 96 | } |
| 97 | 97 | fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); | ... | ... |
dressup-crud.test.js
| ... | ... | @@ -41,7 +41,7 @@ test.before(async () => { |
| 41 | 41 | // empty and this test can't collide with real seed data or the live |
| 42 | 42 | // pm2 instance. |
| 43 | 43 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-")); |
| 44 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 44 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 45 | 45 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 46 | 46 | } |
| 47 | 47 | ... | ... |
dressup-generate.test.js
| ... | ... | @@ -104,7 +104,7 @@ test.before(async () => { |
| 104 | 104 | await startMockH3(); |
| 105 | 105 | |
| 106 | 106 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-")); |
| 107 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 107 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 108 | 108 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 109 | 109 | } |
| 110 | 110 | // 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 |
| 331 | 331 | const port2 = 22934 + Math.floor(Math.random() * 1000); |
| 332 | 332 | const base2 = `http://localhost:${port2}`; |
| 333 | 333 | const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-timeout-")); |
| 334 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 334 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 335 | 335 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); |
| 336 | 336 | } |
| 337 | 337 | 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 |
| 451 | 451 | const port2 = 23934 + Math.floor(Math.random() * 1000); |
| 452 | 452 | const base2 = `http://localhost:${port2}`; |
| 453 | 453 | const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-flaky-poll-")); |
| 454 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 454 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 455 | 455 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); |
| 456 | 456 | } |
| 457 | 457 | fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); | ... | ... |
dressup-store.js
| ... | ... | @@ -13,4 +13,9 @@ const danceRefs = createStore(path.join(__dirname, "dance-refs.json")); |
| 13 | 13 | // The finished *video* itself lands in library.json (M.7), not here. |
| 14 | 14 | const generationJobs = createStore(path.join(__dirname, "generation-jobs.json")); |
| 15 | 15 | |
| 16 | -module.exports = { characters, garments, danceRefs, generationJobs }; | |
| 16 | +// Auto-moderation results (dev-plan-4.md T26; schema M.9) — one row per | |
| 17 | +// moderateGarmentImage() call, approved or rejected, so a human can audit | |
| 18 | +// what the automated gate did even before a real vendor is picked. | |
| 19 | +const moderationLog = createStore(path.join(__dirname, "moderation-log.json")); | |
| 20 | + | |
| 21 | +module.exports = { characters, garments, danceRefs, generationJobs, moderationLog }; | ... | ... |
server.js
| ... | ... | @@ -53,9 +53,10 @@ const { |
| 53 | 53 | saveVideoUpload, |
| 54 | 54 | forwardToH3Assets, |
| 55 | 55 | } = require("./uploads.js"); |
| 56 | -const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js"); | |
| 56 | +const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); | |
| 57 | 57 | const { buildStage2Prompt } = require("./prompt-template.js"); |
| 58 | -const { users: userStore, sessions: userSessions } = require("./db.js"); | |
| 58 | +const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); | |
| 59 | +const { pricing } = require("./pricing.js"); | |
| 59 | 60 | |
| 60 | 61 | const LIBRARY_PATH = path.join(__dirname, "library.json"); |
| 61 | 62 | function getLibrary() { |
| ... | ... | @@ -167,6 +168,44 @@ function sendVerificationEmail(email, token) { |
| 167 | 168 | return verifyUrl; |
| 168 | 169 | } |
| 169 | 170 | |
| 171 | +// T25: no vision-model vendor has been picked yet (dev-plan-4.md §1.2 item | |
| 172 | +// 2 — needed because mode 2's uploader has no admin around to hand-write | |
| 173 | +// garments.appearanceDescriptor the way admin_seed garments do). Same seam | |
| 174 | +// pattern as sendVerificationEmail above: today it's a fixed placeholder, | |
| 175 | +// swapping in a real vendor later is a one-function change, callers (T27) | |
| 176 | +// don't need to know the difference. | |
| 177 | +const VISION_API_KEY = process.env.VISION_API_KEY || ""; | |
| 178 | +async function describeGarmentImage(absImagePath) { | |
| 179 | + if (!VISION_API_KEY) return "用户上传的服装"; | |
| 180 | + throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); | |
| 181 | +} | |
| 182 | + | |
| 183 | +// T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 | |
| 184 | +// item 1 — the requirements doc names a vendor as "TBD" and never actually | |
| 185 | +// lists it as its own task, so this is that gap filled in). Every call logs | |
| 186 | +// to moderation_log (M.9) regardless of which branch runs, so the audit | |
| 187 | +// trail exists before a real vendor does. `assetId` is the caller's own | |
| 188 | +// stable id for the upload (T27 passes the just-saved file's relPath, since | |
| 189 | +// at moderation time there's no garments.id yet — moderation runs before | |
| 190 | +// the garment record is created, per dev-plan-4.md T27's ordering). | |
| 191 | +const MODERATION_API_KEY = process.env.MODERATION_API_KEY || ""; | |
| 192 | +async function moderateGarmentImage(absImagePath, assetId) { | |
| 193 | + let verdict, reason; | |
| 194 | + if (!MODERATION_API_KEY) { | |
| 195 | + // No vendor configured: default-permissive placeholder pass (T2's | |
| 196 | + // upload endpoint already enforces the real trust-boundary checks — | |
| 197 | + // file type by magic bytes, 8MB cap, EXIF-stripped re-encode — this is | |
| 198 | + // only the *content* check, which nothing here can actually perform | |
| 199 | + // without a vendor). | |
| 200 | + verdict = "auto_pass"; | |
| 201 | + reason = null; | |
| 202 | + } else { | |
| 203 | + throw new Error("moderation vendor not implemented — set MODERATION_API_KEY only once a provider is wired up"); | |
| 204 | + } | |
| 205 | + moderationLog.create({ assetType: "garment_upload", assetId, verdict, reason, reviewedBy: "system" }); | |
| 206 | + return { approved: verdict === "auto_pass", verdict, reason }; | |
| 207 | +} | |
| 208 | + | |
| 170 | 209 | // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, |
| 171 | 210 | // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are |
| 172 | 211 | // the same viewer_uid/viewer_nickname cookies those pages already read | ... | ... |
uploads-route.test.js
| ... | ... | @@ -48,7 +48,7 @@ test.before(async () => { |
| 48 | 48 | // pattern as dressup-generate.test.js / dance-channel.test.js, so this |
| 49 | 49 | // test can never touch the real repo's uploads/. |
| 50 | 50 | cwd = fs.mkdtempSync(path.join(os.tmpdir(), "uploads-route-test-")); |
| 51 | - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js"]) { | |
| 51 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 52 | 52 | fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); |
| 53 | 53 | } |
| 54 | 54 | ... | ... |