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