On this page

← All documentation

Mod and skin catalogue: the server contract

The server contract: GET /api/catalog, publication rules, archive checks, the files served.

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.

Source in the site repository: docs/CATALOG.md