Commit bf8317726d82e3632f48117d2a657cc3362373e4

Authored by yibo
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
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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(&quot;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(&quot;generate: a transient non-JSON poll response doesn&#39;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, &quot;dance-refs.json&quot;));
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 () =&gt; {
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  
... ...