# MCCP API

The contract a program is written against for finding, downloading and uploading mods. MCCP is the
Moddable Console Capture Platform; the four letters are what it is called everywhere below, and in
every path and header in this document.

**Reading needs no account.** Listing mods, reading one and downloading its file take no token, no
sign-in and no headers at all - the same promise the website makes to somebody who has never made
an account. Start at [Find mods](#1-find-mods-no-account); you do not need the sign-in document to
write a downloader.

**Writing always names a profile.** Every call that creates or changes something reads an access
token from `Authorization: Bearer` - see [the sign-in API](/docs/accounts/api) for where one comes from -
and files the mod against the profile the token names. That is not only about abuse: a mod owned by
a profile is one its owner can edit from the website later, which a mod owned by an upload never
could be.

Base address: `https://dasmaffin.com`. Everything below hangs off `/api/mccp`.

---

## The shape of a mod

Every call that answers with a mod answers with this:

```json
{
  "id": 12,
  "guid": "5f4e0a1c-9b3d-4a77-9c2e-0d8f6a1b2c3d",
  "title": "Neon Overlay",
  "description": "Recolours the HUD.",
  "kind": "lua",
  "reviewed": false,
  "version": "1.2",
  "games": ["Half-Life 2", "Portal"],
  "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": [],
  "published": false,
  "createdAt": "2026-08-13T02:19:54Z",
  "updatedAt": "2026-08-13T02:31:07Z",
  "url": "/Mccp/12",
  "address": null,
  "assets": [
    { "kind": "code",  "name": "neon.zip",  "bytes": 481920, "url": "/Mccp/file/33" },
    { "kind": "image", "name": "shot.png",  "bytes": 220114, "url": "/Mccp/file/34" }
  ]
}
```

`id` is this site's number for the mod and is in every address here. `url` is the mod's page: `/Mccp/{id}`, or
`/Mccp/{address}` when its owner chose an address of its own on the website (`address`, null when
there is none). Both always work; store the `id`, not the `url`, since an address can change. `guid` is the name it goes by
**outside** the site: it is what another mod writes down to say it needs this one, it never changes,
and it is the one to put in a manifest. A row number would collide with somebody else's and would
say how many mods exist; this says nothing at all.

`requires` and `conflictsWith` are GUIDs of other mods - what this one needs to work, and what it
must not be installed beside. Either may name a mod that is not on this site: a requirement is often
written before the thing it names is published, and refusing that would only push it somewhere this
site cannot read. Reading one mod resolves the whole chain for you - see
[One mod](#one-mod).

`version` is what the current download calls itself - the version of the last file uploaded, and
the heading of the changelog entry that upload wrote. Empty on a mod uploaded before versions were
asked for. It takes no account to read, so an installed copy can be compared against it.

The mod's own `kind` is `lua` or `native`. A Lua mod is a script the app runs in its sandbox and
its author uploads; a native mod is a DLL the app loads at startup, which only ever reaches people
after a reviewer has built it from source - see [Native mods](#14-native-mods-reviewed-dlls).
`reviewed` is true once a native mod has had a submission approved, and always false for Lua.

An asset's `kind` is `code`, `css`, `image` or `native`. A `native` asset is a reviewed DLL and also
carries `platform` (`win-x64` for now) and `sha256` - **refuse the file if its hash does not match**.

A `video` asset is a YouTube video its owner added among the images on the website. Its `url` is the
video's picture (YouTube's thumbnail, passed through this site - nothing of a video is stored here),
and it carries `youtube`, the address to watch it at. Images and videos share one order: show them
together, as the website's carousel does, or skip the ones you cannot play. Ignore any `kind` you do
not know rather than failing on it.
Every `url` is on this site and is the only address you should ever show or store - see
[Files](#7-downloading-what-was-uploaded).

---

## 1. Find mods (no account)

Three calls, none of which take a token or care whether you have one.

### Every published mod

```
GET /api/mccp
```

```json
{
  "count": 20, "total": 43, "page": 1, "size": 20, "more": true,
  "mods": [ { "id": 12, "title": "Neon Overlay", ... } ]
}
```

Each entry is [the shape of a mod](#the-shape-of-a-mod), assets and all - so one call is enough to
show a list with its download links, without a second request per row.

| Query | Meaning |
| --- | --- |
| `q` | Search the title and the games. **Accents fold both ways**: `Pokemon` finds `Pokémon` and the reverse, and so do `ø`, `ß` and the rest. Do not fold it yourself; send what was typed. |
| `game` | One game, matched **whole**. `Portal` never matches `Portal 2`. |
| `sort` | `new` (default) is what changed most recently. `top` is ups minus downs. |
| `page` | 1-based. Default 1. |
| `size` | Default 20, **capped at 100** however large a number is sent. |

`total` is every mod that matched, not the size of this page - page through it with `more`.

### One mod

```
GET /api/mccp/{id}
```

`200` with the mod. `404` if there is no such mod **or it is not published**, told apart nowhere:
a mod exists before its files do, and that window belongs to its owner.

This answer carries two fields the others do not, beside the mod's own:

```json
{
  "id": 12,
  "guid": "5f4e0a1c-...",
  "title": "Neon Overlay",
  "...": "every other field, exactly as above",

  "install": [
    { "id": 40, "guid": "8b7a6c5d-...", "title": "Overlay Core",  "...": "a whole mod" },
    { "id": 12, "guid": "5f4e0a1c-...", "title": "Neon Overlay",  "...": "a whole mod" }
  ],
  "missing": ["3c2b1a09-8877-4665-a443-221100ffeedd"]
}
```

**`install` is the whole chain, in the order to install it.** Everything the mod needs, and
everything those need, resolved as deep as it goes - each entry after everything it requires, and
**the mod you asked for is the last one**. Walk it front to back and you are never holding a mod
whose requirements have not arrived; take the last entry alone if you only want the one you asked
for. Each entry is a complete mod, download URL and all, so this one call is the whole job.

Native mods are in the chain like any other - a Lua mod requires one by its GUID - and every entry
carries `kind`, so you know which downloads go into the bin folder and that a restart follows.

A mod needed by two others appears once. Two mods that need each other still get an order -
everything appears exactly once - because refusing to answer helps nobody.

**`missing`** lists GUIDs named as requirements that are not published here. Say so rather than
carrying on quietly: a chain with a hole in it installs cleanly and then does not work, and the
person holding the GUID is the only one who can go and find it. A mod that exists but is
unpublished is missing too, for the same reason it is a `404` above.

Every other call that answers with a mod gives you `requires` and `conflictsWith` as plain GUID
lists and does **not** resolve them. Listing twenty mods with their full chains would be a very
large answer to "what is on this site".

### Its changelog

```
GET /api/mccp/{id}/changelog
```

```json
{
  "count": 2,
  "notes": [
    { "id": 8, "title": "1.2", "text": "<p>Fixed the flicker.</p>",
      "createdAt": "2026-08-14T12:04:11Z", "editedAt": null, "automatic": false },
    { "id": 7, "title": null, "text": "Uploaded neon.ccmod.",
      "createdAt": "2026-08-13T09:31:52Z", "editedAt": null, "automatic": true }
  ]
}
```

Newest first. `text` is HTML through the same allowlist as a description, so render it the same
way - or strip the tags if your client shows plain text.

An entry's heading is the version of the upload that wrote it, which is why the changelog reads as
a list of versions. Editing a heading edits that record, so a version can be freed by deleting the
entry that used it.

Every entry starts as an upload's - `automatic` says so, and is worth showing - and turns false the
moment its author rewrites it, because from then on the words are theirs. Nothing but an upload
creates an entry; [editing one](#6-the-changelog-owner-only) is the owner's half.

### Its file

```
GET /api/mccp/{id}/download
```

The mod's own file, streamed, as an attachment with the name it was uploaded under - for a native
mod, the reviewed `win-x64` DLL. This is the one call a downloader wants - no token, no reading the mod first, no working out which asset is the
download. `404` when the mod is unpublished or has no file yet.

Individual assets - screenshots, the stylesheet - are fetched from the `url` on each asset, which is
[described below](#7-downloading-what-was-uploaded).

---

## 2. Make a mod

```
POST /api/mccp
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "Neon Overlay", "description": "Recolours the HUD.",
  "games": ["Half-Life 2", "Portal"],
  "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": [] }
```

`201` with the mod, including the `guid` it was given. Only `title` is required.

`kind` is `"lua"` (the default, also what an absent `kind` means) or `"native"`, and it is chosen
here, once: a mod is one or the other for good. A `PUT` that names a different kind is a `400`;
naming the kind it already is, is fine.

A new mod is **not published**. It exists so that files have something to be attached to, and it is
invisible to everybody but its owner until you say otherwise in step 4.

The two GUID lists are checked **before** the mod is made, so a rejected call leaves nothing behind
and retrying it cannot give you two mods.

| Code | Meaning |
| --- | --- |
| `400` | No title, a `kind` that is not `lua` or `native`, or a GUID list that broke one of the rules in [step 3](#3-edit-it) |
| `401` | No token, or one that is expired, edited, or names a login that has been deleted |

---

## 3. Edit it

```
PUT /api/mccp/{id}
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "Neon Overlay 2" }
```

`200` with the mod.

**Only what you send is changed.** Leaving `description` out keeps the description; you do not have
to read the mod back and resend it to avoid losing it.

**`games` is the exception, and it is deliberate.** Omitting it (or sending `null`) leaves the games
alone. Sending a list - *including an empty one* - replaces the set outright. That is the difference
between "I am editing the title" and "this is no longer for that game", and there is no other way to
remove one.

**`requires` and `conflictsWith` follow the same rule as `games`**: omitted means unchanged, a list
replaces the set, an empty list clears it.

```json
{ "requires": ["8b7a6c5d-4e3f-4211-90aa-bbccddeeff00"],
  "conflictsWith": ["3c2b1a09-8877-4665-a443-221100ffeedd"] }
```

Naming a mod that is not on this site is fine and is stored as sent - it shows as the GUID until
that mod turns up, and then it shows as the mod. What is refused, with `400` and a sentence saying
which, is:

- anything in either list that is not a GUID,
- the mod's own GUID, in either list,
- the same GUID in both lists at once,
- more than 50 in either list.

A refusal changes **neither** list, including the one that was fine. Both are checked together even
when only one was sent, or the second call could create a state the first would have been refused
for.

**`version` cannot be set here.** It is written by an upload and by nothing else - see
[step 4](#4-upload-the-files).

---

## 4. Upload the files

Three endpoints, all the same shape. **The file is the raw request body**, not multipart - you have a
file handle, and an envelope would only be taken apart at this end.

```
POST /api/mccp/{id}/code?name=neon.zip&version=1.2
POST /api/mccp/{id}/css?name=custom.css
POST /api/mccp/{id}/images?name=shot.png

Authorization: Bearer <accessToken>
Content-Type: application/zip

<the bytes>
```

`name` is what the file is called when somebody downloads it. `Content-Type` is remembered and sent
back on download, so send a real one.

**A code upload must carry a `version`, and it must be one this mod has not used before.** A code
upload is a build; the other two are not, and they take no version, write no changelog entry, and
can be repeated as often as somebody likes. Two uploads with no version are not two uploads of the
same version. It is
the version people see on the mod, the version this API hands out, and the heading of the changelog
entry the upload writes - one fact, written once. Without it the upload is a `400`; with a version
the mod has had already it is a `409`, because two different downloads answering to one version is
exactly what a version exists to prevent. Nothing is stored in either case. The other two uploads
take no version: a screenshot is not a release.

`201` with the mod, its `assets` now including what you sent.

| | Limit |
| --- | --- |
| Code | 64 MB, **one per mod** - a second replaces the first |
| Custom CSS | 256 KB, **one per mod** - a second replaces the first |
| Images | 2 MB each, **10 MB for the whole mod**, no limit on how many fit in that |

The stylesheet is stored and does nothing yet. Send it; it will start being used without the upload
changing.

| Code | Meaning |
| --- | --- |
| `401` | No token, or the mod is not yours (see below) |
| `400` | A code upload with no `version` (the other two need none) |
| `409` | That version has been used on this mod before (the body carries the `version`), or a code upload to a **native** mod - its DLL is never uploaded by its author, see [Native mods](#14-native-mods-reviewed-dlls) |
| `413` | Over a limit. The body carries `limitBytes`, and nothing is kept |
| `502` | Storage refused it. Not your fault and worth retrying |
| `503` | File storage is not configured on the server. Retrying will not help |

**Sizes are counted as the body arrives, not from `Content-Length`.** A `413` can therefore turn up
after you have finished sending. Nothing is stored when it does.

---

## 5. Publish it

```
POST /api/mccp/{id}/publish
Authorization: Bearer <accessToken>
```

`200` with the mod. Until this succeeds the mod is not on the public list, its page is a `404`, and
its files are a `404`.

`409` if no code has been uploaded, because a listing whose download is missing is a broken page with
a nice description. The body says so. For a **native** mod it is `409` until a reviewer has approved
a submission, with a sentence saying that; after that it publishes as usual.

Public or private belongs to the mod, not to a version: a new submission or an approval never changes
it.

There is no unpublish in this API yet.

---

## 6. The changelog (owner only)

**Entries are written by builds, and there is no call that adds one.** Uploading the mod's file
puts a line in its changelog by itself, headed by that build's version. A screenshot or a
stylesheet writes nothing - it changes the page, not the download, and somebody reading this list
is asking what changed about the thing they installed. That is the whole rule: an entry means a
build, so a reader can trust the list. A call that posted a line with
no update behind it would make the changelog worth less than nothing to somebody deciding whether
to install this - so there is not one, and `POST` to the changelog answers `405`.

What is left is the sentence on an entry, which is its author's to write.

```
PUT /api/mccp/{id}/changelog/{noteId}
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "title": "1.2", "text": "<p>Fixed the flicker on ultrawide.</p>" }
```

`200` with the entry. Send only what changes, like editing a mod - a field you leave out keeps
what it had, so an absent `text` is not an instruction to blank it. `title` is optional and usually
a version; `text` is HTML, cut at 4000 characters.

Rewriting the line an upload left is the expected thing to do with it: it stops being `automatic`
and becomes yours, and the website stops labelling it as the upload's.

```
DELETE /api/mccp/{id}/changelog/{noteId}
```

`204`. For an entry that should not be on the list at all.

`401` for both unless the token owns **that mod**. A note id from somebody else's mod put under one
of yours answers the same way: the mod is checked as well as the note.

---

## 7. Downloading what was uploaded

```
GET /Mccp/file/{assetId}
```

No token. Open to anybody, **once the mod is published** - before that it is a `404`, since this
endpoint has no session to ask who is calling.

Use the `url` from the mod, always. It is this site over https; the file service sits on a plain port
and its address is deliberately not public, so it can move without a single stored link going stale.

Images are served inline, everything else as a download.

---

## 8. Your mods

```
GET /api/mccp/mine
Authorization: Bearer <accessToken>
```

`200` with `{ "count": 2, "mods": [ ... ] }`, newest change first, published and not.

A native mod also carries `submissions`: every one you sent, newest first, each with its state and
its thread - see [the shape of a submission](#the-shape-of-a-submission).

---

## 9. Delete one

```
DELETE /api/mccp/{id}
Authorization: Bearer <accessToken>
```

`204`. The files go too, from storage as well as from here - for a native mod that includes every
submitted source and the reviewed DLLs. There is no undo.

---

## 10. The curated games

```
GET /api/mccp/games
```

**No token** - the list has to be drawable before anybody has signed in.

```json
{
  "count": 1,
  "games": [
    { "name": "half-life-2", "displayName": "Half-Life 2", "iconUrl": "/Mccp/game-icon/3" }
  ]
}
```

Match `name` against the games on a mod, **ignoring case**, and use `displayName` and `iconUrl` when
you find one.

**A miss is normal and is not an error.** Anybody may write any game they like, and only some of them
have been curated. When there is no match, show the string the mod gave you and no icon. There is no
endpoint that will ever tell you a game is invalid, because none of them are.

`iconUrl` is `null` when a curated game has no icon yet, so check it before using it. `displayName`
is never empty - it falls back to `name` - so you can print it without checking.

Read this once at startup and cache it. It changes when somebody adds a game, which is rare.

---

## 11. The app itself (build agent only)

Two endpoints for publishing the desktop app, and they do not use account tokens at all: the
caller is a build machine, so they take a machine key instead.

```
X-Api-Key: <one of the ApiPrivateKeys from the server settings>
```

`401` without it, with a wrong one, or when the server has none configured - and the body says
which of the first two it was: `{"error":"no_api_key"}` when no header arrived, and
`{"error":"unknown_api_key"}` when one arrived and was refused. Nothing distinguishes a wrong key
from a server with no keys, on purpose. `GET /api/mccp/key` answers the same way and changes
nothing, for testing a key without publishing a page.

### Upload a build

```
POST /api/mccp/app/{os}?name=mccp-setup.exe&version=1.2
X-Api-Key: <machine key>
Content-Type: application/octet-stream

<the bytes>
```

`os` is a short lowercase name - `windows`, `linux` - and it is a slot, not a log: **uploading
replaces that OS's build**, and the previous file is deleted from storage. Up to 64 MB. `201` with
the public download address; `413` over the limit and nothing is kept.

### Upload the changelog

```
POST /api/mccp/app/changelog
X-Api-Key: <machine key>

<plain text, up to 256 KB>
```

The newest text is what the download page shows, as text - markup is not rendered.

### What exists (public, no key)

```
GET /api/mccp/app
```

`{ "builds": [ { "os", "version", "name", "bytes", "uploadedAt", "url" } ], "changelog": "..." }` -
which is also how the app itself can check whether it is current.

The download for a person is `GET /Mccp/app/{os}` - no account, no key - and the page for humans
is `https://dasmaffin.com/Mccp/App`.

---

## 12. The developer wiki (machine key only)

MCCP has a wiki, and a wiki is a thing any module on this site can have - so it is documented once,
for all of them, at [/docs/wiki/publishing](/docs/wiki/publishing).

MCCP's is read at `/Mccp/Wiki` and published at `/Mccp/api/wiki/{path}` with a key granted
`Mccp:wiki`. The address this API used to document, `/api/mccp/wiki/{path}`, still answers exactly
as it did.

## 13. Where the files are kept (the file service only)

Not for a client. This is how the machine that holds the files tells the site where it currently
is, and it is documented here so that the endpoint is not a mystery to whoever reads the routes.

```
POST /api/mccp/storage/address
X-Api-Key: <the address key>
Content-Type: application/json

{ "baseUrl": "https://something.trycloudflare.com" }
```

`200` with `{ baseUrl, changed }`. `changed` is false when the address it reported is the one the
site already had, which is the ordinary case for a tunnel that reconnects to the same name.

The file service sits behind a Cloudflare tunnel, and without a Cloudflare account that tunnel
invents a fresh hostname every time it connects. Written into a settings file, that address is
right until the file service restarts, and then every upload answers `503` until a person notices.
So it reports its own address and the site follows.

`400` for anything that is not a plain https origin: no http, no path, no query, no credentials.
`401` for a wrong key, and for **any** key when none is configured - a gate with nothing set is
closed, not open.

The key is its own, deliberately separate from `ApiPrivateKeys`, because this one decides where
every file on the site comes from and the build agent has no business holding it.

---

## 14. Native mods (reviewed DLLs)

A native mod is a DLL the app loads at startup. Native code cannot be sandboxed, so one rule decides
everything in this section: **no DLL is ever served unless a reviewer built it from source
themselves.** The author never uploads a DLL. They submit source; a reviewer reads it, builds it, and
attaches their own build when they approve it. That build is the only thing the mod ever serves.

A native mod is made like any other, with `"kind": "native"` ([step 2](#2-make-a-mod)), and has
everything else a mod has: id, guid, title, games, requires, conflictsWith. A Lua mod requires a
native one by its GUID like any other requirement. Its only download is its DLL.
`POST /api/mccp/{id}/code` on a native mod is a `409` with a sentence saying why.

### Submitting source (the owner)

Every submission carries a `version` and a `changelog`. A version counts as used only once it is
**approved**: re-submitting a declined version under the same number is fine, and a version that was
ever approved is a `409`, as for code uploads. There are two ways to send the source.

**a) An upload: two calls.** The zip first, as the raw body like every other upload; then the
submission, which names it. Two calls because a changelog does not fit in a query string, and the
body is the zip.

```
POST /api/mccp/{id}/native/source
Authorization: Bearer <accessToken>
Content-Type: application/zip

<the zip, with whatever .gitignore excludes already left out>
```

`201` with what was received:

```json
{ "upload": "0d6f4c2e-5b1a-4c8e-9a77-1f2e3d4c5b6a", "bytes": 482113,
  "sha256": "9f2c...", "files": 84, "binaries": ["lib/native/foo.lib"] }
```

The upload is held until a submission names it. One per mod: a second upload replaces the first.

```
POST /api/mccp/{id}/native
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "version": "1.2", "changelog": "Fixed the crash on alt-tab.",
  "upload": "0d6f4c2e-5b1a-4c8e-9a77-1f2e3d4c5b6a" }
```

**b) GitHub: one call.** A public repository and the exact commit you mean.

```
POST /api/mccp/{id}/native
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "version": "1.2", "changelog": "Fixed the crash on alt-tab.",
  "repo": "https://github.com/someone/neon-native",
  "commit": "3f1c9a0b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a" }
```

`repo` is `https://github.com/owner/repo` (`.git` and a trailing slash are fine). `commit` is the
full 40-character SHA - a branch or a short SHA can move. The site checks the repository is public
and the commit exists, then downloads the archive of **that exact commit and keeps it**: the
reviewer reads what was submitted even if the repository is force-pushed or deleted afterwards. A
private or missing repository, or a commit that is not there, is a `400` that creates nothing, and
its sentence says what to do - for a private repository, make it public or upload the files instead.

From here on both ways are the same. `201` with [the submission](#the-shape-of-a-submission).

- **Prebuilt code cannot be approved.** A submission containing it is accepted, so the author is told
  in review, but it can never be approved while it does: prebuilt code can hold anything, and nothing
  makes it match the repository, so a DLL built against it is not built from source. It is listed in
  `binaries` and at the top of the review page. Prebuilt code means executables and libraries
  (`.dll .exe .lib .a .obj .o .so .pdb .dylib .sys .node .pyd`), compiled bytecode and packages
  (`.class .jar .wasm .nupkg .whl`), packed archives (`.zip .7z .rar .tar .gz .tgz`) - and any file
  whose header is one of these, whatever it is called, so a renamed DLL is caught. Images and other
  data are **not** prebuilt code: they are not text, but nothing runs them.
- **A new submission while an earlier one is pending replaces it.** The old one becomes
  `superseded`. Declined ones stay declined, with their threads.
- **64 MB of source**, counted as it arrives.
- Reviewers are emailed a link to the review page. Nothing from the source is ever put in an email.

| Code | Meaning |
| --- | --- |
| `400` | No `version`; no `changelog`; neither or both of `upload` and `repo`/`commit`; an `upload` id that is not this mod's; a `repo` that is not a GitHub address; a `commit` that is not 40 hex digits; a repository that is private or missing, or a commit that is not in it; an archive that is not a zip, is empty, or holds more than 20 000 files |
| `401` | No token, or the mod is not yours |
| `409` | That version was approved on this mod before, or the mod is a Lua mod |
| `413` | Over 64 MB. Nothing is kept |
| `502` | Storage refused it. Worth retrying |
| `503` | Storage is not configured, or GitHub is limiting how often the site may ask it. Not your fault; try later |

### The shape of a submission

```json
{
  "id": 7, "mod": 12, "version": "1.2", "changelog": "Fixed the crash on alt-tab.",
  "state": "declined",
  "source": "github",
  "repo": "https://github.com/someone/neon-native",
  "commit": "3f1c9a0b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a",
  "sourceBytes": 482113, "sourceSha256": "9f2c...",
  "binaries": ["lib/native/foo.lib"],
  "createdAt": "2026-10-10T12:00:00Z", "updatedAt": "2026-10-10T14:31:00Z",
  "decidedAt": "2026-10-10T14:31:00Z",
  "sourceUrl": "/api/mccp/12/native/7/source",
  "reviewUrl": "/Mccp/Review/7",
  "thread": [
    { "id": 3, "from": "reviewer", "text": "Where does foo.lib come from?", "commit": null,
      "at": "2026-10-10T14:31:00Z" }
  ]
}
```

`state` is `pending`, `approved`, `declined` or `superseded`. `source` is `upload` or `github`;
`repo` and `commit` are null for an upload. `thread` is oldest first; `from` is `author` or
`reviewer`, and `commit` is set on an author's reply that re-pinned the submission.

### The thread (the owner)

A reviewer who declines gives a reason, which is the first message. The author then answers:

```
POST /api/mccp/{id}/native/{submissionId}/reply
Authorization: Bearer <accessToken>
Content-Type: application/json

{ "text": "Rebuilt it from the vendor's source, see the new commit.",
  "commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" }
```

- **Text only** - the reviewer only had a question.
- **Text and a new commit** (GitHub submissions only) - "updated it, see this commit". The site
  fetches and keeps that commit's archive in place of the old one, and the reviewer reads the new code.

Either reply to a **declined** submission puts it back to `pending` and emails the reviewers. A reply
to a pending one is added and changes nothing else. An upload author who changed files sends a new
upload instead; it replaces the pending one.

`200` with the submission. `400` with no text, or a commit on an upload submission (or one that is
not 40 hex digits, or not in the repository); `404` when that submission is not on that mod; `409`
when it is `approved` or `superseded` - those take no more replies.

```
GET /api/mccp/{id}/native
```

Every submission of the mod, newest first, `{ "count", "submissions" }` - for the owner and for
reviewers. `/api/mccp/mine` carries the same list on each native mod.

```
GET /api/mccp/{id}/native/{submissionId}/source
```

The source as submitted, as a zip. **The owner and reviewers only**: everybody else gets `401`, as
for a mod that is not theirs. Source is never public and never appears in any listing.

### Reviewing

Two permissions, granted per account by an admin on the site's permissions page:

| Permission | Number | Lets you |
| --- | --- | --- |
| Review native mods | `2004` | Read submitted source, decline it, and approve **other people's** |
| Approve own native mods | `2005` | Beside the first: approve your own as well. Without it a reviewer's own submissions wait for another reviewer |

The review page is **`https://dasmaffin.com/Mccp/Review`**: pending submissions, oldest first, each
opening to the mod, author, version, changelog, thread, the list of binaries, a source browser in the
page, and the zip. The same decisions are on the API, for a reviewer's own tooling:

```
GET  /api/mccp/native/review                       pending submissions, oldest first
POST /api/mccp/native/{submissionId}/decline       { "reason": "..." }
POST /api/mccp/native/{submissionId}/approve?platform=win-x64&name=neon.dll
     Content-Type: application/octet-stream        <the DLL you built>
```

All three take a bearer token whose account holds the permission: `401` without a token, `403` with
one that may not review (or a decline/approve of your own submission without the second permission),
`503` when the site could not read your permissions just now.

**Decline** needs a `reason` (`400` without), which becomes the first message, and emails the author
with the reason and a link to reply.

**Approve** is only possible by attaching the DLL **you built from the source**: the raw body, up to
64 MB, built for `platform` (`win-x64`, the only one for now - anything else is a `400`; a
`linux-x64` build can be added beside it later without this contract changing). Approving, all at
once:

- stores the DLL with its `sha256`;
- marks the submission `approved`;
- writes the changelog entry for that version, from the submission's `changelog`;
- makes it the version the mod serves - the DLL the previous approval attached for that platform is
  replaced;
- sets the mod's `reviewed` the first time;
- emails the author.

`200` with the submission. `404` for no such submission; `409` when it is not pending, when the
version was approved already, or when the source contains prebuilt code (the body names the files); `413` over 64 MB; `502`/`503` as for uploads.

### What is served

Every public answer about a native mod - the list, one mod, the install chain, `/Mccp/file`,
`/download` - shows only the **latest approved** version: its `version`, its changelog, and the DLL as
an asset with `"kind": "native"`, `"platform"` and `"sha256"`. **The app refuses a file whose hash
does not match.** While a newer submission is pending, declined or superseded, the old approved DLL
keeps being served unchanged.

---

## Things that will bite you if nobody says them

**A mod is not a file, it is a thing files hang off.** Make it, upload to it, then publish it. Three
calls minimum, in that order, and the first one gives you the `id` the other two need.

**Nothing is visible until you publish.** The commonest way to be confused by this API is to upload
everything, look at the website, and find nothing there. Step 4 is not optional.

**`games` replaces. Everything else merges.** Read that sentence again before writing the edit
screen. A `PUT` that sends `games: []` because your form was empty will quietly take a mod off every
list it was on.

**Capitals do not matter and spaces do.** `"Portal"`, `"portal"` and `" Portal "` are stored once as
one game. `"Portal"` and `"Portal 2"` are two different games and always will be.

**A mod may be for at most 20 games**, titles are cut at 120 characters, descriptions at 8000, and
game names at 120. Nothing is refused for being long - it is trimmed - so do not rely on the server
to tell somebody their title was too long. Say so in your own form.

**`401` means "not yours" as well as "not signed in".** A mod that does not exist and a mod belonging
to somebody else answer identically, on purpose, so that ids cannot be used to count what other
people are working on. Do not word your error as though you know which it was.

**Downloading needs no account, and your program must not add one.** Nothing in
[section 1](#1-find-mods-no-account) reads a token or a cookie. If your app asks somebody to sign in
before they can browse or install a mod, that is a door your program built, not one this API has.
Sign-in is for uploading, and for voting and commenting on the website.

**Uploading is a token away from being anonymous, and must not be.** If your program has an "upload"
button that works before somebody has signed in, that is a bug in your program - every one of these
calls will refuse it, and the person will see a failure they cannot act on. Ask them to sign in
first.

**A native mod cannot be published before a review.** Make it, submit source, and wait: `publish`
answers `409` until a reviewer has approved a submission. Say so in your app rather than showing a
publish button that fails.

**An update to a native mod keeps serving the old DLL until it is approved.** A new submission
changes nothing anybody downloads - not while it is pending, not if it is declined. Only an approval
does. Do not tell somebody their update is live because the submission call answered `201`.

**Prebuilt code in source is a dead end, not a warning.** A submission with a `.dll`, `.lib`, `.zip`
or the like in its source is accepted but can never be approved. Tell the author before they submit:
your app knows what is in the folder it is about to zip, and `binaries` in the answer to
`POST .../native/source` lists exactly what the site will object to.

**Check the hash before loading a native DLL.** Every native asset carries `sha256`. A download that
does not match is not the file a reviewer attached, and must not be put in the bin folder.

**`503` is a server that is not finished being set up.** It means nobody has configured storage yet.
Say so plainly rather than reporting it as an upload failure, and do not retry in a loop.
