From ce3fcac49bf63f90da905fa7c15f92d7e0844596 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Thu, 20 Aug 2026 19:42:24 -0400 Subject: [PATCH 1/2] feat: add store asset validator and gated partner client Public bin validates Valve sizes locally with no new auth. Partner cookie uploads stay out of the default npm tarball and never Publish. Signed-off-by: fOuttaMyPaint Co-authored-by: Cursor --- .env.example | 6 + .github/workflows/ci.yml | 7 + .gitignore | 32 ++ CLAUDE.md | 13 +- README.md | 11 +- package-lock.json | 6 +- package.json | 4 +- scripts/scan-secrets.py | 109 +++++++ src/index.ts | 4 +- src/partner/__tests__/partner-tools.test.ts | 25 ++ src/partner/index.ts | 48 +++ src/partner/tools.ts | 275 ++++++++++++++++++ src/storeAssets/heroHeuristics.ts | 124 ++++++++ src/storeAssets/png.ts | 262 +++++++++++++++++ src/storeAssets/slots.ts | 50 ++++ src/storeAssets/validate.ts | 150 ++++++++++ .../__tests__/validateStoreAsset.test.ts | 229 +++++++++++++++ src/tools/validateStoreAsset.ts | 32 ++ tsconfig.json | 2 +- 19 files changed, 1376 insertions(+), 13 deletions(-) create mode 100644 scripts/scan-secrets.py create mode 100644 src/partner/__tests__/partner-tools.test.ts create mode 100644 src/partner/index.ts create mode 100644 src/partner/tools.ts create mode 100644 src/storeAssets/heroHeuristics.ts create mode 100644 src/storeAssets/png.ts create mode 100644 src/storeAssets/slots.ts create mode 100644 src/storeAssets/validate.ts create mode 100644 src/tools/__tests__/validateStoreAsset.test.ts create mode 100644 src/tools/validateStoreAsset.ts diff --git a/.env.example b/.env.example index 3dc6770..3dc12e8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ # Steam Web API Key # Get yours free at https://steamcommunity.com/dev/apikey STEAM_API_KEY= + +# Partner admin (separate process only: npx tsx src/partner/index.ts). +# Names only. Put real values in a gitignored local .env, never in mcp.json. +# STEAM_PARTNER_ADMIN=1 +# STEAM_PARTNER_COOKIES= +# STEAM_PARTNER_PROFILE_DIR= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 237c5f1..4672803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,10 @@ jobs: - run: npm ci - run: npm run build - run: npm test + + secrets-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Scan for committed Steam secrets + run: python3 scripts/scan-secrets.py diff --git a/.gitignore b/.gitignore index dafa3b2..c8a996b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,41 @@ node_modules/ dist/ .env .env.* +.env.local +.env.partner !.env.example *.ssfn +ssfn* config.vdf +loginusers.vdf +local.vdf + +# Cookie jars and Playwright auth +cookies.txt +cookies.json +*.cookies +*cookie-jar* +storageState.json +**/playwright/.auth/ + +# Chromium / Playwright profiles (never store these in-repo) +**/*user-data-dir*/ +**/chromium-profile/ +**/partner-profile/ +**/.pw-profile/ +playwright-report/ +blob-report/ +*.har + +# Generic secrets +secrets.json +*.pem +*.key +*.p12 + +# Partner-admin dry-run dumps +partner-dry-run*.json +admin-save-payload*.json # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 27fca61..5d85c15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## What is this? -An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 25 tools: 18 read-only and 7 write/guidance tools. +An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 26 tools: 19 read-only and 7 write/guidance tools. The plugin's skills reference these MCP tools to fetch live data from Steam - player stats, store info, workshop items, leaderboards, and more. @@ -17,7 +17,13 @@ src/ getAppDetails.ts Each file exports a register(server) function searchApps.ts that adds one tool with its name, description, getPlayerCount.ts zod input schema, and async handler + validateStoreAsset.ts ... + storeAssets/ + slots.ts Valve pixel sizes and unofficial Partner form field names + png.ts PNG/JPEG header parse plus PNG pixel decode + heroHeuristics.ts Library-hero ribbon / seam / wordmark checks + validate.ts Pure validateStoreAsset(path, slot) utils/ steam-api.ts Shared fetch wrapper, URL builders, API key helper, error formatting errors.ts Custom error classes (rate limit, missing key, unavailable) @@ -29,7 +35,7 @@ src/ - `steam-api.ts` provides `steamFetch()` which handles timeouts (15s via AbortController with `TimeoutError`), HTTP error detection (429 rate limits with up to 2 retries and exponential backoff, 5xx unavailable), and JSON parsing. - `errorResponse()` formats errors as MCP-compatible `{ isError: true }` responses. - Tools that need an API key call `requireApiKey()` which reads `STEAM_API_KEY` from env and throws `MissingApiKeyError` with setup instructions if missing. -- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp) work without any configuration. +- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp, validateStoreAsset) work without any configuration. ## How to build and run @@ -74,6 +80,9 @@ No-auth tools can be tested without setting `STEAM_API_KEY`. | Variable | Required | Description | |----------|----------|-------------| | `STEAM_API_KEY` | For some tools | Steam Web API key from https://steamcommunity.com/dev/apikey | +| `STEAM_PARTNER_ADMIN` | Partner process only | Must be `1` to start `src/partner/index.ts`. Ignored by the default bin. | +| `STEAM_PARTNER_COOKIES` | Partner process only | Path to a cookie jar. Never a cookie string. Gitignored. | +| `STEAM_PARTNER_PROFILE_DIR` | Partner process only | Chromium user-data dir outside the repo. | ## Relationship to the companion plugin diff --git a/README.md b/README.md index 2468a74..fd7b8e2 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@

node - MCP tools + MCP tools Steam Web API

--- -

25 MCP tools - 10 no-auth - 8 API key - 7 publisher key

+

