Status: implemented and deployed (step 1: the catalog is populated by the game's author with prism-admin; accounts and player submissions
come later). The field names below are the contract: the game client relies on them. The document only ever grows:
readers must ignore unknown fields; there will be no "v2".
Publication rules
| Skin | Mod | |
|---|---|---|
| File | .zip (at most 8 MiB) |
.pvmod (at most 2 MiB) |
| State at publication | approved (public immediately) |
pending (invisible until catalog approve) |
| Limit | 2 skins per author (author_key = normalized name: collapsed spaces, lowercase). A new version of a skin does not count as a third one; withdrawing a skin frees up the slot |
none |
| Permissions | none | those the mod declares (stage, skinImport…) |
Row states: pending → approved ⇄ withdrawn. Only approved items are public (API, catalog.json, SHA256SUMS.txt, files).
pending and withdrawn files are stored in catalog/.held/<kind>/, which neither Caddy nor the backend serves: a pending mod cannot be downloaded
by guessing its name. One row per version; the public version of an identifier is the most recent approved one (highest seq).
The archive contents are checked before publication, using the game importer's rules: safe relative paths (no absolute path, no .., no drive
letter, no \), no symbolic links, no encryption, allowed file types (mods: .ts .ttf .otf .png .json .md .txt; skins: .ts .png .ttf .otf), at most 1,024 entries / 512 files / 16 MiB per file / 64 MiB uncompressed, case-only duplicates rejected, declared sizes verified by
actually reading each entry, and an entry file (main.ts or index.ts for a mod, skin.ts for a skin) at the root or in the single root folder.
GET /api/catalog
No authentication, rate-limited per address (120 requests per minute by default; 429 with Retry-After). Strong ETag + Cache-Control: no-cache:
sending If-None-Match yields 304. The body only changes when the content changes (generatedAt = last modification).
Parameter: kind=mod|skin (both types if absent; any other value: 400).
{
"schema": 1,
"generatedAt": "2026-10-11T09:00:00Z",
"items": [
{
"id": "better-hud",
"kind": "mod",
"name": "Better HUD",
"version": "1.2.0",
"seq": 7,
"author": "Ada",
"descriptionEn": "A cleaner HUD.",
"descriptionFr": "Un HUD plus propre.",
"permissions": ["stage"],
"sha256": "43451505e2c8fb5c986c7e1e19a132d333d1b96e6eb03aa576a276903a8738f2",
"size": 18342,
"filename": "better-hud-1.2.0.pvmod",
"url": "/files/catalog/mod/better-hud-1.2.0.pvmod",
"status": "approved",
"owner": "acct:42",
"createdAt": "2026-10-01T10:00:00Z",
"updatedAt": "2026-10-11T08:59:00Z",
"screenshots": ["/files/catalog/mod/better-hud-7-1.png"],
"versions": [
{ "version": "1.2.0", "seq": 7, "publishedAt": "2026-10-11T08:59:00Z", "filename": "better-hud-1.2.0.pvmod", "url": "/files/catalog/mod/better-hud-1.2.0.pvmod", "sha256": "…", "size": 18342 },
{ "version": "1.1.0", "seq": 3, "publishedAt": "2026-10-02T10:00:00Z", "filename": "better-hud-1.1.0.pvmod", "url": "/files/catalog/mod/better-hud-1.1.0.pvmod", "sha256": "…", "size": 17010 }
]
}
]
}
| Field | Meaning |
|---|---|
id |
stable identifier of the item (lowercase letters, digits, -, _, .), unique per type |
kind |
mod or skin |
name |
display name |
version |
free-form label of the most recent approved version: never parse or sort it |
seq |
strictly increasing integer per type (publication order): this is what orders versions |
author |
credited author (display name) |
descriptionEn |
plain English text (always present) |
descriptionFr |
plain French text, absent if not provided |
permissions |
permissions declared by the mod; [] for a skin |
sha256 / size / filename |
lowercase hexadecimal hash, size in bytes, and file name of this version |
url |
download address: relative to the site, or absolute when the server knows its public address (PUBLIC_BASE_URL) |
status |
always approved in public documents |
owner |
account identifier of the owner; absent until accounts exist |
createdAt / updatedAt |
first approved publication of the item / last modification of the current version |
screenshots |
small preview images (URLs), absent if there are none |
versions |
all approved versions, from newest to oldest; the first one is the one described by the fields above |
Items are sorted by type (mod then skin), then by name (case-insensitive), then by id.
GET /api/catalog/{kind}/{id}
A single item, same shape as above (one object, no items). 404 if the type is unknown, if the identifier does not exist, or if no version is
approved. Same ETag, 304, and rate limit.
Files
/files/catalog/<kind>/<filename>, like the game's zips: Content-Disposition: attachment, Cache-Control: public, max-age=31536000, immutable (a file
name is never rewritten), Range supported, X-Content-Type-Options: nosniff. Previews (.png, .jpg, .webp) are served without attachment,
also immutable. Paths containing a hidden segment (.held, .incoming) respond with 404.
Documents generated on every change: /files/catalog/catalog.json (both types, same shape as GET /api/catalog) and
/files/catalog/SHA256SUMS.txt (<sha256> <kind>/<file> for each approved package, verifiable with sha256sum -c).
Administration (on the server, prism-admin catalog …)
catalog add --kind mod|skin --file <.pvmod|.zip> --id <slug> --name <name> --version <label> --author <name>
--desc-en <text> [--desc-fr <text>] [--permissions stage,skinImport] [--owner <account>] [--screenshot <png|jpg|webp>]…
catalog list [--kind …] [--status pending|approved|withdrawn]
catalog approve <id> [<version>] [--kind …] # a mod only becomes public through this
catalog withdraw <id> [<version>] [--kind …] [--reason …]
catalog verify [--kind …] # re-hashes every file (wherever its state puts it)
catalog regenerate # rewrites catalog.json and SHA256SUMS.txt
Rejections: duplicate id + version or file name; an identifier already taken by another author; a 3rd skin from the same author; a file that is too large, is not
a ZIP, or whose archive contains a dangerous entry; a permission declared on a skin; a preview that is not a real image (recognized by its content,
512 KiB at most, three at most).
In the code: CatalogStore::skins_of_author(author_key) returns an author's skins (the counting helper); the owner column (text, nullable) is waiting for accounts.
Site side
Pages /mods/ and /skins/ (/fr/mods/, /fr/skins/): cards (name, credited author, version, explained permission badges for mods, SHA-256, download button,
previews), a "how to install it" note, a calm empty state. They read GET /api/catalog?kind=… on the client side.