A Deno-powered backend service for Plants vs. Zombies: MODDED. [Read-only GitHub mirror] docs.pvzm.net
express typescript expressjs plant deno jspvz pvzm game online backend plants-vs-zombies zombie javascript plants modded vs plantsvszombies openapi pvz noads
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Format "0.6.0 - โญ Add thumbnails to Discord and Bluesky posts for new levels - ๐Ÿ› ๏ธ Fixed issue where levels with no logging data couldn't be deleted - ๐Ÿ“œ Create OpenAPI schema - ๐Ÿ“œ Create " Original commit: https://github.com/ROBlNET13/pvzm-backend/commit/e4809339dd0c666cb3af31a627c68cc94c1fd5f6

Co-authored-by: ClaytonTDM <clay@clay.rip>

+768 -769
+5 -5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - ## __0.6.0__ 3 + ## **0.6.0** 4 4 5 5 - โญ Add thumbnails to Discord and Bluesky posts for new levels 6 6 - ๐Ÿ› ๏ธ Fixed issue where levels with no logging data couldn't be deleted ··· 21 21 22 22 - โญ Add auto-publishing to Discord messages posted in announcement channels 23 23 24 - ## __0.5.0__ 24 + ## **0.5.0** 25 25 26 26 - ๐Ÿ› ๏ธ Refactored logging system to be modular 27 27 - โญ Added Bluesky logging provider ··· 34 34 35 35 - โญ Add current version to `/api/health` 36 36 37 - ## __0.4.0__ 37 + ## **0.4.0** 38 38 39 39 - โญ Support for new zombie picker 40 40 - โญ Add profanity filter (via `bad-words`) 41 41 42 - ## __0.3.0__ 42 + ## **0.3.0** 43 43 44 44 - โญ Add new Featured sort 45 45 46 - ## __0.2.0__ 46 + ## **0.2.0** 47 47 48 48 - ๐Ÿ› ๏ธ Fix a bug where when an admin changes some level metadata (e.g. sun) the level data doesn't reflect the change 49 49 - ๐Ÿ› ๏ธ Fix erroneous login prompt on admin dashboard when authentication is disabled
+4 -7
modules/plantImages.ts
··· 11 11 }; 12 12 13 13 export async function getPlantImages(config: ServerConfig): Promise<{ [key: string]: PlantData }> { 14 - const gameUrl = config.gameUrl.endsWith("/") 15 - ? config.gameUrl.slice(0, -1) 16 - : config.gameUrl; 14 + const gameUrl = config.gameUrl.endsWith("/") ? config.gameUrl.slice(0, -1) : config.gameUrl; 17 15 18 16 // snapshot existing keys so we can clean up after import 19 17 const keysBefore = new Set(Object.keys(g)); 20 18 21 19 // set up dummy globals so plant files can be imported 22 - const defaultGetShadow = (a: { width: number; height: number }) => 23 - "left:" + (a.width * 0.5 - 48) + "px;top:" + (a.height - 22) + "px"; 20 + const defaultGetShadow = (a: { width: number; height: number }) => "left:" + (a.width * 0.5 - 48) + "px;top:" + (a.height - 22) + "px"; 24 21 g.InheritO = (_base: unknown, data: Record<string, unknown>) => { 25 22 return { 26 23 prototype: { ··· 34 31 g.CPlants = { prototype: { PicArr: [], getShadow: defaultGetShadow } }; 35 32 g.$User = { 36 33 Visitor: { 37 - TimeStep: 0 34 + TimeStep: 0, 38 35 }, 39 36 Browser: { 40 37 IE6: false, 41 38 }, 42 39 Client: { 43 40 Mobile: false, 44 - } 41 + }, 45 42 }; 46 43 g.$Random = ""; 47 44 g.window = g;
+2 -4
modules/renderThumbnail.ts
··· 17 17 thumb: number[][], 18 18 isWater: boolean, 19 19 plantImages: { [key: string]: PlantData }, 20 - config: ServerConfig, 20 + config: ServerConfig 21 21 ): Promise<Uint8Array> { 22 - const gameUrl = config.gameUrl.endsWith("/") 23 - ? config.gameUrl.slice(0, -1) 24 - : config.gameUrl; 22 + const gameUrl = config.gameUrl.endsWith("/") ? config.gameUrl.slice(0, -1) : config.gameUrl; 25 23 const baseUrl = `${gameUrl}/game/`; 26 24 27 25 const canvas = createCanvas(900, 600);
+17 -13
modules/routes/admin.ts
··· 217 217 ]).catch((err) => console.error("Warning: Failed to update logging messages for level", levelId, err)); 218 218 } 219 219 220 - deps.loggingManager.sendAuditLog({ 221 - action: "edit", 222 - levelId, 223 - levelName: typedUpdatedLevel.name, 224 - author: typedUpdatedLevel.author, 225 - changes: changes.join("\n"), 226 - }).catch((err) => console.error("Warning: Failed to send audit log for level edit", levelId, err)); 220 + deps.loggingManager 221 + .sendAuditLog({ 222 + action: "edit", 223 + levelId, 224 + levelName: typedUpdatedLevel.name, 225 + author: typedUpdatedLevel.author, 226 + changes: changes.join("\n"), 227 + }) 228 + .catch((err) => console.error("Warning: Failed to send audit log for level edit", levelId, err)); 227 229 } 228 230 229 231 // send to posthog ··· 394 396 ]).catch((err) => console.error("Warning: Failed to clean up logging messages for level", levelId, err)); 395 397 } 396 398 397 - deps.loggingManager.sendAuditLog({ 398 - action: "delete", 399 - levelId, 400 - levelName: typedLevel.name, 401 - author: typedLevel.author, 402 - }).catch((err) => console.error("Warning: Failed to send audit log for level deletion", levelId, err)); 399 + deps.loggingManager 400 + .sendAuditLog({ 401 + action: "delete", 402 + levelId, 403 + levelName: typedLevel.name, 404 + author: typedLevel.author, 405 + }) 406 + .catch((err) => console.error("Warning: Failed to send audit log for level deletion", levelId, err)); 403 407 404 408 try { 405 409 const fileExtension = `izl${typedLevel.version || 3}`;
+740 -740
openapi.yaml
··· 1 1 openapi: 3.0.3 2 2 info: 3 - title: PVZM Backend API 4 - description: "API for the Plants vs. Zombies: MODDED level sharing platform. Supports level uploading, downloading, browsing, favoriting, reporting, and admin management." 5 - version: 0.6.0 6 - contact: 7 - url: https://pvzm.net 3 + title: PVZM Backend API 4 + description: "API for the Plants vs. Zombies: MODDED level sharing platform. Supports level uploading, downloading, browsing, favoriting, reporting, and admin management." 5 + version: 0.6.0 6 + contact: 7 + url: https://pvzm.net 8 8 9 9 servers: 10 - - url: https://backend.pvzm.net 11 - description: Production 10 + - url: https://backend.pvzm.net 11 + description: Production 12 12 13 13 tags: 14 - - name: Health 15 - description: Health check endpoints 16 - - name: Config 17 - description: Server configuration 18 - - name: Levels 19 - description: Public level operations 20 - - name: Auth 21 - description: GitHub OAuth authentication 22 - - name: Admin 23 - description: Admin level management 14 + - name: Health 15 + description: Health check endpoints 16 + - name: Config 17 + description: Server configuration 18 + - name: Levels 19 + description: Public level operations 20 + - name: Auth 21 + description: GitHub OAuth authentication 22 + - name: Admin 23 + description: Admin level management 24 24 25 25 paths: 26 - /api/health: 27 - get: 28 - tags: [Health] 29 - summary: Health check 30 - operationId: getHealth 31 - responses: 32 - "200": 33 - description: Server is healthy 34 - content: 35 - application/json: 36 - schema: 37 - type: object 38 - required: [status, timestamp, version] 39 - properties: 40 - status: 41 - type: string 42 - example: ok 43 - timestamp: 44 - type: string 45 - format: date-time 46 - version: 47 - type: string 48 - example: 0.6.0 26 + /api/health: 27 + get: 28 + tags: [Health] 29 + summary: Health check 30 + operationId: getHealth 31 + responses: 32 + "200": 33 + description: Server is healthy 34 + content: 35 + application/json: 36 + schema: 37 + type: object 38 + required: [status, timestamp, version] 39 + properties: 40 + status: 41 + type: string 42 + example: ok 43 + timestamp: 44 + type: string 45 + format: date-time 46 + version: 47 + type: string 48 + example: 0.6.0 49 49 50 - /api/config: 51 - get: 52 - tags: [Config] 53 - summary: Get server configuration 54 - operationId: getConfig 55 - responses: 56 - "200": 57 - description: Current server configuration 58 - content: 59 - application/json: 60 - schema: 61 - type: object 62 - required: [turnstileEnabled, turnstileSiteKey, moderationEnabled] 63 - properties: 64 - turnstileEnabled: 65 - type: boolean 66 - turnstileSiteKey: 67 - type: string 68 - nullable: true 69 - moderationEnabled: 70 - type: boolean 50 + /api/config: 51 + get: 52 + tags: [Config] 53 + summary: Get server configuration 54 + operationId: getConfig 55 + responses: 56 + "200": 57 + description: Current server configuration 58 + content: 59 + application/json: 60 + schema: 61 + type: object 62 + required: [turnstileEnabled, turnstileSiteKey, moderationEnabled] 63 + properties: 64 + turnstileEnabled: 65 + type: boolean 66 + turnstileSiteKey: 67 + type: string 68 + nullable: true 69 + moderationEnabled: 70 + type: boolean 71 71 72 - /api/levels: 73 - get: 74 - tags: [Levels] 75 - summary: List levels 76 - description: Retrieve a paginated, filterable list of levels. When `sort=featured`, an author diversity algorithm is applied. 77 - operationId: getLevels 78 - parameters: 79 - - name: page 80 - in: query 81 - schema: 82 - type: integer 83 - default: 1 84 - minimum: 1 85 - - name: limit 86 - in: query 87 - schema: 88 - type: integer 89 - default: 10 90 - minimum: 1 91 - - name: sort 92 - in: query 93 - schema: 94 - type: string 95 - enum: [plays, recent, favorites, featured] 96 - default: plays 97 - - name: reversed_order 98 - in: query 99 - schema: 100 - type: string 101 - enum: ["true", "false"] 102 - default: "false" 103 - - name: author 104 - in: query 105 - description: Filter by author name (partial match) 106 - schema: 107 - type: string 108 - - name: is_water 109 - in: query 110 - schema: 111 - type: string 112 - enum: ["true", "false"] 113 - - name: version 114 - in: query 115 - schema: 116 - type: integer 117 - - name: token 118 - in: query 119 - description: One-time token to fetch a specific level by ID. Ignores all other filters when provided. 120 - schema: 121 - type: string 122 - responses: 123 - "200": 124 - description: Paginated list of levels 125 - content: 126 - application/json: 127 - schema: 128 - type: object 129 - required: [levels, pagination] 130 - properties: 131 - levels: 132 - type: array 133 - items: 134 - $ref: "#/components/schemas/LevelSummary" 135 - pagination: 136 - $ref: "#/components/schemas/Pagination" 137 - "429": 138 - $ref: "#/components/responses/RateLimited" 72 + /api/levels: 73 + get: 74 + tags: [Levels] 75 + summary: List levels 76 + description: Retrieve a paginated, filterable list of levels. When `sort=featured`, an author diversity algorithm is applied. 77 + operationId: getLevels 78 + parameters: 79 + - name: page 80 + in: query 81 + schema: 82 + type: integer 83 + default: 1 84 + minimum: 1 85 + - name: limit 86 + in: query 87 + schema: 88 + type: integer 89 + default: 10 90 + minimum: 1 91 + - name: sort 92 + in: query 93 + schema: 94 + type: string 95 + enum: [plays, recent, favorites, featured] 96 + default: plays 97 + - name: reversed_order 98 + in: query 99 + schema: 100 + type: string 101 + enum: ["true", "false"] 102 + default: "false" 103 + - name: author 104 + in: query 105 + description: Filter by author name (partial match) 106 + schema: 107 + type: string 108 + - name: is_water 109 + in: query 110 + schema: 111 + type: string 112 + enum: ["true", "false"] 113 + - name: version 114 + in: query 115 + schema: 116 + type: integer 117 + - name: token 118 + in: query 119 + description: One-time token to fetch a specific level by ID. Ignores all other filters when provided. 120 + schema: 121 + type: string 122 + responses: 123 + "200": 124 + description: Paginated list of levels 125 + content: 126 + application/json: 127 + schema: 128 + type: object 129 + required: [levels, pagination] 130 + properties: 131 + levels: 132 + type: array 133 + items: 134 + $ref: "#/components/schemas/LevelSummary" 135 + pagination: 136 + $ref: "#/components/schemas/Pagination" 137 + "429": 138 + $ref: "#/components/responses/RateLimited" 139 139 140 - post: 141 - tags: [Levels] 142 - summary: Upload a level 143 - description: | 144 - Upload a new level in IZL3 binary format. Rate limited to 1 upload per 60 seconds per IP. 140 + post: 141 + tags: [Levels] 142 + summary: Upload a level 143 + description: | 144 + Upload a new level in IZL3 binary format. Rate limited to 1 upload per 60 seconds per IP. 145 145 146 - The request body must be sent as raw binary (Content-Type: application/octet-stream) containing the IZL3 level file. 146 + The request body must be sent as raw binary (Content-Type: application/octet-stream) containing the IZL3 level file. 147 147 148 - Validation includes: IZL3 format check, name/author content moderation (OpenAI + bad-words filter), 149 - plant/zombie placement rules, and optional Turnstile CAPTCHA verification. 150 - operationId: createLevel 151 - parameters: 152 - - name: author 153 - in: query 154 - required: true 155 - description: Author name (max 11 characters) 156 - schema: 157 - type: string 158 - maxLength: 11 159 - - name: turnstileResponse 160 - in: query 161 - description: Cloudflare Turnstile CAPTCHA token (required if Turnstile is enabled) 162 - schema: 163 - type: string 164 - responses: 165 - "201": 166 - description: Level created successfully 167 - content: 168 - application/json: 169 - schema: 170 - type: object 171 - required: [id, name, author, created_at, sun, is_water, version] 172 - properties: 173 - id: 174 - type: integer 175 - name: 176 - type: string 177 - author: 178 - type: string 179 - created_at: 180 - type: integer 181 - description: Unix timestamp 182 - sun: 183 - type: integer 184 - is_water: 185 - type: boolean 186 - version: 187 - type: integer 188 - "400": 189 - $ref: "#/components/responses/BadRequest" 190 - "415": 191 - description: Unsupported media type (Content-Type must be application/octet-stream) 192 - content: 193 - application/json: 194 - schema: 195 - $ref: "#/components/schemas/Error" 196 - "429": 197 - $ref: "#/components/responses/RateLimited" 148 + Validation includes: IZL3 format check, name/author content moderation (OpenAI + bad-words filter), 149 + plant/zombie placement rules, and optional Turnstile CAPTCHA verification. 150 + operationId: createLevel 151 + parameters: 152 + - name: author 153 + in: query 154 + required: true 155 + description: Author name (max 11 characters) 156 + schema: 157 + type: string 158 + maxLength: 11 159 + - name: turnstileResponse 160 + in: query 161 + description: Cloudflare Turnstile CAPTCHA token (required if Turnstile is enabled) 162 + schema: 163 + type: string 164 + responses: 165 + "201": 166 + description: Level created successfully 167 + content: 168 + application/json: 169 + schema: 170 + type: object 171 + required: [id, name, author, created_at, sun, is_water, version] 172 + properties: 173 + id: 174 + type: integer 175 + name: 176 + type: string 177 + author: 178 + type: string 179 + created_at: 180 + type: integer 181 + description: Unix timestamp 182 + sun: 183 + type: integer 184 + is_water: 185 + type: boolean 186 + version: 187 + type: integer 188 + "400": 189 + $ref: "#/components/responses/BadRequest" 190 + "415": 191 + description: Unsupported media type (Content-Type must be application/octet-stream) 192 + content: 193 + application/json: 194 + schema: 195 + $ref: "#/components/schemas/Error" 196 + "429": 197 + $ref: "#/components/responses/RateLimited" 198 198 199 - /api/levels/{id}: 200 - get: 201 - tags: [Levels] 202 - summary: Get a level by ID 203 - operationId: getLevel 204 - parameters: 205 - - $ref: "#/components/parameters/LevelId" 206 - responses: 207 - "200": 208 - description: Level details 209 - content: 210 - application/json: 211 - schema: 212 - $ref: "#/components/schemas/LevelSummary" 213 - "400": 214 - $ref: "#/components/responses/BadRequest" 215 - "404": 216 - $ref: "#/components/responses/NotFound" 199 + /api/levels/{id}: 200 + get: 201 + tags: [Levels] 202 + summary: Get a level by ID 203 + operationId: getLevel 204 + parameters: 205 + - $ref: "#/components/parameters/LevelId" 206 + responses: 207 + "200": 208 + description: Level details 209 + content: 210 + application/json: 211 + schema: 212 + $ref: "#/components/schemas/LevelSummary" 213 + "400": 214 + $ref: "#/components/responses/BadRequest" 215 + "404": 216 + $ref: "#/components/responses/NotFound" 217 217 218 - /api/levels/{id}/download: 219 - get: 220 - tags: [Levels] 221 - summary: Download a level file 222 - description: Downloads the IZL3 level file. Increments the play count. Rate limited to 5 downloads per 5 seconds per IP. 223 - operationId: downloadLevel 224 - parameters: 225 - - $ref: "#/components/parameters/LevelId" 226 - responses: 227 - "200": 228 - description: Level file download 229 - headers: 230 - Content-Disposition: 231 - schema: 232 - type: string 233 - example: attachment; filename="MyLevel.izl3" 234 - content: 235 - application/octet-stream: 236 - schema: 237 - type: string 238 - format: binary 239 - "400": 240 - $ref: "#/components/responses/BadRequest" 241 - "404": 242 - $ref: "#/components/responses/NotFound" 243 - "429": 244 - $ref: "#/components/responses/RateLimited" 218 + /api/levels/{id}/download: 219 + get: 220 + tags: [Levels] 221 + summary: Download a level file 222 + description: Downloads the IZL3 level file. Increments the play count. Rate limited to 5 downloads per 5 seconds per IP. 223 + operationId: downloadLevel 224 + parameters: 225 + - $ref: "#/components/parameters/LevelId" 226 + responses: 227 + "200": 228 + description: Level file download 229 + headers: 230 + Content-Disposition: 231 + schema: 232 + type: string 233 + example: attachment; filename="MyLevel.izl3" 234 + content: 235 + application/octet-stream: 236 + schema: 237 + type: string 238 + format: binary 239 + "400": 240 + $ref: "#/components/responses/BadRequest" 241 + "404": 242 + $ref: "#/components/responses/NotFound" 243 + "429": 244 + $ref: "#/components/responses/RateLimited" 245 245 246 - /api/levels/{id}/report: 247 - post: 248 - tags: [Levels] 249 - summary: Report a level 250 - operationId: reportLevel 251 - parameters: 252 - - $ref: "#/components/parameters/LevelId" 253 - requestBody: 254 - required: true 255 - content: 256 - application/json: 257 - schema: 258 - type: object 259 - required: [reason] 260 - properties: 261 - reason: 262 - type: string 263 - description: Description of why the level is being reported 264 - responses: 265 - "200": 266 - description: Report submitted 267 - content: 268 - application/json: 269 - schema: 270 - type: object 271 - required: [success] 272 - properties: 273 - success: 274 - type: boolean 275 - example: true 276 - "404": 277 - $ref: "#/components/responses/NotFound" 246 + /api/levels/{id}/report: 247 + post: 248 + tags: [Levels] 249 + summary: Report a level 250 + operationId: reportLevel 251 + parameters: 252 + - $ref: "#/components/parameters/LevelId" 253 + requestBody: 254 + required: true 255 + content: 256 + application/json: 257 + schema: 258 + type: object 259 + required: [reason] 260 + properties: 261 + reason: 262 + type: string 263 + description: Description of why the level is being reported 264 + responses: 265 + "200": 266 + description: Report submitted 267 + content: 268 + application/json: 269 + schema: 270 + type: object 271 + required: [success] 272 + properties: 273 + success: 274 + type: boolean 275 + example: true 276 + "404": 277 + $ref: "#/components/responses/NotFound" 278 278 279 - /api/levels/{id}/favorite: 280 - post: 281 - tags: [Levels] 282 - summary: Toggle favorite on a level 283 - description: Toggles the favorite state for the requesting IP. Rate limited to 30 actions per 10 seconds per IP. 284 - operationId: toggleFavorite 285 - parameters: 286 - - $ref: "#/components/parameters/LevelId" 287 - responses: 288 - "200": 289 - description: Favorite toggled 290 - content: 291 - application/json: 292 - schema: 293 - type: object 294 - required: [success, level] 295 - properties: 296 - success: 297 - type: boolean 298 - example: true 299 - level: 300 - type: object 301 - required: [id, name, author, favorites] 302 - properties: 303 - id: 304 - type: integer 305 - name: 306 - type: string 307 - author: 308 - type: string 309 - favorites: 310 - type: integer 311 - "400": 312 - $ref: "#/components/responses/BadRequest" 313 - "404": 314 - $ref: "#/components/responses/NotFound" 315 - "429": 316 - $ref: "#/components/responses/RateLimited" 279 + /api/levels/{id}/favorite: 280 + post: 281 + tags: [Levels] 282 + summary: Toggle favorite on a level 283 + description: Toggles the favorite state for the requesting IP. Rate limited to 30 actions per 10 seconds per IP. 284 + operationId: toggleFavorite 285 + parameters: 286 + - $ref: "#/components/parameters/LevelId" 287 + responses: 288 + "200": 289 + description: Favorite toggled 290 + content: 291 + application/json: 292 + schema: 293 + type: object 294 + required: [success, level] 295 + properties: 296 + success: 297 + type: boolean 298 + example: true 299 + level: 300 + type: object 301 + required: [id, name, author, favorites] 302 + properties: 303 + id: 304 + type: integer 305 + name: 306 + type: string 307 + author: 308 + type: string 309 + favorites: 310 + type: integer 311 + "400": 312 + $ref: "#/components/responses/BadRequest" 313 + "404": 314 + $ref: "#/components/responses/NotFound" 315 + "429": 316 + $ref: "#/components/responses/RateLimited" 317 317 318 - # --- Auth --- 318 + # --- Auth --- 319 319 320 - /api/auth/github: 321 - get: 322 - tags: [Auth] 323 - summary: Initiate GitHub OAuth login 324 - description: Redirects the user to GitHub for OAuth authentication. 325 - operationId: githubLogin 326 - responses: 327 - "302": 328 - description: Redirect to GitHub OAuth 320 + /api/auth/github: 321 + get: 322 + tags: [Auth] 323 + summary: Initiate GitHub OAuth login 324 + description: Redirects the user to GitHub for OAuth authentication. 325 + operationId: githubLogin 326 + responses: 327 + "302": 328 + description: Redirect to GitHub OAuth 329 329 330 - /api/auth/github/callback: 331 - get: 332 - tags: [Auth] 333 - summary: GitHub OAuth callback 334 - description: Handles the OAuth callback from GitHub. Redirects to `/admin.html` on success. 335 - operationId: githubCallback 336 - parameters: 337 - - name: code 338 - in: query 339 - schema: 340 - type: string 341 - responses: 342 - "302": 343 - description: Redirect to admin page on success 330 + /api/auth/github/callback: 331 + get: 332 + tags: [Auth] 333 + summary: GitHub OAuth callback 334 + description: Handles the OAuth callback from GitHub. Redirects to `/admin.html` on success. 335 + operationId: githubCallback 336 + parameters: 337 + - name: code 338 + in: query 339 + schema: 340 + type: string 341 + responses: 342 + "302": 343 + description: Redirect to admin page on success 344 344 345 - /api/auth/status: 346 - get: 347 - tags: [Auth] 348 - summary: Check authentication status 349 - operationId: getAuthStatus 350 - responses: 351 - "200": 352 - description: Authentication status 353 - content: 354 - application/json: 355 - schema: 356 - oneOf: 357 - - type: object 358 - required: [authenticated, user] 359 - properties: 360 - authenticated: 361 - type: boolean 362 - enum: [true] 363 - user: 364 - type: object 365 - required: [username, displayName, profileUrl, avatarUrl] 366 - properties: 367 - username: 368 - type: string 369 - displayName: 370 - type: string 371 - profileUrl: 372 - type: string 373 - avatarUrl: 374 - type: string 375 - - type: object 376 - required: [authenticated] 377 - properties: 378 - authenticated: 379 - type: boolean 380 - enum: [false] 345 + /api/auth/status: 346 + get: 347 + tags: [Auth] 348 + summary: Check authentication status 349 + operationId: getAuthStatus 350 + responses: 351 + "200": 352 + description: Authentication status 353 + content: 354 + application/json: 355 + schema: 356 + oneOf: 357 + - type: object 358 + required: [authenticated, user] 359 + properties: 360 + authenticated: 361 + type: boolean 362 + enum: [true] 363 + user: 364 + type: object 365 + required: [username, displayName, profileUrl, avatarUrl] 366 + properties: 367 + username: 368 + type: string 369 + displayName: 370 + type: string 371 + profileUrl: 372 + type: string 373 + avatarUrl: 374 + type: string 375 + - type: object 376 + required: [authenticated] 377 + properties: 378 + authenticated: 379 + type: boolean 380 + enum: [false] 381 381 382 - /api/auth/logout: 383 - get: 384 - tags: [Auth] 385 - summary: Logout 386 - operationId: logout 387 - responses: 388 - "200": 389 - description: Logged out successfully 382 + /api/auth/logout: 383 + get: 384 + tags: [Auth] 385 + summary: Logout 386 + operationId: logout 387 + responses: 388 + "200": 389 + description: Logged out successfully 390 390 391 - # --- Admin --- 391 + # --- Admin --- 392 392 393 - /api/admin/levels: 394 - get: 395 - tags: [Admin] 396 - summary: List levels (admin) 397 - description: Paginated level list with search. Requires GitHub OAuth. 398 - operationId: getAdminLevels 399 - security: 400 - - githubOAuth: [] 401 - parameters: 402 - - name: page 403 - in: query 404 - schema: 405 - type: integer 406 - default: 1 407 - - name: limit 408 - in: query 409 - schema: 410 - type: integer 411 - default: 10 412 - - name: q 413 - in: query 414 - description: Search query (searches name, author, ID) 415 - schema: 416 - type: string 417 - responses: 418 - "200": 419 - description: Admin level listing 420 - content: 421 - application/json: 422 - schema: 423 - type: object 424 - required: [levels, total, page, limit, totalPages] 425 - properties: 426 - levels: 427 - type: array 428 - items: 429 - $ref: "#/components/schemas/LevelRecord" 430 - total: 393 + /api/admin/levels: 394 + get: 395 + tags: [Admin] 396 + summary: List levels (admin) 397 + description: Paginated level list with search. Requires GitHub OAuth. 398 + operationId: getAdminLevels 399 + security: 400 + - githubOAuth: [] 401 + parameters: 402 + - name: page 403 + in: query 404 + schema: 405 + type: integer 406 + default: 1 407 + - name: limit 408 + in: query 409 + schema: 410 + type: integer 411 + default: 10 412 + - name: q 413 + in: query 414 + description: Search query (searches name, author, ID) 415 + schema: 416 + type: string 417 + responses: 418 + "200": 419 + description: Admin level listing 420 + content: 421 + application/json: 422 + schema: 423 + type: object 424 + required: [levels, total, page, limit, totalPages] 425 + properties: 426 + levels: 427 + type: array 428 + items: 429 + $ref: "#/components/schemas/LevelRecord" 430 + total: 431 + type: integer 432 + page: 433 + type: integer 434 + limit: 435 + type: integer 436 + totalPages: 437 + type: integer 438 + "401": 439 + $ref: "#/components/responses/Unauthorized" 440 + 441 + /api/admin/levels/{id}: 442 + put: 443 + tags: [Admin] 444 + summary: Update a level 445 + description: Update level metadata. Requires GitHub OAuth or a valid one-time token. 446 + operationId: updateLevel 447 + security: 448 + - githubOAuth: [] 449 + - oneTimeToken: [] 450 + parameters: 451 + - $ref: "#/components/parameters/LevelId" 452 + - name: token 453 + in: query 454 + description: One-time admin token (alternative to OAuth) 455 + schema: 456 + type: string 457 + requestBody: 458 + required: true 459 + content: 460 + application/json: 461 + schema: 462 + type: object 463 + properties: 464 + name: 465 + type: string 466 + author: 467 + type: string 468 + sun: 469 + type: integer 470 + is_water: 471 + type: integer 472 + enum: [0, 1] 473 + difficulty: 474 + type: integer 475 + favorites: 476 + type: integer 477 + plays: 478 + type: integer 479 + featured: 480 + type: integer 481 + enum: [0, 1] 482 + featured_at: 483 + type: integer 484 + nullable: true 485 + responses: 486 + "200": 487 + description: Level updated 488 + content: 489 + application/json: 490 + schema: 491 + type: object 492 + required: [success, level] 493 + properties: 494 + success: 495 + type: boolean 496 + example: true 497 + level: 498 + $ref: "#/components/schemas/LevelRecord" 499 + "400": 500 + $ref: "#/components/responses/BadRequest" 501 + "401": 502 + $ref: "#/components/responses/Unauthorized" 503 + "404": 504 + $ref: "#/components/responses/NotFound" 505 + 506 + delete: 507 + tags: [Admin] 508 + summary: Delete a level 509 + description: Permanently deletes a level, its file, and all associated data. Requires GitHub OAuth or a valid one-time token. 510 + operationId: deleteLevel 511 + security: 512 + - githubOAuth: [] 513 + - oneTimeToken: [] 514 + parameters: 515 + - $ref: "#/components/parameters/LevelId" 516 + - name: token 517 + in: query 518 + description: One-time admin token (alternative to OAuth) 519 + schema: 520 + type: string 521 + responses: 522 + "200": 523 + description: Level deleted 524 + content: 525 + application/json: 526 + schema: 527 + type: object 528 + required: [success] 529 + properties: 530 + success: 531 + type: boolean 532 + example: true 533 + "400": 534 + $ref: "#/components/responses/BadRequest" 535 + "401": 536 + $ref: "#/components/responses/Unauthorized" 537 + "404": 538 + $ref: "#/components/responses/NotFound" 539 + 540 + /api/admin/levels/{id}/token: 541 + post: 542 + tags: [Admin] 543 + summary: Generate a one-time token for a level 544 + description: Creates a single-use token scoped to a specific level, allowing unauthenticated edit/delete access. 545 + operationId: generateToken 546 + security: 547 + - githubOAuth: [] 548 + parameters: 549 + - $ref: "#/components/parameters/LevelId" 550 + responses: 551 + "200": 552 + description: Token generated 553 + content: 554 + application/json: 555 + schema: 556 + type: object 557 + required: [token, level_id] 558 + properties: 559 + token: 560 + type: string 561 + example: token_abc123... 562 + level_id: 563 + type: integer 564 + "400": 565 + $ref: "#/components/responses/BadRequest" 566 + "401": 567 + $ref: "#/components/responses/Unauthorized" 568 + "404": 569 + $ref: "#/components/responses/NotFound" 570 + 571 + /api/admin/levels/{id}/feature: 572 + post: 573 + tags: [Admin] 574 + summary: Feature a level 575 + description: Marks a level as featured. Requires GitHub OAuth. 576 + operationId: featureLevel 577 + security: 578 + - githubOAuth: [] 579 + parameters: 580 + - $ref: "#/components/parameters/LevelId" 581 + responses: 582 + "200": 583 + description: Level featured 584 + content: 585 + application/json: 586 + schema: 587 + type: object 588 + required: [success, level] 589 + properties: 590 + success: 591 + type: boolean 592 + example: true 593 + level: 594 + $ref: "#/components/schemas/LevelRecord" 595 + "400": 596 + $ref: "#/components/responses/BadRequest" 597 + "401": 598 + $ref: "#/components/responses/Unauthorized" 599 + "404": 600 + $ref: "#/components/responses/NotFound" 601 + 602 + delete: 603 + tags: [Admin] 604 + summary: Unfeature a level 605 + description: Removes the featured status from a level. Requires GitHub OAuth. 606 + operationId: unfeatureLevel 607 + security: 608 + - githubOAuth: [] 609 + parameters: 610 + - $ref: "#/components/parameters/LevelId" 611 + responses: 612 + "200": 613 + description: Level unfeatured 614 + content: 615 + application/json: 616 + schema: 617 + type: object 618 + required: [success, level] 619 + properties: 620 + success: 621 + type: boolean 622 + example: true 623 + level: 624 + $ref: "#/components/schemas/LevelRecord" 625 + "400": 626 + $ref: "#/components/responses/BadRequest" 627 + "401": 628 + $ref: "#/components/responses/Unauthorized" 629 + "404": 630 + $ref: "#/components/responses/NotFound" 631 + 632 + components: 633 + securitySchemes: 634 + githubOAuth: 635 + type: http 636 + scheme: bearer 637 + description: GitHub OAuth2 session-based authentication (via browser cookies) 638 + oneTimeToken: 639 + type: apiKey 640 + in: query 641 + name: token 642 + description: Single-use token scoped to a specific level ID 643 + 644 + parameters: 645 + LevelId: 646 + name: id 647 + in: path 648 + required: true 649 + schema: 650 + type: integer 651 + description: Level ID 652 + 653 + schemas: 654 + LevelSummary: 655 + type: object 656 + required: [id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version, featured, featured_at] 657 + properties: 658 + id: 431 659 type: integer 432 - page: 660 + name: 661 + type: string 662 + author: 663 + type: string 664 + created_at: 665 + type: integer 666 + description: Unix timestamp 667 + sun: 668 + type: integer 669 + is_water: 670 + type: boolean 671 + favorites: 433 672 type: integer 434 - limit: 673 + plays: 674 + type: integer 675 + difficulty: 676 + type: integer 677 + version: 435 678 type: integer 436 - totalPages: 679 + featured: 437 680 type: integer 438 - "401": 439 - $ref: "#/components/responses/Unauthorized" 681 + enum: [0, 1] 682 + featured_at: 683 + type: integer 684 + nullable: true 685 + description: Unix timestamp 686 + thumbnail: 687 + type: array 688 + nullable: true 689 + description: Array of plant placement tuples [plantIndex, eleLeft, eleTop, eleWidth, eleHeight, zIndex] 690 + items: 691 + type: array 692 + items: 693 + type: number 440 694 441 - /api/admin/levels/{id}: 442 - put: 443 - tags: [Admin] 444 - summary: Update a level 445 - description: Update level metadata. Requires GitHub OAuth or a valid one-time token. 446 - operationId: updateLevel 447 - security: 448 - - githubOAuth: [] 449 - - oneTimeToken: [] 450 - parameters: 451 - - $ref: "#/components/parameters/LevelId" 452 - - name: token 453 - in: query 454 - description: One-time admin token (alternative to OAuth) 455 - schema: 456 - type: string 457 - requestBody: 458 - required: true 459 - content: 460 - application/json: 461 - schema: 462 - type: object 463 - properties: 695 + LevelRecord: 696 + type: object 697 + required: [id, name, author, sun, is_water, difficulty, favorites, plays, version, featured, featured_at, logging_data] 698 + properties: 699 + id: 700 + type: integer 464 701 name: 465 - type: string 702 + type: string 466 703 author: 467 - type: string 704 + type: string 468 705 sun: 469 - type: integer 706 + type: integer 470 707 is_water: 471 - type: integer 472 - enum: [0, 1] 708 + type: integer 709 + enum: [0, 1] 473 710 difficulty: 474 - type: integer 711 + type: integer 475 712 favorites: 476 - type: integer 713 + type: integer 477 714 plays: 478 - type: integer 715 + type: integer 716 + version: 717 + type: integer 479 718 featured: 480 - type: integer 481 - enum: [0, 1] 719 + type: integer 720 + enum: [0, 1] 482 721 featured_at: 483 - type: integer 484 - nullable: true 485 - responses: 486 - "200": 487 - description: Level updated 488 - content: 489 - application/json: 490 - schema: 491 - type: object 492 - required: [success, level] 493 - properties: 494 - success: 495 - type: boolean 496 - example: true 497 - level: 498 - $ref: "#/components/schemas/LevelRecord" 499 - "400": 500 - $ref: "#/components/responses/BadRequest" 501 - "401": 502 - $ref: "#/components/responses/Unauthorized" 503 - "404": 504 - $ref: "#/components/responses/NotFound" 505 - 506 - delete: 507 - tags: [Admin] 508 - summary: Delete a level 509 - description: Permanently deletes a level, its file, and all associated data. Requires GitHub OAuth or a valid one-time token. 510 - operationId: deleteLevel 511 - security: 512 - - githubOAuth: [] 513 - - oneTimeToken: [] 514 - parameters: 515 - - $ref: "#/components/parameters/LevelId" 516 - - name: token 517 - in: query 518 - description: One-time admin token (alternative to OAuth) 519 - schema: 520 - type: string 521 - responses: 522 - "200": 523 - description: Level deleted 524 - content: 525 - application/json: 526 - schema: 527 - type: object 528 - required: [success] 529 - properties: 530 - success: 531 - type: boolean 532 - example: true 533 - "400": 534 - $ref: "#/components/responses/BadRequest" 535 - "401": 536 - $ref: "#/components/responses/Unauthorized" 537 - "404": 538 - $ref: "#/components/responses/NotFound" 539 - 540 - /api/admin/levels/{id}/token: 541 - post: 542 - tags: [Admin] 543 - summary: Generate a one-time token for a level 544 - description: Creates a single-use token scoped to a specific level, allowing unauthenticated edit/delete access. 545 - operationId: generateToken 546 - security: 547 - - githubOAuth: [] 548 - parameters: 549 - - $ref: "#/components/parameters/LevelId" 550 - responses: 551 - "200": 552 - description: Token generated 553 - content: 554 - application/json: 555 - schema: 556 - type: object 557 - required: [token, level_id] 558 - properties: 559 - token: 560 - type: string 561 - example: token_abc123... 562 - level_id: 563 722 type: integer 564 - "400": 565 - $ref: "#/components/responses/BadRequest" 566 - "401": 567 - $ref: "#/components/responses/Unauthorized" 568 - "404": 569 - $ref: "#/components/responses/NotFound" 570 - 571 - /api/admin/levels/{id}/feature: 572 - post: 573 - tags: [Admin] 574 - summary: Feature a level 575 - description: Marks a level as featured. Requires GitHub OAuth. 576 - operationId: featureLevel 577 - security: 578 - - githubOAuth: [] 579 - parameters: 580 - - $ref: "#/components/parameters/LevelId" 581 - responses: 582 - "200": 583 - description: Level featured 584 - content: 585 - application/json: 586 - schema: 587 - type: object 588 - required: [success, level] 589 - properties: 590 - success: 591 - type: boolean 592 - example: true 593 - level: 594 - $ref: "#/components/schemas/LevelRecord" 595 - "400": 596 - $ref: "#/components/responses/BadRequest" 597 - "401": 598 - $ref: "#/components/responses/Unauthorized" 599 - "404": 600 - $ref: "#/components/responses/NotFound" 601 - 602 - delete: 603 - tags: [Admin] 604 - summary: Unfeature a level 605 - description: Removes the featured status from a level. Requires GitHub OAuth. 606 - operationId: unfeatureLevel 607 - security: 608 - - githubOAuth: [] 609 - parameters: 610 - - $ref: "#/components/parameters/LevelId" 611 - responses: 612 - "200": 613 - description: Level unfeatured 614 - content: 615 - application/json: 616 - schema: 617 - type: object 618 - required: [success, level] 619 - properties: 620 - success: 621 - type: boolean 622 - example: true 623 - level: 624 - $ref: "#/components/schemas/LevelRecord" 625 - "400": 626 - $ref: "#/components/responses/BadRequest" 627 - "401": 628 - $ref: "#/components/responses/Unauthorized" 629 - "404": 630 - $ref: "#/components/responses/NotFound" 631 - 632 - components: 633 - securitySchemes: 634 - githubOAuth: 635 - type: http 636 - scheme: bearer 637 - description: GitHub OAuth2 session-based authentication (via browser cookies) 638 - oneTimeToken: 639 - type: apiKey 640 - in: query 641 - name: token 642 - description: Single-use token scoped to a specific level ID 643 - 644 - parameters: 645 - LevelId: 646 - name: id 647 - in: path 648 - required: true 649 - schema: 650 - type: integer 651 - description: Level ID 723 + nullable: true 724 + logging_data: 725 + type: string 726 + nullable: true 727 + description: JSON string containing Discord/Bluesky message IDs for logging management 652 728 653 - schemas: 654 - LevelSummary: 655 - type: object 656 - required: [id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version, featured, featured_at] 657 - properties: 658 - id: 659 - type: integer 660 - name: 661 - type: string 662 - author: 663 - type: string 664 - created_at: 665 - type: integer 666 - description: Unix timestamp 667 - sun: 668 - type: integer 669 - is_water: 670 - type: boolean 671 - favorites: 672 - type: integer 673 - plays: 674 - type: integer 675 - difficulty: 676 - type: integer 677 - version: 678 - type: integer 679 - featured: 680 - type: integer 681 - enum: [0, 1] 682 - featured_at: 683 - type: integer 684 - nullable: true 685 - description: Unix timestamp 686 - thumbnail: 687 - type: array 688 - nullable: true 689 - description: Array of plant placement tuples [plantIndex, eleLeft, eleTop, eleWidth, eleHeight, zIndex] 690 - items: 691 - type: array 692 - items: 693 - type: number 729 + Pagination: 730 + type: object 731 + required: [total, page, limit, pages] 732 + properties: 733 + total: 734 + type: integer 735 + page: 736 + type: integer 737 + limit: 738 + type: integer 739 + pages: 740 + type: integer 694 741 695 - LevelRecord: 696 - type: object 697 - required: [id, name, author, sun, is_water, difficulty, favorites, plays, version, featured, featured_at, logging_data] 698 - properties: 699 - id: 700 - type: integer 701 - name: 702 - type: string 703 - author: 704 - type: string 705 - sun: 706 - type: integer 707 - is_water: 708 - type: integer 709 - enum: [0, 1] 710 - difficulty: 711 - type: integer 712 - favorites: 713 - type: integer 714 - plays: 715 - type: integer 716 - version: 717 - type: integer 718 - featured: 719 - type: integer 720 - enum: [0, 1] 721 - featured_at: 722 - type: integer 723 - nullable: true 724 - logging_data: 725 - type: string 726 - nullable: true 727 - description: JSON string containing Discord/Bluesky message IDs for logging management 742 + Error: 743 + type: object 744 + required: [error, message] 745 + properties: 746 + error: 747 + type: string 748 + message: 749 + type: string 750 + retryAfterSeconds: 751 + type: number 752 + description: Present on 429 responses 728 753 729 - Pagination: 730 - type: object 731 - required: [total, page, limit, pages] 732 - properties: 733 - total: 734 - type: integer 735 - page: 736 - type: integer 737 - limit: 738 - type: integer 739 - pages: 740 - type: integer 741 - 742 - Error: 743 - type: object 744 - required: [error, message] 745 - properties: 746 - error: 747 - type: string 748 - message: 749 - type: string 750 - retryAfterSeconds: 751 - type: number 752 - description: Present on 429 responses 753 - 754 - responses: 755 - BadRequest: 756 - description: Invalid input or validation failure 757 - content: 758 - application/json: 759 - schema: 760 - $ref: "#/components/schemas/Error" 761 - NotFound: 762 - description: Resource not found 763 - content: 764 - application/json: 765 - schema: 766 - $ref: "#/components/schemas/Error" 767 - Unauthorized: 768 - description: Authentication required 769 - content: 770 - application/json: 771 - schema: 772 - $ref: "#/components/schemas/Error" 773 - RateLimited: 774 - description: Too many requests 775 - headers: 776 - Retry-After: 777 - schema: 778 - type: integer 779 - content: 780 - application/json: 781 - schema: 782 - $ref: "#/components/schemas/Error" 754 + responses: 755 + BadRequest: 756 + description: Invalid input or validation failure 757 + content: 758 + application/json: 759 + schema: 760 + $ref: "#/components/schemas/Error" 761 + NotFound: 762 + description: Resource not found 763 + content: 764 + application/json: 765 + schema: 766 + $ref: "#/components/schemas/Error" 767 + Unauthorized: 768 + description: Authentication required 769 + content: 770 + application/json: 771 + schema: 772 + $ref: "#/components/schemas/Error" 773 + RateLimited: 774 + description: Too many requests 775 + headers: 776 + Retry-After: 777 + schema: 778 + type: integer 779 + content: 780 + application/json: 781 + schema: 782 + $ref: "#/components/schemas/Error"