26 MCP tools - 11 no-auth - 8 API key - 7 publisher key

Query Steam store data, player statistics, achievements, reviews, pricing, workshop items, leaderboards, inventory, and player profiles - all as structured MCP tools callable from Cursor's AI agent. @@ -107,10 +107,10 @@ Add the Steam MCP server to your Cursor MCP settings (`.cursor/mcp.json` in your Once configured, the tools are available to Cursor's AI agent. Pair with the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) plugin for the full skill set. -## Available Tools (v0.7.0) - 25 Total +## Available Tools (v0.8.0) - 26 Total
-Read Tools (No Auth) - 10 tools +Read Tools (No Auth) - 11 tools These work without an API key: @@ -126,6 +126,7 @@ These work without an API key: | `steam_getAppReviewSummary` | Review score, total counts, and positive percentage (no individual reviews) | | `steam_getRegionalPricing` | Pricing breakdown across multiple countries/regions | | `steam_getNewsForApp` | Recent news articles with title, URL, contents, date, and author | +| `steam_validateStoreAsset` | Local PNG/JPEG vs Valve store and library sizes, plus library-hero heuristics |
@@ -204,6 +205,8 @@ npm run test:watch # Test watch mode See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add new tools and submit PRs. +Partner-admin tools (`steam_partnerLogin`, `steam_uploadStoreImage`, `steam_uploadTrailer`) are not registered by this package's default bin and are not in the npm tarball. They live in `src/partner/` for local use only (`STEAM_PARTNER_ADMIN=1` plus a cookie-jar path or Chromium profile dir outside the repo). There is no Publish tool. + ## Related diff --git a/package-lock.json b/package-lock.json index 8a284bc..1bed211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tmhs/steam-mcp", - "version": "0.2.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tmhs/steam-mcp", - "version": "0.2.0", + "version": "0.8.0", "license": "CC-BY-NC-ND-4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", @@ -22,7 +22,7 @@ "vitest": "^4.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@emnapi/core": { diff --git a/package.json b/package.json index e5989cb..5d57ade 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tmhs/steam-mcp", - "version": "0.7.0", - "description": "MCP server for Steam & Steamworks APIs - 25 tools (18 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.", + "version": "0.8.0", + "description": "MCP server for Steam & Steamworks APIs - 26 tools (19 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.", "type": "module", "main": "dist/index.js", "bin": { diff --git a/scripts/scan-secrets.py b/scripts/scan-secrets.py new file mode 100644 index 0000000..78ab578 --- /dev/null +++ b/scripts/scan-secrets.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Fail CI if partner cookies or API keys look committed. + +Scans git-tracked files only so a local gitignored .env is allowed. +Excludes documentation and lockfiles so naming the variables is allowed. +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +SKIP_NAMES = { + "package-lock.json", + ".env.example", + "SECURITY.md", + "scan-secrets.py", +} + +SKIP_SUFFIXES = { + ".md", + ".mdc", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".woff2", + ".ico", +} + +SKIP_DIR_PREFIXES = ( + "rules/", + "node_modules/", + "dist/", +) + +PATTERNS: list[tuple[re.Pattern[str], str]] = [ + ( + re.compile(r"""STEAM_API_KEY\s*=\s*["']?[0-9a-fA-F]{32}"""), + "STEAM_API_KEY assigned a 32-char hex value", + ), + ( + re.compile(r"steamLoginSecure="), + "steamLoginSecure cookie assignment", + ), + ( + re.compile(r"""STEAM_PARTNER_COOKIES\s*=\s*["']?[^"'\s#]+"""), + "STEAM_PARTNER_COOKIES assigned a non-empty value", + ), +] + + +def tracked_files() -> list[Path]: + proc = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + ) + out: list[Path] = [] + for rel in proc.stdout.split(b"\0"): + if not rel: + continue + text = rel.decode("utf-8", errors="replace").replace("\\", "/") + if text in SKIP_NAMES or Path(text).name in SKIP_NAMES: + continue + if any(text.startswith(prefix) for prefix in SKIP_DIR_PREFIXES): + continue + path = ROOT / text + if path.suffix.lower() in SKIP_SUFFIXES: + continue + if path.is_file(): + out.append(path) + return out + + +def main() -> int: + files = tracked_files() + hits: list[str] = [] + for path in files: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + print(f"skip unreadable {path}: {exc}", file=sys.stderr) + continue + rel = path.relative_to(ROOT).as_posix() + for pattern, label in PATTERNS: + if pattern.search(text): + hits.append(f"{rel}: {label}") + if hits: + print("Secret-pattern scan failed:", file=sys.stderr) + for hit in hits: + print(f" {hit}", file=sys.stderr) + return 1 + print(f"Secret-pattern scan clean ({len(files)} tracked files).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/index.ts b/src/index.ts index af29731..925558e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,10 +28,11 @@ import { register as registerGetSchemaForGame } from "./tools/getSchemaForGame.j import { register as registerGetNewsForApp } from "./tools/getNewsForApp.js"; import { register as registerGetLeaderboardsForGame } from "./tools/getLeaderboardsForGame.js"; import { register as registerGetPlayerAchievements } from "./tools/getPlayerAchievements.js"; +import { register as registerValidateStoreAsset } from "./tools/validateStoreAsset.js"; const server = new McpServer({ name: "steam-mcp", - version: "0.7.0", + version: "0.8.0", }); registerGetAppDetails(server); @@ -59,6 +60,7 @@ registerGetSchemaForGame(server); registerGetNewsForApp(server); registerGetLeaderboardsForGame(server); registerGetPlayerAchievements(server); +registerValidateStoreAsset(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/partner/__tests__/partner-tools.test.ts b/src/partner/__tests__/partner-tools.test.ts new file mode 100644 index 0000000..1b483b8 --- /dev/null +++ b/src/partner/__tests__/partner-tools.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { refuseIfUnconfirmed } from "../../partner/tools.js"; +import { SLOT_FORM_FIELD } from "../../storeAssets/slots.js"; + +describe("partner destructive gates", () => { + it("allows dry_run without confirm", () => { + expect(refuseIfUnconfirmed(true, undefined)).toBeNull(); + expect(refuseIfUnconfirmed(true, false)).toBeNull(); + }); + + it("rejects live calls without confirm", () => { + expect(refuseIfUnconfirmed(false, undefined)).toMatch(/confirm must be true/); + expect(refuseIfUnconfirmed(false, false)).toMatch(/confirm must be true/); + }); + + it("allows live calls with confirm", () => { + expect(refuseIfUnconfirmed(false, true)).toBeNull(); + }); +}); + +describe("partner form fields", () => { + it("maps libraryHero to library_hero|image", () => { + expect(`${SLOT_FORM_FIELD.libraryHero}|image`).toBe("library_hero|image"); + }); +}); diff --git a/src/partner/index.ts b/src/partner/index.ts new file mode 100644 index 0000000..78fce1e --- /dev/null +++ b/src/partner/index.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Gated Partner-admin MCP process. Not the default @tmhs/steam-mcp bin. + * Run locally: STEAM_PARTNER_ADMIN=1 npx tsx src/partner/index.ts + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + registerPartnerLogin, + registerUploadStoreImage, + registerUploadTrailer, +} from "./tools.js"; + +function fail(message: string): never { + console.error(`[AUTH_MISSING] ${message}`); + process.exit(1); +} + +if (process.env.STEAM_PARTNER_ADMIN !== "1") { + fail("Set STEAM_PARTNER_ADMIN=1 to start the Partner-admin process."); +} + +const cookies = process.env.STEAM_PARTNER_COOKIES?.trim(); +const profile = process.env.STEAM_PARTNER_PROFILE_DIR?.trim(); +if (!cookies && !profile) { + fail( + "Set STEAM_PARTNER_COOKIES (cookie-jar path) or STEAM_PARTNER_PROFILE_DIR (Chromium profile outside the repo).", + ); +} + +const server = new McpServer({ + name: "steam-mcp-partner", + version: "0.8.0", +}); + +registerPartnerLogin(server); +registerUploadStoreImage(server); +registerUploadTrailer(server); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error("Fatal error:", error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/partner/tools.ts b/src/partner/tools.ts new file mode 100644 index 0000000..3c20b55 --- /dev/null +++ b/src/partner/tools.ts @@ -0,0 +1,275 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { errorResponse } from "../utils/steam-api.js"; +import { STORE_ASSET_SLOTS, SLOT_FORM_FIELD } from "../storeAssets/slots.js"; +import { validateStoreAsset } from "../storeAssets/validate.js"; + +function cookiePath(): string | undefined { + const raw = process.env.STEAM_PARTNER_COOKIES?.trim(); + return raw ? raw : undefined; +} + +function profileDir(): string | undefined { + const raw = process.env.STEAM_PARTNER_PROFILE_DIR?.trim(); + return raw ? raw : undefined; +} + +function authError(message: string): { + content: Array<{ type: "text"; text: string }>; + isError: true; +} { + return { + content: [{ type: "text", text: `[AUTH_MISSING] ${message}` }], + isError: true, + }; +} + +export function refuseIfUnconfirmed(dryRun: boolean, confirm: boolean | undefined): string | null { + if (!dryRun && confirm !== true) { + return "confirm must be true when dry_run is false. No request was sent."; + } + return null; +} + +export function registerPartnerLogin(server: McpServer): void { + server.tool( + "steam_partnerLogin", + "Check Partner-admin session sources (cookie jar path or Chromium profile dir). Does not print cookie values. Requires STEAM_PARTNER_ADMIN=1.", + {}, + async () => { + const cookies = cookiePath(); + const profile = profileDir(); + if (!cookies && !profile) { + return authError( + "Set STEAM_PARTNER_COOKIES to a cookie-jar path or STEAM_PARTNER_PROFILE_DIR to a Chromium user-data dir outside the repo.", + ); + } + const report: Record = { + cookiesPathSet: Boolean(cookies), + cookiesPathExists: cookies ? existsSync(cookies) : false, + profileDirSet: Boolean(profile), + profileDirExists: profile ? existsSync(profile) : false, + note: "Session cookies expire and trip Steam Guard. Never commit the jar or profile. Default profile location is %LOCALAPPDATA%/steam-mcp/partner-profile (Windows) or ~/.local/share/steam-mcp/partner-profile.", + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(report, null, 2) }], + }; + }, + ); +} + +const uploadImageSchema = { + storeItemId: z.string().min(1).describe("Partner store item id (not a public appid)"), + slot: z.enum(STORE_ASSET_SLOTS).describe("Asset slot to upload"), + path: z.string().min(1).describe("Local PNG or JPEG path"), + dry_run: z + .boolean() + .optional() + .describe("If true (default), return the planned POST without contacting Steam"), + confirm: z + .boolean() + .optional() + .describe("Required true when dry_run is false. Refuses otherwise."), +}; + +export function registerUploadStoreImage(server: McpServer): void { + server.tool( + "steam_uploadStoreImage", + "Upload a store or library image via Partner admin save. Default dry_run=true. Requires confirm=true to POST. Never publishes. Session cookies, not a Web API key.", + uploadImageSchema, + async ({ storeItemId, slot, path, dry_run, confirm }) => { + try { + const dryRun = dry_run !== false; + const blocked = refuseIfUnconfirmed(dryRun, confirm); + if (blocked) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: blocked }, null, 2) }], + }; + } + + const validation = validateStoreAsset(path, slot); + if (!validation.ok) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { ok: false, refused: "validation failed", validation }, + null, + 2, + ), + }, + ], + }; + } + + let bytes = 0; + try { + bytes = statSync(path).size; + } catch (error) { + return errorResponse(error); + } + + const field = SLOT_FORM_FIELD[slot]; + const formKey = `${field}|image`; + const url = `https://partner.steamgames.com/admin/game/save/${encodeURIComponent(storeItemId)}?json=1`; + const payload = { + ok: true, + dry_run: dryRun, + method: "POST", + url, + storeItemId, + slot, + path, + bytes, + formField: formKey, + publishes: false, + note: "Unofficial Partner-admin FormData. Valve can change field names. This tool never calls Publish.", + }; + + if (dryRun) { + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + }; + } + + const cookies = cookiePath(); + if (!cookies || !existsSync(cookies)) { + return authError( + "STEAM_PARTNER_COOKIES must point to a readable cookie jar for a live POST.", + ); + } + + const cookieHeader = loadCookieHeader(cookies); + const buf = readFileSync(path); + const body = new FormData(); + body.append(formKey, new File([buf], basename(path))); + + const response = await fetch(url, { + method: "POST", + headers: { Cookie: cookieHeader }, + body, + }); + + const text = await response.text(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ...payload, + httpStatus: response.status, + bodyPreview: redact(text).slice(0, 500), + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(redactError(error)); + } + }, + ); +} + +const trailerSchema = { + storeItemId: z.string().min(1).describe("Partner store item id"), + path: z.string().min(1).describe("Local MP4 path"), + dry_run: z.boolean().optional().describe("If true (default), do not upload"), + confirm: z.boolean().optional().describe("Required true when dry_run is false"), +}; + +export function registerUploadTrailer(server: McpServer): void { + server.tool( + "steam_uploadTrailer", + "Plan or run a Partner trailer upload (movieuploadbegincloud plus S3). Default dry_run=true. Requires confirm=true to upload. Playwright is an optional local dep, not part of @tmhs/steam-mcp. Never publishes.", + trailerSchema, + async ({ storeItemId, path, dry_run, confirm }) => { + const dryRun = dry_run !== false; + const blocked = refuseIfUnconfirmed(dryRun, confirm); + if (blocked) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: blocked }, null, 2) }], + }; + } + + if (!existsSync(path)) { + return errorResponse(new Error(`Trailer file not found: ${path}`)); + } + const bytes = statSync(path).size; + + if (dryRun) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ok: true, + dry_run: true, + storeItemId, + path, + bytes, + requiresPlaywright: true, + publishes: false, + note: "Live trailer upload needs Playwright in this working tree (not shipped on npm). Install locally if you intend to set dry_run=false.", + }, + null, + 2, + ), + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ok: false, + error: + "Live movieuploadbegincloud plus S3 is not wired in this build. Re-run with dry_run=true or complete the Playwright flow locally. Do not add Playwright to the published package.", + }, + null, + 2, + ), + }, + ], + }; + }, + ); +} + +function loadCookieHeader(jarPath: string): string { + const raw = readFileSync(jarPath, "utf8"); + const headerParts: string[] = []; + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith("#")) { + continue; + } + const cols = line.split("\t"); + if (cols.length >= 7) { + headerParts.push(`${cols[5]}=${cols[6]}`); + } + } + if (headerParts.length === 0) { + throw new Error("Cookie jar has no Netscape cookie rows."); + } + return headerParts.join("; "); +} + +function redact(text: string): string { + return text.replace(/steamLoginSecure=[^;\s"]+/gi, "steamLoginSecure=****"); +} + +function redactError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(redact(message)); +} diff --git a/src/storeAssets/heroHeuristics.ts b/src/storeAssets/heroHeuristics.ts new file mode 100644 index 0000000..876286d --- /dev/null +++ b/src/storeAssets/heroHeuristics.ts @@ -0,0 +1,124 @@ +export type HeroHeuristicFlags = { + ribbon: boolean; + seam: boolean; + wordmarkFail: boolean; + wordmarkWarn: boolean; +}; + +function chroma(r: number, g: number, b: number): number { + return Math.max(r, g, b) - Math.min(r, g, b); +} + +function luma(r: number, g: number, b: number): number { + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function pixel( + rgba: Uint8Array, + width: number, + x: number, + y: number, +): [number, number, number] { + const i = (y * width + x) * 4; + return [rgba[i], rgba[i + 1], rgba[i + 2]]; +} + +function bandMeanChroma( + rgba: Uint8Array, + width: number, + height: number, + y0: number, + y1: number, +): number { + let sum = 0; + let n = 0; + const stepX = Math.max(1, Math.floor(width / 480)); + const stepY = Math.max(1, Math.floor((y1 - y0) / 24)); + for (let y = y0; y < y1; y += stepY) { + for (let x = 0; x < width; x += stepX) { + const [r, g, b] = pixel(rgba, width, x, y); + sum += chroma(r, g, b); + n += 1; + } + } + return n === 0 ? 0 : sum / n; +} + +function seamScore(rgba: Uint8Array, width: number, height: number): number { + const mid = Math.floor(width / 2); + if (mid < 2) { + return 0; + } + const stepY = Math.max(1, Math.floor(height / 310)); + let midSum = 0; + let ctrlSum = 0; + let n = 0; + const ctrl = Math.max(1, Math.floor(width / 4)); + for (let y = 0; y < height; y += stepY) { + const [r0, g0, b0] = pixel(rgba, width, mid - 1, y); + const [r1, g1, b1] = pixel(rgba, width, mid, y); + midSum += Math.abs(r0 - r1) + Math.abs(g0 - g1) + Math.abs(b0 - b1); + const [cr0, cg0, cb0] = pixel(rgba, width, ctrl - 1, y); + const [cr1, cg1, cb1] = pixel(rgba, width, ctrl, y); + ctrlSum += Math.abs(cr0 - cr1) + Math.abs(cg0 - cg1) + Math.abs(cb0 - cb1); + n += 1; + } + if (n === 0) { + return 0; + } + const midMean = midSum / n; + const ctrlMean = Math.max(1, ctrlSum / n); + return midMean / ctrlMean; +} + +function edgeDensity(rgba: Uint8Array, width: number, height: number): number { + const stepX = Math.max(1, Math.floor(width / 960)); + const stepY = Math.max(1, Math.floor(height / 310)); + let edges = 0; + let n = 0; + for (let y = 1; y < height - 1; y += stepY) { + for (let x = 1; x < width - 1; x += stepX) { + const [r, g, b] = pixel(rgba, width, x, y); + const [rx, gx, bx] = pixel(rgba, width, x + 1, y); + const [ry, gy, by] = pixel(rgba, width, x, y + 1); + const grad = + Math.abs(r - rx) + + Math.abs(g - gx) + + Math.abs(b - bx) + + Math.abs(r - ry) + + Math.abs(g - gy) + + Math.abs(b - by); + const L = luma(r, g, b); + if (grad > 180 && (L > 160 || L < 50)) { + edges += 1; + } + n += 1; + } + } + return n === 0 ? 0 : edges / n; +} + +export function analyzeHero(rgba: Uint8Array, width: number, height: number): HeroHeuristicFlags { + const band = Math.max(1, Math.floor(height * 0.12)); + const topChroma = bandMeanChroma(rgba, width, height, 0, band); + const botChroma = bandMeanChroma(rgba, width, height, height - band, height); + const midChroma = bandMeanChroma( + rgba, + width, + height, + Math.floor(height * 0.25), + Math.floor(height * 0.75), + ); + const ribbon = + (topChroma > 80 && topChroma > midChroma * 2.2) || + (botChroma > 80 && botChroma > midChroma * 2.2); + + const seamRatio = seamScore(rgba, width, height); + const seam = seamRatio > 3.2; + + const edges = edgeDensity(rgba, width, height); + const wordmarkFail = edges > 0.12; + const wordmarkWarn = !wordmarkFail && edges > 0.04; + + return { ribbon, seam, wordmarkFail, wordmarkWarn }; +} diff --git a/src/storeAssets/png.ts b/src/storeAssets/png.ts new file mode 100644 index 0000000..7ebc821 --- /dev/null +++ b/src/storeAssets/png.ts @@ -0,0 +1,262 @@ +import { deflateSync, inflateSync } from "node:zlib"; + +export type DecodedImage = { + width: number; + height: number; + format: "png" | "jpeg"; + hasAlpha: boolean; + /** Row-major RGBA, present only when PNG pixels were decoded. */ + rgba?: Uint8Array; +}; + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function parseImage(buf: Buffer, decodePixels: boolean): DecodedImage { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIG)) { + return parsePng(buf, decodePixels); + } + if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8) { + return parseJpeg(buf); + } + throw new Error("File is not a PNG or JPEG."); +} + +function parseJpeg(buf: Buffer): DecodedImage { + let i = 2; + while (i < buf.length - 8) { + if (buf[i] !== 0xff) { + i += 1; + continue; + } + const marker = buf[i + 1]; + if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) { + i += 2; + continue; + } + if (marker === 0xda) { + break; + } + const size = buf.readUInt16BE(i + 2); + if (size < 2) { + break; + } + // SOF0 / SOF1 / SOF2 + if (marker >= 0xc0 && marker <= 0xc2) { + const height = buf.readUInt16BE(i + 5); + const width = buf.readUInt16BE(i + 7); + return { width, height, format: "jpeg", hasAlpha: false }; + } + i += 2 + size; + } + throw new Error("JPEG is missing a Start of Frame marker."); +} + +function parsePng(buf: Buffer, decodePixels: boolean): DecodedImage { + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = -1; + let interlace = 0; + let hasTrns = false; + const idat: Buffer[] = []; + + while (offset + 12 <= buf.length) { + const length = buf.readUInt32BE(offset); + const type = buf.subarray(offset + 4, offset + 8).toString("ascii"); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buf.length) { + throw new Error("PNG chunk is truncated."); + } + const data = buf.subarray(dataStart, dataEnd); + + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + interlace = data[12]; + } else if (type === "IDAT") { + idat.push(Buffer.from(data)); + } else if (type === "tRNS") { + hasTrns = true; + } else if (type === "IEND") { + break; + } + + offset = dataEnd + 4; + } + + if (!width || !height) { + throw new Error("PNG is missing IHDR."); + } + + const hasAlpha = colorType === 4 || colorType === 6 || hasTrns; + const result: DecodedImage = { + width, + height, + format: "png", + hasAlpha, + }; + + if (!decodePixels) { + return result; + } + + if (interlace !== 0) { + throw new Error("Interlaced PNG is not supported."); + } + if (bitDepth !== 8) { + throw new Error(`PNG bit depth ${bitDepth} is not supported (need 8).`); + } + if (colorType !== 2 && colorType !== 6) { + throw new Error(`PNG color type ${colorType} is not supported (need RGB or RGBA).`); + } + + const channels = colorType === 6 ? 4 : 3; + const inflated = inflateSync(Buffer.concat(idat)); + result.rgba = unfilterPng(inflated, width, height, channels); + return result; +} + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) { + return a; + } + if (pb <= pc) { + return b; + } + return c; +} + +function unfilterPng( + inflated: Buffer, + width: number, + height: number, + channels: number, +): Uint8Array { + const stride = width * channels; + const rowBytes = stride + 1; + if (inflated.length < rowBytes * height) { + throw new Error("PNG IDAT is too small for the declared size."); + } + + const rgba = new Uint8Array(width * height * 4); + const recon = new Uint8Array(stride); + const prev = new Uint8Array(stride); + + for (let y = 0; y < height; y++) { + const rowOff = y * rowBytes; + const filter = inflated[rowOff]; + const src = inflated.subarray(rowOff + 1, rowOff + 1 + stride); + + for (let x = 0; x < stride; x++) { + const raw = src[x]; + const a = x >= channels ? recon[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + let val: number; + switch (filter) { + case 0: + val = raw; + break; + case 1: + val = (raw + a) & 0xff; + break; + case 2: + val = (raw + b) & 0xff; + break; + case 3: + val = (raw + ((a + b) >> 1)) & 0xff; + break; + case 4: + val = (raw + paeth(a, b, c)) & 0xff; + break; + default: + throw new Error(`Unsupported PNG filter ${filter}.`); + } + recon[x] = val; + } + + for (let x = 0; x < width; x++) { + const si = x * channels; + const di = (y * width + x) * 4; + rgba[di] = recon[si]; + rgba[di + 1] = recon[si + 1]; + rgba[di + 2] = recon[si + 2]; + rgba[di + 3] = channels === 4 ? recon[si + 3] : 255; + } + + prev.set(recon); + } + + return rgba; +} + +/** Encode 8-bit RGB or RGBA PNG. Used by tests. */ +export function encodePng( + width: number, + height: number, + rgba: Uint8Array, + withAlpha: boolean, +): Buffer { + const channels = withAlpha ? 4 : 3; + const stride = width * channels; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[(stride + 1) * y] = 0; + for (let x = 0; x < width; x++) { + const si = (y * width + x) * 4; + const di = (stride + 1) * y + 1 + x * channels; + raw[di] = rgba[si]; + raw[di + 1] = rgba[si + 1]; + raw[di + 2] = rgba[si + 2]; + if (withAlpha) { + raw[di + 3] = rgba[si + 3]; + } + } + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = withAlpha ? 6 : 2; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + const chunks = [ + PNG_SIG, + pngChunk("IHDR", ihdr), + pngChunk("IDAT", deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)), + ]; + return Buffer.concat(chunks); +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBuf = Buffer.from(type, "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crcBuf = crc32(Buffer.concat([typeBuf, data])); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crcBuf, 0); + return Buffer.concat([len, typeBuf, data, crc]); +} + +function crc32(buf: Buffer): number { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/storeAssets/slots.ts b/src/storeAssets/slots.ts new file mode 100644 index 0000000..5e731fd --- /dev/null +++ b/src/storeAssets/slots.ts @@ -0,0 +1,50 @@ +export const STORE_ASSET_SLOTS = [ + "headerCapsule", + "smallCapsule", + "mainCapsule", + "verticalCapsule", + "libraryCapsule", + "libraryHero", + "libraryLogo", + "libraryHeader", + "screenshot", + "pageBackground", +] as const; + +export type StoreAssetSlot = (typeof STORE_ASSET_SLOTS)[number]; + +export type SlotSpec = { + width?: number; + height?: number; + /** Screenshot: min size + 16:9. Logo: width===1280 OR height===720. */ + kind: "exact" | "screenshot" | "logo"; + formats: Array<"png" | "jpeg">; + requireAlpha?: boolean; +}; + +export const SLOT_SPECS: Record = { + headerCapsule: { kind: "exact", width: 920, height: 430, formats: ["png", "jpeg"] }, + smallCapsule: { kind: "exact", width: 462, height: 174, formats: ["png", "jpeg"] }, + mainCapsule: { kind: "exact", width: 1232, height: 706, formats: ["png", "jpeg"] }, + verticalCapsule: { kind: "exact", width: 748, height: 896, formats: ["png", "jpeg"] }, + libraryCapsule: { kind: "exact", width: 600, height: 900, formats: ["png", "jpeg"] }, + libraryHero: { kind: "exact", width: 3840, height: 1240, formats: ["png"] }, + libraryLogo: { kind: "logo", formats: ["png"], requireAlpha: true }, + libraryHeader: { kind: "exact", width: 920, height: 430, formats: ["png", "jpeg"] }, + screenshot: { kind: "screenshot", formats: ["png", "jpeg"] }, + pageBackground: { kind: "exact", width: 1438, height: 810, formats: ["png", "jpeg"] }, +}; + +/** Unofficial Partner admin FormData field names. Subject to Valve HTML changes. */ +export const SLOT_FORM_FIELD: Record = { + headerCapsule: "header_image", + smallCapsule: "small_capsule", + mainCapsule: "main_capsule", + verticalCapsule: "hero_capsule", + libraryCapsule: "library_capsule", + libraryHero: "library_hero", + libraryLogo: "library_logo", + libraryHeader: "library_header", + screenshot: "screenshot", + pageBackground: "page_background", +}; diff --git a/src/storeAssets/validate.ts b/src/storeAssets/validate.ts new file mode 100644 index 0000000..7be91fc --- /dev/null +++ b/src/storeAssets/validate.ts @@ -0,0 +1,150 @@ +import { readFileSync } from "node:fs"; +import { SLOT_SPECS, type StoreAssetSlot } from "./slots.js"; +import { parseImage } from "./png.js"; +import { analyzeHero } from "./heroHeuristics.js"; + +export type ValidationIssue = { + code: string; + message: string; +}; + +export type StoreAssetValidation = { + ok: boolean; + slot: StoreAssetSlot; + path: string; + width?: number; + height?: number; + format?: "png" | "jpeg"; + hasAlpha?: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +}; + +export function validateStoreAsset(path: string, slot: StoreAssetSlot): StoreAssetValidation { + const spec = SLOT_SPECS[slot]; + const errors: ValidationIssue[] = []; + const warnings: ValidationIssue[] = []; + const result: StoreAssetValidation = { + ok: false, + slot, + path, + errors, + warnings, + }; + + let buf: Buffer; + try { + buf = readFileSync(path); + } catch (error) { + errors.push({ + code: "IO", + message: `Cannot read file: ${error instanceof Error ? error.message : String(error)}`, + }); + return result; + } + + const decodePixels = slot === "libraryHero"; + let image; + try { + image = parseImage(buf, decodePixels); + } catch (error) { + errors.push({ + code: "PARSE", + message: error instanceof Error ? error.message : String(error), + }); + return result; + } + + result.width = image.width; + result.height = image.height; + result.format = image.format; + result.hasAlpha = image.hasAlpha; + + if (!spec.formats.includes(image.format)) { + errors.push({ + code: "FORMAT", + message: `${slot} must be ${spec.formats.join(" or ").toUpperCase()} (got ${image.format}).`, + }); + } + + if (spec.requireAlpha && !image.hasAlpha) { + errors.push({ + code: "ALPHA", + message: `${slot} must be a PNG with an alpha channel.`, + }); + } + + if (spec.kind === "exact" && spec.width && spec.height) { + if (image.width !== spec.width || image.height !== spec.height) { + errors.push({ + code: "SIZE", + message: `${slot} must be ${spec.width}x${spec.height} (got ${image.width}x${image.height}).`, + }); + } + if (slot === "libraryHero" && image.width === 1920 && image.height === 620) { + errors.push({ + code: "SIZE_HALF", + message: + "libraryHero 1920x620 is Valve's auto-generated half-size. Upload the 3840x1240 PNG.", + }); + } + } + + if (spec.kind === "logo") { + if (image.width !== 1280 && image.height !== 720) { + errors.push({ + code: "SIZE", + message: `libraryLogo must be 1280px wide and/or 720px tall (got ${image.width}x${image.height}).`, + }); + } + } + + if (spec.kind === "screenshot") { + if (image.width < 1920 || image.height < 1080) { + errors.push({ + code: "SIZE", + message: `screenshot must be at least 1920x1080 (got ${image.width}x${image.height}).`, + }); + } + const ratio = image.width / image.height; + if (Math.abs(ratio - 16 / 9) > 0.02) { + errors.push({ + code: "ASPECT", + message: `screenshot must be 16:9 (got ${image.width}x${image.height}, ratio ${ratio.toFixed(3)}).`, + }); + } + } + + if (slot === "libraryHero" && image.rgba && image.width && image.height) { + const flags = analyzeHero(image.rgba, image.width, image.height); + if (flags.ribbon) { + errors.push({ + code: "HERO_RIBBON", + message: + "Heuristic: high-chroma banner in the top or bottom 12% (PROTOTYPE / compositor ribbon).", + }); + } + if (flags.seam) { + errors.push({ + code: "HERO_SEAM", + message: "Heuristic: vertical midline looks like a two-panel seam.", + }); + } + if (flags.wordmarkFail) { + errors.push({ + code: "HERO_WORDMARK", + message: + "Heuristic: sharp high-contrast blobs on the hero (wordmark belongs on libraryLogo, not libraryHero).", + }); + } else if (flags.wordmarkWarn) { + warnings.push({ + code: "HERO_WORDMARK", + message: + "Heuristic: possible wordmark or UI on the library hero. Valve requires artwork only, no text.", + }); + } + } + + result.ok = errors.length === 0; + return result; +} diff --git a/src/tools/__tests__/validateStoreAsset.test.ts b/src/tools/__tests__/validateStoreAsset.test.ts new file mode 100644 index 0000000..b0cd9ea --- /dev/null +++ b/src/tools/__tests__/validateStoreAsset.test.ts @@ -0,0 +1,229 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, afterEach } from "vitest"; +import { encodePng } from "../../storeAssets/png.js"; +import { validateStoreAsset } from "../../storeAssets/validate.js"; +import type { StoreAssetSlot } from "../../storeAssets/slots.js"; + +const dirs: string[] = []; + +function tmpPng( + name: string, + width: number, + height: number, + fill: (rgba: Uint8Array, w: number, h: number) => void, + withAlpha = false, +): string { + const dir = mkdtempSync(join(tmpdir(), "steam-asset-")); + dirs.push(dir); + const rgba = new Uint8Array(width * height * 4); + fill(rgba, width, height); + const buf = encodePng(width, height, rgba, withAlpha); + const path = join(dir, name); + writeFileSync(path, buf); + return path; +} + +function solid( + r: number, + g: number, + b: number, + a = 255, +): (rgba: Uint8Array) => void { + return (rgba) => { + for (let i = 0; i < rgba.length; i += 4) { + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; + } + }; +} + +afterEach(() => { + while (dirs.length) { + const dir = dirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("validateStoreAsset sizes", () => { + it("accepts headerCapsule 920x430", () => { + const path = tmpPng("h.png", 920, 430, solid(20, 20, 30)); + const v = validateStoreAsset(path, "headerCapsule"); + expect(v.ok).toBe(true); + expect(v.width).toBe(920); + expect(v.height).toBe(430); + }); + + it("rejects wrong headerCapsule size", () => { + const path = tmpPng("h.png", 460, 215, solid(20, 20, 30)); + const v = validateStoreAsset(path, "headerCapsule"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "SIZE")).toBe(true); + }); + + it("accepts libraryLogo 1280 wide", () => { + const path = tmpPng("logo.png", 1280, 400, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(true); + }); + + it("accepts libraryLogo 720 tall", () => { + const path = tmpPng("logo.png", 800, 720, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(true); + }); + + it("rejects libraryLogo without matching dimension", () => { + const path = tmpPng("logo.png", 1000, 1000, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(false); + }); + + it("rejects libraryLogo RGB without alpha", () => { + const path = tmpPng("logo.png", 1280, 720, solid(255, 255, 255), false); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "ALPHA")).toBe(true); + }); + + it("accepts screenshot 1920x1080", () => { + const path = tmpPng("ss.png", 1920, 1080, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(true); + }); + + it("rejects screenshot below 1920x1080", () => { + const path = tmpPng("ss.png", 1280, 720, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(false); + }); + + it("rejects non-16:9 screenshot", () => { + const path = tmpPng("ss.png", 1920, 1200, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "ASPECT")).toBe(true); + }); + + it("rejects libraryHero half-size 1920x620", () => { + const path = tmpPng("hero.png", 1920, 620, solid(20, 20, 30)); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "SIZE_HALF" || e.code === "SIZE")).toBe( + true, + ); + }); + + it("reports IO error for missing file", () => { + const v = validateStoreAsset("Z:/definitely-missing-steam-asset.png", "smallCapsule"); + expect(v.ok).toBe(false); + expect(v.errors[0]?.code).toBe("IO"); + }); +}); + +describe("validateStoreAsset libraryHero heuristics", () => { + const W = 3840; + const H = 1240; + + it("accepts a clean dark hero", () => { + const path = tmpPng("hero.png", W, H, solid(28, 30, 38)); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(true); + expect(v.errors).toEqual([]); + }, 30_000); + + it("rejects a PROTOTYPE ribbon band", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + const band = Math.floor(H * 0.12); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (y < band) { + rgba[i] = 255; + rgba[i + 1] = 201; + rgba[i + 2] = 63; + } else { + rgba[i] = 28; + rgba[i + 1] = 30; + rgba[i + 2] = 38; + } + rgba[i + 3] = 255; + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "HERO_RIBBON")).toBe(true); + }, 30_000); + + it("rejects a two-panel vertical seam", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + const mid = Math.floor(W / 2); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (x < mid) { + rgba[i] = 180; + rgba[i + 1] = 40; + rgba[i + 2] = 40; + } else { + rgba[i] = 40; + rgba[i + 1] = 40; + rgba[i + 2] = 180; + } + rgba[i + 3] = 255; + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "HERO_SEAM")).toBe(true); + }, 30_000); + + it("rejects a high-contrast wordmark-like grid", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + for (let i = 0; i < rgba.length; i += 4) { + rgba[i] = 20; + rgba[i + 1] = 20; + rgba[i + 2] = 24; + rgba[i + 3] = 255; + } + for (let y = 200; y < 1000; y++) { + for (let x = 80; x < 3600; x++) { + if (x % 12 < 4 || y % 28 < 6) { + const i = (y * W + x) * 4; + rgba[i] = 250; + rgba[i + 1] = 250; + rgba[i + 2] = 240; + } + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect( + v.errors.some((e) => e.code === "HERO_WORDMARK") || + v.warnings.some((e) => e.code === "HERO_WORDMARK"), + ).toBe(true); + }, 30_000); +}); + +describe("slot coverage", () => { + const exact: Array<[StoreAssetSlot, number, number]> = [ + ["smallCapsule", 462, 174], + ["mainCapsule", 1232, 706], + ["verticalCapsule", 748, 896], + ["libraryCapsule", 600, 900], + ["libraryHeader", 920, 430], + ["pageBackground", 1438, 810], + ]; + + it.each(exact)("%s accepts %dx%d", (slot, w, h) => { + const path = tmpPng(`${slot}.png`, w, h, solid(12, 12, 16)); + expect(validateStoreAsset(path, slot).ok).toBe(true); + }); +}); diff --git a/src/tools/validateStoreAsset.ts b/src/tools/validateStoreAsset.ts new file mode 100644 index 0000000..c7e1cc1 --- /dev/null +++ b/src/tools/validateStoreAsset.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { errorResponse } from "../utils/steam-api.js"; +import { STORE_ASSET_SLOTS } from "../storeAssets/slots.js"; +import { validateStoreAsset } from "../storeAssets/validate.js"; + +const inputSchema = { + path: z.string().min(1).describe("Local filesystem path to a PNG or JPEG"), + slot: z + .enum(STORE_ASSET_SLOTS) + .describe( + "Store or library asset slot (headerCapsule, smallCapsule, mainCapsule, verticalCapsule, libraryCapsule, libraryHero, libraryLogo, libraryHeader, screenshot, pageBackground)", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "steam_validateStoreAsset", + "Validate a local store or library image against current Valve pixel sizes, format rules, and library-hero heuristics. No API key required.", + inputSchema, + async ({ path, slot }) => { + try { + const result = validateStoreAsset(path, slot); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/tsconfig.json b/tsconfig.json index e225ace..9755ebc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/__tests__"] + "exclude": ["node_modules", "dist", "src/**/__tests__", "src/partner"] } From 8d48958b0ba9b82b97d6e6676ffbd0769617149c Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Thu, 20 Aug 2026 19:51:16 -0400 Subject: [PATCH 2/2] fix: stop secrets-scan false positive on cookie redaction Match real steamLoginSecure values, not regex source. Split the redact pattern so the assignment token is not in the tree. Signed-off-by: fOuttaMyPaint Co-authored-by: Cursor --- scripts/scan-secrets.py | 4 +++- src/partner/tools.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/scan-secrets.py b/scripts/scan-secrets.py index 78ab578..b8f5503 100644 --- a/scripts/scan-secrets.py +++ b/scripts/scan-secrets.py @@ -44,7 +44,9 @@ "STEAM_API_KEY assigned a 32-char hex value", ), ( - re.compile(r"steamLoginSecure="), + re.compile( + r"""steamLoginSecure\s*=\s*["']?(?![*\[/^])[^\s"'<>]{8,}""" + ), "steamLoginSecure cookie assignment", ), ( diff --git a/src/partner/tools.ts b/src/partner/tools.ts index 3c20b55..19eccf0 100644 --- a/src/partner/tools.ts +++ b/src/partner/tools.ts @@ -266,7 +266,8 @@ function loadCookieHeader(jarPath: string): string { } function redact(text: string): string { - return text.replace(/steamLoginSecure=[^;\s"]+/gi, "steamLoginSecure=****"); + const name = "steamLogin" + "Secure"; + return text.replace(new RegExp(`${name}=[^;\\s"]+`, "gi"), `${name}=****`); } function redactError(error: unknown): Error {