{"openapi":"3.1.0","info":{"title":"MJOLNIR Hub API","version":"1.0.0","description":"Open API for the MJOLNIR mod platform for Halo Campaign Evolved. Reads are public and unauthenticated. Browser sessions use Discord OAuth with an HttpOnly cookie; third-party tools authenticate with `Authorization: Bearer mjc_…` API keys carrying scopes (mods:read, mods:write, ratings:write, comments:write), minted at /account/api-keys. Desktop clients with no browser session pair instead: /auth/device/start, show the user the code, and poll /auth/device/token for a scoped key. Any authenticated write may additionally answer 401 (no identity), 403 (missing scope), or 429 (per-subject hourly write budget) with the standard Error shape."},"servers":[{"url":"https://mjolnircore.com","description":"Production"},{"url":"http://localhost:3000","description":"Local development"}],"components":{"schemas":{"Health":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"service":{"type":"string","enum":["mjolnir-hub"]},"timestamp":{"type":"string"}},"required":["status","service","timestamp"]},"Error":{"type":"object","properties":{"error":{"type":"string","example":"not_found"},"message":{"type":"string","example":"No mod with that slug."}},"required":["error"]},"User":{"type":"object","properties":{"id":{"type":"string","example":"0198c2f4-6c1e-7c33-a1b0-9a1c2d3e4f56"},"username":{"type":"string","example":"devnull9090"},"display_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"],"description":"Discord CDN avatar, when the user has one."},"role":{"type":"string","enum":["user","moderator","admin"]},"created_at":{"type":"string"}},"required":["id","username","display_name","avatar_url","role","created_at"]},"DeviceStart":{"type":"object","properties":{"device_code":{"type":"string","description":"Secret for polling. Never display it."},"user_code":{"type":"string","example":"H7QK-M3XB","description":"Show this to the user."},"verification_url":{"type":"string","example":"https://mjolnircore.com/link"},"interval":{"type":"integer","description":"Seconds to wait between polls."},"expires_in":{"type":"integer"},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]},"description":"What approval will grant — echoed back so a client can show it too."}},"required":["device_code","user_code","verification_url","interval","expires_in","scopes"]},"DevicePoll":{"type":"object","properties":{"status":{"type":"string","enum":["pending","approved","denied","expired"]},"key":{"type":"string","description":"The minted API key, returned exactly once — on the first poll after approval. Store it; it is not retrievable again."},"user":{"$ref":"#/components/schemas/User"},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]},"description":"What the key carries. Sent with the key so a client can check it got what it asked for instead of failing later at the write."}},"required":["status"]},"ModList":{"type":"object","properties":{"mods":{"type":"array","items":{"$ref":"#/components/schemas/Mod"}},"next_cursor":{"type":["string","null"],"description":"Pass as `cursor` to fetch the next page; null when exhausted."}},"required":["mods","next_cursor"]},"Mod":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string","example":"mjolnir-flycam"},"name":{"type":"string","example":"MJOLNIRFlyCam"},"summary":{"type":["string","null"]},"type":{"type":"string","enum":["content","script","native"],"description":"Trust tier. `content` mods are inert game data and open to community upload. `script` (UE4SS Lua) and `native` (DLL) mods can execute code, so they ship exclusively from the mjolnir-core repository's reviewed, signed CI releases."},"category":{"type":"string","example":"camera"},"license":{"type":["string","null"]},"nsfw":{"type":"boolean"},"download_count":{"type":"integer"},"rating_count":{"type":"integer"},"rating_mean":{"type":["number","null"]},"author":{"type":"string","description":"Owner's display name (or Discord username)."},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","slug","name","summary","type","category","license","nsfw","download_count","rating_count","rating_mean","author","created_at","updated_at"]},"ModDetail":{"allOf":[{"$ref":"#/components/schemas/Mod"},{"type":"object","properties":{"description_md":{"type":["string","null"],"description":"Full mod page body, Markdown."}},"required":["description_md"]}]},"ReleaseList":{"type":"object","properties":{"releases":{"type":"array","items":{"$ref":"#/components/schemas/Release"}}},"required":["releases"]},"Release":{"type":"object","properties":{"id":{"type":"string"},"mod_id":{"type":"string"},"version":{"type":"string","example":"1.2.0"},"channel":{"type":"string","enum":["stable","beta"]},"changelog_md":{"type":["string","null"]},"file_size":{"type":["integer","null"]},"sha256":{"type":["string","null"],"description":"Hash of the release archive; verify after download."},"signature":{"type":["string","null"],"description":"Base64 Ed25519 signature over the lowercase hex `sha256`, made with the platform release key (keys/mod-signing.pub). Present on signed script/native releases; null for community content uploads, whose integrity rests on `sha256` plus the upload scan. Clients that know the key must reject a release whose signature is present and wrong."},"build_min":{"type":["string","null"],"description":"Oldest game build this release is declared compatible with."},"build_max":{"type":["string","null"]},"download_count":{"type":"integer"},"created_at":{"type":"string"},"published_by":{"type":["string","null"],"description":"Account that created this release; null for releases predating attribution."},"published_by_username":{"type":["string","null"]},"signer_fingerprint":{"type":["string","null"],"description":"Fingerprint of the author signing key whose signature verified at publish. Distinct from `signature`, which is the platform key over the archive hash; this one is the author's own key over the archive contents (docs/mod_signing_design.md)."},"signer_key_revoked":{"type":"boolean","description":"True when the author's signing key has been revoked since publish."}},"required":["id","mod_id","version","channel","changelog_md","file_size","sha256","signature","build_min","build_max","download_count","created_at","published_by","published_by_username","signer_fingerprint","signer_key_revoked"]},"ModCreate":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{1,63}$","example":"my-texture-pack"},"name":{"type":"string","minLength":1,"maxLength":120},"summary":{"type":"string","maxLength":300},"description_md":{"type":"string","maxLength":65536},"category":{"type":"string","maxLength":40,"default":"gameplay"},"license":{"type":"string","maxLength":60},"nsfw":{"type":"boolean","default":false}},"required":["slug","name"]},"ReleaseCreate":{"type":"object","properties":{"version":{"type":"string","pattern":"^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$"},"channel":{"type":"string","enum":["stable","beta"],"default":"stable"},"changelog_md":{"type":"string","maxLength":65536},"build_min":{"type":"string","maxLength":80},"build_max":{"type":"string","maxLength":80}},"required":["version"]},"ReleaseStatus":{"type":"object","properties":{"id":{"type":"string"},"mod_id":{"type":"string"},"version":{"type":"string"},"status":{"type":"string","enum":["pending","scanning","published","rejected","yanked"]},"sha256":{"type":["string","null"]},"signature":{"type":["string","null"]},"file_size":{"type":["integer","null"]},"chunk_count":{"type":"integer","description":"IoStore chunks this release claims, once scanned."},"findings":{"type":"array","items":{"$ref":"#/components/schemas/ScanFinding"}},"created_at":{"type":"string"},"published_by":{"type":["string","null"],"description":"Account that created this release; null for releases predating attribution."},"published_by_username":{"type":["string","null"]},"signer_fingerprint":{"type":["string","null"],"description":"Fingerprint of the author key whose signature verified at publish, if any."},"signer_key_revoked":{"type":"boolean","description":"True when the signing key has been revoked since this release published."}},"required":["id","mod_id","version","status","sha256","signature","file_size","chunk_count","findings","created_at","published_by","published_by_username","signer_fingerprint","signer_key_revoked"]},"ScanFinding":{"type":"object","properties":{"level":{"type":"string","enum":["error","warning"]},"code":{"type":"string","example":"forbidden_file"},"message":{"type":"string"}},"required":["level","code","message"]},"ConflictList":{"type":"object","properties":{"release_id":{"type":"string"},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/ConflictEntry"}}},"required":["release_id","conflicts"]},"ConflictEntry":{"type":"object","properties":{"release_id":{"type":"string"},"mod_slug":{"type":"string"},"mod_name":{"type":"string"},"version":{"type":"string"},"shared_chunks":{"type":"integer","description":"How many IoStore chunk IDs both releases claim."}},"required":["release_id","mod_slug","mod_name","version","shared_chunks"]},"ConflictCheckResponse":{"type":"object","properties":{"pairs":{"type":"array","items":{"$ref":"#/components/schemas/ConflictPair"},"description":"Every conflicting pair among the requested releases. Empty means the set installs cleanly in any order."}},"required":["pairs"]},"ConflictPair":{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"string"},"shared_chunks":{"type":"integer"},"sample_chunk_ids":{"type":"array","items":{"type":"string"},"description":"Up to 10 shared chunk IDs, hex-encoded 12-byte identifiers."}},"required":["a","b","shared_chunks","sample_chunk_ids"]},"ConflictCheckRequest":{"type":"object","properties":{"release_ids":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":50,"description":"Releases a client intends to install together."}},"required":["release_ids"]},"MediaList":{"type":"object","properties":{"media":{"type":"array","items":{"$ref":"#/components/schemas/Media"}}},"required":["media"]},"Media":{"type":"object","properties":{"id":{"type":"string"},"mod_id":{"type":"string"},"url":{"type":"string","description":"Where the image is served from."},"kind":{"type":"string","enum":["screenshot","thumbnail"]},"alt_text":{"type":"string","description":"Author-provided description; required on upload."},"width":{"type":["integer","null"]},"height":{"type":["integer","null"]},"position":{"type":"integer"},"created_at":{"type":"string"}},"required":["id","mod_id","url","kind","alt_text","width","height","position","created_at"]},"RatingPut":{"type":"object","properties":{"score":{"type":"integer","minimum":1,"maximum":5},"review_md":{"type":"string","maxLength":8192}},"required":["score"]},"RatingSummary":{"type":"object","properties":{"count":{"type":"integer"},"mean":{"type":["number","null"]},"distribution":{"type":"object","additionalProperties":{"type":"integer"},"description":"Score → number of ratings, keys '1'..'5'."},"mine":{"type":["integer","null"],"description":"The caller's own score, when signed in and rated."},"reviews":{"type":"array","items":{"type":"object","properties":{"author":{"type":"string"},"score":{"type":"integer"},"review_md":{"type":"string"},"created_at":{"type":"string"}},"required":["author","score","review_md","created_at"]}}},"required":["count","mean","distribution","mine","reviews"]},"CommentList":{"type":"object","properties":{"comments":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}},"required":["comments"]},"Comment":{"type":"object","properties":{"id":{"type":"string"},"mod_id":{"type":"string"},"parent_id":{"type":["string","null"]},"author":{"type":["string","null"],"description":"Null when the comment was deleted."},"author_id":{"type":["string","null"],"description":"The author's user id, so a client can tell whose comments carry a delete button without matching on display names. Null when deleted."},"author_avatar":{"type":["string","null"]},"body_md":{"type":["string","null"]},"deleted":{"type":"boolean"},"created_at":{"type":"string"}},"required":["id","mod_id","parent_id","author","author_id","author_avatar","body_md","deleted","created_at"]},"CommentCreate":{"type":"object","properties":{"body_md":{"type":"string","minLength":1,"maxLength":8192},"parent_id":{"type":"string"}},"required":["body_md"]},"Me":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"}},"required":["id","username"]},"SigningKey":{"type":"object","properties":{"id":{"type":"string"},"fingerprint":{"type":"string","description":"Lowercase hex sha256 of the raw public key."},"public_key":{"type":"string","description":"Raw 32-byte Ed25519 public key, base64."},"label":{"type":"string"},"created_at":{"type":"string"},"last_used_at":{"type":["string","null"]}},"required":["id","fingerprint","public_key","label","created_at","last_used_at"]},"SigningKeyCreate":{"type":"object","properties":{"public_key":{"type":"string","minLength":40,"maxLength":60,"description":"Raw 32-byte Ed25519 public key, base64. The private key never leaves the device."},"label":{"type":"string","minLength":1,"maxLength":80,"example":"DESKTOP-4XJ2"}},"required":["public_key","label"]},"ApiKey":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"key_prefix":{"type":"string","example":"mjc_a1b2c3","description":"For telling keys apart; not the key."},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]}},"last_used_at":{"type":["string","null"]},"expires_at":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","name","key_prefix","scopes","last_used_at","expires_at","created_at"]},"ApiKeyCreated":{"allOf":[{"$ref":"#/components/schemas/ApiKey"},{"type":"object","properties":{"key":{"type":"string","description":"The full key. Shown exactly once — store it now.","example":"mjc_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}},"required":["key"]}]},"ApiKeyCreate":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80,"example":"my-mod-manager"},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]},"minItems":1,"default":["mods:read"]},"expires_in_days":{"type":"integer","minimum":1,"maximum":365}},"required":["name"]},"ReportCreate":{"type":"object","properties":{"subject_type":{"type":"string","enum":["mod","release","comment","media","user"]},"subject_id":{"type":"string"},"reason":{"type":"string","enum":["malware","stolen","broken","nsfw","spam","other"]},"detail":{"type":"string","maxLength":2000}},"required":["subject_type","subject_id","reason"]},"Report":{"type":"object","properties":{"id":{"type":"string"},"reporter":{"type":"string"},"subject_type":{"type":"string"},"subject_id":{"type":"string"},"reason":{"type":"string"},"detail":{"type":["string","null"]},"status":{"type":"string","enum":["open","resolved","dismissed"]},"created_at":{"type":"string"}},"required":["id","reporter","subject_type","subject_id","reason","detail","status","created_at"]},"ReportDecision":{"type":"object","properties":{"action":{"type":"string","enum":["resolve","dismiss"]}},"required":["action"]},"CodeSyncResult":{"type":"object","properties":{"set_version":{"type":"string"},"mods_synced":{"type":"integer"},"releases_created":{"type":"integer"}},"required":["set_version","mods_synced","releases_created"]}},"parameters":{}},"paths":{"/api/v1/health":{"get":{"tags":["meta"],"summary":"Service health","responses":{"200":{"description":"The service is up.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/api/v1/auth/discord":{"get":{"tags":["auth"],"summary":"Begin Discord OAuth","description":"Redirects the browser to Discord's consent screen. Pass `next` to return to a specific page after login.","parameters":[{"schema":{"type":"string"},"required":false,"name":"next","in":"query"}],"responses":{"302":{"description":"Redirect to Discord."},"500":{"description":"OAuth is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/discord/callback":{"get":{"tags":["auth"],"summary":"OAuth callback","description":"Discord redirects here; on success a session cookie is set.","parameters":[{"schema":{"type":"string"},"required":false,"name":"code","in":"query"},{"schema":{"type":"string"},"required":false,"name":"state","in":"query"}],"responses":{"302":{"description":"Logged in; redirect to `next`."},"400":{"description":"State mismatch or missing code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The account is banned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Discord rejected the exchange.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["auth"],"summary":"Who the caller is","description":"Resolves either credential: the browser session cookie, or a Bearer API key. Desktop clients that paired a key ask this to find out whose account they are acting as.","responses":{"200":{"description":"The signed-in user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}},"401":{"description":"No valid session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/logout":{"post":{"tags":["auth"],"summary":"Sign out","responses":{"200":{"description":"Session cookie cleared.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}}}}},"/api/v1/auth/device/start":{"post":{"tags":["auth"],"summary":"Begin pairing a desktop client","description":"Returns a short `user_code` to show the user and a secret `device_code` to poll with. Send the user to `verification_url` to approve. Codes live for 10 minutes.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"client_name":{"type":"string","minLength":1,"maxLength":60,"default":"MJOLNIR Launcher","description":"Shown on the approval page so the user knows what they are approving."},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]},"minItems":1,"description":"What to ask for. Omit for read, rate and comment — what pairing granted before scopes were requestable. Ask for the narrowest set that works: the user sees this list.","example":["mods:read","mods:write"]}}}}}},"responses":{"201":{"description":"Pairing started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceStart"}}}},"429":{"description":"Too many handshakes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/device/token":{"post":{"tags":["auth"],"summary":"Poll a pairing for its key","description":"Answers `pending` until the user decides. The first poll after approval carries the key and is the only one that does.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"device_code":{"type":"string","minLength":1}},"required":["device_code"]}}}},"responses":{"200":{"description":"Current state of the pairing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DevicePoll"}}}},"404":{"description":"Unknown device code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Polling too fast.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/device/approve":{"post":{"tags":["auth"],"summary":"Approve or deny a pairing code","description":"Called from a signed-in browser session. Approving mints an API key carrying the scopes the client asked for at handshake time — expiring in 180 days, or 90 if it can publish — which the waiting client collects on its next poll. Only cookie sessions may approve — a key cannot pair another device.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"user_code":{"type":"string","minLength":4,"maxLength":20},"approve":{"type":"boolean"}},"required":["user_code","approve"]}}}},"responses":{"200":{"description":"Decision recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["approved","denied"]},"client_name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]}}},"required":["status","client_name","scopes"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API keys cannot approve pairings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such code, or it expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already decided.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many attempts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/device/pending/{user_code}":{"get":{"tags":["auth"],"summary":"Describe a pending pairing","description":"Lets the approval page name the client, and say what it is asking for, before the user commits. Returns nothing that helps an attacker: the client name is a label the client chose for itself, and the scopes are what it would be granted anyway.","parameters":[{"schema":{"type":"string"},"required":true,"name":"user_code","in":"path"}],"responses":{"200":{"description":"The pending pairing.","content":{"application/json":{"schema":{"type":"object","properties":{"client_name":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["mods:read","mods:write","ratings:write","comments:write"]}},"key_ttl_days":{"type":"integer","description":"How long the minted key would last. Served rather than assumed so the approval page cannot promise a lifetime the mint no longer honours."},"expires_at":{"type":"string","description":"When this code stops working."}},"required":["client_name","scopes","key_ttl_days","expires_at"]}}}},"404":{"description":"Unknown or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods":{"get":{"tags":["mods"],"summary":"List published mods","parameters":[{"schema":{"type":"string","description":"Opaque pagination cursor from a previous response."},"required":false,"description":"Opaque pagination cursor from a previous response.","name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":20,"description":"Page size, 1-100."},"required":false,"description":"Page size, 1-100.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Search in name and summary."},"required":false,"description":"Search in name and summary.","name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"category","in":"query"},{"schema":{"type":"string","enum":["content","script","native"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","enum":["newest","downloads","rating"],"default":"newest"},"required":false,"name":"sort","in":"query"}],"responses":{"200":{"description":"A page of mods.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModList"}}}},"400":{"description":"Bad cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["publish"],"summary":"Create a mod","description":"Creates a draft `content` mod owned by the signed-in user. Script and native mods cannot be created here; they ship from the mjolnir-core repository's release pipeline.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModCreate"}}}},"responses":{"201":{"description":"The new mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModDetail"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Slug taken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods/{slug}":{"get":{"tags":["mods"],"summary":"A mod by slug","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"The mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModDetail"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods/{slug}/releases":{"get":{"tags":["mods"],"summary":"Published releases of a mod, newest first","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"The releases.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseList"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["publish"],"summary":"Create a pending release","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseCreate"}}}},"responses":{"201":{"description":"The pending release; upload its archive next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Release"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner, or not a content mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Version exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}/archive":{"put":{"tags":["publish"],"summary":"Upload the .mjolnir archive","description":"Raw zip bytes, at most 50 MiB. Re-uploading while pending or rejected replaces the archive and resets the release to pending.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/zip":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Stored; call /complete to scan and publish.","content":{"application/json":{"schema":{"type":"object","properties":{"sha256":{"type":"string"},"file_size":{"type":"integer"}},"required":["sha256","file_size"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Release is published or yanked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Archive too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}/complete":{"post":{"tags":["publish"],"summary":"Scan the uploaded archive and publish or reject","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Scan finished; status says whether it published.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseStatus"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"No archive uploaded, or already decided.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}":{"get":{"tags":["releases"],"summary":"Release status and scan findings","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseStatus"}}}},"404":{"description":"No such release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}/download":{"get":{"tags":["releases"],"summary":"Download the release archive","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The .mjolnir archive (zip)."},"302":{"description":"Signed code-mod artifact; redirect to its canonical URL."},"404":{"description":"Not published.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}/conflicts":{"get":{"tags":["conflicts"],"summary":"Published releases that claim any of the same chunks","description":"Two content releases conflict exactly when their IoStore containers claim at least one chunk ID in common; whichever mounts later wins those chunks. Computed from the uploaded containers, not from author declarations.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Conflicting releases, most-overlapping first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictList"}}}},"404":{"description":"No such release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/conflicts/check":{"post":{"tags":["conflicts"],"summary":"Conflict matrix for a set of releases","description":"The endpoint mod managers integrate against: given the releases a profile intends to install, returns every pair that claims the same chunk. An empty list means any load order works.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictCheckRequest"}}}},"responses":{"200":{"description":"All conflicting pairs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConflictCheckResponse"}}}}}}},"/api/v1/mods/{slug}/media":{"get":{"tags":["media"],"summary":"A mod's screenshots","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"The images.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaList"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["media"],"summary":"Upload a screenshot","description":"multipart/form-data with `file` (png, jpeg or webp, ≤ 8 MiB) and `alt_text` (required — every image ships with a description).","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"},"alt_text":{"type":"string","minLength":1,"maxLength":500}},"required":["alt_text"]}}}},"responses":{"201":{"description":"The stored image.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Media"}}}},"400":{"description":"Missing alt text or not an image.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Image too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/media/{id}":{"delete":{"tags":["media"],"summary":"Delete a screenshot","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Gone.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such image.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["media"],"summary":"Serve an image","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The image bytes."},"404":{"description":"No such image.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods/{slug}/ratings/me":{"put":{"tags":["ratings"],"summary":"Rate a mod","description":"One rating per user per mod; calling again replaces yours.","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RatingPut"}}}},"responses":{"200":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods/{slug}/ratings":{"get":{"tags":["ratings"],"summary":"Rating summary and recent reviews","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"The summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RatingSummary"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/mods/{slug}/comments":{"get":{"tags":["comments"],"summary":"A mod's comment thread","description":"Flat list, oldest first; `parent_id` links replies. Clients assemble the tree.","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"responses":{"200":{"description":"The comments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentList"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["comments"],"summary":"Post a comment","parameters":[{"schema":{"type":"string"},"required":true,"name":"slug","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentCreate"}}}},"responses":{"201":{"description":"Posted.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}}}},"400":{"description":"Bad parent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such mod.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/comments/{id}":{"delete":{"tags":["comments"],"summary":"Delete a comment","description":"Author or moderator. Soft delete: the thread keeps its shape, the body is blanked.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such comment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/account/me":{"get":{"tags":["account"],"summary":"Who am I","description":"The authenticated account, for tools that need to embed their own identity — the tag editor puts it in signed release statements.","responses":{"200":{"description":"You.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/account/signing-keys":{"get":{"tags":["account"],"summary":"List your signing keys","responses":{"200":{"description":"Active (non-revoked) signing keys.","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/SigningKey"}}},"required":["keys"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["account"],"summary":"Register a signing key","description":"Binds an Ed25519 public key to your account. Idempotent for a key you already registered. A key belongs to exactly one account, ever.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKeyCreate"}}}},"responses":{"201":{"description":"Registered (or already yours).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SigningKey"}}}},"400":{"description":"Bad key, or too many keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Key belongs to another account, or was revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/account/signing-keys/{id}":{"delete":{"tags":["account"],"summary":"Revoke a signing key","description":"Future uploads signed with this key are rejected. Releases it already signed keep their history; yank those separately if needed.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/account/api-keys":{"get":{"tags":["account"],"summary":"List your API keys","responses":{"200":{"description":"Active (non-revoked) keys.","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}},"required":["keys"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"tags":["account"],"summary":"Create an API key","description":"Keys are created from a browser session only — a key cannot mint more keys. The full key appears once in the response and never again.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreate"}}}},"responses":{"201":{"description":"The key — store it now.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreated"}}}},"400":{"description":"Too many keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"API keys cannot create keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/account/api-keys/{id}":{"delete":{"tags":["account"],"summary":"Revoke an API key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/reports":{"post":{"tags":["moderation"],"summary":"Report content","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportCreate"}}}},"responses":{"201":{"description":"Filed.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Slow down.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/moderation/reports":{"get":{"tags":["moderation"],"summary":"The report queue (moderators)","parameters":[{"schema":{"type":"string","enum":["open","resolved","dismissed"],"default":"open"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Reports, oldest first.","content":{"application/json":{"schema":{"type":"object","properties":{"reports":{"type":"array","items":{"$ref":"#/components/schemas/Report"}}},"required":["reports"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Moderators only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/moderation/reports/{id}":{"post":{"tags":["moderation"],"summary":"Resolve or dismiss a report (moderators)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportDecision"}}}},"responses":{"200":{"description":"Decided.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Moderators only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such open report.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/releases/{id}/yank":{"post":{"tags":["moderation"],"summary":"Yank a published release","description":"Owner or moderator. A yanked release stops being downloadable and leaves the conflict index, but its record and scan history remain.","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Yanked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such published release.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/code-mods/sync":{"post":{"tags":["mods"],"summary":"Mirror the signed code-mod set onto hub pages","description":"Fetches the latest signed release manifest, verifies its Ed25519 signature against the platform's committed public key, and upserts a hub mod page and release per entry. Idempotent; callable by anyone because the signature, not the caller, is the authority. CI calls it after every code-mods release.","responses":{"200":{"description":"What the sync did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodeSyncResult"}}}},"429":{"description":"Slow down.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Manifest unreachable, unparseable, or its signature failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"webhooks":{}}