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 @@
-
+
---
-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..b8f5503
--- /dev/null
+++ b/scripts/scan-secrets.py
@@ -0,0 +1,111 @@
+#!/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\s*=\s*["']?(?![*\[/^])[^\s"'<>]{8,}"""
+ ),
+ "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..19eccf0
--- /dev/null
+++ b/src/partner/tools.ts
@@ -0,0 +1,276 @@
+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 {
+ const name = "steamLogin" + "Secure";
+ return text.replace(new RegExp(`${name}=[^;\\s"]+`, "gi"), `${name}=****`);
+}
+
+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"]
}