PageSourceSearch

https://twodos.app/api-docs/swagger-ui-init.js

js twodos.app collected 2026-10-03 20:07:22 UTC 395,903 bytes, 9,962 lines download raw bytes

1
2window.onload = function() {
3  // Build a system
4  var url = window.location.search.match(/url=([^&]+)/);
5  if (url && url.length > 1) {
6    url = decodeURIComponent(url[1]);
7  } else {
8    url = window.location.origin;
9  }
10  var options = {
11  "swaggerDoc": {
12    "openapi": "3.0.0",
13    "info": {
14      "title": "twodos API",
15      "description": "REST API for the twodos app. Authenticate using a **Bearer token** — obtain one from `POST /api/account/login`.\n\n## Rate limiting\n\nUnauthenticated endpoints that accept a credential, a token or an email address are throttled. When the limit is hit the response is **HTTP 200** with `statusCode: 429` in the body, like every other outcome in this API — a client that only checks the status line will read a throttled response as a success.\n\n```json\n{ \"statusCode\": 429, \"reason\": \"TOO_MANY_REQUESTS\",\n  \"statusMessage\": \"Too many requests. Please wait a moment and try again.\" }\n```\n\nShape: `#/components/schemas/TooManyRequestsResponse`. Handle `TOO_MANY_REQUESTS` on every endpoint below — back off and retry rather than treating it as a credential failure, which would send a user to re-authenticate for no reason.\n\nThrottled: `POST /account/login`, `POST /account/login-otp`, `POST /account/request-otp`, `POST /account/social-login`, `PUT /account/validate-email`, `PUT /account/reset-password`, `PUT /account/password-reset-token`, `PUT /account/change-password`, `POST /account/email-token`, `GET /account/email-token/{email}`, `POST /account/complete-invite`, `POST /account/resend-invite`, `POST /account/complete-deletion`.\n\n`POST /account/refresh` is deliberately **not** in that list. Refresh tokens are full UUIDv4 values with replay detection and platform-mismatch lockout, so guessing is not the threat — and a per-IP limit would lock out everyone sharing a carrier NAT. It remains under the general API limit.\n\nEverything else under `/api/` is covered by a general per-user limit. Limits are operator-configurable, so treat the exact numbers as subject to change and the `reason` string as the stable contract.\n\n## Real-time events\n\nReal-time delivery over Socket.IO is the primary notification path — REST polling is the fallback, not the other way round. The full contract, with a payload example and a Flutter handler for every event, is published at **`/docs/sockets.md`**. The three things clients most often get wrong are answered here so they are never only in a repo file:\n\n### The notification key differs by transport\n\nThe socket `NOTIFICATION` payload names the type field **`type`**. The REST DTO on `GET /api/notifications` names the same field **`notificationType`**. The values are identical; only the key differs. A client sharing one model across both transports must map it, or its type switch will silently never match on the socket path.\n\nThe `data` field differs too: on the socket it is an **already-parsed object**, and on REST it is a **JSON string** that must be decoded first.\n\n```json\n// socket: NOTIFICATION\n{ \"id\": \"uuid\", \"type\": \"NOTIFICATION::INVITE_RECEIVED\", \"title\": \"…\", \"body\": \"…\",\n  \"data\": { \"listId\": \"uuid\" }, \"read\": false, \"createdAt\": \"2026-09-01T10:00:00.000Z\" }\n\n// REST: GET /api/notifications\n{ \"id\": \"uuid\", \"notificationType\": \"NOTIFICATION::INVITE_RECEIVED\", \"title\": \"…\", \"body\": \"…\",\n  \"data\": \"{\\\"listId\\\":\\\"uuid\\\"}\", \"read\": false, \"readAt\": null, \"createdAt\": \"…\" }\n```\n\n### `actorId`\n\n`actorId` is the id of the user whose action caused an event. Compare it against the signed-in user to skip re-fetching a change you made yourself. It matters most on multi-device setups, where your own second device receives your changes.\n\nIt is **not** on every event. Anything relayed to the other member of a list gets it injected (`LIST::*`, `CHALLENGE::CREATED`/`ACCEPTED`/`DECLINED`/`SCORED`). It is absent from `NOTIFICATION`, `LOCATION_TRIGGER`, `LIST::PARTNER_REMOVED` and `PARTNER::*`, which are addressed to you rather than broadcast about someone. `CHALLENGE::ENDED` carries it only when a person cancelled; a deadline or early finish has no actor.\n\n### Event inventory\n\nServer → client, with the fields each payload actually carries. Full examples and Flutter handlers are in `/docs/sockets.md`.\n\n| Event | Persisted | 
15Carries |\n|---|---|---|\n| `NOTIFICATION` | yes | `id`, `type`, `title`, `body`, `data`, `read`, `createdAt` |\n| `LIST::UPDATED` | no | `listId`, `actorId`, plus only the changed field(s): `label`+`favorite`, `color`, `priority`, `doBefore`, or `location` |\n| `LIST::ARCHIVED` | no | `listId`, `archived`, `actorId` |\n| `LIST::INVITE_DECLINED` | no | `listId`, `actorId` |\n| `LIST::PARTNER_REMOVED` | no | `listId` — no `actorId` |\n| `LIST::ITEM_ADDED` | no | `listId`, `todoId`, `title`, `actorId` |\n| `LIST::ITEM_UPDATED` | no | `listId`, `todoId`, `actorId`, plus only the changed field(s): `done`, `title`, or `doBefore` |\n| `LIST::ITEM_ASSIGNED` | no | `listId`, `todoId`, `assigneeId` (null means unclaimed), `actorId` |\n| `LIST::ITEM_DELETED` | no | `listId`, `todoId`, `actorId` |\n| `LIST::ITEMS_CLEARED` | no | `listId`, `actorId` |\n| `LIST::ITEMS_REORDERED` | no | **two shapes**: a batch reorder sends `listId`, `todoIds` (new order), `actorId`; a single move sends `listId`, `todoId`, `newIndex`, `actorId` |\n| `LIST::ITEM_EDITING_START` / `_STOP` | no | `listId`, `itemId` (nullable), `actorId` — typing indicator |\n| `LIST::GROUP_CREATED` / `_UPDATED` | no | `listId`, `group` object (`id`, `title`, `order`), `actorId` |\n| `LIST::GROUP_DELETED` | no | `listId`, `groupId`, `actorId` |\n| `CHALLENGE::CREATED` / `::ACCEPTED` / `::DECLINED` | no | `listId`, `challengeId`, `actorId` |\n| `CHALLENGE::SCORED` | no | `listId`, `challengeId`, `scores`, `actorId` |\n| `CHALLENGE::ENDED` | no | `listId`, `challengeId`, `winnerId` (null on a draw or a cancellation), `scores`, `endReason`; `actorId` only on a cancellation |\n| `LOCATION_TRIGGER` | no | `listId`, `event` (`ARRIVE`/`LEAVE`), `todoIds` (only for per-item triggers) — no `actorId`, and sent to **both** members |\n| `PARTNER::ONLINE` | no | `userId`, `name` |\n| `PARTNER::OFFLINE` | no | `userId`, `lastSeenAt` |\n| `ERROR` | no | protocol violations, e.g. joining another user's room |\n| `PONG` | no | reply to a client `PING` |\n\nClient → server: `PING`, `JOIN_ROOM` (own room only), `LEAVE_ROOM`, `LIST::ITEM_EDITING_START`, `LIST
15::ITEM_EDITING_STOP`.\n\nTransient events are lost if the socket is down — re-fetch open lists on reconnect. `NOTIFICATION` is always recoverable via `GET /api/notifications?unread=true`.",
16      "version": "1.37.1"
17    },
18    "servers": [
19      {
20        "url": "http://localhost:1234",
21        "description": "Local dev"
22      }
23    ],
24    "components": {
25      "securitySchemes": {
26        "bearerAuth": {
27          "type": "http",
28          "scheme": "bearer"
29        }
30      },
31      "schemas": {
32        "CreateTodoRequest": {
33          "type": "object",
34          "required": [
35            "title"
36          ],
37          "properties": {
38            "title": {
39              "type": "string",
40              "maxLength": 255,
41              "example": "Buy milk",
42              "description": "Item title (1–255 characters)"
43            },
44            "done": {
45              "type": "boolean",
46              "example": false,
47              "description": "Initial completion state. Defaults to false."
48            },
49            "groupId": {
50              "type": "string",
51              "nullable": true,
52              "description": "UUID of a TodoGroup on this list. Null or omit for ungrouped."
53            },
54            "description": {
55              "type": "string",
56              "nullable": true,
57              "maxLength": 1000,
58              "example": "Oat, not almond. Recipe: https://example.com/porridge",
59              "description": "Optional long-form detail for the task, up to 1000 characters. Omit it, or send null or an empty string, for a task with no description — all three store null and the item comes back with description: null. Stored and returned as plain text exactly as sent; the server does not parse, sanitise or render it. Clients that want rich text should send Markdown and render it themselves. Images and links belong in here as URLs — inline base64 image data will not fit the limit and is not supported."
60            }
61          }
62        },
63        "UpdateTodoRequest": {
64          "type": "object",
65          "description": "Partial update — only provided fields are changed. Omitted fields are left untouched.",
66          "properties": {
67            "title": {
68              "type": "string",
69              "maxLength": 255,
70              "example": "Buy oat milk",
71              "description": "New item title (1–255 characters)"
72            },
73            "description": {
74              "type": "string",
75              "nullable": true,
76              "maxLength": 1000,
77              "example": "Oat, not almond. Recipe: https://example.com/porridge",
78              "description": "Long-form detail for the task, up to 1000 characters. Omit the field to leave any existing description untouched. Send null or an empty string to clear it — both store null. Stored and returned as plain text exactly as sent; the server does not parse, sanitise or render it. Send Markdown if you want rich text, and reference images by URL — inline base64 data will not fit the limit."
79            },
80            "done": {
81              "type": "boolean",
82              "example": true,
83              "description": "Completion state"
84            },
85            "order": {
86              "type": "integer",
87              "example": 2,
88              "description": "Positional order within the list"
89            },
90            "doBefore": {
91              "type": "string",
92              "format": "date-time",
93              "nullable": true,
94              "example": "2026-12-31T23:59:59Z",
95              "description": "Item deadline as an ISO 8601 date-time string. Stored and returned in UTC. Accepted: full datetime with Z or offset, datetime without timezone (treated as UTC), date-only (treated as UTC midnight), microsecond precision (Flutter-compatible). Send null to clear an existing deadline."
96            },
97            "groupId": {
98              "type": "string",
99              "nullable": true,
100              "description": "Move item to a different group. Pass a group UUID to move, null to ungroup. The group must belong to this list."
101            },
102            "alarmTarget": {
103              "type": "string",
104              "enum": [
105                "ALARM::TARGET::SELF",
106                "ALARM::TARGET::PARTNER",
107                "ALARM::TARGET::BOTH"
108              ],
109              "default": "ALARM::TARGET::SELF",
110              "description": "Who should receive the auto-alarm. Only applied when doBefore is also set."
111            }
112          }
113        },
114        "CreateTodosListRequest": {
115          "type": "object",
116          "required": [
117            "label"
118          ],
119          "properties": {
120            "label": {
121              "type": "string",
122              "example": "Groceries",
123              "description": "List display name (1–250 characters)"
124            },
125            "favorite": {
126              "type": "boolean",
127              "example": false,
128              "description": "Pin the list as a favourite"
129            },
130            "partnerEmail": {
131              "type": "string",
132              "format": "email",
133              "example": "[email protected]",
134              "description": "Email of the user to share this list with. Omit for a personal list. Still fully supported; new clients should prefer `partner`."
135            },
136            "partner": {
137              "type": "string",
138              "example": "@sara_k",
139              "description": "Who to share this list with — an email address, or a username with or without a leading '@'. Takes precedence over partnerEmail when both are sent. IMPORTANT: the two behave differently for someone who has no account. An unknown EMAIL creates a pending invitation and mails them; an unknown USERNAME returns 404 USERNAME_NOT_FOUND, because a handle gives us nowhere to send anything. Present this to users as: invite anyone by email, inv
139ite existing users by handle."
140            }
141          }
142        },
143        "UpdateTodosListRequest": {
144          "type": "object",
145          "description": "Partial update — only the fields present in the body are changed. Omitted fields are left untouched.",
146          "properties": {
147            "label": {
148              "type": "string",
149              "maxLength": 250,
150              "example": "Groceries",
151              "description": "New list name (1–250 characters)."
152            },
153            "favorite": {
154              "type": "boolean",
155              "example": true,
156              "description": "Pin or unpin as favourite."
157            },
158            "icon": {
159              "type": "string",
160              "nullable": true,
161              "maxLength": 250,
162              "example": "🛒",
163              "description": "Icon shown beside the list. A short client-defined token — an emoji or an icon name — stored and returned verbatim; the server does not interpret it. Send null or an empty string to clear it. Both store null."
164            },
165            "order": {
166              "type": "integer",
167              "minimum": 0,
168              "example": 2,
169              "description": "Position of this list in the ordering shown to the caller. Must be zero or greater."
170            }
171          }
172        },
173        "ArchiveTodosListRequest": {
174          "type": "object",
175          "required": [
176            "archive"
177          ],
178          "properties": {
179            "archive": {
180              "type": "boolean",
181              "example": true,
182              "description": "True to archive, false to unarchive"
183            }
184          }
185        },
186        "UpdateColorTodoListRequest": {
187          "type": "object",
188          "required": [
189            "color"
190          ],
191          "properties": {
192            "color": {
193              "type": "string",
194              "example": "#6366f1",
195              "description": "Hex color or Flutter color int str
195ing"
196            }
197          }
198        },
199        "UpdatePriorityTodoListRequest": {
200          "type": "object",
201          "required": [
202            "priority"
203          ],
204          "properties": {
205            "priority": {
206              "type": "string",
207              "enum": [
208                "TODOLIST::PRIORITY::URGENT",
209                "TODOLIST::PRIORITY::NONURGENT"
210              ],
211              "example": "TODOLIST::PRIORITY::URGENT"
212            }
213          }
214        },
215        "UpdateTodoListDeadlineRequest": {
216          "type": "object",
217          "required": [
218            "deadline"
219          ],
220          "properties": {
221            "deadline": {
222              "type": "string",
223              "format": "date-time",
224              "nullable": true,
225              "example": "2026-12-31T23:59:59Z",
226              "description": "The deadline as an ISO 8601 date-time string. The server always stores and returns dates in UTC.\n\nAccepted formats:\n  \"2026-12-31T23:59:59Z\"          — datetime with explicit UTC (recommended)\n  \"2026-12-31T23:59:59+05:30\"     — datetime with timezone offset (converted to UTC on write)\n  \"2026-12-31T23:59:59.000000Z\"   — microsecond precision accepted (Flutter DateTime.toIso8601String())\n  \"2026-12-31T23:59:59\"            — datetime with no timezone: treated as UTC\n  \"2026-12-31\"                     — date-only: treated as UTC midnight\n\nSend null to clear an existing deadline.\n\nThe server always returns doBefore as a UTC ISO string (\"...Z\"). Clients should convert\nthis to local time for display using their platform date API."
227            },
228            "alarmTarget": {
229              "type": "string",
230              "enum": [
231                "ALARM::TARGET::SELF",
232                "ALARM::TARGET::PARTNER",
233                "ALARM::TARGET::BOTH"
234              ],
235              "default": "ALARM::TARGET::SELF",
236              "description": "Who receives the auto-alarm for this deadline."
237            }
238          }
239        },
240        "LoginRequest": {
241          "type": "object",
242          "required": [
243            "email",
244            "password"
245          ],
246          "properties": {
247            "email": {
248              "type": "string",
249              "format": "email",
250              "example": "[email protected]"
251            },
252            "password": {
253              "type": "string",
254              "example": "MyPassword123"
255            },
256            "platform": {
257              "type": "string",
258              "enum": [
259                "PLATFORM::IOS",
260                "PLATFORM::ANDROID",
261                "PLATFORM::WEB",
262                "PLATFORM::MACOS",
263                "PLATFORM::WINDOWS",
264                "PLATFORM::LINUX",
265                "PLATFORM::UNKNOWN"
266              ],
267              "description": "Optional. Defaults to PLATFORM::UNKNOWN if omitted. Recommended for session tracking."
268            }
269          }
270        },
271        "RegisterRequest": {
272          "type": "object",
273          "required": [
274            "name",
275            "email",
276            "password"
277          ],
278          "properties": {
279            "name": {
280              "type": "string",
281              "example": "Jane Doe"
282            },
283            "email": {
284              "type": "string",
285              "format": "email",
286              "example": "[email protected]"
287            },
288            "password": {
289              "type": "string",
290              "example": "MyPassword123"
291            },
292            "username": {
293              "type": "string",
294              "example": "sara_k",
295              "description": "Optional handle, claimed as part of registration. 3-24 characters, starting with a letter, lowercase letters, numbers and underscores only. If it cannot be claimed the account is still created without one — re-offer it via PUT /api/v1/account/username, which returns a reason. Omit for no handle; that is a permanent, supported state."
296            }
297          }
298        },
299        "ChangePasswordRequest": {
300          "type": "object",
301          "required": [
302            "email",
303            "currentPassword",
304            "newPassword"
305          ],
306          "properties": {
307            "email": {
308              "type": "string",
309              "format": "email"
310            },
311            "currentPassword": {
312              "type": "string"
313            },
314            "newPassword": {
315              "type": "string"
316            }
317          }
318        },
319        "ResetPasswordRequest": {
320          "type": "object",
321          "required": [
322            "email",
323            "token",
324            "password"
325          ],
326          "properties": {
327            "email": {
328              "type": "string",
329              "format": "email"
330            },
331            "token": {
332              "type": "string"
333            },
334            "password": {
335              "type": "string"
336            }
337          }
338        },
339        "PasswordResetTokenRequest": {
340          "type": "object",
341          "required": [
342            "email"
343          ],
344          "properties": {
345            "email": {
346              "type": "string",
347              "format": "email"
348            }
349          }
350        },
351        "ValidateEmailRequest": {
352          "type": "object",
353          "required": [
354            "email",
355            "token"
356          ],
357          "properties": {
358            "email": {
359              "type": "string",
360              "format": "email"
361            },
362            "token": {
363              "type": "string"
364            }
365          }
366        },
367        "AccountDeletionRequest": {
368          "type": "object",
369          "required": [
370            "email"
371          ],
372          "properties": {
373            "email": {
374              "type": "string",
375              "format": "email"
376            }
377          }
378        },
379        "CompleteAccountDeletionRequest": {
380          "type": "object",
381          "required": [
382            "email",
383            "token"
384          ],
385          "properties": {
386            "email": {
387              "type": "string",
388              "format": "email"
389            },
390            "token": {
391              "type": "string"
392            }
393          }
394        },
395        "CompleteInviteRequest": {
396          "type": "object",
397          "required": [
398            "email",
399            "token",
400            "password"
401          ],
402          "properties": {
403            "email": {
404              "type": "string",
405              "format": "email"
406            },
407            "token": {
408              "type": "string"
409            },
410            "password": {
411              "type": "string"
412            }
413          }
414        },
415        "OtpRequest": {
416          "type": "object",
417          "required": [
418            "email"
419          ],
420          "properties": {
421            "email": {
422              "type": "string",
423              "format": "email",
424              "example": "[email protected]"
425            }
426          }
427        },
428        "OtpLoginRequest": {
429          "type": "object",
430          "required": [
431            "email",
432            "otp"
433          ],
434          "properties": {
435            "email": {
436              "type": "string",
437              "format": "email",
438              "example": "[email protected]"
439            },
440            "otp": {
441              "type": "string",
442              "example": "A1B2C3",
443              "description": "The one-time password received via email"
444            },
445            "platform": {
446              "type": "string",
447              "enum": [
448                "PLATFORM::IOS",
449                "PLATFORM::ANDROID",
450                "PLATFORM::WEB",
451                "PLATFORM::MACOS",
452                "PLATFORM::WINDOWS",
453                "PLATFORM::LINUX",
454                "PLATFORM::UNKNOWN"
455              ],
456              "description": "Optional. Defaults to PLATFORM::UNKNOWN if omitted. Recommended for session tracking."
457            }
458          }
459        },
460        "RefreshTokenRequest": {
461          "type": "object",
462          "required": [
463            "refreshToken",
464            "platform"
465          ],
466          "properties": {
467            "refreshToken": {
468              "type": "string",
469              "description": "The refresh token from the most recent login or refresh response."
470            },
471            "platform": {
472              "type": "string",
473              "enum": [
474                "PLATFORM::IOS",
475                "PLATFORM::ANDROID",
476                "PLATFORM::WEB",
477                "PLATFORM::MACOS",
478                "PLATFORM::WINDOWS",
479                "PLATFORM::LINUX",
480                "PLATFORM::UNKNOWN"
481              ],
482              "description": "Optional. Should match the platform used at login for best tracking. Defaults to PLATFORM::UNKNOWN."
483            }
484          }
485        },
486        "SocialLoginRequest": {
487          "type": "object",
488          "required": [
489            "provider",
490            "token"
491          ],
492          "properties": {
493            "provider": {
494              "type": "string",
495              "enum": [
496                "google",
497                "facebook",
498                "apple"
499              ],
500              "description": "Social identity provider"
501            },
502            "token": {
503              "type": "string",
504              "description": "ID token or access token from the provider"
505            },
506            "name": {
507              "type": "string",
508              "description": "Optional display name for first-time sign-up"
509            },
510            "platform": {
511              "type": "string",
512              "enum": [
513                "PLATFORM::IOS",
514                "PLATFORM::ANDROID",
515                "PLATFORM::WEB",
516                "PLATFORM::MACOS",
517                "PLATFORM::WINDOWS",
518                "PLATFORM::LINUX",
519                "PLATFORM::UNKNOWN"
520              ],
521              "description": "Optional. Defaults to PLATFORM::UNKNOWN if omitted. Recommended for session tracking."
522            }
523          }
524        },
525        "TokenResponse": {
526          "type": "object",
527          "description": "Issued on successful login or token refresh.",
528          "properties": {
529            "token": {
530              "type": "string",
531              "description": "Short-lived access token. Use as Authorization Bearer on all API requests."
532            },
533            "expiresAt": {
534              "type": "string",
535              "format": "date-time",
536              "description": "When the access token expires."
537            },
538            "refreshToken": {
539              "type": "string",
540              "description": "Long-lived refresh token. Store securely."
541            },
542            "refreshExpiresAt": {
543              "type": "string",
544              "format": "date-time",
545              "description": "When the refresh token expires (default 30 days)."
546            }
547          }
548        },
549        "ErrorResponse": {
550          "type": "object",
551          "description": "Standard error envelope. All API errors follow this shape.",
552          "properties": {
553            "statusCode": {
554              "type": "integer",
555              "example": 400,
556              "description": "Application-level status code"
557            },
558            "reason": {
559              "type": "string",
560              "example": "MISSING_FIELD",
561              "description": "Machine-readable error code for programmatic handling."
562            },
563            "statusMessage": {
564              "type": "string",
565              "example": "The \"email\" field is required.",
566              "description": "Human-readable message safe to display to the end user."
567            }
568          }
569        },
570        "TodoGroup": {
571          "type": "object",
572          "properties": {
573            "id": {
574              "type": "string",
575              "description": "Group UUID"
576            },
577            "title": {
578              "type": "string",
579              "description": "Group display name"
580            },
581            "order": {
582              "type": "integer",
583              "description": "Positional order within the list"
584            }
585          }
586        },
587        "SetLocationRequest": {
588          "type": "object",
589          "required": [
590            "locationLat",
591            "locationLng",
592            "locationRadius",
593            "locationTrigger"
594          ],
595          "description": "All four required fields must be sent together, either all with real values (to set or update the reminder) or all as `null` (to clear it). Any other combination — a mix of values and nulls, or an omitted field — is rejected.",
596          "properties": {
597            "locationName": {
598              "type": "string",
599              "nullable": true,
600              "example": "Whole Foods Market"
601            },
602            "locationLat": {
603              "type": "number",
604              "nullable": true,
605              "example": 37.7749
606            },
607            "locationLng": {
608              "type": "number",
609              "nullable": true,
610              "example": -122.4194
611            },
612            "locationRadius": {
613              "type": "number",
614              "nullable": true,
615              "example": 200,
616              "description": "Radius in metres"
617            },
618            "locationTrigger": {
619              "type": "string",
620              "nullable": true,
621              "enum": [
622                "ARRIVE",
623                "LEAVE"
624              ],
625              "example": "ARRIVE"
626            }
627          }
628        },
629        "LocationTriggerRequest": {
630          "type": "object",
631          "required": [
632            "event"
633          ],
634          "properties": {
635            "event": {
636              "type": "string",
637              "enum": [
638                "ARRIVE",
639                "LEAVE"
640              ],
641              "example": "ARRIVE"
642            }
643          }
644        },
645        "CreateAlarmRequest": {
646          "type": "object",
647          "required": [
648            "alarmAt"
649          ],
650          "properties": {
651            "label": {
652              "type": "string",
653              "example": "Take out the trash"
654            },
655            "alarmAt": {
656              "type": "string",
657              "format": "date-time",
658              "example": "2025-12-31T08:00:00Z"
659            },
660            "repeat": {
661              "type": "string",
662              "enum": [
663                "ALARM::REPEAT::NONE",
664                "ALARM::REPEAT::DAILY",
665                "ALARM::REPEAT::WEEKLY",
666                "ALARM::REPEAT::MONTHLY"
667              ],
668              "example": "ALARM::REPEAT::NONE",
669              "description": "Repeat cadence. Omit or NONE for one-time."
670            },
671            "listId": {
672              "type": "string",
673              "example": "uuid-of-list"
674            },
675            "todoId": {
676              "type": "string",
677              "example": "uuid-of-item"
678            },
679            "target": {
680              "type": "string",
681              "enum": [
682                "ALARM::TARGET::SELF",
683                "ALARM::TARGET::PARTNER",
684                "ALARM::TARGET::BOTH"
685              ],
686              "default": "ALARM::TARGET::SELF",
687              "description": "Who receives this alarm."
688            }
689          }
690        },
691        "SnoozeAlarmRequest": {
692          "type": "object",
693          "required": [
694            "minutes"
695          ],
696          "properties": {
697            "minutes": {
698              "type": "integer",
699              "example": 10,
700              "description": "Minutes to snooze"
701            }
702          }
703        },
704        "FileReportRequest": {
705          "type": "object",
706          "required": [
707            "reportedId",
708            "reportType"
709          ],
710          "properties": {
711            "reportedId": {
712              "type": "string",
713              "example": "uuid-of-reported-user"
714            },
715            "reportType": {
716              "type": "string",
717              "enum": [
718                "REPORT::TYPE::SPAM",
719                "REPORT::TYPE::HARASSMENT",
720                "REPORT::TYPE::INAPPROPRIATE_CONTENT",
721                "REPORT::TYPE::OTHER"
722              ],
723              "example": "REPORT::TYPE::SPAM"
724            },
725            "description": {
726              "type": "string",
727              "example": "Sending unsolicited invite requests repeatedly."
728            }
729          }
730        },
731        "ReviewReportRequest": {
732          "type": "object",
733          "required": [
734            "status"
735          ],
736          "properties": {
737            "status": {
738              "type": "string",
739              "enum": [
740                "REPORT::STATUS::REVIEWED",
741                "REPORT::STATUS::DISMISSED",
742                "REPORT::STATUS::ACTIONED"
743              ],
744              "example": "REPORT::STATUS::ACTIONED",
745              "description": "Terminal status. PENDING is set automatically on creation."
746            }
747          }
748        },
749        "Challenge": {
750          "type": "object",
751          "properties": {
752            "id": {
753              "type": "string",
754              "example": "uuid-of-challenge"
755            },
756            "listId": {
757              "type": "string",
758              "example": "uuid-of-list"
759            },
760            "createdBy": {
761              "type": "string",
762              "example": "uuid-of-user",
763              "description": "User id of whoever sent the challenge. Only the *other* member may accept or decline — the creator gets 403 NOT_PARTNER. Compare against the signed-in user to choose between showing Accept/Decline and \"waiting for them to accept\"."
764            },
765            "status": {
766              "type": "string",
767              "enum": [
768                "CHALLENGE::STATUS::PENDING",
769                "CHALLENGE::STATUS::ACTIVE",
770                "CHALLENGE::STATUS::COMPLETE",
771                "CHALLENGE::STATUS::DECLINED",
772                "CHALLENGE::STATUS::CANCELLED"
773              ],
774              "example": "CHALLENGE::STATUS::PENDING",
775              "description": "A challenge is created PENDING and scores nothing until the partner accepts."
776            },
777            "deadline": {
778              "type": "string",
779              "format": "date-time",
780              "description": "When the challenge ends. Always in the future at creation."
781            },
782            "acceptedAt": {
783              "type": "string",
784              "format": "date-time",
785              "nullable": true,
786              "description": "Null while still PENDING."
787            },
788            "endedAt": {
789              "type": "string",
790              "format": "date-time",
791              "nullable": true,
792              "description": "Null while PENDING or ACTIVE."
793            },
794            "winnerId": {
795              "type": "string",
796              "nullable": true,
797              "description": "Set once the challenge completes. Null for a draw, and null before it ends."
798            },
799            "endReason": {
800              "type": "string",
801              "nullable": true,
802              "enum": [
803                "CHALLENGE::END::EARLY_FINISH",
804                "CHALLENGE::END::DEADLINE",
805                "CHALLENGE::END::CANCELLED"
806              ],
807              "description": "Why it ended. Null before it ends."
808            },
809            "scores": {
810              "type": "object",
811              "description": "Score keyed by user id. Both members always appear, even on zero, so a client can render \"0 | 3\" without inventing the missing side. A point goes to whoever ticked the box, not whoever the task was assigned to.",
812              "additionalProperties": {
813                "type": "integer"
814              },
815              "example": {
816                "uuid-of-user-a": 3,
817                "uuid-of-user-b": 1
818              }
819            },
820            "todoIds": {
821              "type": "array",
822              "items": {
823                "type": "string"
824              },
825              "description": "The tasks in the challenge. Released back to the list when it is declined or cancelled."
826            }
827          }
828        },
829        "TooManyRequestsResponse": {
830          "type": "object",
831          "properties": {
832            "statusCode": {
833              "type": "integer",
834              "example": 429
835            },
836            "reason": {
837              "type": "string",
838              "enum": [
839                "TOO_MANY_REQUESTS"
840              ],
841              "example": "TOO_MANY_REQUESTS"
842            },
843            "statusMessage": {
844              "type": "string",
845              "example": "Too many requests. Please wait a moment and try again."
846            }
847          }
848        },
849        "AccountEmail": {
850          "type": "object",
851          "description": "One email address belonging to an account.",
852          "properties": {
853            "id": {
854              "type": "string",
855              "description": "Use this to promote or remove the address.",
856              "example": "9f2c1e64-2b1a-4f0a-9f3e-3a1d2b4c5d6e"
857            },
858            "email": {
859              "type": "string",
860              "format": "email",
861              "example": "[email protected]"
862            },
863            "verified": {
864              "type": "boolean",
865              "description": "Until this is true the address grants nothing — it cannot sign in, cannot receive an invitation, and cannot be made primary.\n",
866              "example": true
867            },
868            "isPrimary": {
869              "type": "boolean",
870              "description": "Exactly one address per account is primary. Notifications, password reset, one-time passwords and deletion mail all go there and nowhere else.\n",
871              "example": false
872            },
873            "source": {
874              "type": "string",
875              "description": "How the address came to be on the account.",
876              "enum": [
877                "EMAIL::SOURCE::REGISTRATION",
878                "EMAIL::SOURCE::INVITE",
879                "EMAIL::SOURCE::MANUAL",
880                "EMAIL::SOURCE::GOOGLE",
881                "EMAIL::SOURCE::APPLE",
882                "EMAIL::SOURCE::FACEBOOK"
883              ],
884              "example": "EMAIL::SOURCE::GOOGLE"
885            },
886            "createdAt": {
887              "type": "string",
888              "format": "date-time"
889            }
890          }
891        },
892        "SocialLinkResponse": {
893          "type": "object",
894          "properties": {
895            "statusCode": {
896              "type": "integer",
897              "example": 201
898            },
899            "data": {
900              "type": "object",
901              "properties": {
902                "provider": {
903                  "type": "string",
904                  "enum": [
905                    "google",
906                    "facebook",
907                    "apple"
908                  ],
909                  "example": "google"
910                },
911                "providerEmail": {
912                  "type": "string",
913                  "format": "email",
914                  "description": "The address held at the provider. May differ from the account's own email — that is the point of linking.",
915                  "example": "[email protected]"
916                },
917                "linkedAt": {
918                  "type": "string",
919                  "format": "date-time",
920                  "example": "2026-09-06T10:15:00.000Z"
921                }
922              }
923            }
924          }
925        }
926      }
927    },
928    "paths": {
929      "/api/v1/users/by-username/{username}": {
930        "get": {
931          "tags": [
932            "Users"
933          ],
934          "summary": "Look up a person by their username",
935          "description": "Resolves a handle the caller already has — typed in, or read off a\nmessage — to the minimum needed to show a confirmation before inviting\nthem: an id, a display name, and the handle itself.\n\n\n**No email address is ever returned here**, in either direction. The\nwhole point of a handle is that it lets somebody reach you without\nlearning your address.\n\n\nThis is a lookup, not a listing. It answers about one handle at a time,\nnever enumerates, requires a bearer token, and is rate-limited. A handle\nthat does not exist and a handle belonging to somebody who has blocked\nthe caller return the **same** `404` — telling those apart would turn a\nblock into a way of confirming the blocker still has an account.\n",
936          "security": [
937            {
938              "bearerAuth": []
939            }
940          ],
941          "parameters": [
942            {
943              "in": "path",
944              "name": "username",
945              "required": true,
946              "schema": {
947                "type": "string"
948              },
949              "description": "With or without a leading `@`. Case-insensitive.",
950              "example": "sara_k"
951            }
952          ],
953          "responses": {
954            "200": {
955              "description": "Always HTTP 200 — check `statusCode` in the body",
956              "content": {
957                "application/json": {
958                  "schema": {
959                    "oneOf": [
960                      {
961                        "description": "Found.",
962                        "type": "object",
963                        "properties": {
964                          "statusCode": {
965                            "type": "integer",
966                            "example": 200
967                          },
968                          "data": {
969                            "type": "object",
970                            "properties": {
971                              "id": {
972                                "type": "string",
973                                "example": "1f3c2e64-2b1a-4f0a-9f3e-3a1d2b4c5d6e"
974                              },
975                              "name": {
976                                "type": "string",
977                                "example": "Sara K"
978                              },
979                              "username": {
980                                "type": "string",
981                                "example": "sara_k"
982                              }
983                            }
984                          }
985                        }
986                      },
987                      {
988                        "description": "No account has that handle, or the caller cannot reach that person. Show \"No account found with that username\" in both cases — the two are deliberately indistinguishable.\n",
989                        "type": "object",
990                        "properties": {
991                          "statusCode": {
992                            "type": "integer",
993                            "example": 404
994                          },
995                          "reason": {
996                            "type": "string",
997                            "example": "USERNAME_NOT_FOUND"
998                          },
999                          "statusMessage": {
1000                            "type": "string",
1001                            "example": "No account found with that username."
1002                          }
1003                        }
1004                      },
1005                      {
1006                        "description": "Missing or invalid bearer token.",
1007                        "type": "object",
1008                        "properties": {
1009                          "statusCode": {
1010                            "type": "integer",
1011                            "example": 401
1012                          },
1013                          "reason": {
1014                            "type": "string",
1015                            "example": "UNAUTHORIZED"
1016                          },
1017                          "statusMessage": {
1018                            "type": "string"
1019                          }
1020                        }
1021                      }
1022                    ]
1023                  }
1024                }
1025              }
1026            }
1027          }
1028        }
1029      },
1030      "/api/todos": {
1031        "get": {
1032          "tags": [
1033            "Todos"
1034          ],
1035          "summary": "Get all todo lists for the authenticated user",
1036          "description": "Returns all lists the user owns or is partnered on, including archived one
1036s.\nEach list may include an `items` array of todo tasks.\n",
1037          "security": [
1038            {
1039              "bearerAuth": []
1040            }
1041          ],
1042          "responses": {
1043            "200": {
1044              "description": "Array of todo lists (empty array if none)",
1045              "content": {
1046                "application/json": {
1047                  "schema": {
1048                    "type": "object",
1049                    "properties": {
1050                      "statusCode": {
1051                        "type": "integer",
1052                        "example": 200
1053                      },
1054                      "data": {
1055                        "type": "array",
1056                        "items": {
1057                          "type": "object",
1058                          "properties": {
1059                            "id": {
1060                              "type": "string"
1061                            },
1062                            "label": {
1063                              "type": "string"
1064                            },
1065                            "favorite": {
1066                              "type": "boolean"
1067                            },
1068                            "createdBy": {
1069                              "type": "string"
1070                            },
1071                            "partnerId": {
1072                              "type": "string",
1073                              "nullable": true
1074                            },
1075                            "inviteStatus": {
1076                              "type": "string",
1077                              "enum": [
1078                                "TODOS::INVITE_STATUS::PENDING",
1079                                "TODOS::INVITE_STATUS::ACCEPTED",
1080                                "TODOS::INVITE_STATUS::REJECTED"
1081                              ],
1082                              "nullable": true
1083                            },
1084                            "archived": {
1085                              "type": "boolean"
1086                            },
1087                            "color": {
1088                              "type": "string",
1089                              "nullable": true
1090                            },
1091                            "priority": {
1092                              "type": "string",
1093                              "enum": [
1094                                "TODOLIST::PRIORITY::URGENT",
1095                                "TODOLIST::PRIORITY::NONURGENT"
1096                              ],
1097                              "nullable": true
1098                            },
1099                            "doBefore": {
1100                              "type": "string",
1101                              "format": "date-time",
1102                              "nullable": true
1103                            },
1104                            "locationName": {
1105                              "type": "string",
1106                              "nullable": true
1107                            },
1108                            "locationTrigger": {
1109                              "type": "string",
1110                              "enum": [
1111                                "ARRIVE",
1112                                "LEAVE"
1113                              ],
1114                              "nullable": true
1115                            },
1116                            "items": {
1117                              "type": "array",
1118                              "description": "Every column of every task on the list. Several of\nthese have been returned for a long time without\nbeing documented, which is why no client used them.\n",
1119                              "items": {
1120                                "type": "object",
1121                                "properties": {
1122                                  "id": {
1123                                    "type": "string",
1124                                    "example": "9a2f7c18-64b1-4e3d-8f0a-1c6b5d4e2f90"
1125                                  },
1126                                  "listId": {
1127                                    "type": "string"
1128                                  },
1129                                  "title": {
1130                                    "type": "string",
1131                                    "example": "Oat milk"
1132                                  },
1133                                  "done": {
1134                                    "type": "boolean"
1135                                  },
1136                                  "order": {
1137                                    "type": "integer"
1138                                  },
1139                                  "doBefore": {
1140                                    "type": "string",
1141                                    "format": "date-time",
1142                                    "nullable": true,
1143                                    "description": "This item's own deadline, independent of the list's."
1144                                  },
1145                                  "assigneeId": {
1146                                    "type": "string",
1147                                    "nullable": true,
1148                                    "description": "Which of the list's two members has taken this\ntask on. null means unclaimed, which is a real\nstate and the default.\n"
1149                                  },
1150                                  "updatedBy": {
1151                                    "type": "string",
1152                                    "nullable": true,
1153                                    "description": "userId of the last person to edit this task.\nRecords any edit, so it is not a record of who\ncompleted it.\n"
1154                                  },
1155                                  "groupId": {
1156                                    "type": "string",
1157                                    "nullable": true,
1158                                    "description": "Section within the list, or null when ungrouped."
1159                                  },
1160                                  "locationName": {
1161                                    "type": "string",
1162                                    "nullable": true,
1163                                    "example": "Tesco Metro"
1164                                  },
1165                                  "locationLat": {
1166                                    "type": "number",
1167                                    "nullable": true
1168                                  },
1169                                  "locationLng": {
1170                                    "type": "number",
1171                                    "nullable": true
1172                                  },
1173                                  "locationRadius": {
1174                                    "type": "number",
1175                                    "nullable": true,
1176                                    "description": "Radius in metres."
1177                                  },
1178                                  "locationTrigger": {
1179                                    "type": "string",
1180                                    "enum": [
1181                                      "ARRIVE",
1182                                      "LEAVE"
1183                                    ],
1184                                    "nullable": true,
1185                                    "description": "Per-item geofence. Set through\nPATCH /api/todos/{listId}/items/{todoId}/location.\n"
1186                                  },
1187                                  "updatedAt": {
1188                                    "type": "string",
1189                                    "format": "date-time"
1190                                  },
1191                                  "createdAt": {
1192                                    "type": "string",
1193                                    "format": "date-time"
1194                                  }
1195                                }
1196                              }
1197                            }
1198                          }
1199                        }
1200                      }
1201                    }
1202                  }
1203                }
1204              }
1205            }
1206          }
1207        },
1208        "post": {
1209          "tags": [
1210            "Todos"
1211          ],
1212          "summary": "Create a new todo list",
1213          "security": [
1214            {
1215              "bearerAuth": []
1216            }
1217          ],
1218          "requestBody": {
1219            "required": true,
1220            "content": {
1221              "application/json": {
1222                "schema": {
1223                  "$ref": "#/components/schemas/CreateTodosListRequest"
1224                }
1225              }
1226            }
1227          },
1228          "responses": {
1229            "200": {
1230              "description": "Always HTTP 200 — check `statusCode` in the body",
1231              "content": {
1232                "application/json": {
1233                  "schema": {
1234                    "oneOf": [
1235                      {
1236                        "description": "List created",
1237                        "type": "object",
1238                        "properties": {
1239                          "statusCode": {
1240                            "type": "integer",
1241                            "example": 200
1242                          },
1243                          "data": {
1244                            "type": "boolean",
1245                            "example": true
1246                          }
1247                        }
1248                      },
1249                      {
1250                        "description": "Label missing or exceeds 250 characters",
1251                        "type": "object",
1252                        "properties": {
1253                          "statusCode": {
1254                            "type": "integer",
1255                            "example": 400
1256                          },
1257                          "statusMessage": {
1258                            "type": "string"
1259                          }
1260                        }
1261                      },
1262                      {
1263                        "description": "Could not create list (unexpected error)",
1264                        "type": "object",
1265                        "properties": {
1266                          "statusCode": {
1267                            "type": "integer",
1268                            "example": 401
1269                          },
1270                          "statusMessage": {
1271                            "type": "string"
1272                          }
1273                        }
1274                      }
1275                    ]
1276                  }
1277                }
1278              }
1279            }
1280          }
1281        }
1282      },
1283      "/api/todos/{todoId}": {
1284        "put": {
1285          "tags": [
1286            "Todos"
1287          ],
1288          "summary": "Update a todo list (label or favorite)",
1289          "security": [
1290            {
1291              "bearerAuth": []
1292            }
1293          ],
1294          "parameters": [
1295            {
1296              "name": "todoId",
1297              "in": "path",
1298              "required": true,
1299              "schema": {
1300                "type": "string"
1301              }
1302            }
1303          ],
1304          "requestBody": {
1305            "required": true,
1306            "content": {
1307              "application/json": {
1308                "schema": {
1309                  "$ref": "#/components/schemas/UpdateTodosListRequest"
1310                }
1311              }
1312            }
1313          },
1314          "responses": {
1315            "200": {
1316              "description": "Always HTTP 200 — check `statusCode` in the body",
1317              "content": {
1318                "application/json": {
1319                  "schema": {
1320                    "oneOf": [
1321                      {
1322                        "description": "List updated",
1323                        "type": "object",
1324                        "properties": {
1325                          "statusCode": {
1326                            "type": "integer",
1327                            "example": 200
1328                          },
1329                          "data": {
1330                            "type": "boolean",
1331                            "example": true
1332                          }
1333                        }
1334                      },
1335                      {
1336                        "description": "List not found or caller is not owner/partner",
1337                        "type": "object",
1338                        "properties": {
1339                          "statusCode": {
1340                            "type": "integer",
1341                            "example": 404
1342                          },
1343                          "statusMessage": {
1344                            "type": "string"
1345                          }
1346                        }
1347                      }
1348                    ]
1349                  }
1350                }
1351              }
1352            }
1353          }
1354        },
1355        "delete": {
1356          "tags": [
1357            "Todos"
1358          ],
1359          "summary": "Delete a todo list and all its tasks",
1360          "description": "Both owner and partner can call this. If a partner deletes the list,\nonly their copy is removed and the owner is notified via\n`NOTIFICATION::LIST_DELETED`. If the owner deletes it, the list is\npermanently soft-deleted for both users.\n",
1361          "security": [
1362            {
1363              "bearerAuth": []
1364            }
1365          ],
1366          "parameters": [
1367            {
1368              "name": "todoId",
1369              "in": "path",
1370              "required": true,
1371              "schema": {
1372                "type": "string"
1373              }
1374            }
1375          ],
1376          "responses": {
1377            "200": {
1378              "description": "Always HTTP 200 — check `statusCode` in the body",
1379              "content": {
1380                "application/json": {
1381                  "schema": {
1382                    "oneOf": [
1383                      {
1384                        "description": "List deleted",
1385                        "type": "object",
1386                        "properties": {
1387                          "statusCode": {
1388                            "type": "integer",
1389                            "example": 200
1390                          },
1391                          "data": {
1392                            "type": "string",
1393                            "example": "The Todo #<id> has been deleted."
1394                          }
1395                        }
1396                      },
1397                      {
1398                        "description": "List not found or caller has no access",
1399                        "type": "object",
1400                        "properties": {
1401                          "statusCode": {
1402                            "type": "integer",
1403                            "example": 404
1404                          },
1405                          "statusMessage": {
1406                            "type": "string"
1407                          }
1408                        }
1409                      },
1410                      {
1411                        "description": "Delete failed (unexpected error)",
1412                        "type": "object",
1413                        "properties": {
1414                          "statusCode": {
1415                            "type": "integer",
1416                            "example": 401
1417                          },
1418                          "statusMessage": {
1419                            "type": "string"
1420                          }
1421                        }
1422                      }
1423                    ]
1424                  }
1425                }
1426              }
1427            }
1428          }
1429        },
1430        "get": {
1431          "tags": [
1432            "Todos"
1433          ],
1434          "summary": "Get a todo list with all its tasks",
1435          "security": [
1436            {
1437              "bearerAuth": []
1438            }
1439          ],
1440          "parameters": [
1441            {
1442              "name": "todoId",
1443              "in": "path",
1444              "required": true,
1445              "schema": {
1446                "type": "string"
1447              }
1448            }
1449          ],
1450          "responses": {
1451            "200": {
1452              "description": "Always HTTP 200 — check `statusCode` in the body",
1453              "content": {
1454                "application/json": {
1455                  "schema": {
1456                    "oneOf": [
1457                      {
1458                        "description": "List found — full record 
1458including items array",
1459                        "type": "object",
1460                        "properties": {
1461                          "statusCode": {
1462                            "type": "integer",
1463                            "example": 200
1464                          },
1465                          "data": {
1466                            "type": "object",
1467                            "description": "TodoList with nested items array",
1468                            "properties": {
1469                              "id": {
1470                                "type": "string"
1471                              },
1472                              "label": {
1473                                "type": "string"
1474                              },
1475                              "createdBy": {
1476                                "type": "string"
1477                              },
1478                              "partnerId": {
1479                                "type": "string",
1480                                "nullable": true
1481                              },
1482                              "inviteStatus": {
1483                                "type": "string",
1484                                "nullable": true
1485                              },
1486                              "archived": {
1487                                "type": "boolean"
1488                              },
1489                              "items": {
1490                                "type": "array",
1491                                "items": {
1492                                  "type": "object",
1493                                  "properties": {
1494                                    "id": {
1495                                      "type": "string"
1496                                    },
1497                                    "title": {
1498                                      "type": "string"
1499                                    },
1500                                    "done": {
1501                                      "type": "boolean"
1502                                    },
1503                                    "order": {
1504                                      "type": "integer"
1505                                    },
1506                                    "doBefore": {
1507                                      "type": "string",
1508                                      "format": "date-time",
1509                                      "nullable": true
1510                                    }
1511                                  }
1512                                }
1513                              }
1514                            }
1515                          }
1516                        }
1517                      },
1518                      {
1519                        "description": "List not found or caller has no access",
1520                        "type": "object",
1521                        "properties": {
1522                          "statusCode": {
1523                            "type": "integer",
1524                            "example": 404
1525                          },
1526                          "statusMessage": {
1527                            "type": "string"
1528                          }
1529                        }
1530                      }
1531                    ]
1532                  }
1533                }
1534              }
1535            }
1536          }
1537        },
1538        "post": {
1539          "tags": [
1540            "Todos"
1541          ],
1542          "summary": "Add a task to a todo list",
1543          "security": [
1544            {
1545              "bearerAuth": []
1546            }
1547          ],
1548          "parameters": [
1549            {
1550              "name": "todoId",
1551              "in": "path",
1552              "required": true,
1553              "schema": {
1554                "type": "string"
1555              }
1556            }
1557          ],
1558          "requestBody": {
1559            "required": true,
1560            "content": {
1561              "application/json": {
1562                "schema": {
1563                  "type": "object",
1564                  "required": [
1565                    "title",
1566                    "done"
1567                  ],
1568                  "properties": {
1569                    "title": {
1570                      "type": "string",
1571                      "maxLength": 255
1572                    },
1573                    "done": {
1574                      "type": "boolean"
1575                    },
1576                    "description": {
1577                      "type": "string",
1578                      "nullable": true,
1579                      "maxLength": 1000,
1580                      "example": "Oat, not almond. Recipe: https://example.com/porridge",
1581                      "description": "Optional long-form detail for the task, up to 1000 characters. Omit it, or send null or an empty string, to create a task with no description — all three store null and the item comes back with description: null. Stored and returned as plain text exactly as sent; the server does not parse, sanitise or render it. Send Markdown if you want rich text, and reference images and links by URL — inline base64 image data will not fit the limit and is not supported.\n"
1582                    },
1583                    "groupId": {
1584                      "type": "string",
1585                      "nullable": true,
1586                      "description": "Optional. UUID of a group that belongs to this list. Omit or pass null to add the item directly to the list (ungrouped). Returns 404 with reason GROUP_NOT_FOUND if the group does not exist or belongs to a different list.\n"
1587                    }
1588                  }
1589                }
1590              }
1591            }
1592          },
1593          "responses": {
1594            "200": {
1595              "description": "Always HTTP 200 — check `statusCode` in the body",
1596              "content": {
1597                "application/json": {
1598                  "schema": {
1599                    "oneOf": [
1600                      {
1601                        "description": "Task added",
1602                        "type": "object",
1603                        "properties": {
1604                          "statusCode": {
1605                            "type": "integer",
1606                            "example": 200
1607                          },
1608                          "data": {
1609                            "type": "boolean",
1610                            "example": true
1611                          }
1612                        }
1613                      },
1614                      {
1615                        "description": "Title exceeds 255 characters, or description exceeds 1000 characters or is not a string or null",
1616                        "type": "object",
1617                        "properties": {
1618                          "statusCode": {
1619                            "type": "integer",
1620                            "example": 400
1621                          },
1622                          "reason": {
1623                            "type": "string",
1624                            "enum": [
1625                              "VALIDATION_ERROR"
1626                            ],
1627                            "example": "VALIDATION_ERROR"
1628                          },
1629                          "statusMessage": {
1630                            "type": "string",
1631                            "example": "The 'description' field must be 1000 characters or fewer."
1632                          }
1633                        }
1634                      },
1635                      {
1636                        "description": "List not found, caller has no access, or groupId does not belong to this list",
1637                        "type": "object",
1638                        "properties": {
1639                          "statusCode": {
1640                            "type": "integer",
1641                            "example": 404
1642                          },
1643                          "reason": {
1644                            "type": "string",
1645                            "enum": [
1646                              "NOT_FOUND",
1647                              "GROUP_NOT_FOUND"
1648                            ]
1649                          },
1650                          "statusMessage": {
1651                            "type": "string"
1652                          }
1653                        }
1654                      }
1655                    ]
1656                  }
1657                }
1658              }
1659            }
1660          }
1661        }
1662      },
1663      "/api/todos/{todoId}/clearDone": {
1664        "put": {
1665          "tags": [
1666            "Todos"
1667          ],
1668          "summary": "Clear completed tasks from a todo list",
1669          "security": [
1670            {
1671              "bearerAuth": []
1672            }
1673          ],
1674          "parameters": [
1675            {
1676              "name": "todoId",
1677              "in": "path",
1678              "required": true,
1679              "schema": {
1680                "type": "string"
1681              }
1682            }
1683          ],
1684          "responses": {
1685            "200": {
1686              "description": "Always HTTP 200 — check `statusCode` in the body",
1687              "content": {
1688                "application/json": {
1689                  "schema": {
1690                    "oneOf": [
1691                      {
1692                        "description": "Done items cleared",
1693                        "type": "object",
1694                        "properties": {
1695                          "statusCode": {
1696                            "type": "integer",
1697                            "example": 200
1698                          },
1699                          "data": {
1700                            "type": "boolean",
1701                            "example": true
1702                          }
1703                        }
1704                      },
1705                      {
1706                        "description": "List not found or caller is not owner/partner",
1707                        "type": "object",
1708                        "properties": {
1709                          "statusCode": {
1710                            "type": "integer",
1711                            "example": 404
1712                          },
1713                          "statusMessage": {
1714                            "type": "string"
1715                          }
1716                        }
1717                      }
1718                    ]
1719                  }
1720                }
1721              }
1722            }
1723          }
1724        }
1725      },
1726      "/api/todos/{todoId}/archive": {
1727        "put": {
1728          "tags": [
1729            "Todos"
1730          ],
1731          "summary": "Archive or unarchive a todo list",
1732          "security": [
1733            {
1734              "bearerAuth": []
1735            }
1736          ],
1737          "parameters": [
1738            {
1739              "name": "todoId",
1740              "in": "path",
1741              "required": true,
1742              "schema": {
1743                "type": "string"
1744              }
1745            }
1746          ],
1747          "requestBody": {
1748            "required": true,
1749            "content": {
1750              "application/json": {
1751                "schema": {
1752                  "$ref": "#/components/schemas/ArchiveTodosListRequest"
1753                }
1754              }
1755            }
1756          },
1757          "responses": {
1758            "200": {
1759              "description": "Always HTTP 200 — check `statusCode` in the body",
1760              "content": {
1761                "application/json": {
1762                  "schema": {
1763                    "oneOf": [
1764                      {
1765                        "description": "Archive state updated",
1766                        "type": "object",
1767                        "properties": {
1768                          "statusCode": {
1769                            "type": "integer",
1770                            "example": 200
1771                          },
1772                          "data": {
1773                            "type": "boolean",
1774                            "example": true
1775                          }
1776                        }
1777                      },
1778                      {
1779                        "description": "List not found or caller is not owner/partner",
1780                        "type": "object",
1781                        "properties": {
1782                          "statusCode": {
1783                            "type": "integer",
1784                            "example": 404
1785                          },
1786                          "statusMessage": {
1787                            "type": "string"
1788                          }
1789                        }
1790                      }
1791                    ]
1792                  }
1793                }
1794              }
1795            }
1796          }
1797        }
1798      },
1799      "/api/todos/{todoId}/accept-invite": {
1800        "put": {
1801          "tags": [
1802            "Todos"
1803          ],
1804          "summary": "Accept a shared-list invite (partner only)",
1805          "description": "Only the user who is set as `partnerId` on the list can call this.\nOn success the list's `inviteStatus` changes to `TODOS::INVITE_STATUS::ACCEPTED`\nand the owner receives a `NOTIFICATION::INVITE_ACCEPTED` notification.\n",
1806          "security": [
1807            {
1808              "bearerAuth": []
1809            }
1810          ],
1811          "parameters": [
1812            {
1813              "name": "todoId",
1814              "in": "path",
1815              "required": true,
1816              "schema": {
1817                "type": "string"
1818              }
1819            }
1820          ],
1821          "responses": {
1822            "200": {
1823              "description": "Always HTTP 200 — check `statusCode` in the body",
1824              "content": {
1825                "application/json": {
1826                  "schema": {
1827                    "oneOf": [
1828                      {
1829                        "description": "Invite accepted",
1830                        "type": "object",
1831                        "properties": {
1832                          "statusCode": {
1833                            "type": "integer",
1834                            "example": 200
1835                          },
1836                          "data": {
1837                            "type": "boolean",
1838                            "example": true
1839                          }
1840                        }
1841                      },
1842                      {
1843                        "description": "List not found, or caller is not the partner",
1844                        "type": "object",
1845                        "properties": {
1846                          "statusCode": {
1847                            "type": "integer",
1848                            "example": 404
1849                          },
1850                          "statusMessage": {
1851                            "type": "string"
1852                          }
1853                        }
1854                      }
1855                    ]
1856                  }
1857                }
1858              }
1859            }
1860          }
1861        }
1862      },
1863      "/api/todos/{todoId}/decline-invite": {
1864        "put": {
1865          "tags": [
1866            "Todos"
1867          ],
1868          "summary": "Decline a shared-list invite (partner only)",
1869          "description": "Only the user who is set as `partnerId` on the list can call this.\nOn success the list's `inviteStatus` changes to `TODOS::INVITE_STATUS::REJECTED`\nand the owner receives a `LIST::INVITE_DECLINED` socket event.\n",
1870          "security": [
1871            {
1872              "bearerAuth": []
1873            }
1874          ],
1875          "parameters": [
1876            {
1877              "name": "todoId",
1878              "in": "path",
1879              "required": true,
1880              "schema": {
1881                "type": "string"
1882              }
1883            }
1884          ],
1885          "responses": {
1886            "200": {
1887              "description": "Always HTTP 200 — check `statusCode` in the body",
1888              "content": {
1889                "application/json": {
1890                  "schema": {
1891                    "oneOf": [
1892                      {
1893                        "description": "Invite declined",
1894                        "type": "object",
1895                        "properties": {
1896                          "statusCode": {
1897                            "type": "integer",
1898                            "example": 200
1899                          },
1900                          "data": {
1901                            "type": "boolean",
1902                            "example": true
1903                          }
1904                        }
1905                      },
1906                      {
1907                        "description": "List not found, or caller is not the partner",
1908                        "type": "object",
1909                        "properties": {
1910                          "statusCode": {
1911                            "type": "integer",
1912                            "example": 404
1913   
1913                       },
1914                          "statusMessage": {
1915                            "type": "string"
1916                          }
1917                        }
1918                      }
1919                    ]
1920                  }
1921                }
1922              }
1923            }
1924          }
1925        }
1926      },
1927      "/api/todos/{todoId}/color": {
1928        "put": {
1929          "tags": [
1930            "Todos"
1931          ],
1932          "summary": "Update the color of a todo list",
1933          "security": [
1934            {
1935              "bearerAuth": []
1936            }
1937          ],
1938          "parameters": [
1939            {
1940              "name": "todoId",
1941              "in": "path",
1942              "required": true,
1943              "schema": {
1944                "type": "string"
1945              }
1946            }
1947          ],
1948          "requestBody": {
1949            "required": true,
1950            "content": {
1951              "application/json": {
1952                "schema": {
1953                  "$ref": "#/components/schemas/UpdateColorTodoListRequest"
1954                }
1955              }
1956            }
1957          },
1958          "responses": {
1959            "200": {
1960              "description": "Always HTTP 200 — check `statusCode` in the body",
1961              "content": {
1962                "application/json": {
1963                  "schema": {
1964                    "oneOf": [
1965                      {
1966                        "description": "Color updated",
1967                        "type": "object",
1968                        "properties": {
1969                          "statusCode": {
1970                            "type": "integer",
1971                            "example": 200
1972                          },
1973                          "data": {
1974                            "type": "boolean",
1975                            "example": true
1976                          }
1977                        }
1978                      },
1979                      {
1980                        "description": "List not found or caller is not owner/partner",
1981                        "type": "object",
1982                        "properties": {
1983                          "statusCode": {
1984                            "type": "integer",
1985                            "example": 404
1986                          },
1987                          "statusMessage": {
1988                            "type": "string"
1989                          }
1990                        }
1991                      }
1992                    ]
1993                  }
1994                }
1995              }
1996            }
1997          }
1998        }
1999      },
2000      "/api/todos/{todoId}/priority": {
2001        "put": {
2002          "tags": [
2003            "Todos"
2004          ],
2005          "summary": "Update the priority of a todo list",
2006          "security": [
2007            {
2008              "bearerAuth": []
2009            }
2010          ],
2011          "parameters": [
2012            {
2013              "name": "todoId",
2014              "in": "path",
2015              "required": true,
2016              "schema": {
2017                "type": "string"
2018              }
2019            }
2020          ],
2021          "requestBody": {
2022            "required": true,
2023            "content": {
2024              "application/json": {
2025                "schema": {
2026                  "$ref": "#/components/schemas/UpdatePriorityTodoListRequest"
2027                }
2028              }
2029            }
2030          },
2031          "responses": {
2032            "200": {
2033              "description": "Always HTTP 200 — check `statusCode` in the body",
2034              "content": {
2035                "application/json": {
2036                  "schema": {
2037                    "oneOf": [
2038                      {
2039                        "description": "Priority updated",
2040                        "type": "object",
2041                        "properties": {
2042                          "statusCode": {
2043                            "type": "integer",
2044                            "example": 200
2045                          },
2046                          "data": {
2047                            "type": "boolean",
2048                            "example": true
2049                          }
2050                        }
2051                      },
2052                      {
2053                        "description": "List not found or caller is not owner/partner",
2054                        "type": "object",
2055                        "properties": {
2056                          "statusCode": {
2057                            "type": "integer",
2058                            "example": 404
2059                          },
2060                          "statusMessage": {
2061                            "type": "string"
2062                          }
2063                        }
2064                      }
2065                    ]
2066                  }
2067                }
2068              }
2069            }
2070          }
2071        }
2072      },
2073      "/api/todos/{todoId}/deadline": {
2074        "put": {
2075          "tags": [
2076            "Todos"
2077          ],
2078          "summary": "Set or clear a deadline on a todo list",
2079          "security": [
2080            {
2081              "bearerAuth": []
2082            }
2083          ],
2084          "parameters": [
2085            {
2086              "name": "todoId",
2087              "in": "path",
2088              "required": true,
2089              "schema": {
2090                "type": "string"
2091              }
2092            }
2093          ],
2094          "requestBody": {
2095            "required": true,
2096            "content": {
2097              "application/json": {
2098                "schema": {
2099                  "$ref": "#/components/schemas/UpdateTodoListDeadlineRequest"
2100                }
2101              }
2102            }
2103          },
2104          "responses": {
2105            "200": {
2106              "description": "Always HTTP 200 — check `statusCode` in the body",
2107              "content": {
2108                "application/json": {
2109                  "schema": {
2110                    "oneOf": [
2111                      {
2112                        "description": "Deadline updated",
2113                        "type": "object",
2114                        "properties": {
2115                          "statusCode": {
2116                            "type": "integer",
2117                            "example": 200
2118                          },
2119                          "data": {
2120                            "type": "boolean",
2121                            "example": true
2122                          }
2123                        }
2124                      },
2125                      {
2126                        "description": "List not found or caller is not owner/partner",
2127                        "type": "object",
2128                        "properties": {
2129                          "statusCode": {
2130                            "type": "integer",
2131                            "example": 404
2132                          },
2133                          "statusMessage": {
2134                            "type": "string"
2135                          }
2136                        }
2137                      }
2138                    ]
2139                  }
2140                }
2141              }
2142            }
2143          }
2144        }
2145      },
2146      "/api/todos/{todoId}/{itemId}/deadline": {
2147        "put": {
2148          "tags": [
2149            "Todos"
2150          ],
2151          "summary": "Set or clear a deadline on a todo item",
2152          "security": [
2153            {
2154              "bearerAuth": []
2155            }
2156          ],
2157          "parameters": [
2158            {
2159              "name": "todoId",
2160              "in": "path",
2161              "required": true,
2162              "schema": {
2163                "type": "string"
2164              }
2165            },
2166            {
2167              "name": "itemId",
2168              "in": "path",
2169              "required": true,
2170              "schema": {
2171                "type": "string"
2172              }
2173            }
2174          ],
2175          "requestBody": {
2176            "required": true,
2177            "content": {
2178              "application/json": {
2179                "schema": {
2180                  "$ref": "#/components/schemas/UpdateTodoListDeadlineRequest"
2181                }
2182              }
2183            }
2184          },
2185          "responses": {
2186            "200": {
2187              "description": "Always HTTP 200 — check `statusCode` in the body",
2188              "content": {
2189                "application/json": {
2190                  "schema": {
2191                    "oneOf": [
2192                      {
2193                        "description": "Deadline updated",
2194                        "type": "object",
2195                        "properties": {
2196                          "statusCode": {
2197                            "type": "integer",
2198                            "example": 200
2199                          },
2200                          "data": {
2201                            "type": "boolean",
2202                            "example": true
2203                          }
2204                        }
2205                      },
2206                      {
2207                        "description": "List or item not found, or caller has no access",
2208                        "type": "object",
2209                        "properties": {
2210                          "statusCode": {
2211                            "type": "integer",
2212                            "example": 404
2213                          },
2214                          "statusMessage": {
2215                            "type": "string"
2216                          }
2217                        }
2218                      }
2219                    ]
2220                  }
2221                }
2222              }
2223            }
2224          }
2225        }
2226      },
2227      "/api/todos/{listId}/restore": {
2228        "put": {
2229          "tags": [
2230            "Todos"
2231          ],
2232          "summary": "Restore a deleted list (undo a delete)",
2233          "description": "Brings back a list that was deleted, together with the alarms and the live challenge that were removed along with it. Deleting a list is a soft delete — the list, its tasks and its groups survive until `CleanupService` purges them after the retention window — so this endpoint is what an Undo button calls.\n\nOnly what was removed *with* the list comes back. An alarm the user had already deleted before deleting the list stays deleted, so an undo cannot resurrect something the user got rid of on purpose.\n\nThere is no short time limit: a list can be restored for as long as it has not been purged. Either member of the list may restore it, whether or not the invitation had been accepted, and the other member receives a `LIST::RESTORED` socket event so their copy reappears without a manual refresh.\n",
2234          "security": [
2235            {
2236              "bearerAuth": []
2237            }
2238          ],
2239          "parameters": [
2240            {
2241              "name": "listId",
2242              "in": "path",
2243              "required": true,
2244              "schema": {
2245                "type": "string"
2246              },
2247              "description": "The ID of the deleted list to restore"
2248            }
2249          ],
2250          "responses": {
2251            "200": {
2252              "description": "Always HTTP 200 — check `statusCode` in the body",
2253              "content": {
2254                "application/json": {
2255                  "schema": {
2256                    "oneOf": [
2257                      {
2258                        "description": "List restored, along with its alarms and any live challenge",
2259                        "type": "object",
2260                        "properties": {
2261                          "statusCode": {
2262                            "type": "integer",
2263                            "example": 200
2264                          },
2265                          "data": {
2266                            "type": "boolean",
2267                            "example": true
2268                          }
2269                        }
2270                      },
2271                      {
2272                        "description": "The list exists and is not deleted, so there is nothing to undo. Safe to treat as success — the list is already present.\n",
2273                        "type": "object",
2274                        "properties": {
2275                          "statusCode": {
2276                            "type": "integer",
2277                            "example": 400
2278                          },
2279                          "reason": {
2280                            "type": "string",
2281                            "enum": [
2282                              "NOT_DELETED"
2283                            ],
2284                            "example": "NOT_DELETED"
2285                          },
2286                          "statusMessage": {
2287                            "type": "string",
2288                            "example": "TodoList #<listId> is not deleted, so there is nothing to restore."
2289                          }
2290                        }
2291                      },
2292                      {
2293                        "description": "No such list, the caller is neither its owner nor its partner, or it has already been permanently purged and cannot be recovered.\n",
2294                        "type": "object",
2295                        "properties": {
2296                          "statusCode": {
2297                            "type": "integer",
2298                            "example": 404
2299                          },
2300                          "reason": {
2301                            "type": "string",
2302                            "enum": [
2303                              "NOT_FOUND"
2304                            ],
2305                            "example": "NOT_FOUND"
2306                          },
2307                          "statusMessage": {
2308                            "type": "string",
2309                            "example": "TodoList #<listId> was not found."
2310                          }
2311                        }
2312                      }
2313                    ]
2314                  }
2315                }
2316              }
2317            },
2318            "401": {
2319              "description": "Missing or invalid bearer token",
2320              "content": {
2321                "application/json": {
2322                  "schema": {
2323                    "$ref": "#/components/schemas/ErrorResponse"
2324                  }
2325                }
2326              }
2327            }
2328          }
2329        }
2330      },
2331      "/api/todos/nearby": {
2332        "get": {
2333          "tags": [
2334            "Todos"
2335          ],
2336          "summary": "Get lists and items whose geofence overlaps a given location",
2337          "security": [
2338            {
2339              "bearerAuth": []
2340            }
2341          ],
2342          "parameters": [
2343            {
2344              "name": "lat",
2345              "in": "query",
2346              "required": true,
2347              "schema": {
2348                "type": "number"
2349              }
2350            },
2351            {
2352              "name": "lng",
2353              "in": "query",
2354              "required": true,
2355              "schema": {
2356                "type": "number"
2357              }
2358            },
2359            {
2360              "name": "radius",
2361              "in": "query",
2362              "required": true,
2363              "schema": {
2364                "type": "number"
2365              },
2366              "description": "Search radius in metres"
2367            }
2368          ],
2369          "responses": {
2370            "200": {
2371              "description": "Always HTTP 200 — check `statusCode` in the body",
2372              "content": {
2373                "application/json": {
2374                  "schema": {
2375                    "oneOf": [
2376                      {
2377                        "description": "Lists and items within radius",
2378                        "type": "object",
2379                        "properties": {
2380                          "statusCode": {
2381                            "type": "integer",
2382                            "example": 200
2383                          },
2384                          "data": {
2385                            "type": "object",
2386                            "description": "Object containing matching lists and items"
2387                          }
2388                        }
2389                      },
2390                      {
2391                        "description": "Missing or non-numeric query params",
2392                        "type": "object",
2393                        "properties": {
2394                          "statusCode": {
2395                            "type": "integer",
2396                            "example": 400
2397                          },
2398                          "statusMessage": {
2399                            "type": "string",
2400                            "example": "lat, lng, and radius are required."
2401                          }
2402                        }
2403      
2403                }
2404                    ]
2405                  }
2406                }
2407              }
2408            }
2409          }
2410        }
2411      },
2412      "/api/todos/reorder/{todoId}": {
2413        "put": {
2414          "tags": [
2415            "Todos"
2416          ],
2417          "summary": "Reorder tasks within a todo list",
2418          "security": [
2419            {
2420              "bearerAuth": []
2421            }
2422          ],
2423          "parameters": [
2424            {
2425              "name": "todoId",
2426              "in": "path",
2427              "required": true,
2428              "schema": {
2429                "type": "string"
2430              },
2431              "description": "The list ID"
2432            }
2433          ],
2434          "requestBody": {
2435            "required": true,
2436            "content": {
2437              "application/json": {
2438                "schema": {
2439                  "type": "object",
2440                  "required": [
2441                    "order"
2442                  ],
2443                  "properties": {
2444                    "todoId": {
2445                      "type": "string",
2446                      "description": "ID of the task to reorder (alias for id)"
2447                    },
2448                    "id": {
2449                      "type": "string",
2450                      "description": "ID of the task to reorder (alias for todoId)"
2451                    },
2452                    "order": {
2453                      "type": "integer",
2454                      "description": "New zero-based position for the task"
2455                    }
2456                  }
2457                }
2458              }
2459            }
2460          },
2461          "responses": {
2462            "200": {
2463              "description": "Always HTTP 200 — check `statusCode` in the body",
2464              "content": {
2465                "application/json": {
2466                  "schema": {
2467                    "oneOf": [
2468                      {
2469                        "description": "Reordered",
2470                        "type": "object",
2471                        "properties": {
2472                          "statusCode": {
2473                            "type": "integer",
2474                            "example": 200
2475                          },
2476                          "data": {
2477                            "type": "boolean",
2478                            "example": true
2479                          }
2480                        }
2481                      },
2482                      {
2483                        "description": "List not found, or caller has no access, or item not found",
2484                        "type": "object",
2485                        "properties": {
2486                          "statusCode": {
2487                            "type": "integer",
2488                            "example": 404
2489                          },
2490                          "statusMessage": {
2491                            "type": "string"
2492                          }
2493                        }
2494                      }
2495                    ]
2496                  }
2497                }
2498              }
2499            }
2500          }
2501        }
2502      },
2503      "/api/todos/clear-done/{todoId}": {
2504        "put": {
2505          "tags": [
2506            "Todos"
2507          ],
2508          "summary": "Delete all completed tasks from a todo list",
2509          "security": [
2510            {
2511              "bearerAuth": []
2512            }
2513          ],
2514          "parameters": [
2515            {
2516              "name": "todoId",
2517              "in": "path",
2518              "required": true,
2519              "schema": {
2520                "type": "string"
2521              }
2522            }
2523          ],
2524          "responses": {
2525            "200": {
2526              "description": "Always HTTP 200 — check `statusCode` in the body",
2527              "content": {
2528                "application/json": {
2529                  "schema": {
2530                    "oneOf": [
2531                      {
2532                        "description": "Done tasks cleared",
2533                        "type": "object",
2534                        "properties": {
2535                          "statusCode": {
2536                            "type": "integer",
2537                            "example": 200
2538                          },
2539                          "data": {
2540                            "type": "boolean",
2541                            "example": true
2542                          }
2543                        }
2544                      },
2545                      {
2546                        "description": "List not found or caller has no access",
2547                        "type": "object",
2548                        "properties": {
2549                          "statusCode": {
2550                            "type": "integer",
2551                            "example": 404
2552                          },
2553                          "statusMessage": {
2554                            "type": "string"
2555                          }
2556                        }
2557                      }
2558                    ]
2559                  }
2560                }
2561              }
2562            }
2563          }
2564        }
2565      },
2566      "/api/todos/{todoId}/{itemId}/done": {
2567        "patch": {
2568          "tags": [
2569            "Todos"
2570          ],
2571          "summary": "Mark a task as done or undone",
2572          "security": [
2573            {
2574              "bearerAuth": []
2575            }
2576          ],
2577          "parameters": [
2578            {
2579              "name": "todoId",
2580              "in": "path",
2581              "required": true,
2582              "schema": {
2583                "type": "string"
2584              }
2585            },
2586            {
2587              "name": "itemId",
2588              "in": "path",
2589              "required": true,
2590              "schema": {
2591                "type": "string"
2592              }
2593            }
2594          ],
2595          "requestBody": {
2596            "required": true,
2597            "content": {
2598              "application/json": {
2599                "schema": {
2600                  "type": "object",
2601                  "required": [
2602                    "done"
2603                  ],
2604                  "properties": {
2605                    "done": {
2606                      "type": "boolean"
2607                    }
2608                  }
2609                }
2610              }
2611            }
2612          },
2613          "responses": {
2614            "200": {
2615              "description": "Always HTTP 200 — check `statusCode` in the body",
2616              "content": {
2617                "application/json": {
2618                  "schema": {
2619                    "oneOf": [
2620                      {
2621                        "description": "Done state updated",
2622                        "type": "object",
2623                        "properties": {
2624                          "statusCode": {
2625                            "type": "integer",
2626                            "example": 200
2627                          },
2628                          "data": {
2629                            "type": "boolean",
2630                            "example": true
2631                          }
2632                        }
2633                      },
2634                      {
2635                        "description": "Missing or invalid `done` field",
2636                        "type": "object",
2637                        "properties": {
2638                          "statusCode": {
2639                            "type": "integer",
2640                            "example": 400
2641                          },
2642                          "statusMessage": {
2643                            "type": "string"
2644                          }
2645                        }
2646                      },
2647                      {
2648                        "description": "List or task not found, or caller has no access",
2649                        "type": "object",
2650                        "properties": {
2651                          "statusCode": {
2652                            "type": "integer",
2653                            "example": 404
2654                          },
2655                          "statusMessage": {
2656                            "type": "string"
2657                          }
2658                        }
2659                      }
2660                    ]
2661                  }
2662                }
2663              }
2664            }
2665          }
2666        }
2667      },
2668      "/api/todos/{todoId}/{itemId}/title": {
2669        "patch": {
2670          "tags": [
2671            "Todos"
2672          ],
2673          "summary": "Update the title of a task",
2674          "security": [
2675            {
2676              "bearerAuth": []
2677            }
2678          ],
2679          "parameters": [
2680            {
2681              "name": "todoId",
2682              "in": "path",
2683              "required": true,
2684              "schema": {
2685                "type": "string"
2686              }
2687            },
2688            {
2689              "name": "itemId",
2690              "in": "path",
2691              "required": true,
2692              "schema": {
2693                "type": "string"
2694              }
2695            }
2696          ],
2697          "requestBody": {
2698            "required": true,
2699            "content": {
2700              "application/json": {
2701                "schema": {
2702                  "type": "object",
2703                  "required": [
2704                    "title"
2705                  ],
2706                  "properties": {
2707                    "title": {
2708                      "type": "string",
2709                      "maxLength": 255
2710                    }
2711                  }
2712                }
2713              }
2714            }
2715          },
2716          "responses": {
2717            "200": {
2718              "description": "Always HTTP 200 — check `statusCode` in the body",
2719              "content": {
2720                "application/json": {
2721                  "schema": {
2722                    "oneOf": [
2723                      {
2724                        "description": "Title updated",
2725                        "type": "object",
2726                        "properties": {
2727                          "statusCode": {
2728                            "type": "integer",
2729                            "example": 200
2730                          },
2731                          "data": {
2732                            "type": "boolean",
2733                            "example": true
2734                          }
2735                        }
2736                      },
2737                      {
2738                        "description": "Missing or invalid `title` field",
2739                        "type": "object",
2740                        "properties": {
2741                          "statusCode": {
2742                            "type": "integer",
2743                            "example": 400
2744                          },
2745                          "statusMessage": {
2746                            "type": "string"
2747                          }
2748                        }
2749                      },
2750                      {
2751                        "description": "List or task not found, or caller has no access",
2752                        "type": "object",
2753                        "properties": {
2754                          "statusCode": {
2755                            "type": "integer",
2756                            "example": 404
2757                          },
2758                          "statusMessage": {
2759                            "type": "string"
2760                          }
2761                        }
2762                      }
2763                    ]
2764                  }
2765                }
2766              }
2767            }
2768          }
2769        }
2770      },
2771      "/api/todos/{todoId}/{itemId}/description": {
2772        "patch": {
2773          "tags": [
2774            "Todos"
2775          ],
2776          "summary": "Set or clear the long-form description of a task",
2777          "description": "Writes only the description, so a client editing a task note cannot over
2777write a title the other member changed in the meantime. Send a string to set it, or null / an empty string to clear it — both store null and the item is returned with `description: null`. The value is stored and returned as plain text exactly as sent; the server does not parse, sanitise or render it. Send Markdown if you want rich text, and reference images and links by URL — inline base64 image data will not fit within the character limit and is not supported. The partner receives a `LIST::ITEM_UPDATED` socket event carrying the new `description`.\n",
2778          "security": [
2779            {
2780              "bearerAuth": []
2781            }
2782          ],
2783          "parameters": [
2784            {
2785              "name": "todoId",
2786              "in": "path",
2787              "required": true,
2788              "schema": {
2789                "type": "string"
2790              },
2791              "description": "The list ID"
2792            },
2793            {
2794              "name": "itemId",
2795              "in": "path",
2796              "required": true,
2797              "schema": {
2798                "type": "string"
2799              },
2800              "description": "The task ID"
2801            }
2802          ],
2803          "requestBody": {
2804            "required": true,
2805            "content": {
2806              "application/json": {
2807                "schema": {
2808                  "type": "object",
2809                  "required": [
2810                    "description"
2811                  ],
2812                  "properties": {
2813                    "description": {
2814                      "type": "string",
2815                      "nullable": true,
2816                      "maxLength": 1000,
2817                      "example": "Oat, not almond. Recipe: https://example.com/porridge",
2818                      "description": "The task detail, up to 1000 characters. Null or an empty string clears it. Leading and trailing whitespace is trimmed, and a value that is only whitespace is treated as a clear.\n"
2819                    }
2820                  }
2821                }
2822              }
2823            }
2824          },
2825          "responses": {
2826            "200": {
2827              "description": "Always HTTP 200 — check `statusCode` in the body",
2828              "content": {
2829                "application/json": {
2830                  "schema": {
2831                    "oneOf": [
2832                      {
2833                        "description": "Description updated or cleared",
2834                        "type": "object",
2835                        "properties": {
2836                          "statusCode": {
2837                            "type": "integer",
2838                            "example": 200
2839                          },
2840                          "data": {
2841                            "type": "boolean",
2842                            "example": true
2843                          }
2844                        }
2845                      },
2846                      {
2847                        "description": "The description field was missing, was not a string or null, or exceeded 1000 characters",
2848                        "type": "object",
2849                        "properties": {
2850                          "statusCode": {
2851                            "type": "integer",
2852                            "example": 400
2853                          },
2854                          "reason": {
2855                            "type": "string",
2856                            "enum": [
2857                              "MISSING_FIELD",
2858                              "VALIDATION_ERROR"
2859                            ],
2860                            "example": "VALIDATION_ERROR"
2861                          },
2862                          "statusMessage": {
2863                            "type": "string",
2864                            "example": "The 'description' field must be 1000 characters or fewer."
2865                          }
2866                        }
2867                      },
2868                      {
2869                        "description": "List or task not found, or caller has no access to the list",
2870                        "type": "object",
2871                        "properties": {
2872                          "statusCode": {
2873                            "type": "integer",
2874                            "example": 404
2875                          },
2876                          "reason": {
2877                            "type": "string",
2878                            "enum": [
2879                              "NOT_FOUND"
2880                            ],
2881                            "example": "NOT_FOUND"
2882                          },
2883                          "statusMessage": {
2884                            "type": "string",
2885                            "example": "The TodoList #<todoId> is not found or you do not have permission to access it."
2886                          }
2887                        }
2888                      }
2889                    ]
2890                  }
2891                }
2892              }
2893            },
2894            "401": {
2895              "description": "Missing or invalid bearer token",
2896              "content": {
2897                "application/json": {
2898                  "schema": {
2899                    "$ref": "#/components/schemas/ErrorResponse"
2900                  }
2901                }
2902              }
2903            }
2904          }
2905        }
2906      },
2907      "/api/todos/{listId}/reorder": {
2908        "put": {
2909          "tags": [
2910            "Todos"
2911          ],
2912          "summary": "Set the order of every task on a list in one call",
2913          "description": "Replaces the whole ordering. Position comes from the array index, so a\nclient sends the order it already has on screen after a drag.\n\nThe existing per-item endpoint (`PUT /api/todos/reorder/{todoId}`) is\nunchanged and still works. It costs one request per moved row, which is\nwhy drag-to-reorder was impractical to ship.\n",
2914          "security": [
2915            {
2916              "bearerAuth": []
2917            }
2918          ],
2919          "parameters": [
2920            {
2921              "name": "listId",
2922              "in": "path",
2923              "required": true,
2924              "schema": {
2925                "type": "string"
2926              }
2927            }
2928          ],
2929          "requestBody": {
2930            "required": true,
2931            "content": {
2932              "application/json": {
2933                "schema": {
2934                  "type": "object",
2935                  "required": [
2936                    "todoIds"
2937                  ],
2938                  "properties": {
2939                    "todoIds": {
2940                      "type": "array",
2941                      "description": "Every task on the list, in the order they should appear.\nIds must all belong to this list and must not repeat.\n",
2942                      "items": {
2943                        "type": "string"
2944                      }
2945                    }
2946                  }
2947                }
2948              }
2949            }
2950          },
2951          "responses": {
2952            "200": {
2953              "description": "Application-level result. HTTP is always 200; read statusCode.",
2954              "content": {
2955                "application/json": {
2956                  "schema": {
2957                    "oneOf": [
2958                      {
2959                        "description": "Order saved",
2960                        "type": "object",
2961                        "properties": {
2962                          "statusCode": {
2963                            "type": "integer",
2964                            "example": 200
2965                          },
2966                          "data": {
2967                            "type": "boolean",
2968                            "example": true
2969                          }
2970                        }
2971                      },
2972                      {
2973                        "description": "todoIds missing, empty, duplicated, or containing an id from another list",
2974                        "type": "object",
2975                        "properties": {
2976                          "statusCode": {
2977                            "type": "integer",
2978                            "example": 400
2979                          },
2980                          "reason": {
2981                            "type": "string",
2982                            "enum": [
2983                              "MISSING_FIELD",
2984                              "INVALID_ITEM"
2985                            ]
2986                          },
2987                          "statusMessage": {
2988                            "type": "string",
2989                            "example": "Every id must belong to this list and appear exactly once."
2990                          }
2991                        }
2992                      },
2993                      {
2994                        "description": "List not found or no access",
2995                        "type": "object",
2996                        "properties": {
2997                          "statusCode": {
2998                            "type": "integer",
2999                            "example": 404
3000                          },
3001                          "reason": {
3002                            "type": "string",
3003                            "enum": [
3004                              "NOT_FOUND"
3005                            ]
3006                          },
3007                          "statusMessage": {
3008                            "type": "string"
3009                          }
3010                        }
3011                      }
3012                    ]
3013                  }
3014                }
3015              }
3016            }
3017          }
3018        }
3019      },
3020      "/api/todos/{todoId}/{itemId}": {
3021        "put": {
3022          "tags": [
3023            "Todos"
3024          ],
3025          "summary": "Update a task (partial update — only provided fields are changed)",
3026          "description": "Both the list owner and partner can edit any item on an accepted shared list.\nThis is a **partial update** — only fields present in the request body are changed;\nomitted fields are left untouched. For example, sending `{ \"done\": true }` changes\nonly the completion state without overwriting the title or order.\n\nTo move an item into a group, set `groupId` to a valid group UUID on this list.\nTo ungroup an item, set `groupId` to `null`. The `updatedBy` field on the item is\nautomatically set to the authenticated user's ID.\n\n`description` follows the same partial-update rule: omit it to leave any existing\ndescription alone, or send `null` or an empty string to clear it — both store null.\nIf you are only editing the note, prefer\n`PATCH /api/todos/{todoId}/{itemId}/description`, which cannot overwrite a title\nthe other member changed in the meantime.\n",
3027          "security": [
3028            {
3029              "bearerAuth": []
3030            }
3031          ],
3032          "parameters": [
3033            {
3034              "name": "todoId",
3035              "in": "path",
3036              "required": true,
3037              "description": "The list ID (not the item ID)",
3038              "schema": {
3039                "type": "string"
3040              }
3041            },
3042            {
3043              "name": "itemId",
3044              "in": "path",
3045              "required": true,
3046              "description": "The item ID to update",
3047              "schema": {
3048                "type": "string"
3049              }
3050            }
3051          ],
3052          "requestBody": {
3053            "required": true,
3054            "content": {
3055              "application/json": {
3056                "schema": {
3057                  "$ref": "#/components/schemas/UpdateTodoRequest"
3058                }
3059              }
3060            }
3061          },
3062          "responses": {
3063            "200": {
3064              "description": "Always HTTP 200 — check `statusCode` in the body",
3065              "content": {
3066                "application/json": {
3067                  "schema": {
3068                    "oneOf": [
3069                      {
3070                        "description": "Task updated successfully",
3071                        "type": "object",
3072                        "properties": {
3073                          "statusCode": {
3074                            "type": "integer",
3075                            "example": 200
3076                          },
3077                          "data": {
3078                            "type": "boolean",
3079                            "example": true
3080                          }
3081                        }
3082                      },
3083                      {
3084                        "description": "List not found, or the caller is not the owner or partner",
3085                        "type": "object",
3086                        "properties": {
3087                          "statusCode": {
3088                            "type": "integer",
3089                            "example": 404
3090                          },
3091                          "reason": {
3092                            "type": "string",
3093                            "example": "NOT_FOUND"
3094                          },
3095                          "statusMessage": {
3096                            "type": "string",
3097                            "example": "Item"
3098                          }
3099                        }
3100                      },
3101                      {
3102                        "description": "The specified groupId does not exist on this list",
3103                        "type": "object",
3104                        "properties": {
3105                          "statusCode": {
3106                            "type": "integer",
3107                            "example": 404
3108                          },
3109                          "reason": {
3110                            "type": "string",
3111                            "example": "GROUP_NOT_FOUND"
3112                          },
3113                          "statusMessage": {
3114                            "type": "string",
3115                            "example": "TodoGroup"
3116                          }
3117                        }
3118                      },
3119                      {
3120                        "description": "Invalid deadline format",
3121                        "type": "object",
3122                        "properties": {
3123                          "statusCode": {
3124                            "type": "integer",
3125                            "example": 400
3126                          },
3127                          "reason": {
3128                            "type": "string",
3129                            "example": "INVALID_DATE"
3130                          },
3131                          "statusMessage": {
3132                            "type": "string"
3133                          }
3134                        }
3135                      },
3136                      {
3137                        "description": "The title exceeded 255 characters, or the description exceeded 1000 characters or was neither a string nor null.\n",
3138                        "type": "object",
3139                        "properties": {
3140                          "statusCode": {
3141                            "type": "integer",
3142                            "example": 400
3143                          },
3144                          "reason": {
3145                            "type": "string",
3146                            "enum": [
3147                              "VALIDATION_ERROR"
3148                            ],
3149                            "example": "VALIDATION_ERROR"
3150                          },
3151                          "statusMessage": {
3152                            "type": "string",
3153                            "example": "The 'description' field must be 1000 characters or fewer."
3154                          }
3155                        }
3156                      }
3157                    ]
3158                  }
3159                }
3160              }
3161            }
3162          }
3163        },
3164        "delete": {
3165          "tags": [
3166            "Todos"
3167          ],
3168          "summary": "Delete a task from a todo list",
3169          "security": [
3170            {
3171              "bearerAuth": []
3172            }
3173          ],
3174          "parameters": [
3175            {
3176              "name": "todoId",
3177              "in": "path",
3178              "required": true,
3179              "schema": {
3180                "type": "string"
3181              }
3182            },
3183            {
3184              "name": "itemId",
3185              "in": "path",
3186              "required": true,
3187              "schema": {
3188                "type": "string"
3189              }
3190            }
3191          ],
3192          "responses": {
3193            "200": {
3194              "description": "Always HTTP 200 — check `statusCode` in the body",
3195              "content": {
3196                "application/json": {
3197                  "schema": {
3198                    "oneOf": [
3199                      {
3200                        "description": "Task deleted",
3201                        "type": "object",
3202                        "properties": {
3203                          "statusCode": {
3204                            "type": "integer",
3205                            "example": 200
3206                          },
3207                          "data": {
3208                            "type": "string",
3209                            "example": "Task #<itemId> of TodoList #<todoId> was deleted."
3210                          }
3211                        }
3212                      },
3213                      {
3214                        "description": "List or task not found, or caller has no access",
3215                        "type": "object",
3216                        "properties": {
3217                          "statusCode": {
3218                            "type": "integer",
3219                            "example": 404
3220                          },
3221                          "statusMessage": {
3222                            "type": "string"
3223                          }
3224                        }
3225                      }
3226                    ]
3227                  }
3228                }
3229              }
3230            }
3231          }
3232        }
3233      },
3234      "/api/todos/{listId}/location": {
3235        "patch": {
3236          "tags": [
3237            "Todos"
3238          ],
3239          "summary": "Set, update, or clear the geofence location on a todo list",
3240          "description": "Send `locationLat`, `locationLng`, `locationRadius`, and `locationTrigger`\nall with real values to set or update the reminder, or all as `null` to\nclear it — there is no separate delete endpoint. Any other combination\n(a mix of values and nulls, or a missing field) is rejected.\n",
3241          "security": [
3242            {
3243              "bearerAuth": []
3244            }
3245          ],
3246          "parameters": [
3247            {
3248              "name": "listId",
3249              "in": "path",
3250              "required": true,
3251              "schema": {
3252                "type": "string"
3253              }
3254            }
3255          ],
3256          "requestBody": {
3257            "required": true,
3258            "content": {
3259              "application/json": {
3260                "schema": {
3261                  "$ref": "#/components/schemas/SetLocationRequest"
3262                }
3263              }
3264            }
3265          },
3266          "responses": {
3267            "200": {
3268              "description": "Always HTTP 200 — check `statusCode` in the body",
3269              "content": {
3270                "application/json": {
3271                  "schema": {
3272                    "oneOf": [
3273                      {
3274                        "description": "Location saved or cleared",
3275                        "type": "object",
3276                        "properties": {
3277                          "statusCode": {
3278                            "type": "integer",
3279                            "example": 200
3280                          },
3281                          "data": {
3282                            "type": "boolean",
3283                            "example": true
3284                          }
3285                        }
3286                      },
3287                      {
3288                        "description": "A required field was missing, or the fields were a mix of values and nulls",
3289                        "type": "object",
3290                        "properties": {
3291                          "statusCode": {
3292                            "type": "integer",
3293                            "example": 400
3294                          },
3295                          "reason": {
3296                            "type": "string",
3297                            "example": "MISSING_FIELD"
3298                          },
3299                          "statusMessage": {
3300                            "type": "string",
3301                            "example": "The fields locationLat, locationLng, locationRadius, and locationTrigger are all required."
3302                          }
3303                        }
3304                      },
3305                      {
3306                        "description": "List not found or caller has no access",
3307                        "type": "object",
3308                        "properties": {
3309                          "statusCode": {
3310                            "type": "integer",
3311                            "example": 404
3312                          },
3313                          "reason": {
3314                            "type": "string",
3315                            "example": "NOT_FOUND"
3316                          },
3317                          "statusMessage": {
3318                            "type": "string"
3319                          }
3320                        }
3321                      }
3322                    ]
3323                  }
3324                }
3325              }
3326            }
3327          }
3328        }
3329      },
3330      "/api/todos/{listId}/items/{todoId}/location": {
3331        "patch": {
3332          "tags": [
3333            "Todos"
3334          ],
3335          "summary": "Set, update, or clear the geofence location on a todo task",
3336          "description": "Send `locationLat`, `locationLng`, `locationRadius`, and `locationTrigger`\nall with real values to set or update the reminder, or all as `null` to\nclear it — there is no separate delete endpoint. Any other combination\n(a mix of values and nulls, or a missing field) is rejected.\n",
3337          "security": [
3338            {
3339              "bearerAuth": []
3340            }
3341          ],
3342          "parameters": [
3343            {
3344              "name": "listId",
3345              "in": "path",
3346              "required": true,
3347              "schema": {
3348                "type": "string"
3349              }
3350            },
3351            {
3352              "name": "todoId",
3353              "in": "path",
3354              "required": true,
3355              "schema": {
3356                "type": "string"
3357              }
3358            }
3359          ],
3360          "requestBody": {
3361            "required": true,
3362            "content": {
3363              "application/json": {
3364                "schema": {
3365                  "$ref": "#/components/schemas/SetLocationRequest"
3366                }
3367              }
3368            }
3369          },
3370          "responses": {
3371            "200": {
3372              "description": "Always HTTP 200 — check `statusCode` in the body",
3373              "content": {
3374                "application/json": {
3375                  "schema": {
3376                    "oneOf": [
3377                      {
3378                        "description": "Location saved or cleared on the task",
3379                        "type": "object",
3380                        "properties": {
3381                          "statusCode": {
3382                            "type": "integer",
3383                            "example": 200
3384                          },
3385                          "data": {
3386                            "type": "boolean",
3387                            "example": true
3388                          }
3389                        }
3390                      },
3391                      {
3392                        "description": "A required field was missing, or the fields were a mix of values and nulls",
3393                        "type": "object",
3394                        "properties": {
3395                          "statusCode": {
3396                            "type": "integer",
3397                            "example": 400
3398                          },
3399                          "reason": {
3400                            "type": "string",
3401                            "example": "MISSING_FIELD"
3402                          },
3403                          "statusMessage": {
3404                            "type": "string",
3405                            "example": "The fields locationLat, locationLng, locationRadius, and locationTrigger are all required."
3406                          }
3407                        }
3408                      },
3409                      {
3410                        "description": "Task or list not found",
3411                        "type": "object",
3412                        "properties": {
3413                          "statusCode": {
3414                            "type": "integer",
3415                            "example": 404
3416                          },
3417                          "reason": {
3418                            "typ
3418e": "string",
3419                            "example": "NOT_FOUND"
3420                          },
3421                          "statusMessage": {
3422                            "type": "string",
3423                            "example": "Item not found."
3424                          }
3425                        }
3426                      }
3427                    ]
3428                  }
3429                }
3430              }
3431            }
3432          }
3433        }
3434      },
3435      "/api/todos/{listId}/items/{todoId}/assignee": {
3436        "patch": {
3437          "tags": [
3438            "Todos"
3439          ],
3440          "summary": "Assign a task to one of the two people on its list, or unclaim it",
3441          "description": "Sets which of the list's two members has taken this task on.\n\nDeliberately symmetric: either member may assign to either member, or\nclear the assignment by passing null. There is no owner of an assignment\nand no approval step.\n\nEmits `LIST::ITEM_ASSIGNED` to the other member with the new value, so\nclients apply it without re-fetching. Creates a\n`NOTIFICATION::TASK_ASSIGNED` notification **only** when the caller\nassigns the task to the other person — claiming a task for yourself, and\nunclaiming, notify nobody.\n",
3442          "security": [
3443            {
3444              "bearerAuth": []
3445            }
3446          ],
3447          "parameters": [
3448            {
3449              "name": "listId",
3450              "in": "path",
3451              "required": true,
3452              "description": "UUID of the list the task belongs to.",
3453              "schema": {
3454                "type": "string",
3455                "example": "6f1c2b40-3d5e-4c1a-9a77-2b8d0e5f1a33"
3456              }
3457            },
3458            {
3459              "name": "todoId",
3460              "in": "path",
3461              "required": true,
3462              "description": "UUID of the task.",
3463              "schema": {
3464                "type": "string",
3465                "example": "9a2f7c18-64b1-4e3d-8f0a-1c6b5d4e2f90"
3466              }
3467            }
3468          ],
3469          "requestBody": {
3470            "required": true,
3471            "content": {
3472              "application/json": {
3473                "schema": {
3474                  "type": "object",
3475                  "required": [
3476                    "assigneeId"
3477                  ],
3478                  "properties": {
3479                    "assigneeId": {
3480                      "type": "string",
3481                      "nullable": true,
3482                      "description": "UUID of the list member taking the task on. Must be the\nlist's creator or its partner. Pass null to unclaim the task\nand leave it for either of them.\n",
3483                      "example": "3c9d1e77-5a2b-4f68-b0c4-7e1a9d2f6b45"
3484                    }
3485                  }
3486                }
3487              }
3488            }
3489          },
3490          "responses": {
3491            "200": {
3492              "description": "Application-level result. HTTP is always 200; read statusCode.",
3493              "content": {
3494                "application/json": {
3495                  "schema": {
3496                    "oneOf": [
3497                      {
3498                        "description": "Assignment saved",
3499                        "type": "object",
3500                        "properties": {
3501                          "statusCode": {
3502                            "type": "integer",
3503                            "example": 200
3504                          },
3505                          "data": {
3506                            "type": "boolean",
3507                            "example": true
3508                          }
3509                        }
3510                      },
3511                      {
3512                        "description": "The assigneeId field was absent from the body",
3513                        "type": "object",
3514                        "properties": {
3515                          "statusCode": {
3516                            "type": "integer",
3517                            "example": 400
3518                          },
3519                          "reason": {
3520                            "type": "string",
3521                            "enum": [
3522                              "MISSING_FIELD"
3523                            ]
3524                          },
3525                          "statusMessage": {
3526                            "type": "string",
3527                            "example": "assigneeId is required. Pass null to remove the assignment."
3528                          }
3529                        }
3530                      },
3531                      {
3532                        "description": "The chosen person is not on this list",
3533                        "type": "object",
3534                        "properties": {
3535                          "statusCode": {
3536                            "type": "integer",
3537                            "example": 403
3538                          },
3539                          "reason": {
3540                            "type": "string",
3541                            "enum": [
3542                              "NOT_A_MEMBER"
3543                            ]
3544                          },
3545                          "statusMessage": {
3546                            "type": "string",
3547                            "example": "That person is not on this list. A task can only be assigned to the list's creator or its partner."
3548                          }
3549                        }
3550                      },
3551                      {
3552                        "description": "List or task not found, or the caller has no access to it",
3553                        "type": "object",
3554                        "properties": {
3555                          "statusCode": {
3556                            "type": "integer",
3557                            "example": 404
3558                          },
3559                          "reason": {
3560                            "type": "string",
3561                            "enum": [
3562                              "NOT_FOUND"
3563                            ]
3564                          },
3565                          "statusMessage": {
3566                            "type": "string",
3567                            "example": "That list or task does not exist, or you do not have access to it."
3568                          }
3569                        }
3570                      }
3571                    ]
3572                  }
3573                }
3574              }
3575            }
3576          }
3577        }
3578      },
3579      "/api/todos/{listId}/location-trigger": {
3580        "post": {
3581          "tags": [
3582            "Todos"
3583          ],
3584          "summary": "Fire a geofence event (ARRIVE or LEAVE) for a list",
3585          "description": "Called by the client when the device crosses the list's geofence boundary.\nEmits a socket event to both the owner and partner so they see the\nlocation update in real time.\n",
3586          "security": [
3587            {
3588              "bearerAuth": []
3589            }
3590          ],
3591          "parameters": [
3592            {
3593              "name": "listId",
3594              "in": "path",
3595              "required": true,
3596              "schema": {
3597                "type": "string"
3598              }
3599            }
3600          ],
3601          "requestBody": {
3602            "required": true,
3603            "content": {
3604              "application/json": {
3605                "schema": {
3606                  "allOf": [
3607                    {
3608                      "$ref": "#/components/schemas/LocationTriggerRequest"
3609                    },
3610                    {
3611                      "type": "object",
3612                      "properties": {
3613                        "todoIds": {
3614                          "type": "array",
3615                          "description": "Optional. The pinned items whose geofence this crossing\nis for. Omit for a list-level crossing, which behaves\nexactly as it always has.\n\nAn array because clients coalesce geofences by place —\narriving at one shop with four pinned items is a single\ncrossing. Maximum 20; every id must belong to this list.\n\nItems whose own locationTrigger does not match `event`\nare ignored, since a region watches both directions.\n",
3616                          "maxItems": 20,
3617                          "items": {
3618                            "type": "string",
3619                            "example": "9a2f7c18-64b1-4e3d-8f0a-1c6b5d4e2f90"
3620                          }
3621                        }
3622                      }
3623                    }
3624                  ]
3625                }
3626              }
3627            }
3628          },
3629          "responses": {
3630            "200": {
3631              "description": "Always HTTP 200 — check `statusCode` in the body",
3632              "content": {
3633                "application/json": {
3634                  "schema": {
3635                    "oneOf": [
3636                      {
3637                        "description": "Trigger processed",
3638                        "type": "object",
3639                        "properties": {
3640                          "statusCode": {
3641                            "type": "integer",
3642                            "example": 200
3643                          },
3644                          "data": {
3645                            "type": "string",
3646                            "example": "Location trigger processed."
3647                          }
3648                        }
3649                      },
3650                      {
3651                        "description": "Missing event field",
3652                        "type": "object",
3653                        "properties": {
3654                          "statusCode": {
3655                            "type": "integer",
3656                            "example": 400
3657                          },
3658                          "statusMessage": {
3659                            "type": "string",
3660                            "example": "event is required."
3661                          }
3662                        }
3663                      }
3664                    ]
3665                  }
3666                }
3667              }
3668            }
3669          }
3670        }
3671      },
3672      "/api/todos/{listId}/challenge": {
3673        "post": {
3674          "tags": [
3675            "Todos"
3676          ],
3677          "summary": "Challenge the other person on a list to a race against a deadline",
3678          "description": "Creates a challenge in `CHALLENGE::STATUS::PENDING`. It does nothing\nuntil the partner accepts — being entered into a competition you did not\nagree to is not something this API allows.\n\nScoring credits whoever *ticked* a task, not whoever it was assigned to,\nso you cannot win by having work done for you.\n",
3679          "security": [
3680            {
3681              "bearerAuth": []
3682            }
3683          ],
3684          "parameters": [
3685            {
3686              "name": "listId",
3687              "in": "path",
3688              "required": true,
3689              "description": "UUID of the shared list.",
3690              "schema": {
3691                "type": "string"
3692              }
3693            }
3694          ],
3695          "requestBody": {
3696            "required": true,
3697            "content": {
3698              "application/json": {
3699                "schema": {
3700                  "type": "object",
3701                  "required": [
3702                    "deadline"
3703                  ],
3704                  "properties": {
3705                    "deadline": {
3706                      "type": "string",
3707                      "format": "date-time",
3708                      "description": "When the challenge ends. Must be in the future.",
3709                      "example": "2026-08-01T18:00:00Z"
3710                    },
3711                    "todoIds": {
3712                      "type": "array",
3713                      "description": "Which tasks are in the challenge. Omit or send an empty\narray to include every open task on the list. Every id must\nbelong to this list, and none may already be done — a\ncompleted task would hand someone a free point.\n",
3714                      "items": {
3715                        "type": "string"
3716                      }
3717                    }
3718                  }
3719                }
3720              }
3721            }
3722          },
3723          "responses": {
3724            "200": {
3725              "description": "Application-level result. HTTP is always 200; read statusCode.",
3726              "content": {
3727                "application/json": {
3728                  "schema": {
3729                    "oneOf": [
3730                      {
3731                        "description": "Challenge created, awaiting the partner. `createdBy` is\nthe caller, and `status` is always\n`CHALLENGE::STATUS::PENDING` on this response.\n",
3732                        "type": "object",
3733                        "properties": {
3734                          "statusCode": {
3735                            "type": "integer",
3736                            "example": 200
3737                          },
3738                          "data": {
3739                            "$ref": "#/components/schemas/Challenge"
3740                          }
3741                        }
3742                      },
3743                      {
3744                        "description": "The deadline field was missing or the date was in the past",
3745                        "type": "object",
3746                        "properties": {
3747                          "statusCode": {
3748                            "type": "integer",
3749                            "example": 400
3750                          },
3751                          "reason": {
3752                            "type": "string",
3753                            "enum": [
3754                              "MISSING_FIELD",
3755                              "DEADLINE_IN_PAST"
3756                            ]
3757                          },
3758                          "statusMessage": {
3759                            "type": "string",
3760                            "example": "The deadline must be a date in the future."
3761                          }
3762                        }
3763                      },
3764                      {
3765                        "description": "One or more tasks are not on this list, are already done, or the list has none open",
3766                        "type": "object",
3767                        "properties": {
3768                          "statusCode": {
3769                            "type": "integer",
3770                            "example": 400
3771                          },
3772                          "reason": {
3773                            "type": "string",
3774                            "enum": [
3775                              "INVALID_ITEM"
3776                            ]
3777                          },
3778                          "statusMessage": {
3779                            "type": "string"
3780                          }
3781                        }
3782                      },
3783                      {
3784                        "description": "A challenge is already running on this list",
3785                        "type": "object",
3786                        "properties": {
3787                          "statusCode": {
3788                            "type": "integer",
3789                            "example": 409
3790                          },
3791                          "reason": {
3792                            "type": "string",
3793                            "enum": [
3794                              "CHALLENGE_EXISTS"
3795                            ]
3796                          },
3797                          "statusMessage": {
3798                            "type": "string",
3799                            "example": "This list already has a challenge. Finish or cancel it before starting another."
3800                          }
3801                        }
3802                      },
3803                      {
3804                        "description": "List not found, not shared with anyone, or no access",
3805                        "type": "object",
3806                        "properties": {
3807                          "statusCode": {
3808                            "type": "integer",
3809                            "example": 404
3810                          },
3811                          "reason": {
3812                            "type": "string",
3813                            "enum": [
3814                              "NOT_FOUND"
3815                            ]
3816                          },
3817                          "statusMessage": {
3818                            "type": "string"
3819                          }
3820                        }
3821                      }
3822                    ]
3823                  }
3824                }
3825              }
3826            }
3827          }
3828        },
3829        "get": {
3830          "tags": [
3831            "Todos"
3832          ],
3833          "summary": "Read the live challenge on a list",
3834          "description": "Returns the pending or active challenge with its computed scoreboard,\nor null when there is none. Scores are keyed by user id and both members\nalways appear, so a client can render \"0 | 3\" without inventing the\nmissing side.\n",
3835          "security": [
3836            {
3837              "bearerAuth": []
3838            }
3839          ],
3840          "parameters": [
3841            {
3842              "name": "listId",
3843              "in": "path",
3844              "required": true,
3845              "schema": {
3846                "type": "string"
3847              }
3848            }
3849          ],
3850          "responses": {
3851            "200": {
3852              "description": "Application-level result. HTTP is always 200; read statusCode.",
3853              "content": {
3854                "application/json": {
3855                  "schema": {
3856                    "oneOf": [
3857                      {
3858                        "description": "The live challenge, or `null` when the list has none.\nOnly a `PENDING` or `ACTIVE` challenge is returned — once\none completes, is declined or is cancelled this goes back\nto `null`, so a client that wants the result of a finished\nchallenge must read it from the `CHALLENGE::ENDED` socket\nevent or the `NOTIFICATION::CHALLENGE_ENDED` notification.\n",
3859                        "type": "object",
3860                        "properties": {
3861                          "statusCode": {
3862                            "type": "integer",
3863                            "example": 200
3864                          },
3865                          "data": {
3866                            "nullable": true,
3867                            "allOf": [
3868                              {
3869                                "$ref": "#/components/schemas/Challenge"
3870                              }
3871                            ]
3872                          }
3873                        }
3874                      },
3875                      {
3876                        "description": "List not found or no access",
3877                        "type": "object",
3878                        "properties": {
3879                          "statusCode": {
3880                            "type": "integer",
3881                            "example": 404
3882                          },
3883                          "reason": {
3884                            "type": "string",
3885                            "enum": [
3886                              "NOT_FOUND"
3887                            ]
3888                          },
3889                          "statusMessage": {
3890                            "type": "string"
3891                          }
3892                        }
3893                      }
3894                    ]
3895                  }
3896                }
3897              }
3898            }
3899          }
3900        }
3901      },
3902      "/api/todos/{listId}/challenge/{challengeId}/accept": {
3903        "put": {
3904          "tags": [
3905            "Todos"
3906          ],
3907          "summary": "Accept a challenge you were sent",
3908          "description": "Only the *partner* may accept — not the person who created it. The\nchallenge becomes `CHALLENGE::STATUS::ACTIVE` and scoring begins.\n",
3909          "security": [
3910            {
3911              "bearerAuth": []
3912            }
3913          ],
3914          "parameters": [
3915            {
3916              "name": "listId",
3917              "in": "path",
3918              "required": true,
3919              "schema": {
3920                "type": "string"
3921              }
3922            },
3923            {
3924              "name": "challengeId",
3925              "in": "path",
3926              "required": true,
3927              "schema": {
3928                "type": "string"
3929              }
3930            }
3931          ],
3932          "responses": {
3933            "200": {
3934              "description": "Application-level result. HTTP is always 200; read statusCode.",
3935              "content": {
3936                "application/json": {
3937                  "schema": {
3938                    "oneOf": [
3939                      {
3940                        "description": "Accepted, now running",
3941                        "type": "object",
3942                        "properties": {
3943                          "statusCode": {
3944                            "type": "integer",
3945                            "example": 200
3946                          },
3947                          "data": {
3948                            "type": "boolean",
3949                            "example": true
3950                          }
3951                        }
3952                      },
3953                      {
3954                        "description": "You created this challenge, so you cannot accept it",
3955                        "type": "object",
3956                        "properties": {
3957                          "statusCode": {
3958                            "type": "integer",
3959                            "example": 403
3960                          },
3961                          "reason": {
3962                            "type": "string",
3963                            "enum": [
3964                              "NOT_PARTNER"
3965                            ]
3966                          },
3967                          "statusMessage": {
3968                            "type": "string",
3969                            "example": "Only the other person on this list can answer the challenge."
3970                          }
3971                        }
3972                      },
3973                      {
3974                        "description": "No pending challenge with that id on this list",
3975                        "type": "object",
3976                        "properties": {
3977                          "statusCode": {
3978                            "type": "integer",
3979                            "example": 404
3980                          },
3981                          "reason": {
3982                            "type": "string",
3983                            "enum": [
3984                              "NOT_FOUND"
3985                            ]
3986                          },
3987                          "statusMessage": {
3988                            "type": "string"
3989                          }
3990                        }
3991                      }
3992                    ]
3993                  }
3994                }
3995              }
3996            }
3997          }
3998        }
3999      },
4000      "/api/todos/{listId}/challenge/{challengeId}/decline": {
4001        "put": {
4002          "tags": [
4003            "Todos"
4004          ],
4005          "summary": "Decline a challenge you were sent",
4006          "description": "Only the partner may decline. The tasks are released and the challenge\nbec
4006omes `CHALLENGE::STATUS::DECLINED`.\n",
4007          "security": [
4008            {
4009              "bearerAuth": []
4010            }
4011          ],
4012          "parameters": [
4013            {
4014              "name": "listId",
4015              "in": "path",
4016              "required": true,
4017              "schema": {
4018                "type": "string"
4019              }
4020            },
4021            {
4022              "name": "challengeId",
4023              "in": "path",
4024              "required": true,
4025              "schema": {
4026                "type": "string"
4027              }
4028            }
4029          ],
4030          "responses": {
4031            "200": {
4032              "description": "Application-level result. HTTP is always 200; read statusCode.",
4033              "content": {
4034                "application/json": {
4035                  "schema": {
4036                    "oneOf": [
4037                      {
4038                        "description": "Declined",
4039                        "type": "object",
4040                        "properties": {
4041                          "statusCode": {
4042                            "type": "integer",
4043                            "example": 200
4044                          },
4045                          "data": {
4046                            "type": "boolean",
4047                            "example": true
4048                          }
4049                        }
4050                      },
4051                      {
4052                        "description": "You created this challenge, so you cannot decline it",
4053                        "type": "object",
4054                        "properties": {
4055                          "statusCode": {
4056                            "type": "integer",
4057                            "example": 403
4058                          },
4059                          "reason": {
4060                            "type": "string",
4061                            "enum": [
4062                              "NOT_PARTNER"
4063                            ]
4064                          },
4065                          "statusMessage": {
4066                            "type": "string"
4067                          }
4068                        }
4069                      },
4070                      {
4071                        "description": "No pending challenge with that id on this list",
4072                        "type": "object",
4073                        "properties": {
4074                          "statusCode": {
4075                            "type": "integer",
4076                            "example": 404
4077                          },
4078                          "reason": {
4079                            "type": "string",
4080                            "enum": [
4081                              "NOT_FOUND"
4082                            ]
4083                          },
4084                          "statusMessage": {
4085                            "type": "string"
4086                          }
4087                        }
4088                      }
4089                    ]
4090                  }
4091                }
4092              }
4093            }
4094          }
4095        }
4096      },
4097      "/api/todos/{listId}/challenge/{challengeId}": {
4098        "delete": {
4099          "tags": [
4100            "Todos"
4101          ],
4102          "summary": "Call off a challenge",
4103          "description": "Either person may cancel, whether it is pending or running. Nobody wins\na cancelled challenge and the tasks are released.\n",
4104          "security": [
4105            {
4106              "bearerAuth": []
4107            }
4108          ],
4109          "parameters": [
4110            {
4111              "name": "listId",
4112              "in": "path",
4113              "required": true,
4114              "schema": {
4115                "type": "string"
4116              }
4117            },
4118            {
4119              "name": "challengeId",
4120              "in": "path",
4121              "required": true,
4122              "schema": {
4123                "type": "string"
4124              }
4125            }
4126          ],
4127          "responses": {
4128            "200": {
4129              "description": "Application-level result. HTTP is always 200; read statusCode.",
4130              "content": {
4131                "application/json": {
4132                  "schema": {
4133                    "oneOf": [
4134                      {
4135                        "description": "Cancelled",
4136                        "type": "object",
4137                        "properties": {
4138                          "statusCode": {
4139                            "type": "integer",
4140                            "example": 200
4141                          },
4142                          "data": {
4143                            "type": "boolean",
4144                            "example": true
4145                          }
4146                        }
4147                      },
4148                      {
4149                        "description": "No live challenge with that id on this list",
4150                        "type": "object",
4151                        "properties": {
4152                          "statusCode": {
4153                            "type": "integer",
4154                            "example": 404
4155                          },
4156                          "reason": {
4157                            "type": "string",
4158                            "enum": [
4159                              "NOT_FOUND"
4160                            ]
4161                          },
4162                          "statusMessage": {
4163                            "type": "string"
4164                          }
4165                        }
4166                      }
4167                    ]
4168                  }
4169                }
4170              }
4171            }
4172          }
4173        }
4174      },
4175      "/api/todos/{listId}/groups": {
4176        "get": {
4177          "tags": [
4178            "Todos"
4179          ],
4180          "summary": "List all groups on a list",
4181          "security": [
4182            {
4183              "bearerAuth": []
4184            }
4185          ],
4186          "parameters": [
4187            {
4188              "in": "path",
4189              "name": "listId",
4190              "required": true,
4191              "schema": {
4192                "type": "string"
4193              }
4194            }
4195          ],
4196          "responses": {
4197            "200": {
4198              "description": "Array of groups ordered by position",
4199              "content": {
4200                "application/json": {
4201                  "schema": {
4202                    "type": "object",
4203                    "properties": {
4204                      "statusCode": {
4205                        "type": "integer",
4206                        "example": 200
4207                      },
4208                      "data": {
4209                        "type": "array",
4210                        "items": {
4211                          "type": "object",
4212                          "properties": {
4213                            "id": {
4214                              "type": "string"
4215                            },
4216                            "title": {
4217                              "type": "string"
4218                            },
4219                            "order": {
4220                              "type": "integer"
4221                            }
4222                          }
4223                        }
4224                      }
4225                    }
4226                  }
4227                }
4228              }
4229            }
4230          }
4231        },
4232        "post": {
4233          "tags": [
4234            "Todos"
4235          ],
4236          "summary": "Create a new group on a list",
4237          "security": [
4238            {
4239              "bearerAuth": []
4240            }
4241          ],
4242          "parameters": [
4243            {
4244              "in": "path",
4245              "name": "listId",
4246              "required": true,
4247              "schema": {
4248                "type": "string"
4249              }
4250            }
4251          ],
4252          "requestBody": {
4253            "required": true,
4254            "content": {
4255              "application/json": {
4256                "schema": {
4257                  "type": "object",
4258                  "required": [
4259                    "title"
4260                  ],
4261                  "properties": {
4262                    "title": {
4263                      "type": "string",
4264                      "description": "Group name (1–250 characters)",
4265                      "example": "Groceries"
4266                    }
4267                  }
4268                }
4269              }
4270            }
4271          },
4272          "responses": {
4273            "200": {
4274              "description": "Always HTTP 200 — check `statusCode` in the body",
4275              "content": {
4276                "application/json": {
4277                  "schema": {
4278                    "oneOf": [
4279                      {
4280                        "description": "Group created successfully",
4281                        "type": "object",
4282                        "properties": {
4283                          "statusCode": {
4284                            "type": "integer",
4285                            "example": 200
4286                          },
4287                          "data": {
4288                            "$ref": "#/components/schemas/TodoGroup"
4289                          }
4290                        }
4291                      },
4292                      {
4293                        "description": "Title is missing or empty",
4294                        "type": "object",
4295                        "properties": {
4296                          "statusCode": {
4297                            "type": "integer",
4298                            "example": 400
4299                          },
4300                          "reason": {
4301                            "type": "string",
4302                            "example": "MISSING_FIELD"
4303                          },
4304                          "statusMessage": {
4305                            "type": "string",
4306                            "example": "The 'title' field is required."
4307                          }
4308                        }
4309                      },
4310                      {
4311                        "description": "Title exceeds 250 characters",
4312                        "type": "object",
4313                        "properties": {
4314                          "statusCode": {
4315                            "type": "integer",
4316                            "example": 400
4317                          },
4318                          "reason": {
4319                            "type": "string",
4320                            "example": "VALIDATION_ERROR"
4321                          },
4322                          "statusMessage": {
4323                            "type": "string",
4324                            "example": "The 'title' field must be 250 characters or fewer."
4325                          }
4326                        }
4327                      },
4328                      {
4329                        "description": "List not found",
4330                        "type": "object",
4331                        "properties": {
4332                          "statusCode": {
4333                            "type": "integer",
4334                            "example": 404
4335                          },
4336                          "reason": {
4337                            "type": "string",
4338                            "example": "NOT_FOUND"
4339                          },
4340                          "statusMessage": {
4341                            "type": "string"
4342                          }
4343                        }
4344                      }
4345                    ]
4346                  }
4347                }
4348              }
4349            }
4350          }
4351        }
4352      },
4353      "/api/todos/{listId}/groups/{groupId}": {
4354        "put": {
4355          "tags": [
4356            "Todos"
4357          ],
4358          "summary": "Rename a group",
4359          "security": [
4360            {
4361              "bearerAuth": []
4362            }
4363          ],
4364          "parameters": [
4365            {
4366              "in": "path",
4367              "name": "listId",
4368              "required": true,
4369              "schema": {
4370                "type": "string"
4371              }
4372            },
4373            {
4374              "in": "path",
4375              "name": "groupId",
4376              "required": true,
4377              "schema": {
4378                "type": "string"
4379              }
4380            }
4381          ],
4382          "requestBody": {
4383            "required": true,
4384            "content": {
4385              "application/json": {
4386                "schema": {
4387                  "type": "object",
4388                  "required": [
4389                    "title"
4390                  ],
4391                  "properties": {
4392                    "title": {
4393                      "type": "string",
4394                      "example": "Updated Group Name"
4395                    }
4396                  }
4397                }
4398              }
4399            }
4400          },
4401          "responses": {
4402            "200": {
4403              "description": "Always HTTP 200 — check `statusCode` in the body",
4404              "content": {
4405                "application/json": {
4406                  "schema": {
4407                    "oneOf": [
4408                      {
4409                        "description": "Group renamed successfully",
4410                        "type": "object",
4411                        "properties": {
4412                          "statusCode": {
4413                            "type": "integer",
4414                            "example": 200
4415                          },
4416                          "statusMessage": {
4417                            "type": "string",
4418                            "example": "Group updated."
4419                          }
4420                        }
4421                      },
4422                      {
4423                        "description": "Title is missing or empty",
4424                        "type": "object",
4425                        "properties": {
4426                          "statusCode": {
4427                            "type": "integer",
4428                            "example": 400
4429                          },
4430                          "reason": {
4431                            "type": "string",
4432                            "example": "MISSING_FIELD"
4433                          },
4434                          "statusMessage": {
4435                            "type": "string",
4436                            "example": "The 'title' field is required."
4437                          }
4438                        }
4439                      },
4440                      {
4441                        "description": "List or group not found",
4442                        "type": "object",
4443                        "properties": {
4444                          "statusCode": {
4445                            "type": "integer",
4446                            "example": 404
4447                          },
4448                          "reason": {
4449                            "type": "string",
4450                            "enum": [
4451                              "NOT_FOUND",
4452                              "GROUP_NOT_FOUND"
4453                            ]
4454                          },
4455                          "statusMessage": {
4456                            "type": "string"
4457                          }
4458                        }
4459                      }
4460                    ]
4461                  }
4462                }
4463              }
4464            }
4465          }
4466        },
4467        "delete": {
4468          "tags": [
4469            "Todos"
4470          ],
4471          "summary": "Delete a group (items in the group become ungrouped)",
4472          "security": [
4473            {
4474              "bearerAuth": []
4475            }
4476          ],
4477          "parameters": [
4478            {
4479              "in": "path",
4480              "name": "listId",
4481              "required": true,
4482              "schema": {
4483                "type": "string"
4484              }
4485            },
4486            {
4487              "in": "path",
4488              "name": "groupId",
4489              "required": true,
4490              "schema": {
4491                "type": "string"
4492              }
4493            }
4494          ],
4495          "responses": {
4496            "200": {
4497              "description": "Always HTTP 200 — check `statusCode` in the body",
4498              "content": {
4499                "application/json": {
4500                  "schema": {
4501                    "oneOf": [
4502                      {
4503                        "description": "Group deleted. Any items that belonged to this group are now ungrouped (groupId set to null).",
4504                        "type": "object",
4505                        "properties": {
4506                          "statusCode": {
4507                            "type": "integer",
4508                            "example": 200
4509                          },
4510                          "statusMessage": {
4511                            "type": "string",
4512                            "example": "Gr
4512oup deleted."
4513                          }
4514                        }
4515                      },
4516                      {
4517                        "description": "List or group not found",
4518                        "type": "object",
4519                        "properties": {
4520                          "statusCode": {
4521                            "type": "integer",
4522                            "example": 404
4523                          },
4524                          "reason": {
4525                            "type": "string",
4526                            "enum": [
4527                              "NOT_FOUND",
4528                              "GROUP_NOT_FOUND"
4529                            ]
4530                          },
4531                          "statusMessage": {
4532                            "type": "string"
4533                          }
4534                        }
4535                      }
4536                    ]
4537                  }
4538                }
4539              }
4540            }
4541          }
4542        }
4543      },
4544      "/api/reports": {
4545        "post": {
4546          "tags": [
4547            "Reports"
4548          ],
4549          "summary": "File a report against another user",
4550          "security": [
4551            {
4552              "bearerAuth": []
4553            }
4554          ],
4555          "requestBody": {
4556            "required": true,
4557            "content": {
4558              "application/json": {
4559                "schema": {
4560                  "type": "object",
4561                  "required": [
4562                    "reportedUserId",
4563                    "reportType"
4564                  ],
4565                  "properties": {
4566                    "reportedUserId": {
4567                      "type": "string"
4568                    },
4569                    "reportType": {
4570                      "type": "string",
4571                      "enum": [
4572                        "REPORT::TYPE::SPAM",
4573                        "REPORT::TYPE::HARASSMENT",
4574                        "REPORT::TYPE::INAPPROPRIATE_CONTENT",
4575                        "REPORT::TYPE::OTHER"
4576                      ]
4577                    },
4578                    "description": {
4579                      "type": "string"
4580                    }
4581                  }
4582                }
4583              }
4584            }
4585          },
4586          "responses": {
4587            "200": {
4588              "description": "Always HTTP 200 — check `statusCode` in the body",
4589              "content": {
4590                "application/json": {
4591                  "schema": {
4592                    "oneOf": [
4593                      {
4594                        "description": "Report filed",
4595                        "type": "object",
4596                        "properties": {
4597                          "statusCode": {
4598                            "type": "integer",
4599                            "example": 200
4600                          },
4601                          "data": {
4602                            "type": "string"
4603                          }
4604                        }
4605                      },
4606                      {
4607                        "description": "Missing required fields or invalid reportType",
4608                        "type": "object",
4609                        "properties": {
4610                          "statusCode": {
4611                            "type": "integer",
4612                            "example": 400
4613                          },
4614                          "statusMessage": {
4615                            "type": "string"
4616                          }
4617                        }
4618                      }
4619                    ]
4620                  }
4621                }
4622              }
4623            }
4624          }
4625        },
4626        "get": {
4627          "tags": [
4628            "Reports"
4629          ],
4630          "summary": "List all reports — admin only",
4631          "security": [
4632            {
4633              "bearerAuth": []
4634            }
4635          ],
4636          "parameters": [
4637            {
4638              "name": "status",
4639              "in": "query",
4640              "schema": {
4641                "type": "string",
4642                "enum": [
4643                  "REPORT::STATUS::PENDING",
4644                  "REPORT::STATUS::REVIEWED",
4645                  "REPORT::STATUS::DISMISSED",
4646                  "REPORT::STATUS::ACTIONED"
4647                ]
4648              },
4649              "description": "Filter by report status (omit to return all)"
4650            }
4651          ],
4652          "responses": {
4653            "200": {
4654              "description": "Always HTTP 200 — check `statusCode` in the body",
4655              "content": {
4656                "application/json": {
4657                  "schema": {
4658                    "oneOf": [
4659                      {
4660                        "description": "Array of reports (admin)",
4661                        "type": "object",
4662                        "properties": {
4663                          "statusCode": {
4664                            "type": "integer",
4665                            "example": 200
4666                          },
4667                          "data": {
4668                            "type": "array",
4669                            "items": {
4670                              "type": "object",
4671                              "properties": {
4672                                "id": {
4673                                  "type": "string"
4674                                },
4675                                "reporterId": {
4676                                  "type": "string"
4677                                },
4678                                "reportedId": {
4679                                  "type": "string"
4680                                },
4681                                "reportType": {
4682                                  "type": "string"
4683                                },
4684                                "description": {
4685                                  "type": "string",
4686                                  "nullable": true
4687                                },
4688                                "status": {
4689                                  "type": "string"
4690                                },
4691                                "reviewedBy": {
4692                                  "type": "string",
4693                                  "nullable": true
4694                                },
4695                                "reviewedAt": {
4696                                  "type": "string",
4697                                  "format": "date-time",
4698                                  "nullable": true
4699                                },
4700                                "createdAt": {
4701                                  "type": "string",
4702                                  "format": "date-time"
4703                                }
4704                              }
4705                            }
4706                          }
4707                        }
4708                      },
4709                      {
4710                        "description": "Caller is not an admin",
4711                        "type": "object",
4712                        "properties": {
4713                          "statusCode": {
4714                            "type": "integer",
4715                            "example": 403
4716                          },
4717                          "statusMessage": {
4718                            "type": "string",
4719                            "example": "Admin access required."
4720                          }
4721                        }
4722                      }
4723                    ]
4724                  }
4725                }
4726              }
4727            }
4728          }
4729        }
4730      },
4731      "/api/reports/{reportId}": {
4732        "patch": {
4733          "tags": [
4734            "Reports"
4735          ],
4736          "summary": "Update report status — admin only",
4737          "security": [
4738            {
4739              "bearerAuth": []
4740            }
4741          ],
4742          "parameters": [
4743            {
4744              "name": "reportId",
4745              "in": "path",
4746              "required": true,
4747              "schema": {
4748                "type": "string"
4749              }
4750            }
4751          ],
4752          "requestBody": {
4753            "required": true,
4754            "content": {
4755              "application/json": {
4756                "schema": {
4757                  "type": "object",
4758                  "required": [
4759                    "status"
4760                  ],
4761                  "properties": {
4762                    "status": {
4763                      "type": "string",
4764                      "enum": [
4765                        "REPORT::STATUS::REVIEWED",
4766                        "REPORT::STATUS::DISMISSED",
4767                        "REPORT::STATUS::ACTIONED"
4768                      ]
4769                    }
4770                  }
4771                }
4772              }
4773            }
4774          },
4775          "responses": {
4776            "200": {
4777              "description": "Always HTTP 200 — check `statusCode` in the body",
4778              "content": {
4779                "application/json": {
4780                  "schema": {
4781                    "oneOf": [
4782                      {
4783                        "description": "Report status updated",
4784                        "type": "object",
4785                        "properties": {
4786                          "statusCode": {
4787                            "type": "integer",
4788                            "example": 200
4789                          },
4790                          "data": {
4791                            "type": "string"
4792                          }
4793                        }
4794                      },
4795                      {
4796                        "description": "Missing status field or invalid value",
4797                        "type": "object",
4798                        "properties": {
4799                          "statusCode": {
4800                            "type": "integer",
4801                            "example": 400
4802                          },
4803                          "statusMessage": {
4804                            "type": "string"
4805                          }
4806                        }
4807                      },
4808                      {
4809                        "description": "Caller is not an admin",
4810                        "type": "object",
4811                        "properties": {
4812                          "statusCode": {
4813                            "type": "integer",
4814                            "example": 403
4815                          },
4816                          "statusMessage": {
4817                            "type": "string",
4818                            "example": "Admin access required."
4819                          }
4820                        }
4821                      }
4822                    ]
4823                  }
4824                }
4825              }
4826            }
4827          }
4828        }
4829      },
4830      "/api/notifications": {
4831        "get": {
4832          "tags": [
4833            "Notifications"
4834          ],
4835          "summary": "Get notifications for the current user",
4836          "security": [
4837            {
4838              "bearerAuth": []
4839            }
4840          ],
4841          "parameters": [
4842            {
4843              "name": "unread",
4844              "in": "query",
4845              "schema": {
4846                "type": "boolean"
4847              },
4848              "description": "Set to true to return only unread notifications"
4849            }
4850          ],
4851          "responses": {
4852            "200": {
4853              "description": "Array of notifications (empty array if none)",
4854              "content": {
4855                "application/json": {
4856                  "schema": {
4857                    "type": "object",
4858                    "properties": {
4859                      "statusCode": {
4860                        "type": "integer",
4861                        "example": 200
4862                      },
4863                      "data": {
4864                        "type": "array",
4865                        "items": {
4866                          "type": "object",
4867                          "properties": {
4868                            "id": {
4869                              "type": "string"
4870                            },
4871                            "notificationType": {
4872                              "type": "string",
4873                              "description": "Every type the server can write. Treat this list as\nexhaustive for `switch` handling, but keep a default\nbranch — new types are additive and may appear\nbefore a client ships support for them.\n",
4874                              "enum": [
4875                                "NOTIFICATION::INVITE_RECEIVED",
4876                                "NOTIFICATION::INVITE_ACCEPTED",
4877                                "NOTIFICATION::INVITE_DECLINED",
4878                                "NOTIFICATION::LIST_DELETED",
4879                                "NOTIFICATION::PARTNER_REMOVED",
4880                                "NOTIFICATION::ALARM_FIRED",
4881                                "NOTIFICATION::TASK_ASSIGNED",
4882                                "NOTIFICATION::PARTNER_AT_PLACE",
4883                                "NOTIFICATION::CHALLENGE_RECEIVED",
4884                                "NOTIFICATION::CHALLENGE_ACCEPTED",
4885                                "NOTIFICATION::CHALLENGE_DECLINED",
4886                                "NOTIFICATION::CHALLENGE_ENDED",
4887                                "NOTIFICATION::SYSTEM"
4888                              ]
4889                            },
4890                            "title": {
4891                              "type": "string"
4892                            },
4893                            "body": {
4894                              "type": "string"
4895                            },
4896                            "data": {
4897                              "type": "string",
4898                              "nullable": true,
4899                              "description": "JSON **string** — parse it before use. The keys\ndepend on `notificationType`:\n\n| Type | `data` keys |\n|---|---|\n| `INVITE_RECEIVED` | `listId` |\n| `INVITE_ACCEPTED` | `listId` |\n| `INVITE_DECLINED` | `listId` |\n| `LIST_DELETED` | `listId` |\n| `PARTNER_REMOVED` | `listId` |\n| `ALARM_FIRED` | `alarmId`, `listId`, `todoId`, `createdBy` |\n| `TASK_ASSIGNED` | `listId`, `todoId` |\n| `PARTNER_AT_PLACE` | `listId`, `event` |\n| `CHALLENGE_RECEIVED` | `listId`, `challengeId` |\n| `CHALLENGE_ACCEPTED` | `listId`, `challengeId` |\n| `CHALLENGE_DECLINED` | `listId`, `challengeId` |\n| `CHALLENGE_ENDED` | `listId`, `challengeId`, `endReason` |\n| `SYSTEM` | varies — reserved, no producer today |\n\n`endReason` on `CHALLENGE_ENDED` is one of\n`CHALLENGE::END::EARLY_FINISH`,\n`CHALLENGE::END::DEADLINE` or\n`CHALLENGE::END::CANCELLED`.\n\n`listId` and `todoId` on `ALARM_FIRED` are null for\na standalone alarm not attached to a list or task.\n",
4900                              "example": "{\"listId\":\"5f1c…\",\"todoId\":\"9a2e…\"}"
4901                            },
4902                            "read": {
4903                              "type": "boolean"
4904                            },
4905                            "readAt": {
4906                              "type": "string",
4907                              "format": "date-time",
4908                              "nullable": true
4909                            },
4910                            "createdAt": {
4911                              "type": "string",
4912                              "format": "date-time"
4913                            }
4914                          }
4915                        }
4916                      }
4917                    }
4918                  }
4919                }
4920              }
4921            }
4922          }
4923        }
4924      },
4925      "/api/notifications/{notificationId}/read": {
4926        "patch": {
4927          "tags": [
4928            "Notifications"
4929          ],
4930          "summary": "Mark a single notification as read",
4931          "security": [
4932            {
4933              "bearerAuth": []
4934            }
4935          ],
4936          "parameters": [
4937            {
4938              "name": "notificationId",
4939              "in": "path",
4940              "required": true,
4941              "schema": {
4942                "type": "string"
4943              }
4944            }
4945          ],
4946          "responses": {
4947            "200": {
4948              "description": "Always HTTP 200 — check `statusCode` in the body",
4949              "content": {
4950                "application/json": {
4951                  "schema": {
4952                    "oneOf": [
4953                      {
4954                        "description": "Marked as read",
4955                        "type": "object",
4956                        "properties": {
4957                          "statusCode": {
4958                            "type": "integer",
4959            
4959                "example": 200
4960                          },
4961                          "data": {
4962                            "type": "string",
4963                            "example": "Marked as read."
4964                          }
4965                        }
4966                      },
4967                      {
4968                        "description": "Notification not found or belongs to another user",
4969                        "type": "object",
4970                        "properties": {
4971                          "statusCode": {
4972                            "type": "integer",
4973                            "example": 404
4974                          },
4975                          "statusMessage": {
4976                            "type": "string",
4977                            "example": "Notification not found."
4978                          }
4979                        }
4980                      }
4981                    ]
4982                  }
4983                }
4984              }
4985            }
4986          }
4987        }
4988      },
4989      "/api/notifications/read-all": {
4990        "post": {
4991          "tags": [
4992            "Notifications"
4993          ],
4994          "summary": "Mark all notifications as read",
4995          "security": [
4996            {
4997              "bearerAuth": []
4998            }
4999          ],
5000          "responses": {
5001            "200": {
5002              "description": "Count of notifications that were marked as read",
5003              "content": {
5004                "application/json": {
5005                  "schema": {
5006                    "type": "object",
5007                    "properties": {
5008                      "statusCode": {
5009                        "type": "integer",
5010                        "example": 200
5011                      },
5012                      "data": {
5013                        "type": "object",
5014                        "properties": {
5015                          "count": {
5016                            "type": "integer",
5017                            "description": "Number of notifications marked as read"
5018                          }
5019                        }
5020                      }
5021                    }
5022                  }
5023                }
5024              }
5025            }
5026          }
5027        }
5028      },
5029      "/api/v1/devices": {
5030        "post": {
5031          "tags": [
5032            "Devices"
5033          ],
5034          "summary": "Register this device to receive push notifications",
5035          "description": "Call after the user grants notification permission, and again whenever\nthe operating system issues a new token — they rotate, and a stale one\nsilently stops delivering.\n\nRegistering a token that already exists moves it to the calling user and\nclears any revocation, so two people sharing a device works.\n\nApple platforms (`PLATFORM::IOS`, `PLATFORM::MACOS`) send their APNs\ndevice token; `PLATFORM::ANDROID` sends its FCM registration token.\n\n**Only those three platforms receive push**, and only those three are\naccepted. `PLATFORM::WEB`, `PLATFORM::WINDOWS` and `PLATFORM::LINUX`\nare rejected with `VALIDATION_ERROR` — they have no push transport, and\nstoring a token for one produced a registration that looked healthy\nwhile dispatch silently skipped it forever. Those clients receive events\nover the Socket.IO connection while the app is open (see\n`/docs/sockets.md`); a closed browser tab receives nothing at all.\n",
5036          "security": [
5037            {
5038              "bearerAuth": []
5039            }
5040          ],
5041          "requestBody": {
5042            "required": true,
5043            "content": {
5044              "application/json": {
5045                "schema": {
5046                  "type": "object",
5047                  "required": [
5048                    "platform",
5049                    "token"
5050                  ],
5051                  "properties": {
5052                    "platform": {
5053                      "type": "string",
5054                      "description": "Which push service this token belongs to.",
5055                      "enum": [
5056                        "PLATFORM::IOS",
5057                        "PLATFORM::MACOS",
5058                        "PLATFORM::ANDROID"
5059                      ],
5060                      "example": "PLATFORM::IOS"
5061                    },
5062                    "token": {
5063                      "type": "string",
5064                      "description": "The APNs device token or FCM registration token.",
5065                      "example": "8f3a1c2e9b7d4a6f0c5e2b8d1a4f7c3e9b6d0a2f5c8e1b4d
50657a0f3c6e9b2d5a8f"
5066                    },
5067                    "bundleId": {
5068                      "type": "string",
5069                      "description": "The app's bundle identifier, sent verbatim as the\n`apns-topic` header on every push to this device.\n\n**Apple clients should always send this.** The server falls\nback to the `apns_default_topic` setting when it is omitted,\nand that setting ships empty — if it has not been filled in\nfor the environment, an omitted `bundleId` means an empty\n`apns-topic` and Apple rejects every notification. The\nregistration still succeeds, so the failure is invisible\nfrom the client side.\n\nSend the exact `PRODUCT_BUNDLE_IDENTIFIER` of the build that\nobtained the token. The iOS and macOS builds have different\nidentifiers, which is why they cannot share a registration —\nsending the wrong one fails the same silent way.\n\nIgnored for `PLATFORM::ANDROID`.\n",
5070                      "example": "com.example.myapp"
5071                    }
5072                  }
5073                }
5074              }
5075            }
5076          },
5077          "responses": {
5078            "200": {
5079              "description": "Application-level result. HTTP is always 200; read statusCode.",
5080              "content": {
5081                "application/json": {
5082                  "schema": {
5083                    "oneOf": [
5084                      {
5085                        "description": "Device registered",
5086                        "type": "object",
5087                        "properties": {
5088                          "statusCode": {
5089                            "type": "integer",
5090                            "example": 200
5091                          },
5092                          "data": {
5093                            "type": "object",
5094                            "properties": {
5095                              "id": {
5096                                "type": "string",
5097                                "description": "Pass this to DELETE to stop delivery to this device."
5098                              }
5099                            }
5100                          }
5101                        }
5102                      },
5103                      {
5104                        "description": "Rejected. Three distinct causes share these two reasons:\n\n- `MISSING_FIELD` — `platform` or `token` was absent.\n- `VALIDATION_ERROR` — `platform` is not a known platform\n  at all, **or** it is a real platform with no push\n  transport (`PLATFORM::WEB`, `PLATFORM::WINDOWS`,\n  `PLATFORM::LINUX`, `PLATFORM::DASHBOARD`,\n  `PLATFORM::UNKNOWN`). `statusMessage` distinguishes the\n  two and is safe to show the user.\n",
5105                        "type": "object",
5106                        "properties": {
5107                          "statusCode": {
5108                            "type": "integer",
5109                            "example": 400
5110                          },
5111                          "reason": {
5112                            "type": "string",
5113                            "enum": [
5114                              "MISSING_FIELD",
5115                              "VALIDATION_ERROR"
5116                            ]
5117                          },
5118                          "statusMessage": {
5119                            "type": "string",
5120                            "example": "Platform \"PLATFORM::WEB\" cannot receive push notifications. Only PLATFORM::IOS, PLATFORM::MACOS and PLATFORM::ANDROID have a push transport; web and desktop clients receive events over the Socket.IO connection while the app is open."
5121                          }
5122                        }
5123                      }
5124                    ]
5125                  }
5126                }
5127              }
5128            }
5129          }
5130        }
5131      },
5132      "/api/v1/devices/{tokenId}": {
5133        "delete": {
5134          "tags": [
5135            "Devices"
5136          ],
5137          "summary": "Stop sending push notifications to a device",
5138          "description": "Call on sign-out, so a shared or handed-on device stops receiving the\nprevious user's notifications.\n\nThe row is marked revoked rather than deleted, which keeps the history\navailable for support without ever sending to it again.\n",
5139          "security": [
5140            {
5141              "bearerAuth": []
5142            }
5143          ],
5144          "parameters": [
5145            {
5146              "name": "tokenId",
5147              "in": "path",
5148              "required": true,
5149              "description": "The id returned when the device was registered.",
5150              "schema": {
5151                "type": "string"
5152              }
5153            }
5154          ],
5155          "responses": {
5156            "200": {
5157              "description": "Application-level result. HTTP is always 200; read statusCode.",
5158              "content": {
5159                "application/json": {
5160                  "schema": {
5161                    "oneOf": [
5162                      {
5163                        "description": "Device revoked",
5164                        "type": "object",
5165                        "properties": {
5166                          "statusCode": {
5167                            "type": "integer",
5168                            "example": 200
5169                          },
5170                          "data": {
5171                            "type": "boolean",
5172                            "example": true
5173                          }
5174                        }
5175                      },
5176                      {
5177                        "description": "No such device registered to you",
5178                        "type": "object",
5179                        "properties": {
5180                          "statusCode": {
5181                            "type": "integer",
5182                            "example": 404
5183                          },
5184                          "reason": {
5185                            "type": "string",
5186                            "enum": [
5187                              "NOT_FOUND"
5188                            ]
5189                          },
5190                          "statusMessage": {
5191                            "type": "string",
5192                            "example": "That device is not registered to your account."
5193                          }
5194                        }
5195                      }
5196                    ]
5197                  }
5198                }
5199              }
5200            }
5201          }
5202        }
5203      },
5204      "/api/v1/client-config": {
5205        "get": {
5206          "tags": [
5207            "Config"
5208          ],
5209          "summary": "Live runtime configuration for the client app",
5210          "description": "Public endpoint — no authentication required.\nReturns a curated set of runtime values the client needs on startup.\n\nCall this once after launch (and again after a token refresh or app\nforeground) to 
5210stay in sync with admin-configured limits.\n\n**Clients should use these values to:**\n- Gate the ping button (`ping.cooldownSeconds`)\n- Show / hide social sign-in buttons (`social.*`)\n- Drive the OAuth flows themselves (`socialAuth.*`) — client IDs,\n  redirect URIs and endpoints for Google, Facebook and Apple. These are\n  public values only; no secret is ever served here.\n- Give the user informative feedback before they hit rate limits (`rateLimits.*`)\n",
5211          "responses": {
5212            "200": {
5213              "description": "Always HTTP 200",
5214              "content": {
5215                "application/json": {
5216                  "schema": {
5217                    "type": "object",
5218                    "properties": {
5219                      "statusCode": {
5220                        "type": "integer",
5221                        "example": 200
5222                      },
5223                      "data": {
5224                        "type": "object",
5225                        "properties": {
5226                          "rateLimits": {
5227                            "type": "object",
5228                            "properties": {
5229                              "auth": {
5230                                "type": "object",
5231                                "properties": {
5232                                  "max": {
5233                                    "type": "integer",
5234                                    "example": 10
5235                                  },
5236                                  "windowMinutes": {
5237                                    "type": "integer",
5238                                    "example": 15
5239                                  }
5240                                }
5241                              },
5242                              "api": {
5243                                "type": "object",
5244                                "properties": {
5245                                  "max": {
5246                                    "type": "integer",
5247                                    "example": 300
5248                                  },
5249                                  "windowMinutes": {
5250                                    "type": "integer",
5251                                    "example": 1
5252                                  }
5253                                }
5254                              }
5255                            }
5256                          },
5257                          "social": {
5258                            "type": "object",
5259                            "properties": {
5260                              "google": {
5261                                "type": "boolean"
5262                              },
5263                              "facebook": {
5264                                "type": "boolean"
5265                              },
5266                              "apple": {
5267                                "type": "boolean"
5268                              }
5269                            }
5270                          }
5271                        }
5272                      }
5273                    }
5274                  }
5275                }
5276              }
5277            }
5278          }
5279        }
5280      },
5281      "/api/block/{userId}": {
5282        "post": {
5283          "tags": [
5284            "Block"
5285          ],
5286          "summary": "Block a user — prevents them from inviting you or appearing in your partner list",
5287          "security": [
5288            {
5289              "bearerAuth": []
5290            }
5291          ],
5292          "parameters": [
5293            {
5294              "name": "userId",
5295              "in": "path",
5296              "required": true,
5297              "schema": {
5298                "type": "string"
5299              }
5300            }
5301          ],
5302          "responses": {
5303            "200": {
5304              "description": "Always HTTP 200 — check `statusCode` in the body",
5305              "content": {
5306                "application/json": {
5307                  "schema": {
5308                    "oneOf": [
5309                      {
5310                        "description": "User blocked",
5311                        "type": "object",
5312                        "properties": {
5313                          "statusCode": {
5314                            "type": "integer",
5315                            "example": 200
5316                          },
5317                          "data": {
5318                            "type": "string"
5319                          }
5320                        }
5321                      },
5322                      {
5323                        "description": "Cannot block (e.g. blocking yourself, already blocked)",
5324                        "type": "object",
5325                        "properties": {
5326                          "statusCode": {
5327                            "type": "integer",
5328                            "example": 400
5329                          },
5330                          "statusMessage": {
5331                            "type": "string"
5332                          }
5333                        }
5334                      }
5335                    ]
5336                  }
5337                }
5338              }
5339            }
5340          }
5341        },
5342        "delete": {
5343          "tags": [
5344            "Block"
5345          ],
5346          "summary": "Unblock a previously blocked user",
5347          "security": [
5348            {
5349              "bearerAuth": []
5350            }
5351          ],
5352          "parameters": [
5353            {
5354              "name": "userId",
5355              "in": "path",
5356              "required": true,
5357              "schema": {
5358                "type": "string"
5359              }
5360            }
5361          ],
5362          "responses": {
5363            "200": {
5364              "description": "Always HTTP 200 — check `statusCode` in the body",
5365              "content": {
5366                "application/json": {
5367                  "schema": {
5368                    "oneOf": [
5369                      {
5370                        "description": "User unblocked",
5371                        "type": "object",
5372                        "properties": {
5373                          "statusCode": {
5374                            "type": "integer",
5375                            "example": 200
5376                          },
5377                          "data": {
5378                            "type": "string"
5379                          }
5380                        }
5381                      },
5382                      {
5383                        "description": "User was not blocked by the caller",
5384                        "type": "object",
5385                        "properties": {
5386                          "statusCode": {
5387                            "type": "integer",
5388                            "example": 400
5389                          },
5390                          "statusMessage": {
5391                            "type": "string"
5392                          }
5393                        }
5394                      }
5395                    ]
5396                  }
5397                }
5398              }
5399            }
5400          }
5401        }
5402      },
5403      "/api/block": {
5404        "get": {
5405          "tags": [
5406            "Block"
5407          ],
5408          "summary": "Get the list of userIds the current user has blocked",
5409          "security": [
5410            {
5411              "bearerAuth": []
5412            }
5413          ],
5414          "responses": {
5415            "200": {
5416              "description": "Array of blocked userIds (empty array if none)",
5417              "content": {
5418                "application/json": {
5419                  "schema": {
5420                    "type": "object",
5421                    "properties": {
5422                      "statusCode": {
5423                        "type": "integer",
5424                        "example": 200
5425                      },
5426                      "data": {
5427                        "type": "array",
5428                        "items": {
5429                          "type": "string"
5430                        },
5431                        "description": "Array of blocked user IDs"
5432                      }
5433                    }
5434                  }
5435                }
5436              }
5437            }
5438          }
5439        }
5440      },
5441      "/api/alarms": {
5442        "get": {
5443          "tags": [
5444            "Alarms"
5445          ],
5446          "summary": "Get all alarms for the current user",
5447          "security": [
5448            {
5449              "bearerAuth": []
5450            }
5451          ],
5452          "responses": {
5453            "200": {
5454              "description": "Array of alarms ordered by alarmAt (empty array if none)",
5455              "content": {
5456                "application/json": {
5457                  "schema": {
5458                    "type": "object",
5459                    "properties": {
5460                      "statusCode": {
5461                        "type": "integer",
5462                        "example": 200
5463                      },
5464                      "data": {
5465                        "type": "array",
5466                        "items": {
5467                          "type": "object",
5468                          "properties": {
5469                            "id": {
5470                              "type": "string"
5471                            },
5472                            "userId": {
5473                              "type": "string"
5474                            },
5475                            "listId": {
5476                              "type": "string",
5477                              "nullable": true
5478                            },
5479                            "todoId": {
5480                              "type": "string",
5481                              "nullable": true
5482                            },
5483                            "label": {
5484                              "type": "string"
5485                            },
5486                            "alarmAt": {
5487                              "type": "string",
5488                              "format": "date-time"
5489                            },
5490                            "repeat": {
5491                              "type": "string",
5492                              "enum": [
5493                                "ALARM::REPEAT::NONE",
5494                                "ALARM::REPEAT::DAILY",
5495                                "ALARM::REPEAT::WEEKLY",
5496                                "ALARM::REPEAT::MONTHLY"
5497                              ]
5498                            },
5499                            "snoozedUntil": {
5500                              "type": "string",
5501                              "format": "date-time",
5502                              "nullable": true
5503                            },
5504                            "fired": {
5505                              "type": "boolean"
5506                            },
5507                            "firedAt": {
5508                              "type": "string",
5509                              "format": "date-time",
5510                              "nullable": true
5511                            }
5512                          }
5513                        }
5514                      }
5515                    }
5516                  }
5517                }
5518              }
5519            }
5520          }
5521        },
5522        "post": {
5523          "tags": [
5524            "Alarms"
5525          ],
5526          "summary": "Create a new alarm",
5527          "security": [
5528            {
5529              "bearerAuth": []
5530            }
5531          ],
5532          "requestBody": {
5533            "required": true,
5534            "content": {
5535              "application/json": {
5536                "schema": {
5537                  "type": "object",
5538                  "required": [
5539                    "label",
5540                    "alarmAt"
5541                  ],
5542                  "properties": {
5543                    "label": {
5544                      "type": "string"
5545                    },
5546                    "alarmAt": {
5547                      "type": "string",
5548                      "format": "date-time"
5549                    },
5550                    "repeat": {
5551                      "type": "string",
5552                      "enum": [
5553                        "ALARM::REPEAT::NONE",
5554                        "ALARM::REPEAT::DAILY",
5555                        "ALARM::REPEAT::WEEKLY",
5556                        "ALARM::REPEAT::MONTHLY"
5557                      ]
5558                    },
5559                    "listId": {
5560                      "type": "string",
5561                      "description": "Required when target is PARTNER or BOTH — used to identify the collaborator."
5562                    },
5563                    "todoId": {
5564                      "type": "string"
5565                    },
5566                    "target": {
5567                      "type": "string",
5568                      "enum": [
5569                        "ALARM::TARGET::SELF",
5570                        "ALARM::TARGET::PARTNER",
5571                        "ALARM::TARGET::BOTH"
5572                      ],
5573                      "description": "Who the alarm fires for. Defaults to SELF.\nPARTNER and BOTH require listId — the list must have an accepted partner.\nPARTNER-only creates the alarm on the partner's device only (not yours).\nBOTH creates one alarm for each party.\n"
5574                    }
5575                  }
5576                }
5577              }
5578            }
5579          },
5580          "responses": {
5581            "200": {
5582              "description": "Always HTTP 200 — check `statusCode` in the body",
5583              "content": {
5584                "application/json": {
5585                  "schema": {
5586                    "oneOf": [
5587                      {
5588                        "description": "Alarm created",
5589                        "type": "object",
5590                        "properties": {
5591                          "statusCode": {
5592                            "type": "integer",
5593                            "example": 200
5594                          },
5595                          "data": {
5596                            "type": "object",
5597                            "description": "The created Alarm record",
5598                            "properties": {
5599                              "id": {
5600                                "type": "string"
5601                              },
5602                              "label": {
5603                                "type": "string"
5604                              },
5605                              "alarmAt": {
5606                                "type": "string",
5607                                "format": "date-time"
5608                              },
5609                              "repeat": {
5610                                "type": "string"
5611                              },
5612                              "snoozedUntil": {
5613                                "type": "string",
5614                                "format": "date-time",
5615                                "nullable": true
5616                              },
5617                              "fired": {
5618                                "type": "boolean"
5619                              },
5620                              "listId": {
5621                                "type": "string",
5622                                "nullable": true
5623                              },
5624                              "todoId": {
5625                                "type": "string",
5626                                "nullable": true
5627                              }
5628                            }
5629                          }
5630                        }
5631                      },
5632                      {
5633                        "description": "Missing required fields or invalid date",
5634                        "type": "object",
5635                        "properties": {
5636                          "statusCode": {
5637                            "type": "integer",
5638                            "example": 400
5639                          },
5640                          "statusMessage": {
5641                            "type": "string"
5642                          }
5643                        }
5644                      }
5645                    ]
5646                  }
5647                }
5648              }
5649            }
5650          }
5651        }
5652      },
5653      "/api/alarms/{alarmId}": {
5654        "put": {
5655          "tags": [
5656            "Alarms"
5657          ],
5658          "summary": "Update an alarm",
5659          "description": "Partial update — only the fields present in the body are changed, and omitted fields are left untouched. The alarm keeps its id, so this is what a client should use to reschedule rather than deleting and recreating.\n\n
5659Moving `alarmAt` to a time in the future clears `fired`, `firedAt` and any active snooze, so a rescheduled alarm rings again. Moving it to a time in the past leaves those flags as they are — the alarm is not resurrected retroactively.\n\nOnly the owner of the alarm may edit it. A shared alarm (`ALARM::TARGET::PARTNER` or `ALARM::TARGET::BOTH`) is stored as one row per person, so editing yours never changes your partner's copy. Auto-created deadline alarms may be edited like any other.\n",
5660          "security": [
5661            {
5662              "bearerAuth": []
5663            }
5664          ],
5665          "parameters": [
5666            {
5667              "name": "alarmId",
5668              "in": "path",
5669              "required": true,
5670              "schema": {
5671                "type": "string"
5672              },
5673              "description": "The alarm ID"
5674            }
5675          ],
5676          "requestBody": {
5677            "required": true,
5678            "content": {
5679              "application/json": {
5680                "schema": {
5681                  "type": "object",
5682                  "description": "Send at least one field. An empty body is rejected.",
5683                  "properties": {
5684                    "label": {
5685                      "type": "string",
5686                      "maxLength": 250,
5687                      "example": "Take out the trash",
5688                      "description": "What the user reads when the alarm fires. 1-250 characters."
5689                    },
5690                    "alarmAt": {
5691                      "type": "string",
5692                      "format": "date-time",
5693                      "example": "2027-12-31T08:00:00Z",
5694                      "description": "New time as an ISO 8601 date-time string. A future time re-arms the alarm."
5695                    },
5696                    "repeat": {
5697                      "type": "string",
5698                      "enum": [
5699                        "ALARM::REPEAT::NONE",
5700                        "ALARM::REPEAT::DAILY",
5701                        "ALARM::REPEAT::WEEKLY",
5702                        "ALARM::REPEAT::MONTHLY"
5703                      ],
5704                      "example": "ALARM::REPEAT::DAILY",
5705                      "description": "Repeat cadence."
5706                    },
5707                    "target": {
5708                      "type": "string",
5709                      "enum": [
5710                        "ALARM::TARGET::SELF",
5711                        "ALARM::TARGET::PARTNER",
5712                        "ALARM::TARGET::BOTH"
5713                      ],
5714                      "example": "ALARM::TARGET::SELF",
5715                      "description": "Who this alarm is for. Changing it affects only this row; it does not create or remove the partner's copy.\n"
5716                    }
5717                  }
5718                }
5719              }
5720            }
5721          },
5722          "responses": {
5723            "200": {
5724              "description": "Always HTTP 200 — check `statusCode` in the body",
5725              "content": {
5726                "application/json": {
5727                  "schema": {
5728                    "oneOf": [
5729                      {
5730                        "description": "Alarm updated. The full updated alarm is returned.",
5731                        "type": "object",
5732                        "properties": {
5733                          "statusCode": {
5734                            "type": "integer",
5735                            "example": 200
5736                          },
5737                          "data": {
5738                            "type": "object",
5739                            "description": "The updated alarm row."
5740                          }
5741                        }
5742                      },
5743                      {
5744                        "description": "No editable field was sent, the label was empty or longer than 250 characters, alarmAt was not a valid ISO 8601 date-time, or repeat or target was not one of the listed enum values.\n",
5745                        "type": "object",
5746                        "properties": {
5747                          "statusCode": {
5748                            "type": "integer",
5749                            "example": 400
5750                          },
5751                          "reason": {
5752                            "type": "string",
5753                            "enum": [
5754                              "MISSING_FIELD",
5755                              "VALIDATION_ERROR",
5756                              "INVALID_DATE"
5757                            ],
5758                            "example": "VALIDATION_ERROR"
5759                          },
5760                          "statusMessage": {
5761                            "type": "string",
5762                            "example": "The 'label' field must be 250 characters or fewer."
5763                          }
5764                        }
5765                      },
5766                      {
5767                        "description": "No alarm with that id belongs to the signed-in user.",
5768                        "type": "object",
5769                        "properties": {
5770                          "statusCode": {
5771                            "type": "integer",
5772                            "example": 404
5773                          },
5774                          "reason": {
5775                            "type": "string",
5776                            "enum": [
5777                              "NOT_FOUND"
5778                            ],
5779                            "example": "NOT_FOUND"
5780                          },
5781                          "statusMessage": {
5782                            "type": "string",
5783                            "example": "Alarm #<alarmId> was not found."
5784                          }
5785                        }
5786                      }
5787                    ]
5788                  }
5789                }
5790              }
5791            },
5792            "401": {
5793              "description": "Missing or invalid bearer token",
5794              "content": {
5795                "application/json": {
5796                  "schema": {
5797                    "$ref": "#/components/schemas/ErrorResponse"
5798                  }
5799                }
5800              }
5801            }
5802          }
5803        },
5804        "delete": {
5805          "tags": [
5806            "Alarms"
5807          ],
5808          "summary": "Delete an alarm",
5809          "security": [
5810            {
5811              "bearerAuth": []
5812            }
5813          ],
5814          "parameters": [
5815            {
5816              "name": "alarmId",
5817              "in": "path",
5818              "required": true,
5819              "schema": {
5820                "type": "string"
5821              }
5822            }
5823          ],
5824          "responses": {
5825            "200": {
5826              "description": "Always HTTP 200 — check `statusCode` in the body",
5827              "content": {
5828                "application/json": {
5829                  "schema": {
5830                    "oneOf": [
5831                      {
5832                        "description": "Alarm deleted",
5833                        "type": "object",
5834                        "properties": {
5835                          "statusCode": {
5836                            "type": "integer",
5837                            "example": 200
5838                          },
5839                          "data": {
5840                            "type": "string"
5841                          }
5842                        }
5843                      },
5844                      {
5845                        "description": "Alarm not found or belongs to another user",
5846                        "type": "object",
5847                        "properties": {
5848                          "statusCode": {
5849                            "type": "integer",
5850                            "example": 404
5851                          },
5852                          "statusMessage": {
5853                            "type": "string"
5854                          }
5855                        }
5856                      }
5857                    ]
5858                  }
5859                }
5860              }
5861            }
5862          }
5863        }
5864      },
5865      "/api/alarms/{alarmId}/snooze": {
5866        "post": {
5867          "tags": [
5868            "Alarms"
5869          ],
5870          "summary": "Snooze a fired alarm",
5871          "security": [
5872            {
5873              "bearerAuth": []
5874            }
5875          ],
5876          "parameters": [
5877            {
5878              "name": "alarmId",
5879              "in": "path",
5880              "required": true,
5881              "schema": {
5882                "type": "string"
5883              }
5884            }
5885          ],
5886          "requestBody": {
5887            "required": true,
5888            "content": {
5889              "application/json": {
5890                "schema": {
5891                  "type": "object",
5892                  "required": [
5893                    "minutes"
5894                  ],
5895                  "properties": {
5896                    "minutes": {
5897                      "type": "number",
5898                      "description": "Minutes to snooze for"
5899                    }
5900                  }
5901                }
5902              }
5903            }
5904          },
5905          "responses": {
5906            "200": {
5907              "description": "Always HTTP 200 — check `statusCode` in the body",
5908              "content": {
5909                "application/json": {
5910                  "schema": {
5911                    "oneOf": [
5912                      {
5913                        "description": "Alarm snoozed — updated Alarm record returned",
5914                        "type": "object",
5915                        "properties": {
5916                          "statusCode": {
5917                            "type": "integer",
5918                            "example": 200
5919                          },
5920                          "data": {
5921                            "type": "object",
5922                            "description": "Updated Alarm with new snoozedUntil value",
5923                            "properties": {
5924                              "id": {
5925                                "type": "string"
5926                              },
5927                              "snoozedUntil": {
5928                                "type": "string",
5929                                "format": "date-time"
5930                              }
5931                            }
5932                          }
5933                        }
5934                      },
5935                      {
5936                        "description": "Missing or invalid minutes f
5936ield",
5937                        "type": "object",
5938                        "properties": {
5939                          "statusCode": {
5940                            "type": "integer",
5941                            "example": 400
5942                          },
5943                          "statusMessage": {
5944                            "type": "string"
5945                          }
5946                        }
5947                      },
5948                      {
5949                        "description": "Alarm not found or belongs to another user",
5950                        "type": "object",
5951                        "properties": {
5952                          "statusCode": {
5953                            "type": "integer",
5954                            "example": 404
5955                          },
5956                          "statusMessage": {
5957                            "type": "string"
5958                          }
5959                        }
5960                      }
5961                    ]
5962                  }
5963                }
5964              }
5965            }
5966          }
5967        }
5968      },
5969      "/api/account/me": {
5970        "get": {
5971          "tags": [
5972            "Accounts"
5973          ],
5974          "summary": "Get the authenticated user's profile",
5975          "security": [
5976            {
5977              "bearerAuth": []
5978            }
5979          ],
5980          "responses": {
5981            "200": {
5982              "description": "Always HTTP 200 — check `statusCode` in the body",
5983              "content": {
5984                "application/json": {
5985                  "schema": {
5986                    "oneOf": [
5987                      {
5988                        "description": "Authenticated",
5989                        "type": "object",
5990                        "properties": {
5991                          "statusCode": {
5992                            "type": "integer",
5993                            "example": 200
5994                          },
5995                          "id": {
5996                            "type": "string"
5997                          },
5998                          "name": {
5999                            "type": "string"
6000                          },
6001                          "email": {
6002                            "type": "string"
6003                          },
6004                          "emailVerified": {
6005                            "type": "boolean"
6006                          },
6007                          "authProvider": {
6008                            "type": "string",
6009                            "description": "How the account was first created. Historical only, and it defaults to `email` on every row that never set it — including social ones. Do not branch on it to decide what sign-in options to show; use `hasPassword` and `socialProviders` below, which are read from the things sign-in actually consults.\n",
6010                            "example": "email"
6011                          },
6012                          "hasPassword": {
6013                            "type": "boolean",
6014                            "description": "Whether this account can sign in at `POST /api/v1/account/login`. False for an account created through a provider that has never called `POST /api/v1/account/set-password`. Show \"Add a password\" when false and \"Change password\" when true.\n",
6015                            "example": false
6016                          },
6017                          "username": {
6018                            "type": "string",
6019                            "nullable": true,
6020                            "description": "The account's handle, or null if none has been claimed. Optional and permanent — null is a supported, lasting state, and a claimed handle never changes. Show \"Choose a username\" when null and a read-only value when set.\n",
6021                            "example": "sara_k"
6022                          },
6023                          "emails": {
6024                            "type": "array",
6025                            "description": "Every address on this account, primary first. `email` above is the primary one and keeps that meaning permanently, so a client that only reads `email` is correct and always will be. Manage these with `/api/v1/account/emails`.\n",
6026                            "items": {
6027                              "$ref": "#/components/schemas/AccountEmail"
6028                            }
6029                          },
6030                          "socialProviders": {
6031                            "type": "array",
6032                            "description": "Providers linked to this account, in the order they were linked. Signing in with any of them lands here. Empty for an email/password-only account. Manage with `POST` / `DELETE /api/v1/account/social-links`.\n",
6033                            "items": {
6034                              "type": "string",
6035                              "enum": [
6036                                "google",
6037                                "facebook",
6038                                "apple"
6039                              ]
6040                            },
6041                            "example": [
6042                              "google"
6043                            ]
6044                          },
6045                          "avatarUrl": {
6046                            "type": "string",
6047                            "nullable": true,
6048                            "description": "The account's profile picture, or null to draw initials. Absolute when this deployment knows its own public origin and a bare path otherwise, so resolve it against the API base URL you already hold rather than assuming either form. The URL is immutable: a changed picture is a different URL, so it is safe to cache for as long as you like. Pictures only ever come from a linked provider — there is no upload — so this is null until one is connected.\n",
6049                            "example": "/api/v1/account/avatar/6f1e2c34-9b7a-4f10-8f2d-2a0b1c3d4e5f"
6050                          },
6051                          "avatarProvider": {
6052                            "type": "string",
6053                            "nullable": true,
6054                            "enum": [
6055                              "google",
6056                              "facebook",
6057                              "apple",
6058                              null
6059                            ],
6060                            "description": "Which linked provider the picture came from, so a settings screen can mark it as the one in use. Null exactly when `avatarUrl` is null. Never `apple` in practice — Sign in with Apple supplies no picture.\n",
6061                            "example": "google"
6062                          }
6063                        }
6064                      },
6065                      {
6066                        "description": "Unauthenticated. Check the `reason` field to determine next action:\n- `TOKEN_EXPIRED` — the access token has expired. Call `POST /api/account/refresh`\n  with the stored `refreshToken`. On success retry this request; on failure log the user out.\n- `UNAUTHORIZED` — no valid token exists. Log the user out and show the login screen.\n",
6067                        "type": "object",
6068                        "properties": {
6069                          "statusCode": {
6070                            "type": "integer",
6071                            "example": 401
6072                          },
6073                          "reason": {
6074                            "type": "string",
6075                            "enum": [
6076                              "TOKEN_EXPIRED",
6077                              "UNAUTHORIZED"
6078                            ],
6079                            "example": "TOKEN_EXPIRED"
6080                          },
6081                          "statusMessage": {
6082                            "type": "string",
6083                            "example": "Your session has expired. Please refresh your token or log in again."
6084                          }
6085                        }
6086                      }
6087                    ]
6088                  }
6089                }
6090              }
6091            }
6092          }
6093        },
6094        "patch": {
6095          "tags": [
6096            "Accounts"
6097          ],
6098          "summary": "Update the authenticated user's display name",
6099          "security": [
6100            {
6101              "bearerAuth": []
6102            }
6103          ],
6104          "requestBody": {
6105            "required": true,
6106            "content": {
6107              "application/json": {
6108                "schema": {
6109                  "type": "object",
6110                  "required": [
6111                    "name"
6112                  ],
6113                  "properties": {
6114                    "name": {
6115                      "type": "string",
6116                      "example": "Jane Doe"
6117                    }
6118                  }
6119                }
6120              }
6121            }
6122          },
6123          "responses": {
6124            "200": {
6125              "description": "Always HTTP 200 — check statusCode in the body",
6126              "content": {
6127                "application/json": {
6128                  "schema": {
6129                    "oneOf": [
6130                      {
6131                        "description": "Name updated",
6132                        "type": "object",
6133                        "properties": {
6134                          "statusCode": {
6135                            "type": "integer",
6136                            "example": 200
6137                          },
6138                          "data": {
6139                            "type": "object",
6140                            "properties": {
6141                              "name": {
6142                                "type": "string"
6143                              }
6144                            }
6145                          }
6146                        }
6147                      },
6148                      {
6149                        "description": "Validation error or unauthenticated",
6150                        "type": "object",
6151                        "properties": {
6152                          "statusCode": {
6153                            "type": "integer",
6154                            "example": 401
6155                          },
6156                          "statusMessage": {
6157                            "type": "string"
6158                          }
6159                        }
6160                      }
6161                    ]
6162                  }
6163                }
6164              }
6165            }
6166          }
6167        }
6168      },
6169      "/api/account/invites": {
6170        "get": {
6171          "tags": [
6172            "Accounts"
6173          ],
6174          "summary": "Get all pending list invitations for the authenticated user",
6175          "description": "Returns every list where the current user is the invited partner and the invite\nhas not yet been accepted or declined (`inviteStatus` is `TODOS::INVITE_STATUS::PENDING`\nor `null`).  The client should display these on the profile / inbox screen so the user\ncan call `PUT /api/todos/{id}/accept-invite` or `PUT /api/todos/{id}/decline-invite`.\n",
6176          "security": [
6177            {
6178              "bearerAuth": []
6179            }
6180          ],
6181          "responses": {
6182            "200": {
6183              "description": "Always HTTP 200 — check `statusCode` in the body",
6184              "content": {
6185                "application/json": {
6186                  "schema": {
6187                    "type": "object",
6188                    "properties": {
6189                      "statusCode": {
6190                        "type": "integer",
6191                        "example": 200
6192                      },
6193                      "data": {
6194                        "type": "array",
6195                        "items": {
6196                          "type": "object",
6197                          "properties": {
6198                            "listId": {
6199                              "type": "string",
6200                              "description": "ID of the list to pass to accept-invite / decline-invite"
6201                            },
6202                            "label": {
6203                              "type": "string"
6204                            },
6205                            "createdBy": {
6206                              "type": "object",
6207                              "nullable": true,
6208                              "properties": {
6209                                "id": {
6210                                  "type": "string"
6211                                },
6212                                "name": {
6213                                  "type": "string"
6214                                },
6215                                "email": {
6216                                  "type": "string"
6217                                },
6218                                "username": {
6219                                  "type": "string",
6220                                  "nullable": true,
6221                                  "description": "Their handle, or null if they have never claimed one. Null is a permanent, supported state.",
6222                                  "example": "sara_k"
6223                                }
6224                              }
6225                            },
6226                            "createdAt": {
6227                              "type": "string",
6228                              "format": "date-time"
6229                            }
6230                          }
6231                        }
6232                      }
6233                    }
6234                  }
6235                }
6236              }
6237            },
6238            "401": {
6239              "description": "Unauthorized"
6240            }
6241          }
6242        }
6243      },
6244      "/api/account/partners": {
6245        "get": {
6246          "tags": [
6247            "Accounts"
6248          ],
6249          "summary": "Get all partners for the authenticated user, each with their invite status",
6250          "security": [
6251            {
6252              "bearerAuth": []
6253            }
6254          ],
6255          "responses": {
6256            "200": {
6257              "description": "Always HTTP 200 — check `statusCode` in the body",
6258              "content": {
6259                "application/json": {
6260                  "schema": {
6261                    "oneOf": [
6262                      {
6263                        "description": "Success",
6264                        "type": "object",
6265                        "properties": {
6266                          "statusCode": {
6267                            "type": "integer",
6268                            "example": 200
6269                          },
6270                          "data": {
6271                            "type": "array",
6272                            "description": "All users who share at least one list with the current user, regardless of invite status",
6273                            "items": {
6274                              "type": "object",
6275                              "properties": {
6276                                "id": {
6277                                  "type": "string"
6278                                },
6279                                "name": {
6280                                  "type": "string"
6281                                },
6282                                "email": {
6283                                  "type": "string"
6284                                },
6285                                "username": {
6286                                  "type": "string",
6287                                  "nullable": true,
6288                                  "description": "Their handle, or null if they have never claimed one. Null is a permanent, supported state.",
6289                                  "example": "sara_k"
6290                                },
6291                                "status": {
6292                                  "type": "string",
6293                                  "description": "Best invite status for this partner across all shared lists",
6294                                  "enum": [
6295                                    "TODOS::INVITE_STATUS::ACCEPTED",
6296                                    "TODOS::INVITE_STATUS::PENDING",
6297                                    "TODOS::INVITE_STATUS::REJECTED"
6298                                  ]
6299                                },
6300                                "avatarUrl": {
6301                                  "type": "string",
6302                                  "nullable": true,
6303                                  "description": "This partner's profile picture, or null to draw their initials. Absolute when this deployment knows its own public origin and a bare path otherwise, so resolve it against the API base URL you already hold. Immutable and safe to cache inde
6303finitely — a changed picture arrives as a different URL.\n",
6304                                  "example": "/api/v1/account/avatar/6f1e2c34-9b7a-4f10-8f2d-2a0b1c3d4e5f"
6305                                }
6306                              }
6307                            }
6308                          }
6309                        }
6310                      },
6311                      {
6312                        "description": "Unauthorized",
6313                        "type": "object",
6314                        "properties": {
6315                          "statusCode": {
6316                            "type": "integer",
6317                            "example": 401
6318                          },
6319                          "statusMessage": {
6320                            "type": "string"
6321                          }
6322                        }
6323                      }
6324                    ]
6325                  }
6326                }
6327              }
6328            }
6329          }
6330        }
6331      },
6332      "/api/account/request-deletion": {
6333        "post": {
6334          "tags": [
6335            "Accounts"
6336          ],
6337          "summary": "Request account deletion — sends a confirmation email",
6338          "security": [
6339            {
6340              "bearerAuth": []
6341            }
6342          ],
6343          "requestBody": {
6344            "required": true,
6345            "content": {
6346              "application/json": {
6347                "schema": {
6348                  "$ref": "#/components/schemas/AccountDeletionRequest"
6349                }
6350              }
6351            }
6352          },
6353          "responses": {
6354            "200": {
6355              "description": "Always HTTP 200 — check `statusCode` in the body",
6356              "content": {
6357                "application/json": {
6358                  "schema": {
6359                    "oneOf": [
6360                      {
6361                        "description": "Deletion email sent",
6362                        "type": "object",
6363                        "properties": {
6364                          "statusCode": {
6365                            "type": "integer",
6366                            "example": 200
6367                          },
6368                          "data": {
6369                            "type": "string"
6370                          }
6371                        }
6372                      },
6373                      {
6374                        "description": "Failed (e.g. account not found)",
6375                        "type": "object",
6376                        "properties": {
6377                          "statusCode": {
6378                            "type": "integer",
6379                            "example": 401
6380                          },
6381                          "statusMessage": {
6382                            "type": "string"
6383                          }
6384                        }
6385                      }
6386                    ]
6387                  }
6388                }
6389              }
6390            }
6391          }
6392        }
6393      },
6394      "/api/account/complete-deletion": {
6395        "post": {
6396          "tags": [
6397            "Accounts"
6398          ],
6399          "summary": "Complete account deletion using the token from email",
6400          "requestBody": {
6401            "required": true,
6402            "content": {
6403              "application/json": {
6404                "schema": {
6405                  "$ref": "#/components/schemas/CompleteAccountDeletionRequest"
6406                }
6407              }
6408            }
6409          },
6410          "responses": {
6411            "200": {
6412              "description": "Always HTTP 200 — check `statusCode` in the body",
6413              "content": {
6414                "application/json": {
6415                  "schema": {
6416                    "oneOf": [
6417                      {
6418                        "description": "Account permanently deleted",
6419                        "type": "object",
6420                        "properties": {
6421                          "statusCode": {
6422                            "type": "integer",
6423                            "example": 200
6424                          },
6425                          "data": {
6426                            "type": "string"
6427                          }
6428                        }
6429                      },
6430                      {
6431                        "description": "Invalid or expired token",
6432                        "type": "object",
6433                        "properties": {
6434                          "statusCode": {
6435                            "type": "integer",
6436                            "example": 401
6437                          },
6438                          "statusMessage": {
6439                            "type": "string"
6440                          }
6441                        }
6442                      }
6443                    ]
6444                  }
6445                }
6446              }
6447            }
6448          }
6449        }
6450      },
6451      "/api/account/register": {
6452        "post": {
6453          "tags": [
6454            "Accounts"
6455          ],
6456          "summary": "Register a new account",
6457          "requestBody": {
6458            "required": true,
6459            "content": {
6460              "application/json": {
6461                "schema": {
6462                  "$ref": "#/components/schemas/RegisterRequest"
6463                }
6464              }
6465            }
6466          },
6467          "responses": {
6468            "200": {
6469              "description": "Always HTTP 200 — check `statusCode` in the body",
6470              "content": {
6471                "application/json": {
6472                  "schema": {
6473                    "oneOf": [
6474                      {
6475                        "description": "Account created — verification email sent",
6476                        "type": "object",
6477                        "properties": {
6478                          "statusCode": {
6479                            "type": "integer",
6480                            "example": 200
6481                          },
6482                          "data": {
6483                            "type": "string"
6484                          }
6485                        }
6486                      },
6487                      {
6488                        "description": "This email already has a pending invitation from a partner (a stub account exists, `invited = true`). The client should route the user to `POST /api/account/complete-invite` (
6488offering `POST /api/account/resend-invite` if they need a new token) rather than retry registration.",
6489                        "type": "object",
6490                        "properties": {
6491                          "statusCode": {
6492                            "type": "integer",
6493                            "example": 401
6494                          },
6495                          "reason": {
6496                            "type": "string",
6497                            "example": "PENDING_INVITE"
6498                          },
6499                          "statusMessage": {
6500                            "type": "string",
6501                            "example": "This email has a pending invitation. Check your inbox for the invite email, or use the resend option."
6502                          }
6503                        }
6504                      },
6505                      {
6506                        "description": "An account already exists with this email and signs in **only** through a social provider — it has no password, so routing the user to the login form sends them to a refusal they cannot get past. `data.providers` names the providers that do work; show those buttons instead. If they want a password, they sign in with the provider first and then call `POST /api/v1/account/set-password`.\n",
6507                        "type": "object",
6508                        "properties": {
6509                          "statusCode": {
6510                            "type": "integer",
6511                            "example": 401
6512                          },
6513                          "reason": {
6514                            "type": "string",
6515                            "example": "SOCIAL_ACCOUNT"
6516                          },
6517                          "statusMessage": {
6518                            "type": "string",
6519                            "example": "This email address is already used by an account that signs in with Google. Continue with that provider instead."
6520                          },
6521                          "data": {
6522                            "type": "object",
6523                            "properties": {
6524                              "providers": {
6525                                "type": "array",
6526                                "items": {
6527                                  "type": "string",
6528                                  "enum": [
6529                                    "google",
6530                                    "facebook",
6531                                    "apple"
6532                                  ]
6533                                },
6534                                "example": [
6535                                  "google"
6536                                ]
6537                              }
6538                            }
6539                          }
6540                        }
6541                      },
6542                      {
6543                        "description": "An account already exists with this email, is not pending an invite, and has a password. The client should offer to log in instead. `data.providers` is present and non-empty when the account *also* has providers linked, so the login screen can offer both; it is absent when the account is password-only.\n",
6544                        "type": "object",
6545                        "properties": {
6546                          "statusCode": {
6547                            "type": "integer",
6548                            "example": 401
6549                          },
6550                          "reason": {
6551                            "type": "string",
6552                            "example": "ACCOUNT_EXISTS"
6553                          },
6554                          "statusMessage": {
6555                            "type": "string",
6556                            "example": "An account already exists with this email address. Try logging in instead."
6557                          },
6558                          "data": {
6559                            "type": "object",
6560                            "properties": {
6561                              "providers": {
6562                                "type": "array",
6563                                "items": {
6564                                  "type": "string",
6565                                  "enum": [
6566                                    "google",
6567                                    "facebook",
6568                                    "apple"
6569                                  ]
6570                                },
6571                                "example": []
6572                              }
6573                            }
6574                          }
6575                        }
6576                      },
6577                      {
6578                        "description": "The name, email, or password failed validation.",
6579                        "type": "object",
6580                        "properties": {
6581                          "statusCode": {
6582                            "type": "integer",
6583                            "example": 401
6584                          },
6585                          "reason": {
6586                            "type": "string",
6587                            "example": "UNAUTHORIZED"
6588                          },
6589                          "statusMessage": {
6590                            "type": "string"
6591                          }
6592                        }
6593                      }
6594                    ]
6595                  }
6596                }
6597              }
6598            }
6599          }
6600        }
6601      },
6602      "/api/account/resend-invite": {
6603        "post": {
6604          "tags": [
6605            "Accounts"
6606          ],
6607          "summary": "Resend the invitation email for a pending invited account",
6608          "description": "Always returns 200 — response is deliberately vague to avoid leaking invite status.",
6609          "requestBody": {
6610            "required": true,
6611            "content": {
6612              "application/json": {
6613                "schema": {
6614                  "type": "object",
6615                  "required": [
6616                    "email"
6617                  ],
6618                  "properties": {
6619                    "email": {
6620                      "type": "string",
6621                      "format": "email"
6622                    }
6623                  }
6624                }
6625              }
6626            }
6627          },
6628          "responses": {
6629            "200": {
6630              "description": "Always HTTP 200 regardless of whether a pending invite exists",
6631              "content": {
6632                "application/json": {
6633                  "schema": {
6634                    "type": "object",
6635                    "properties": {
6636                      "statusCode": {
6637                        "type": "integer",
6638                        "example": 200
6639                      },
6640                      "statusMessage": {
6641                        "type": "string",
6642                        "example": "If this email has a pending invitation, you will receive it shortly."
6643                      }
6644                    }
6645                  }
6646                }
6647              }
6648            }
6649          }
6650        }
6651      },
6652      "/api/account/complete-invite": {
6653        "post": {
6654          "tags": [
6655            "Accounts"
6656          ],
6657          "summary": "Complete an invite (create account via invite link)",
6658          "description": "Called when a new user follows an invite link. Sets their password and\nactivates the account. After success the client should direct the user\nto login (POST /api/account/login).\n",
6659          "requestBody": {
6660            "required": true,
6661            "content": {
6662              "application/json": {
6663                "schema": {
6664                  "$ref": "#/components/schemas/CompleteInviteRequest"
6665                }
6666              }
6667            }
6668          },
6669          "responses": {
6670            "200": {
6671              "description": "Always HTTP 200 — check `statusCode` in the body",
6672              "content": {
6673                "application/json": {
6674                  "schema": {
6675                    "oneOf": [
6676                      {
6677                        "description": "Account activated — user can now log in",
6678                        "type": "object",
6679                        "properties": {
6680                          "statusCode": {
6681                            "type": "integer",
6682                            "example": 200
6683                          },
6684                          "data": {
6685                            "type": "string"
6686                          }
6687                        }
6688                      },
6689                      {
6690                        "description": "Validation error (e.g. password too weak, token missing)",
6691                        "type": "object",
6692                        "properties": {
6693                          "statusCode": {
6694                            "type": "integer",
6695                            "example": 400
6696                          },
6697                          "statusMessage": {
6698                            "type": "string"
6699                          }
6700                        }
6701                      },
6702                      {
6703                        "description": "Invalid or expired invite token",
6704                        "type": "object",
6705                        "properties": {
6706                          "statusCode": {
6707                            "type": "integer",
6708                            "example": 401
6709                          },
6710                          "statusMessage": {
6711                            "type": "string"
6712                          }
6713                        }
6714                      }
6715                    ]
6716                  }
6717                }
6718              }
6719            }
6720          }
6721        }
6722      },
6723      "/api/account/login": {
6724        "post": {
6725          "tags": [
6726            "Accounts"
6727          ],
6728          "summary": "Log in and receive a Bearer token + refresh token",
6729          "description": "Standard email + password login. On success, store all four fields from `data`:\n`token`, `expiresAt`, `refreshToken`, `refreshExpiresAt`.\n\n**The `platform` field is recommended.** If omitted it defaults to `PLATFORM::UNKNOWN`.\nPass one of `PLATFORM::IOS`, `PLATFORM::ANDROID`, `PLATFORM::WEB`, `PLATFORM::MACOS`,\n`PLATFORM::WINDOWS`, `PLATFORM::LINUX` for better session tracking.\n\nSchedule a call to `POST /api/account/refresh` before `expiresAt` to get a new\ntoken pair without re-entering credentials.\n",
6730          "requestBody": {
6731            "required": true,
6732            "content": {
6733              "application/json": {
6734                "schema": {
6735                  "$ref": "#/components/schemas/LoginRequest"
6736                }
6737              }
6738            }
6739          },
6740          "responses": {
6741            "200": {
6742              "description": "Always HTTP 200 — check `statusCode` in the body",
6743              "content": {
6744                "application/json": {
6745                  "schema": {
6746                    "oneOf": [
6747                      {
6748                        "description": "Login successful",
6749                        "type": "object",
6750                        "properties": {
6751                          "statusCode": {
6752                            "type": "integer",
6753                            "example": 200
6754                          },
6755                          "data": {
6756                            "type": "object",
6757                            "properties": {
6758                              "token": {
6759                                "type": "string",
6760                                "description": "Short-lived access token — include as Authorization: Bearer <token>"
6761                              },
6762                              "expiresAt": {
6763                                "type": "string",
6764                                "format": "date-time",
6765                                "description": "When the access token expires. Schedule a call to POST /api/account/refresh before this time, or handle reason: TOKEN_EXPIRED reactively."
6766                              },
6767                              "refreshToken": {
6768                                "type": "string",
6769                                "description": "Long-lived refresh token. Store securely — pass to POST /api/account/refresh to obtain a new token pair."
6770                              },
6771                              "refreshExpiresAt": {
6772                                "type": "string",
6773                                "format": "date-time",
6774                                "description": "When the refresh token expires. After this point the user must log in again."
6775                              }
6776                            }
6777                          }
6778                        }
6779                      },
6780                      {
6781                        "description": "Bad credentials — email not found or password incorrect",
6782                        "type": "object",
6783                        "properties": {
6784                          "statusCode": {
6785                            "type": "integer",
6786                            "example": 401
6787                          },
6788                          "reason": {
6789                            "type": "string",
6790                            "example": "UNAUTHORIZED"
6791                          },
6792                          "statusMessage": {
6793                            "type": "string",
6794                            "example": "The username or password is incorrect."
6795                          }
6796                        }
6797                      },
6798                      {
6799                        "description": "Account locked after too many failed login attempts. Wait for the lock to expire or ask an admin to unlock.",
6800                        "type": "object",
6801                        "properties": {
6802                          "statusCode": {
6803                            "type": "integer",
6804                            "example": 423
6805                          },
6806                          "reason": {
6807                            "type": "string",
6808                            "example": "ACCOUNT_LOCKED"
6809                          },
6810                          "statusMessage": {
6811                            "type": "string",
6812                            "example": "Your account is locked due to multiple failed login attempts. Try again in 5 minutes."
6813                          }
6814                        }
6815                      },
6816                      {
6817                        "description": "Email not verified — the user must verify their email before logging in",
6818                        "type": "object",
6819                        "properties": {
6820                          "statusCode": {
6821                            "type": "integer",
6822                            "example": 403
6823                          },
6824                          "reason": {
6825                            "type": "string",
6826                            "example": "EMAIL_NOT_VERIFIED"
6827                          },
6828                          "statusMessage": {
6829                            "type": "string"
6830                          }
6831                        }
6832                      }
6833                    ]
6834                  }
6835                }
6836              }
6837            }
6838          }
6839        }
6840      },
6841      "/api/account/email-token": {
6842        "post": {
6843          "tags": [
6844            "Accounts"
6845          ],
6846          "summary": "Send an email verification token to the given address",
6847          "description": "Always returns 200 to avoid leaking account existence.\nIf the email is not found, or is already verified, the request is silently ignored.\n",
6848          "requestBody": {
6849            "required": true,
6850            "content": {
6851              "application/json": {
6852                "schema": {
6853                  "type": "object",
6854                  "required": [
6855                    "email"
6856                  ],
6857                  "properties": {
6858                    "email": {
6859                      "type": "string",
6860                      "format": "email"
6861                    }
6862                  }
6863                }
6864              }
6865            }
6866          },
6867          "responses": {
6868            "200": {
6869              "description": "Always HTTP 200",
6870              "content": {
6871                "application/json": {
6872                  "schema": {
6873                    "type": "object",
6874                    "properties": {
6875                      "statusCode": {
6876                        "type": "integer",
6877                        "exampl
6877e": 200
6878                      },
6879                      "data": {
6880                        "type": "string",
6881                        "example": "Check your inbox for the confirmation token if your email exists in our database."
6882                      }
6883                    }
6884                  }
6885                }
6886              }
6887            }
6888          }
6889        }
6890      },
6891      "/api/account/validate-email": {
6892        "put": {
6893          "tags": [
6894            "Accounts"
6895          ],
6896          "summary": "Verify email address using token from email",
6897          "requestBody": {
6898            "required": true,
6899            "content": {
6900              "application/json": {
6901                "schema": {
6902                  "$ref": "#/components/schemas/ValidateEmailRequest"
6903                }
6904              }
6905            }
6906          },
6907          "responses": {
6908            "200": {
6909              "description": "Always HTTP 200 — check `statusCode` in the body",
6910              "content": {
6911                "application/json": {
6912                  "schema": {
6913                    "oneOf": [
6914                      {
6915                        "description": "Email verified — user may now log in",
6916                        "type": "object",
6917                        "properties": {
6918                          "statusCode": {
6919                            "type": "integer",
6920                            "example": 200
6921                          },
6922                          "data": {
6923                            "type": "string"
6924                          }
6925                        }
6926                      },
6927                      {
6928                        "description": "This account was created by a partner's invitation rather than by self-registration, so it has no email-verification token to check — it has an invite token instead. The client should route the user to `POST /api/account/complete-invite` instead of retrying verification.",
6929                        "type": "object",
6930                        "properties": {
6931                          "statusCode": {
6932                            "type": "integer",
6933                            "example": 401
6934                          },
6935                          "reason": {
6936                            "type": "string",
6937                            "example": "PENDING_INVITE"
6938                          },
6939                          "statusMessage": {
6940                            "type": "string",
6941                            "example": "This account was created by invitation. Use the invite link from your email to activate it instead."
6942                          }
6943                        }
6944                      },
6945                      {
6946                        "description": "No account found, or the token is missing, incorrect, or already used.",
6947                        "type": "object",
6948                        "properties": {
6949                          "statusCode": {
6950                            "type": "integer",
6951                            "example": 401
6952                          },
6953                          "reason": {
6954                            "type": "string",
6955                            "example": "UNAUTHORIZED"
6956                          },
6957                          "statusMessage": {
6958                            "type": "string"
6959                          }
6960                        }
6961                      }
6962                    ]
6963                  }
6964                }
6965              }
6966            }
6967          }
6968        }
6969      },
6970      "/api/account/password-reset-token": {
6971        "put": {
6972          "tags": [
6973            "Accounts"
6974          ],
6975          "summary": "Request a password reset token (sends email)",
6976          "description": "Always returns 200 regardless of whether the email exists, to avoid\nleaking account existence. Token is consumed by PUT /api/account/reset-password.\n",
6977          "requestBody": {
6978            "required": true,
6979            "content": {
6980              "application/json": {
6981                "schema": {
6982                  "$ref": "#/components/schemas/PasswordResetTokenRequest"
6983                }
6984              }
6985            }
6986          },
6987          "responses": {
6988            "200": {
6989              "description": "Always HTTP 200",
6990              "content": {
6991                "application/json": {
6992                  "schema": {
6993                    "type": "object",
6994                    "properties": {
6995                      "statusCode": {
6996                        "type": "integer",
6997                        "example": 200
6998                      },
6999                      "statusMessage": {
7000                        "type": "string",
7001                        "example": "A token has been sent to your email address if an account is found."
7002                      },
7003                      "data": {
7004                        "type": "string",
7005                        "example": "A token has been sent to your email address if an account is found."
7006                      }
7007                    }
7008                  }
7009                }
7010              }
7011            }
7012          }
7013        }
7014      },
7015      "/api/account/reset-password": {
7016        "put": {
7017          "tags": [
7018            "Accounts"
7019          ],
7020          "summary": "Reset a forgotten password using a token from email",
7021          "requestBody": {
7022            "required": true,
7023            "content": {
7024              "application/json": {
7025                "schema": {
7026                  "$ref": "#/components/schemas/ResetPasswordRequest"
7027                }
7028              }
7029            }
7030          },
7031          "responses": {
7032            "200": {
7033              "description": "Always HTTP 200 — check `statusCode` in the body",
7034              "content": {
7035                "application/json": {
7036                  "schema": {
7037                    "oneOf": [
7038                      {
7039                        "description": "Password updated — user may 
7039now log in",
7040                        "type": "object",
7041                        "properties": {
7042                          "statusCode": {
7043                            "type": "integer",
7044                            "example": 200
7045                          },
7046                          "data": {
7047                            "type": "string"
7048                          }
7049                        }
7050                      },
7051                      {
7052                        "description": "Validation error (e.g. password too weak)",
7053                        "type": "object",
7054                        "properties": {
7055                          "statusCode": {
7056                            "type": "integer",
7057                            "example": 400
7058                          },
7059                          "statusMessage": {
7060                            "type": "string"
7061                          }
7062                        }
7063                      },
7064                      {
7065                        "description": "Invalid or expired reset token",
7066                        "type": "object",
7067                        "properties": {
7068                          "statusCode": {
7069                            "type": "integer",
7070                            "example": 401
7071                          },
7072                          "statusMessage": {
7073                            "type": "string"
7074                          }
7075                        }
7076                      }
7077                    ]
7078                  }
7079                }
7080              }
7081            }
7082          }
7083        }
7084      },
7085      "/api/v1/account/set-password": {
7086        "post": {
7087          "tags": [
7088            "Accounts"
7089          ],
7090          "summary": "Set the first password on an account that signs in socially",
7091          "description": "For an account created through Google, Apple or Facebook, which has no\npassword. `PUT /api/v1/account/change-password` cannot serve these\naccounts — it confirms the current password, and there is none — so this\nis the route that adds one.\n\n\n**This is the endpoint `DELETE /api/v1/account/social-links/{provider}`\npoints at.** That call refuses with `LAST_LOGIN_METHOD` when unlinking\nwould leave the account with no way in, and the remedy is to set a\npassword here first, then retry the unlink.\n\n\nThe bearer token is the proof of identity: the caller signed in with\ntheir provider to obtain it. Nothing is emailed and nothing needs\nconfirming — the password works immediately at\n`POST /api/v1/account/login`. A notification is sent to the address on\nthe account, because the account has gained a way in that it did not\nhave before.\n\n\nAdding a password does **not** unlink any provider. Both continue to\nwork; whether to keep the provider is the user's choice. Read the\ncurrent state from `GET /api/v1/account/me` → `hasPassword` and\n`socialProviders`.\n",
7092          "security": [
7093            {
7094              "bearerAuth": []
7095            }
7096          ],
7097          "requestBody": {
7098            "required": true,
7099            "content": {
7100              "application/json": {
7101                "schema": {
7102                  "type": "object",
7103                  "required": [
7104                    "password"
7105                  ],
7106                  "properties": {
7107                    "password": {
7108                      "type": "string",
7109                      "minLength": 8,
7110                      "maxLength": 50,
7111                      "description": "The password to set. The bounds are server configuration (`password_min_length` / `password_max_length`), not fixed by this contract — 8 and 50 are the shipped defaults and what the `minLength`/`maxLength` above reflect. An operator can change them, so treat a `VALIDATION_ERROR` as authoritative over these numbers; its `statusMessage` always names the bounds actually in force.\n",
7112                      "example": "correct-horse-battery"
7113                    }
7114                  }
7115                }
7116              }
7117            }
7118          },
7119          "responses": {
7120            "200": {
7121              "description": "Always HTTP 200 — branch on `statusCode` and `reason` in the body",
7122              "content": {
7123                "application/json": {
7124                  "schema": {
7125                    "oneOf": [
7126                      {
7127                        "description": "The password was set and can be used immediately",
7128                        "type": "object",
7129                        "properties": {
7130                          "statusCode": {
7131                            "type": "integer",
7132                            "example": 200
7133                          },
7134                          "statusMessage": {
7135                            "type": "string",
7136                            "example": "The password has been set. You can now sign in with your email address and password."
7137                          }
7138                        }
7139                      },
7140                      {
7141                        "description": "The account already has a password. Not an error the user needs to see — re-read `GET /account/me` and show the change-password screen instead.\n",
7142                        "type": "object",
7143                        "properties": {
7144                          "statusCode": {
7145                            "type": "integer",
7146                            "example": 400
7147                          },
7148                          "reason": {
7149                            "type": "string",
7150                            "example": "PASSWORD_ALREADY_SET"
7151                          },
7152                          "statusMessage": {
7153                            "type": "string",
7154                            "example": "This account already has a password. Use the change-password endpoint to replace it."
7155                          }
7156                        }
7157                      },
7158                      {
7159                        "description": "The password is missing or outside the permitted length",
7160                        "type": "object",
7161                        "properties": {
7162                          "statusCode": {
7163                            "type": "integer",
7164                            "example": 400
7165                          },
7166                          "reason": {
7167                            "type": "string",
7168                            "example": "VALIDATION_ERROR"
7169                          },
7170                          "statusMessage": {
7171                            "type": "string",
7172                            "example": "The password must be 8–50 characters long."
7173                          }
7174                        }
7175                      },
7176                      {
7177                        "description": "No bearer token, or the token is not val
7177id. Emitted by the auth middleware, so the wording is the same on every authenticated endpoint rather than specific to this one.\n",
7178                        "type": "object",
7179                        "properties": {
7180                          "statusCode": {
7181                            "type": "integer",
7182                            "example": 401
7183                          },
7184                          "reason": {
7185                            "type": "string",
7186                            "enum": [
7187                              "UNAUTHORIZED",
7188                              "TOKEN_EXPIRED"
7189                            ],
7190                            "example": "UNAUTHORIZED"
7191                          },
7192                          "statusMessage": {
7193                            "type": "string",
7194                            "example": "Authentication required."
7195                          }
7196                        }
7197                      }
7198                    ]
7199                  }
7200                }
7201              }
7202            }
7203          }
7204        }
7205      },
7206      "/api/account/change-password": {
7207        "put": {
7208          "tags": [
7209            "Accounts"
7210          ],
7211          "summary": "Change password (requires current password)",
7212          "security": [
7213            {
7214              "bearerAuth": []
7215            }
7216          ],
7217          "requestBody": {
7218            "required": true,
7219            "content": {
7220              "application/json": {
7221                "schema": {
7222                  "$ref": "#/components/schemas/ChangePasswordRequest"
7223                }
7224              }
7225            }
7226          },
7227          "responses": {
7228            "200": {
7229              "description": "Always HTTP 200 — check `statusCode` in the body",
7230              "content": {
7231                "application/json": {
7232                  "schema": {
7233                    "oneOf": [
7234                      {
7235                        "description": "Password updated",
7236                        "type": "object",
7237                        "properties": {
7238                          "statusCode": {
7239                            "type": "integer",
7240                            "example": 200
7241                          },
7242                          "data": {
7243                            "type": "string"
7244                          }
7245                        }
7246                      },
7247                      {
7248                        "description": "Missing fields or wrong current password",
7249                        "type": "object",
7250                        "properties": {
7251                          "statusCode": {
7252                            "type": "integer",
7253                            "example": 401
7254                          },
7255                          "statusMessage": {
7256                            "type": "string"
7257                          }
7258                        }
7259                      }
7260                    ]
7261                  }
7262                }
7263              }
7264            }
7265          }
7266        }
7267      },
7268      "/api/v1/account/emails": {
7269        "get": {
7270          "tags": [
7271            "Accounts"
7272          ],
7273          "summary": "List every email address on the account",
7274          "description": "Primary first, then in the order they were added. The same array appears\non `GET /api/v1/account/me`, so a settings screen needs only one call.\n\n\nVerification tokens are never returned here. They exist only in the mail\nsent to the address they belong to.\n",
7275          "security": [
7276            {
7277              "bearerAuth": []
7278            }
7279          ],
7280          "responses": {
7281            "200": {
7282              "description": "Always HTTP 200 — check `statusCode` in the body",
7283              "content": {
7284                "application/json": {
7285                  "schema": {
7286                    "type": "object",
7287                    "properties": {
7288                      "statusCode": {
7289                        "type": "integer",
7290                        "example": 200
7291                      },
7292                      "data": {
7293                        "type": "array",
7294                        "items": {
7295                          "$ref": "#/components/schemas/AccountEmail"
7296                        }
7297                      }
7298                    }
7299                  }
7300                }
7301              }
7302            }
7303          }
7304        },
7305        "post": {
7306          "tags": [
7307            "Accounts"
7308          ],
7309          "summary": "Add an email address to the account",
7310          "description": "Sends a confirmation token to the address. **The address grants nothing\nuntil that token comes back** through\n`POST /api/v1/account/emails/verify` — it cannot sign in, cannot receive\nan invitation, and cannot be made primary. Adding it does not change\nwhere any mail is sent.\n\n\nRe-adding an address that is still unverified reissues the token rather\nthan failing, so a user whose first mail never arrived can simply ask\nagain. The token lasts 24 hours.\n",
7311          "security": [
7312            {
7313              "bearerAuth": []
7314            }
7315          ],
7316          "requestBody": {
7317            "required": true,
7318            "content": {
7319              "application/json": {
7320                "schema": {
7321                  "type": "object",
7322                  "required": [
7323                    "email"
7324                  ],
7325                  "properties": {
7326                    "email": {
7327                      "type": "string",
7328                      "format": "email",
7329                      "maxLength": 500,
7330                      "example": "[email protected]"
7331                    }
7332                  }
7333                }
7334              }
7335            }
7336          },
7337          "responses": {
7338            "200": {
7339              "description": "Always HTTP 200 — branch on `statusCode` and `reason`",
7340              "content": {
7341                "application/json": {
7342                  "schema": {
7343                    "oneOf": [
7344                      {
7345                        "description": "Added, unverified. A token is on its way to it.",
7346                        "type": "object",
7347                        "properties": {
7348                          "statusCode": {
7349                            "type": "integer",
7350                            "example": 201
7351                          },
7352                          "statusMessage": {
7353                            "type": "string",
7354                            "example": "Check that address for a confirmation token."
7355                          },
7356                          "data": {
7357                            "$ref": "#/components/schemas/AccountEmail"
7358                          }
7359                        }
7360                      },
7361                      {
7362                        "description": "The address is malformed or missing.",
7363                        "type": "object",
7364                        "properties": {
7365                          "statusCode": {
7366                            "type": "integer",
7367                            "example": 400
7368                          },
7369                          "reason": {
7370                            "type": "string",
7371                            "enum": [
7372                              "MISSING_FIELD",
7373                              "VALIDATION_ERROR"
7374                            ],
7375                            "example": "VALIDATION_ERROR"
7376                          },
7377                          "statusMessage": {
7378                            "type": "string"
7379                          }
7380                        }
7381                      },
7382                      {
7383                        "description": "Another account already holds that address, or it is already confirmed on this one. `EMAIL_ALREADY_ADDED` is not worth showing as an error — re-read the list instead.\n",
7384                        "type": "object",
7385                        "properties": {
7386                          "statusCode": {
7387                            "type": "integer",
7388                            "example": 409
7389                          },
7390                          "reason": {
7391                            "type": "string",
7392                            "enum": [
7393                              "EMAIL_ALREADY_REGISTERED",
7394                              "EMAIL_ALREADY_ADDED"
7395                            ],
7396                            "example": "EMAIL_ALREADY_REGISTERED"
7397                          },
7398                          "statusMessage": {
7399                            "type": "string",
7400                            "example": "That email address already belongs to another twodos account. Sign in to that account and remove the address, or delete that account, then add it here."
7401                          }
7402                        }
7403                      }
7404                    ]
7405                  }
7406                }
7407              }
7408            }
7409          }
7410        }
7411      },
7412      "/api/v1/account/emails/verify": {
7413        "post": {
7414          "tags": [
7415            "Accounts"
7416          ],
7417          "summary": "Confirm an added email address with the token sent to it",
7418          "description": "Confirming an address does **not** make it primary. The user asked to\nadd an address, not to move where their mail goes — promoting is a\nseparate, explicit act via `PUT /api/v1/account/emails/primary`.\n\n\nOnce confirmed the address can be used to sign in and to receive\ninvitations. Confirming an address that is already confirmed returns\n`200`, so a retry after a dropped response is safe.\n",
7419          "security": [
7420            {
7421              "bearerAuth": []
7422            }
7423          ],
7424          "requestBody": {
7425            "required": true,
7426            "content": {
7427              "application/json": {
7428                "schema": {
7429                  "type": "object",
7430                  "required": [
7431                    "email",
7432                    "token"
7433                  ],
7434                  "properties": {
7435                    "email": {
7436                      "type": "string",
7437                      "format": "email",
7438                      "example": "[email protected]"
7439                    },
7440                    "token": {
7441                      "type": "string",
7442                      "description": "The token from the mail sent to that address. Case-insensitive.",
7443                      "example": "A1B2C3"
7444                    }
7445                  }
7446                }
7447              }
7448            }
7449          },
7450          "responses": {
7451            "200": {
7452              "description": "Always HTTP 200 — branch on `statusCode` and `reason`",
7453              "content": {
7454                "application/json": {
7455                  "schema": {
7456                    "oneOf": [
7457                      {
7458                        "description": "Confirmed, or already confirmed.",
7459                        "type": "object",
7460                        "properties": {
7461                          "statusCode": {
7462                            "type": "integer",
7463                            "example": 200
7464                          },
7465                          "statusMessage": {
7466                            "type": "string",
7467                            "example": "That address is now confirmed. You can sign in with it, and receive invitations at it."
7468                          },
7469                          "data": {
7470                            "$ref": "#/components/schemas/AccountEmail"
7471                          }
7472                        }
7473                      },
7474                      {
7475                        "description": "The token is wrong, expired, or a field is missing. `TOKEN_EXPIRED` means add the address again to reissue.\n",
7476                        "type": "object",
7477                        "properties": {
7478                          "statusCode": {
7479                            "type": "integer",
7480                            "example": 400
7481                          },
7482                          "reason": {
7483                            "type": "string",
7484                            "enum": [
7485                              "MISSING_FIELD",
7486                              "INVALID_TOKEN",
7487                              "TOKEN_EXPIRED"
7488                            ],
7489                            "example": "INVALID_TOKEN"
7490                          },
7491                          "statusMessage": {
7492                            "type": "string"
7493                          }
7494                        }
7495                      },
7496                      {
7497                        "description": "That address is not on this account.",
7498                        "type": "object",
7499                        "properties": {
7500                          "statusCode": {
7501                            "type": "integer",
7502                            "example": 404
7503                          },
7504                          "reason": {
7505                            "type": "string",
7506                            "example": "NOT_FOUND"
7507                          },
7508                          "statusMessage": {
7509                            "type": "string"
7510                          }
7511                        }
7512                      }
7513                    ]
7514                  }
7515                }
7516              }
7517            }
7518          }
7519        }
7520      },
7521      "/api/v1/account/emails/primary": {
7522        "put": {
7523          "tags": [
7524            "Accounts"
7525          ],
7526          "summary": "Make one of the account's addresses the primary one",
7527          "description": "The primary address is where notifications, password reset, one-time\npasswords and deletion mail are sent. It is also what\n`GET /api/v1/account/me` returns as `email`.\n\n\nOnly a **confirmed** address can be promoted. Promoting an unconfirmed\none would redirect the account's recovery mail to a mailbox nobody has\nproved they can read, which is the one thing this endpoint must never\nallow.\n",
7528          "security": [
7529            {
7530              "bearerAuth": []
7531            }
7532          ],
7533          "requestBody": {
7534            "required": true,
7535            "content": {
7536              "application/json": {
7537                "schema": {
7538                  "type": "object",
7539                  "required": [
7540                    "emailId"
7541                  ],
7542                  "properties": {
7543                    "emailId": {
7544                      "type": "string",
7545                      "description": "The `id` of the address, from `GET /api/v1/account/emails`.",
7546                      "example": "9f2c1e64-2b1a-4f0a-9f3e-3a1d2b4c5d6e"
7547                    }
7548                  }
7549                }
7550              }
7551            }
7552          },
7553          "responses": {
7554            "200": {
7555              "description": "Always HTTP 200 — branch on `statusCode` and `reason`",
7556              "content": {
7557                "application/json": {
7558                  "schema": {
7559                    "oneOf": [
7560                      {
7561                        "description": "Promoted. `me.email` now returns this address.",
7562                        "type": "object",
7563                        "properties": {
7564                          "statusCode": {
7565                            "type": "integer",
7566                            "example": 200
7567                          },
7568                          "statusMessage": {
7569                            "type": "string",
7570                            "example": "That address is now your primary one."
7571                          },
7572                          "data": {
7573                            "$ref": "#/components/schemas/AccountEmail"
7574                          }
7575                        }
7576                      },
7577                      {
7578                        "description": "The address is not confirmed yet. Send the user to confirm it first — the token was mailed to it when it was added.\n",
7579                        "type": "object",
7580                        "properties": {
7581                          "statusCode": {
7582                            "type": "integer",
7583                            "example": 400
7584                          },
7585                          "reason": {
7586                            "type": "string",
7587                            "enum": [
7588                              "MISSING_FIELD",
7589                              "EMAIL_NOT_VERIFIED"
7590                            ],
7591                            "example": "EMAIL_NOT_VERIFIED"
7592                          },
7593                          "statusMessage": {
7594                            "type": "string",
7595                            "example": "Confirm that address before making it your primary one. Password resets and one-time passwords are sent there."
7596                          }
7597                        }
7598                      },
7599                      {
7600                        "description": "No such address on this account.",
7601                        "type": "object",
7602                        "properties": {
7603                          "statusCode": {
7604                            "type": "integer",
7605                            "example": 404
7606                          },
7607                          "reason": {
7608                            "type": "string",
7609                            "example": "NOT_FOUND"
7610                          },
7611                          "statusMessage": {
7612                            "type": "string"
7613                          }
7614                        }
7615                      }
7616                    ]
7617                  }
7618                }
7619              }
7620            }
7621          }
7622        }
7623      },
7624      "/api/v1/account/emails/{emailId}": {
7625        "delete": {
7626          "tags": [
7627            "Accounts"
7628          ],
7629          "summary": "Remove an email address from the account",
7630          "description": "The address stops working for sign-in and for invitations immediately,\nand becomes available for somebody else to register.\n\n\n**The primary address cannot be removed.** That refusal\n(`PRIMARY_EMAIL`) needs a screen rather than a toast, the same way\n`LAST_LOGIN_METHOD` does on unlinking a provider: the answer is to\npromote a different address first, then retry.\n",
7631          "security": [
7632            {
7633              "bearerAuth": []
7634            }
7635          ],
7636          "parameters": [
7637            {
7638              "in": "path",
7639              "name": "emailId",
7640              "required": true,
7641              "schema": {
7642                "type": "string"
7643              },
7644              "description": "The `id` from `GET /api/v1/account/emails`."
7645            }
7646          ],
7647          "responses": {
7648            "200": {
7649              "description": "Always HTTP 200 — branch on `statusCode` and `reason`",
7650              "content": {
7651                "application/json": {
7652                  "schema": {
7653                    "oneOf": [
7654                      {
7655                        "description": "Removed.",
7656                        "type": "object",
7657                        "properties": {
7658                          "statusCode": {
7659                            "type": "integer",
7660                            "example": 200
7661                          },
7662                          "statusMessage": {
7663                            "type": "string",
7664                            "example": "That address has been removed from your account."
7665                          }
7666                        }
7667                      },
7668                      {
7669                        "description": "This is the primary address. Offer to promote another address first, then retry this call.\n",
7670                        "type": "object",
7671                        "properties": {
7672                          "statusCode": {
7673                            "type": "integer",
7674                            "example": 400
7675                          },
7676                          "reason": {
7677                            "type": "string",
7678                            "example": "PRIMARY_EMAIL"
7679                          },
7680                          "statusMessage": {
7681                            "type": "string",
7682                            "example": "That is your primary address, where password resets and one-time passwords are sent. Make another address primary first, then remove this one."
7683                          }
7684                        }
7685                      },
7686                      {
7687                        "description": "No such address on this account.",
7688                        "type": "object",
7689                        "properties": {
7690                          "statusCode": {
7691                            "type": "integer",
7692                            "example": 404
7693                          },
7694                          "reason": {
7695                            "type": "string",
7696                            "example": "NOT_FOUND"
7697                          },
7698                          "statusMessage": {
7699                            "type": "string"
7700                          }
7701                        }
7702                      }
7703                    ]
7704                  }
7705                }
7706              }
7707            }
7708          }
7709        }
7710      },
7711      "/api/v1/account/username": {
7712        "put": {
7713          "tags": [
7714            "Accounts"
7715          ],
7716          "summary": "Claim the account's username",
7717          "description": "**This succeeds once.** A username is permanent: the value of a handle\nis that inviting the one you remember reaches the person you meant, and\na handle that can change quietly stops guaranteeing that. Every later\ncall returns `409 USERNAME_ALREADY_SET`.\n\n\nBecause it cannot be undone, the screen that collects it should confirm\
7717nthe choice before sending, and say plainly that it is permanent. Check\nthe value first with `GET /api/v1/account/username-available`, and offer\n`GET /api/v1/account/username-suggestions` rather than an empty box.\n\n\nSomeone who claimed one in haste is unstuck by support clearing it, not\nby claiming over the top. A cleared handle is never reissued to anybody.\n\n\nRules: 3–24 characters, starting with a letter, lowercase letters,\nnumbers and underscores only. Stored and compared lowercased.\n",
7718          "security": [
7719            {
7720              "bearerAuth": []
7721            }
7722          ],
7723          "requestBody": {
7724            "required": true,
7725            "content": {
7726              "application/json": {
7727                "schema": {
7728                  "type": "object",
7729                  "required": [
7730                    "username"
7731                  ],
7732                  "properties": {
7733                    "username": {
7734                      "type": "string",
7735                      "minLength": 3,
7736                      "maxLength": 24,
7737                      "pattern": "^[a-z][a-z0-9_]{2,23}$",
7738                      "example": "sara_k"
7739                    }
7740                  }
7741                }
7742              }
7743            }
7744          },
7745          "responses": {
7746            "200": {
7747              "description": "Always HTTP 200 — branch on `statusCode` and `reason`",
7748              "content": {
7749                "application/json": {
7750                  "schema": {
7751                    "oneOf": [
7752                      {
7753                        "description": "Claimed. It cannot be changed from now on.",
7754                        "type": "object",
7755                        "properties": {
7756                          "statusCode": {
7757                            "type": "integer",
7758                            "example": 200
7759                          },
7760                          "statusMessage": {
7761                            "type": "string",
7762                            "example": "Your username is @sara_k. It cannot be changed later."
7763                          },
7764                          "data": {
7765                            "type": "object",
7766                            "properties": {
7767                              "username": {
7768                                "type": "string",
7769                                "example": "sara_k"
7770                              }
7771                            }
7772                          }
7773                        }
7774                      },
7775                      {
7776                        "description": "The value is not a valid handle. `statusMessage` states the rules.",
7777                        "type": "object",
7778                        "properties": {
7779                          "statusCode": {
7780                            "type": "integer",
7781                            "example": 400
7782                          },
7783                          "reason": {
7784                            "type": "string",
7785                            "enum": [
7786                              "MISSING_FIELD",
7787                              "VALIDATION_ERROR"
7788                            ],
7789                            "example": "VALIDATION_ERROR"
7790                          },
7791                          "statusMessage": {
7792                            "type": "string",
7793                            "example": "A username is 3 to 24 characters, starts with a letter, and uses only lowercase letters, numbers and underscores."
7794                          }
7795                        }
7796                      },
7797                      {
7798                        "description": "Not available, or this account already has one. `USERNAME_ALREADY_SET` is the permanent answer — do not offer a retry; show the existing handle instead. `USERNAME_TAKEN` also covers handles somebody has given up, which are never reissued.\n",
7799                        "type": "object",
7800                        "properties": {
7801                          "statusCode": {
7802                            "type": "integer",
7803                            "example": 409
7804                          },
7805                          "reason": {
7806                            "type": "string",
7807                            "enum": [
7808                              "USERNAME_ALREADY_SET",
7809                              "USERNAME_TAKEN",
7810                              "USERNAME_RESERVED"
7811                            ],
7812                            "example": "USERNAME_TAKEN"
7813                          },
7814                          "statusMessage": {
7815                            "type": "string"
7816                          }
7817                        }
7818                      }
7819                    ]
7820                  }
7821                }
7822              }
7823            }
7824          }
7825        }
7826      },
7827      "/api/v1/account/username-available": {
7828        "get": {
7829          "tags": [
7830            "Accounts"
7831          ],
7832          "summary": "Check whether a username can be claimed",
7833          "description": "Answers for one handle the caller already has in mind. It does not list\nor enumerate anything, and it never reveals whose an unavailable handle\nis — the answer is the same whether it is held by an account or was\ngiven up by one.\n\n\nAuthenticated and rate-limited, so it cannot be used to sweep the\nnamespace.\n",
7834          "security": [
7835            {
7836              "bearerAuth": []
7837            }
7838          ],
7839          "parameters": [
7840            {
7841              "in": "query",
7842              "name": "username",
7843              "required": true,
7844              "schema": {
7845                "type": "string"
7846              },
7847              "example": "sara_k"
7848            }
7849          ],
7850          "responses": {
7851            "200": {
7852              "description": "Always HTTP 200",
7853              "content": {
7854                "application/json": {
7855                  "schema": {
7856                    "type": "object",
7857                    "properties": {
7858                      "statusCode": {
7859                        "type": "integer",
7860                        "example": 200
7861                      },
7862                      "data": {
7863                        "type": "object",
7864                        "properties": {
7865                          "username": {
7866                            "type": "string",
7867                            "description": "The normalised (lowercased, trimmed) form that would be claimed.",
7868                            "example": "sara_k"
7869                          },
7870                          "available": {
7871                            "type": "boolean",
7872                            "example": false
7873                          },
7874                          "reason": {
7875                            "type": "string",
7876                            "nullable": true,
7877                            "description": "Absent when available.",
7878                            "enum": [
7879                              "VALIDATION_ERROR",
7880                              "USERNAME_RESERVED",
7881                              "USERNAME_TAKEN"
7882                            ],
7883                            "example": "USERNAME_TAKEN"
7884                          },
7885                          "statusMessage": {
7886                            "type": "string",
7887                            "nullable": true,
7888                            "description": "A sentence to show the user. Absent when available."
7889                          }
7890                        }
7891                      }
7892                    }
7893                  }
7894                }
7895              }
7896            }
7897          }
7898        }
7899      },
7900      "/api/v1/account/username-suggestions": {
7901        "get": {
7902          "tags": [
7903            "Accounts"
7904          ],
7905          "summary": "Available handles this person might want",
7906          "description": "Built from the account's display name and the local part of its primary\naddress, filtered to handles that can actually be claimed. Intended for\nthe registration and settings screens, so they can offer something\nrather than an empty box.\n\n\n**A suggestion is never applied on the user's behalf.** The handle is\npermanent, so the choice has to be theirs. An empty array is a valid\nanswer — show the free-text field.\n",
7907          "security": [
7908            {
7909              "bearerAuth": []
7910            }
7911          ],
7912          "responses": {
7913            "200": {
7914              "description": "Always HTTP 200",
7915              "content": {
7916                "application/json": {
7917                  "schema": {
7918                    "type": "object",
7919                    "properties": {
7920                      "statusCode": {
7921                        "type": "integer",
7922                        "example": 200
7923                      },
7924                      "data": {
7925                        "type": "array",
7926                        "items": {
7927                          "type": "string"
7928                        },
7929                        "example": [
7930                          "sara_kaur",
7931                          "sarak",
7932                          "sara_k1"
7933                        ]
7934                      }
7935                    }
7936                  }
7937                }
7938              }
7939            }
7940          }
7941        }
7942      },
7943      "/api/account/logout": {
7944        "post": {
7945          "tags": [
7946            "Accounts"
7947          ],
7948          "summary": "Invalidate the current Bearer token",
7949          "security": [
7950            {
7951              "bearerAuth": []
7952            }
7953          ],
7954          "responses": {
7955            "200": {
7956              "description": "Always HTTP 200 — token invalidated (or was already invali
7956d)",
7957              "content": {
7958                "application/json": {
7959                  "schema": {
7960                    "type": "object",
7961                    "properties": {
7962                      "statusCode": {
7963                        "type": "integer",
7964                        "example": 200
7965                      },
7966                      "statusMessage": {
7967                        "type": "string",
7968                        "example": "OK"
7969                      }
7970                    }
7971                  }
7972                }
7973              }
7974            }
7975          }
7976        }
7977      },
7978      "/api/account/refresh": {
7979        "post": {
7980          "tags": [
7981            "Accounts"
7982          ],
7983          "summary": "Exchange a refresh token for a new access + refresh token pair",
7984          "description": "Issues a brand-new access token and refresh token, and immediately invalidates\nthe tokens that were just exchanged (refresh-token rotation).\n\n**When to call this endpoint:**\n- **Proactively** — schedule a call a few minutes before the stored `expiresAt`.\n- **Reactively** — call immediately when any protected endpoint returns\n  `{ \"reason\": \"TOKEN_EXPIRED\" }`.\n\n**On success:** replace both stored tokens (`token` + `refreshToken`) and\nreschedule the next proactive refresh using the new `expiresAt`.\n\n**On failure:** the `reason` field tells the client what to do next:\n\n| reason | statusCode | Action |\n|---|---|---|\n| `TOKEN_EXPIRED` | 401 | Refresh token has expired — log user out, show login screen |\n| `UNAUTHORIZED` | 401 | Token not found or revoked (e.g. after logout) — log user out |\n| `FORBIDDEN` | 403 | Security event (replay attack / platform mismatch) — all sessions revoked, log out all devices, show login with security notice |\n",
7985          "requestBody": {
7986            "required": true,
7987            "content": {
7988              "application/json": {
7989                "schema": {
7990                  "$ref": "#/components/schemas/RefreshTokenRequest"
7991                }
7992              }
7993            }
7994          },
7995          "responses": {
7996            "200": {
7997              "description": "Always HTTP 200 — check `statusCode` in the body",
7998              "content": {
7999                "application/json": {
8000                  "schema": {
8001                    "oneOf": [
8002                      {
8003                        "description": "New token pair issued — replace stored tokens and reschedule refresh",
8004                        "type": "object",
8005                        "properties": {
8006                          "statusCode": {
8007                            "type": "integer",
8008                            "example": 200
8009                          },
8010                          "data": {
8011                            "type": "object",
8012                            "properties": {
8013                              "token": {
8014                                "type": "string",
8015                                "description": "New short-lived access token. Use as Authorization Bearer on all subsequent requests."
8016                              },
8017                              "expiresAt": {
8018                                "type": "string",
8019                                "format": "date-time",
8020                                "description": "When the new access token expires. Use to schedule the next refresh."
8021                              },
8022                              "refreshToken": {
8023                                "type": "string",
8024                                "description": "New refresh token. Replaces the one just used — store it securely."
8025                              },
8026                              "refreshExpiresAt": {
8027                                "type": "string",
8028                                "format": "date-time",
8029                                "description": "When the new refresh token expires."
8030                              }
8031                            }
8032                          }
8033                        }
8034                      },
8035                      {
8036                        "description": "Refresh token expired — log user out",
8037                        "type": "object",
8038                        "properties": {
8039                          "statusCode": {
8040                            "type": "integer",
8041                            "example": 401
8042                          },
8043                          "reason": {
8044                            "type": "string",
8045                            "example": "TOKEN_EXPIRED"
8046                          },
8047                          "statusMessage": {
8048                            "type": "string"
8049                          }
8050                        }
8051                      },
8052                      {
8053                        "description": "Refresh token invalid or revoked (e.g. after logout) — log user out",
8054                        "type": "object",
8055                        "properties": {
8056                          "statusCode": {
8057                            "type": "integer",
8058                            "example": 401
8059                          },
8060                          "reason": {
8061                            "type": "string",
8062                            "example": "UNAUTHORIZED"
8063                          },
8064                          "statusMessage": {
8065                            "type": "string"
8066                          }
8067                        }
8068                      },
8069                      {
8070                        "description": "Security event — all sessions revoked. Log out all devices and show login with security notice.",
8071                        "type": "object",
8072                        "properties": {
8073                          "statusCode": {
8074                            "type": "integer",
8075                            "example": 403
8076                          },
8077                          "reason": {
8078                            "type": "string",
8079                            "example": "FORBIDDEN"
8080                          },
8081                          "statusMessage": {
8082                            "type": "string"
8083                          }
8084                        }
8085                      },
8086                      {
8087                        "description": "Missing required fields",
8088                        "type": "object",
8089                        "properties": {
8090                          "statusCode": {
8091                            "type": "integer",
8092                            "example": 400
8093                          },
8094                          "reason": {
8095                            "type": "string",
8096                            "example": "MISSING_FIELD"
8097                          },
8098                          "statusMessage": {
8099                            "type": "string"
8100                          }
8101                        }
8102                      }
8103                    ]
8104                  }
8105                }
8106              }
8107            }
8108          }
8109        }
8110      },
8111      "/api/account/id/{id}": {
8112        "get": {
8113          "tags": [
8114            "Accounts"
8115          ],
8116          "summary": "Resolve a user ID to an email address (or \"Myself\" if it is the caller's own ID)",
8117          "security": [
8118            {
8119              "bearerAuth": []
8120            }
8121          ],
8122          "parameters": [
8123            {
8124              "name": "id",
8125              "in": "path",
8126              "required": true,
8127              "schema": {
8128                "type": "string"
8129              }
8130            }
8131          ],
8132          "responses": {
8133            "200": {
8134              "description": "Always HTTP 200 — check `statusCode` in the body",
8135              "content": {
8136                "application/json": {
8137                  "schema": {
8138                    "oneOf": [
8139                      {
8140                        "description": "Success — returns the user's id, name, and email",
8141                        "type": "object",
8142                        "properties": {
8143                          "statusCode": {
8144                            "type": "integer",
8145                            "example": 200
8146                          },
8147                          "data": {
8148                            "type": "object",
8149                            "properties": {
8150                              "id": {
8151                                "type": "string"
8152                              },
8153                              "name": {
8154                                "type": "string"
8155                              },
8156                              "email": {
8157                                "type": "string"
8158                              },
8159                              "username": {
8160                                "type": "string",
8161                                "nullable": true,
8162                                "description": "Their handle, or null if they have never claimed one. Null is a permanent, supported state.",
8163                                "example": "sara_k"
8164                              },
8165                              "lastSeenAt": {
8166                                "type": "string",
8167                                "format": "date-time",
8168                                "nullable": true,
8169                                "description": "When the user last connected or disconnected a socket. Null if they have never connected."
8170                              },
8171                              "avatarUrl": {
8172                                "type": "string",
8173                                "nullable": true,
8174                                "description": "Their profile picture, or null to draw initials. The same value and the same rules as on `GET /api/v1/account/partners`, so one client model decodes both.\n",
8175                                "example": "/api/v1/account/avatar/6f1e2c34-9b7a-4f10-8f2d-2a0b1c3d4e5f"
8176                              }
8177                            }
8178                          }
8179                        }
8180                      },
8181                      {
8182                        "description": "Unknown ID or not a partner",
8183                        "type": "object",
8184                        "properties": {
8185                          "statusCode": {
8186                            "type": "integer",
8187                            "example": 404
8188                          },
8189                          "statusMessage": {
8190                            "type": "string",
8191                            "example": "User not found."
8192                          }
8193                        }
8194                      }
8195                    ]
8196                  }
8197                }
8198              }
8199            }
8200          }
8201        }
8202      },
8203      "/api/account/request-otp": {
8204        "post": {
8205          "tags": [
8206            "Accounts"
8207          ],
8208          "summary": "Request a one-time password sent to the user's email",
8209          "description": "Generates a short-lived OTP and emails it to the user. The OTP can then\nbe exchanged for a Bearer token via `POST /api/account/login-otp`.\nAlways returns 200 to avoid leaking account existence.\nOTP expiry is controlled by the `otp_expiry_minutes` configuration setting.\n",
8210          "requestBody": {
8211            "required": true,
8212            "content": {
8213              "application/json": {
8214                "schema": {
8215                  "$ref": "#/components/schemas/OtpRequest"
8216                }
8217              }
8218            }
8219          },
8220          "responses": {
8221            "200": {
8222              "description": "Always HTTP 200 — OTP sent (or silently ignored if account does not exist)",
8223              "content": {
8224                "application/json": {
8225                  "schema": {
8226                    "type": "object",
8227                    "properties": {
8228                      "statusCode": {
8229                        "type": "integer",
8230                        "example": 200
8231                      },
8232                      "statusMessage": {
8233                        "type": "string",
8234                        "example": "If an account exists with this email, a one-time password has been sent."
8235                      }
8236                    }
8237                  }
8238                }
8239              }
8240            }
8241          }
8242        }
8243      },
8244      "/api/account/login-otp": {
8245        "post": {
8246          "tags": [
8247            "Accounts"
8248          ],
8249          "summary": "Log in using a one-time password",
8250          "description": "Exchange a valid OTP (obtained via `POST /api/account/request-otp`) for\na Bearer token. The OTP is single-use — it is cleared after a successful\nlogin. Failed attempts count towards the account lock threshold.\nThe returned Bearer token works identically to a password-based login\ntoken and can be invalidated via `POST /api/account/logout`.\n",
8251          "requestBody": {
8252            "required": true,
8253            "content": {
8254              "application/json": {
8255                "schema": {
8256                  "$ref": "#/components/schemas/OtpLoginRequest"
8257                }
8258              }
8259            }
8260          },
8261          "responses": {
8262            "200": {
8263              "description": "Always HTTP 200 — check `statusCode` in the body",
8264              "content": {
8265                "application/json": {
8266                  "schema": {
8267                    "oneOf": [
8268                      {
8269                        "description": "OTP accepted — Bearer token issued",
8270                        "type": "object",
8271                        "properties": {
8272                          "statusCode": {
8273                            "type": "integer",
8274                            "example": 200
8275                          },
8276                          "data": {
8277                            "type": "object",
8278                            "properties": {
8279                              "token": {
8280                                "type": "string"
8281                              },
8282                              "expiresAt": {
8283                                "type": "string",
8284                                "format": "date-time"
8285                              }
8286                            }
8287                          }
8288                        }
8289                      },
8290                      {
8291                        "description": "OTP invalid, expired, or account locked",
8292                        "type": "object",
8293                        "properties": {
8294                          "statusCode": {
8295                            "type": "integer",
8296                            "example": 401
8297                          },
8298                          "statusMessage": {
8299                            "type": "string",
8300                            "example": "The one-time password is invali
8300d or has expired."
8301                          }
8302                        }
8303                      }
8304                    ]
8305                  }
8306                }
8307              }
8308            }
8309          }
8310        }
8311      },
8312      "/api/account/social-login": {
8313        "post": {
8314          "tags": [
8315            "Accounts"
8316          ],
8317          "summary": "Sign in or sign up with a social identity provider",
8318          "description": "Accepts an identity token from Google, Facebook, or Apple and returns\na Bearer token identical to the one issued by the standard login endpoint.\n\n**How an account is found.** First by the provider's own user\nidentifier — the same Google account signing in from iOS and from\nAndroid carries a different `aud` and an identical `sub`, so both land\non one account with no further setup. Only if that identity has never\nbeen seen does the server fall back to matching on email, and only when\nthe provider states it has verified the address.\n\n**One account per email** is enforced on that fallback: if the verified\nemail returned by the provider already exists, the social account is\nlinked to the existing user rather than creating a duplicate.\n\n**When the addresses differ.** Two providers holding two different\naddresses for one person are two different accounts as far as this\nendpoint can tell. To join them, sign in and call\n`POST /api/v1/account/social-links` — see that endpoint for the flow.\n\n**Test / CI mode** (MOCHA_TESTING=true): pass a mock token to bypass\nexternal API calls.\nFormat: `mock:<provider>:<email>:<name>:<providerUserId>`\nExample: `mock:google:[email protected]:Test User:g-uid-001`\n\n**Server configuration per provider** (dashboard config row, or the\nenvironment variable as a fallback):\n- Google:   `social_google_ios_client_id` / `social_google_android_client_id`\n  (`GOOGLE_IOS_CLIENT_ID`, `GOOGLE_ANDROID_CLIENT_ID`) — a token from a\n  handset carries the platform client ID as its `aud`, so the web client\n  ID alone will reject every real mobile sign-in.\n- Facebook: `social_facebook_app_id`, `social_facebook_app_secret`\n  (`FACEBOOK_APP_ID`, `FACEBOOK_APP_SECRET`)\n- Apple:    `social_apple_client_id` (`APPLE_CLIENT_ID`) — iOS only.\n",
8319          "requestBody": {
8320            "required": true,
8321            "content": {
8322              "application/json": {
8323                "schema": {
8324                  "$ref": "#/components/schemas/SocialLoginRequest"
8325                }
8326              }
8327            }
8328          },
8329          "responses": {
8330            "200": {
8331              "description": "Always HTTP 200 — check `statusCode` in the body",
8332              "content": {
8333                "application/json": {
8334                  "schema": {
8335                    "oneOf": [
8336                      {
8337                        "description": "Authenticated (sign-in or sign-up) — Bearer token + refresh token issued",
8338                        "type": "object",
8339                        "properties": {
8340                          "statusCode": {
8341                            "type": "integer",
8342                            "example": 200
8343                          },
8344                          "data": {
8345                            "$ref": "#/components/schemas/TokenResponse"
8346                          }
8347                        }
8348                      },
8349                      {
8350                        "description": "Missing required fields",
8351                        "type": "object",
8352                        "properties": {
8353                          "statusCode": {
8354                            "type": "integer",
8355                            "example": 400
8356                          },
8357                          "reason": {
8358                            "type": "string",
8359                            "example": "MISSING_FIELD"
8360                          },
8361                          "statusMessage": {
8362                            "type": "string",
8363                            "example": "The fields \"provider\" and \"token\" are required."
8364                          }
8365                        }
8366                      },
8367                      {
8368                        "description": "Token rejected by the identity provider, or the provider returned an error",
8369                        "type": "object",
8370                        "properties": {
8371                          "statusCode": {
8372                            "type": "integer",
8373                            "example": 401
8374                          },
8375                          "reason": {
8376                            "type": "string",
8377                            "example": "UNAUTHORIZED"
8378                          },
8379                          "statusMessage": {
8380                            "type": "string",
8381                            "example": "Token verification failed."
8382                          }
8383                        }
8384                      },
8385                      {
8386                        "description": "The provider has not verified the email address on the\nsocial account, so it cannot be used to reach an existing\ntwodos account. Show the message and send the user to\nverify the address with their provider.\n",
8387                        "type": "object",
8388                        "properties": {
8389                          "statusCode": {
8390                            "type": "integer",
8391                            "example": 401
8392                          },
8393                          "reason": {
8394                            "type": "string",
8395                            "example": "EMAIL_NOT_VERIFIED"
8396                          },
8397                          "statusMessage": {
8398                            "type": "string",
8399                            "example": "The provider has not verified the email address on this account, so it cannot be used to sign in. Verify the address with your provider and try again."
8400                          }
8401                        }
8402                      },
8403                      {
8404                        "description": "A *different* account at the same provider is already\nlinked to the twodos account holding this email address.\nOffer to sign in with the originally linked account, or to\nunlink it from within the app first.\n",
8405                        "type": "object",
8406                        "properties": {
8407                          "statusCode": {
8408                            "type": "integer",
8409                            "example": 409
8410                          },
8411                          "reason": {
8412                            "type": "string",
8413                            "example": "PROVIDER_ALREADY_LINKED"
8414                          },
8415                          "statusMessage": {
8416                            "type": "string",
8417                            "example": "A different google account is already linked to this twodos account. Unlink it first, then link this one."
8418                          }
8419                        }
8420                      },
8421                      {
8422                        "description": "Account is locked and cannot be signed in to",
8423                        "type": "object",
8424                        "properties": {
8425                          "statusCode": {
8426                            "type": "integer",
8427                            "example": 423
8428                          },
8429                          "reason": {
8430                            "type": "string",
8431                            "example": "ACCOUNT_LOCKED"
8432                          },
8433                          "statusMessage": {
8434                            "type": "string",
8435                            "example": "This account is locked. Contact support."
8436                          }
8437                        }
8438                      },
8439                      {
8440                        "description": "The named provider is not one this API supports. Only\n`google`, `facebook` and `apple` are accepted.\n",
8441                        "type": "object",
8442                        "properties": {
8443                          "statusCode": {
8444                            "type": "integer",
8445                            "example": 400
8446                          },
8447                          "reason": {
8448                            "type": "string",
8449                            "example": "INVALID_PROVIDER"
8450                          },
8451                          "statusMessage": {
8452                            "type": "string",
8453                            "example": "Unknown provider \"myspace\". Use \"google\", \"facebook\", or \"apple\"."
8454                          }
8455                        }
8456                      },
8457                      {
8458                        "description": "The provider is switched off by the operator, or no\ncredentials for it are configured on this server — the two\nare reported identically, because a provider with no\ncredentials cannot sign anyone in whatever the toggle says.\n\n**This is not the user's fault and retrying c
8458annot fix\nit.** Do not send the user back through the provider\nsheet, and do not clear their session: hide the button and\noffer another way in. `GET /api/v1/client-config` reports\nwhich providers are usable, so a client that reads it at\nlaunch will not have offered the button at all.\n",
8459                        "type": "object",
8460                        "properties": {
8461                          "statusCode": {
8462                            "type": "integer",
8463                            "example": 401
8464                          },
8465                          "reason": {
8466                            "type": "string",
8467                            "example": "PROVIDER_DISABLED"
8468                          },
8469                          "statusMessage": {
8470                            "type": "string",
8471                            "example": "Sign-in with \"apple\" is currently disabled."
8472                          }
8473                        }
8474                      }
8475                    ]
8476                  }
8477                }
8478              }
8479            }
8480          }
8481        }
8482      },
8483      "/api/account/social/apple/start": {
8484        "get": {
8485          "tags": [
8486            "Accounts"
8487          ],
8488          "summary": "Begin the Apple web sign-in flow (Android only)",
8489          "description": "Redirects (302) to Apple's authorisation page. Android opens this URL in\na Custom Tab; iOS does not use it, because the native Sign in with Apple\nsheet returns an identity token directly.\n\nThe result arrives back at `/api/account/social/apple/callback`, which\nredirects to `<scheme>://social/apple` carrying either `id_token` (plus\n`name` on the very first authorisation) or `error`.\n",
8490          "parameters": [
8491            {
8492              "in": "query",
8493              "name": "state",
8494              "required": true,
8495              "schema": {
8496                "type": "string"
8497              },
8498              "description": "Opaque value generated by the app and echoed back unchanged. The app must reject a mismatch."
8499            }
8500          ],
8501          "responses": {
8502            "302": {
8503              "description": "Redirect to Apple"
8504            },
8505            "400": {
8506              "description": "The Apple web flow is not configured on this server"
8507            }
8508          }
8509        }
8510      },
8511      "/api/account/social/apple/callback": {
8512        "post": {
8513          "tags": [
8514            "Accounts"
8515          ],
8516          "summary": "Receive Apple's form_post and bounce it back to the app",
8517          "description": "Called by **Apple**, not by the client. Redirects (302) to\n`<scheme>://social/apple` with `id_token`, `state` and — only on the\nuser's first authorisation — `name`. On failure it carries `error`\ninstead, so the app always gets a callback and never hangs on an open\nbrowser tab.\n\nApple sends the display name exactly once, in a `user` field holding\nJSON, and never again; it is forwarded here for the same reason iOS\nforwards it from the native sheet.\n",
8518          "responses": {
8519            "302": {
8520              "description": "Redirect back into the app over the custom scheme"
8521            }
8522          }
8523        }
8524      },
8525      "/api/account/social/facebook/start": {
8526        "get": {
8527          "tags": [
8528            "Accounts"
8529          ],
8530          "summary": "Begin the Facebook web sign-in flow (mobile)",
8531          "description": "Redirects (302) to Facebook's authorisation dialog. The app opens this\nURL in a Custom Tab or equivalent.\n\nThe result arrives back at `/api/account/social/facebook/callback`,\nwhich redirects to `<scheme>://social/facebook` carrying either\n`access_token` or `error`.\n",
8532          "parameters": [
8533            {
8534              "in": "query",
8535              "name": "state",
8536              "required": true,
8537              "schema": {
8538                "type": "string"
8539              },
8540              "description": "Opaque value generated by the app and echoed back unchanged. The app must reject a mismatch."
8541            }
8542          ],
8543          "responses": {
8544            "302": {
8545              "description": "Redirect to Facebook"
8546            },
8547            "400": {
8548              "description": "The Facebook web flow is not configured on this server"
8549            }
8550          }
8551        }
8552      },
8553      "/api/account/social/facebook/callback": {
8554        "get": {
8555          "tags": [
8556            "Accounts"
8557          ],
8558          "summary": "Receive Facebook's redirect, exchange the code, and bounce it back to the app",
8559          "description": "Called by **Facebook**, not by the client. Exchanges the authorisation\ncode for a user access token and redirects (302) to\n`<scheme>://social/facebook` with `access_token` and `state`. On any\nfailure it carries `error` instead, so the app always gets a callback\nand never hangs on an open browser tab.\n",
8560          "responses": {
8561            "302": {
8562              "description": "Redirect back into the app over the custom scheme"
8563            }
8564          }
8565        }
8566      },
8567      "/api/account/social-links": {
8568        "post": {
8569          "tags": [
8570            "Accounts"
8571          ],
8572          "summary": "Link an additional social provider to the signed-in account",
8573          "description": "Attaches a provider identity to **the account the bearer token belongs\nto**, rather than to whichever account shares the provider's email\naddress. Use this whenever the user wants one account across platforms\nand the addresses do not match.\n\n**When you need this rather than `POST /social-login`:**\n`social-login` finds an existing account by email. That is enough when\nthe same address is on both providers. It is *not* enough when a person\nsigned up with Facebook as `[email protected]` and now wants to use\nGoogle as `[email protected]` — those look like two different people from\nthe outside, and `social-login` would create a second account. Calling\nthis endpoint while signed in says \"both of these are me\", with the\nbearer token as the proof.\n\n**Typical journey.** The user signs in with Facebook on the web, calls\nthis endpoint with a Google token, and from then on `POST /social-login`\nwith a Google token from an Android handset returns a session on that\nsame account.\n\nLinking is keyed on the provider's own user identifier, not on the\nemail, so it survives the user later changing the address on their\
8573nprovider account. It is idempotent: re-linking the same identity\nreturns 200 and refreshes the stored address.\n",
8574          "security": [
8575            {
8576              "bearerAuth": []
8577            }
8578          ],
8579          "requestBody": {
8580            "required": true,
8581            "content": {
8582              "application/json": {
8583                "schema": {
8584                  "type": "object",
8585                  "required": [
8586                    "provider",
8587                    "token"
8588                  ],
8589                  "properties": {
8590                    "provider": {
8591                      "type": "string",
8592                      "enum": [
8593                        "google",
8594                        "facebook",
8595                        "apple"
8596                      ],
8597                      "description": "Which identity provider issued the token.",
8598                      "example": "google"
8599                    },
8600                    "token": {
8601                      "type": "string",
8602                      "description": "The provider's token. Google and Apple send the OIDC\n`id_token`; Facebook sends the user access token.\n",
8603                      "example": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2NzUw..."
8604                    }
8605                  }
8606                }
8607              }
8608            }
8609          },
8610          "responses": {
8611            "200": {
8612              "description": "Already linked to this same provider identity. Nothing changed\nexcept a refresh of the recorded provider email.\n",
8613              "content": {
8614                "application/json": {
8615                  "schema": {
8616                    "$ref": "#/components/schemas/SocialLinkResponse"
8617                  }
8618                }
8619              }
8620            },
8621            "201": {
8622              "description": "Provider linked. Signing in with it now returns this account.",
8623              "content": {
8624                "application/json": {
8625                  "schema": {
8626                    "$ref": "#/components/schemas/SocialLinkResponse"
8627                  }
8628                }
8629              }
8630            },
8631            "400": {
8632              "description": "The request body is incomplete or names an unknown provider.",
8633              "content": {
8634                "application/json": {
8635                  "schema": {
8636                    "type": "object",
8637                    "properties": {
8638                      "statusCode": {
8639                        "type": "integer",
8640                        "example": 400
8641                      },
8642                      "reason": {
8643                        "type": "string",
8644                        "enum": [
8645                          "MISSING_FIELD",
8646                          "INVALID_PROVIDER"
8647                        ],
8648                        "example": "MISSING_FIELD"
8649                      },
8650                      "statusMessage": {
8651                        "type": "string",
8652                        "example": "The fields \"provider\" and \"token\" are required."
8653                      }
8654                    }
8655                  }
8656                }
8657              }
8658            },
8659            "401": {
8660              "description": "The bearer token is missing or expired, or the provider rejected the\nsocial token. `INVALID_TOKEN` means the social token failed\nverification — have the user repeat the provider sign-in and retry.\n",
8661              "content": {
8662                "application/json": {
8663                  "schema": {
8664                    "type": "object",
8665                    "properties": {
8666                      "statusCode": {
8667                        "type": "integer",
8668                        "example": 401
8669                      },
8670                      "reason": {
8671                        "type": "string",
8672                        "enum": [
8673                          "UNAUTHORIZED",
8674                          "INVALID_TOKEN"
8675                        ],
8676                        "example": "INVALID_TOKEN"
8677                      },
8678                      "statusMessage": {
8679                        "type": "string",
8680                        "example": "Invalid Google token: missing email claim."
8681                      }
8682                    }
8683                  }
8684                }
8685              }
8686            },
8687            "404": {
8688              "description": "The account behind the bearer token no longer exists.",
8689              "content": {
8690                "application/json": {
8691                  "schema": {
8692                    "type": "object",
8693                    "properties": {
8694                      "statusCode": {
8695                        "type": "integer",
8696                        "example": 404
8697                      },
8698                      "reason": {
8699                        "type": "string",
8700                        "example": "NOT_FOUND"
8701                      },
8702                      "statusMessage": {
8703                        "type": "string",
8704                        "example": "User not found."
8705                      }
8706                    }
8707                  }
8708                }
8709              }
8710            },
8711            "409": {
8712              "description": "The link cannot be made without the user resolving a conflict first.\n\n- `PROVIDER_ALREADY_LINKED` — this provider account belongs to a\n  *different* twodos account. Tell the user to sign in to that one\n  and unlink 
8712it, or to sign in with it instead.\n- `PROVIDER_SLOT_TAKEN` — this twodos account already has a\n  different account from the same provider linked. Offer to unlink\n  it via `DELETE /account/social-links/{provider}` and retry.\n- `EMAIL_ALREADY_REGISTERED` — the *email address* on this provider\n  account already belongs to a separate twodos account. Linking\n  anyway would leave two accounts for one mailbox and a sign-in that\n  resolves to whichever was reached first. The user either deletes\n  that other account — they can reach it, since they have just\n  proved they read its mailbox, via `POST /account/request-otp` if\n  they have no password for it — or links a different provider\n  account here.\n",
8713              "content": {
8714                "application/json": {
8715                  "schema": {
8716                    "type": "object",
8717                    "properties": {
8718                      "statusCode": {
8719                        "type": "integer",
8720                        "example": 409
8721                      },
8722                      "reason": {
8723                        "type": "string",
8724                        "enum": [
8725                          "PROVIDER_ALREADY_LINKED",
8726                          "PROVIDER_SLOT_TAKEN",
8727                          "EMAIL_ALREADY_REGISTERED"
8728                        ],
8729                        "example": "PROVIDER_ALREADY_LINKED"
8730                      },
8731                      "statusMessage": {
8732                        "type": "string",
8733                        "example": "This google account is already linked to a different twodos account. Sign in to that account and unlink it first."
8734                      }
8735                    }
8736                  }
8737                }
8738              }
8739            }
8740          }
8741        },
8742        "get": {
8743          "tags": [
8744            "Accounts"
8745          ],
8746          "summary": "List the social providers linked to the authenticated user",
8747          "security": [
8748            {
8749              "bearerAuth": []
8750            }
8751          ],
8752          "responses": {
8753            "200": {
8754              "description": "Array of linked social providers (empty array if none)",
8755              "content": {
8756                "application/json": {
8757                  "schema": {
8758                    "type": "object",
8759                    "properties": {
8760                      "statusCode": {
8761                        "type": "integer",
8762                        "example": 200
8763                      },
8764                      "data": {
8765                        "type": "array",
8766                        "items": {
8767                          "type": "object",
8768                          "properties": {
8769                            "provider": {
8770                              "type": "string",
8771                              "enum": [
8772                                "google",
8773                                "facebook",
8774                                "apple"
8775                              ]
8776                            },
8777                            "providerEmail": {
8778                              "type": "string",
8779                              "format": "email"
8780                            },
8781                            "linkedAt": {
8782                              "type": "string",
8783                              "format": "date-time"
8784                            },
8785                            "hasPicture": {
8786                              "type": "boolean",
8787                              "description": "Whether this connection supplied a profile picture — which is the same question as whether it can be offered at `PATCH /api/v1/account/avatar`. Always false for Apple, which supplies none, and false for a Google or Facebook account whose owner has never set one. Offer the choice only where this is true.\n",
8788                              "example": true
8789                            }
8790                          }
8791                        }
8792                      }
8793                    }
8794                  }
8795                }
8796              }
8797            }
8798          }
8799        }
8800      },
8801      "/api/account/social-links/{provider}": {
8802        "delete": {
8803          "tags": [
8804            "Accounts"
8805          ],
8806          "summary": "Unlink a social provider from the authenticated user's account",
8807          "security": [
8808            {
8809              "bearerAuth": []
8810            }
8811          ],
8812          "parameters": [
8813            {
8814              "in": "path",
8815              "name": "provider",
8816              "required": true,
8817              "schema": {
8818                "type": "string",
8819                "enum": [
8820                  "google",
8821                  "facebook",
8822                  "apple"
8823                ]
8824              }
8825            }
8826          ],
8827          "description": "Removes the link between this account and that provider. The account\nitself is untouched, and nothing the user owns is deleted — only the\nability to sign in with that provider.\n\n**An account must keep at least one way in.** The last remaining link\ncannot be removed from an account that has no password; that refusal\ncomes back as `LAST_LOGIN_METHOD` and is the one error here that needs a\nscreen rather than a message. Send the user to `POST /api/v1/account/set-password` first,\nthen retry.\n\nUnlinking does not revoke sessions already issued. The user stays signed\nin on every device until each token expires.\
8827n",
8828          "responses": {
8829            "200": {
8830              "description": "Always HTTP 200 — check `statusCode` in the body",
8831              "content": {
8832                "application/json": {
8833                  "schema": {
8834                    "oneOf": [
8835                      {
8836                        "description": "Provider unlinked. Signing in with it no longer reaches this account.",
8837                        "type": "object",
8838                        "properties": {
8839                          "statusCode": {
8840                            "type": "integer",
8841                            "example": 200
8842                          },
8843                          "statusMessage": {
8844                            "type": "string",
8845                            "example": "google account unlinked."
8846                          }
8847                        }
8848                      },
8849                      {
8850                        "description": "The provider named in the path is not one this API\nsupports. Only `google`, `facebook` and `apple` are\naccepted.\n",
8851                        "type": "object",
8852                        "properties": {
8853                          "statusCode": {
8854                            "type": "integer",
8855                            "example": 400
8856                          },
8857                          "reason": {
8858                            "type": "string",
8859                            "example": "INVALID_PROVIDER"
8860                          },
8861                          "statusMessage": {
8862                            "type": "string",
8863                            "example": "Unknown provider \"myspace\". Use \"google\", \"facebook\", or \"apple\"."
8864                          }
8865                        }
8866                      },
8867                      {
8868                        "description": "That provider was not linked to this account. Safe to\ntreat as success on a retry — the end state the caller\nasked for is the state the account is already in.\n",
8869                        "type": "object",
8870                        "properties": {
8871                          "statusCode": {
8872                            "type": "integer",
8873                            "example": 400
8874                          },
8875                          "reason": {
8876                            "type": "string",
8877                            "example": "NOT_LINKED"
8878                          },
8879                          "statusMessage": {
8880                            "type": "string",
8881                            "example": "No google account is linked."
8882                          }
8883                        }
8884                      },
8885                      {
8886                        "description": "This is the account's only remaining way in — no password\nis set and no other provider is linked. Removing it would\nlock the user out of their own account, so it is refused.\n\nOffer to set a password (`POST /api/v1/account/set-password`)\nor to link a second provider first, then retry.\n",
8887                        "type": "object",
8888                        "properties": {
8889                          "statusCode": {
8890                            "type": "integer",
8891                            "example": 400
8892                          },
8893                          "reason": {
8894                            "type": "string",
8895                            "example": "LAST_LOGIN_METHOD"
8896                          },
8897                          "statusMessage": {
8898                            "type": "string",
8899                            "example": "Cannot unlink the only login method. Set a password first."
8900                          }
8901                        }
8902                      },
8903                      {
8904                        "description": "The account behind the bearer token no longer exists.",
8905                        "type": "object",
8906                        "properties": {
8907                          "statusCode": {
8908                            "type": "integer",
8909                            "example": 404
8910                          },
8911                          "reason": {
8912                            "type": "string",
8913                            "example": "NOT_FOUND"
8914                          },
8915                          "statusMessage": {
8916                            "type": "string",
8917                            "example": "User not found."
8918                          }
8919                        }
8920                      }
8921                    ]
8922                  }
8923                }
8924              }
8925            }
8926          }
8927        }
8928      },
8929      "/api/account/avatar": {
8930        "patch": {
8931          "tags": [
8932            "Accounts"
8933          ],
8934          "summary": "Choose which linked provider's picture the account shows",
8935          "description": "Pictures are never uploaded. They arrive with a provider: signing in with Google or Facebook, or connecting one at `POST /api/v1/account/social-links`, fetches that provider's current picture and keeps a copy. This endpoint only chooses between the copies already held, or turns the picture off.\n\n**`provider` must be present, and `null` is a value.** Sending `{\"provider\": null}` goes back to initials; omitting the key entirely is a client bug and is refused with `MISSING_FIELD`, because the two are too easy to confuse and one of them silently clears the user's picture.\n\nTurning the picture off keeps the stored copies, so the choice can be reversed from the same screen without signing in again. Unlinking the provider is what destroys the picture.\n\nRead the new state from `GET /api/v1/account/me` → `avatarUrl` and `avatarProvider`, and which providers can be chosen from `GET /api/v1/account/social-links` → `hasPicture`.\n",
8936          "security": [
8937            {
8938              "bearerAuth": []
8939            }
8940          ],
8941          "requestBody": {
8942            "required": true,
8943            "content": {
8944              "application/json": {
8945                "schema": {
8946                  "type": "object",
8947                  "required": [
8948                    "provider"
8949                  ],
8950                  "properties": {
8951                    "provider": {
8952                      "type": "string",
8953                      "nullable": true,
8954                      "enum": [
8955                        "google",
8956                        "facebook",
8957                        "apple",
8958                        null
8959                      ],
8960                      "description": "The linked provider whose picture to show, or `null` for initials. `apple` is a valid provider name but never has a picture — Sign in with Apple supplies none — so it answers `NO_PICTURE`.\n",
8961                      "example": "google"
8962                    }
8963                  }
8964                }
8965              }
8966            }
8967          },
8968          "responses": {
8969            "200": {
8970              "description": "Always HTTP 200 — check `statusCode` in the body",
8971              "content": {
8972                "application/json": {
8973                  "schema": {
8974                    "oneOf": [
8975                      {
8976                        "description": "The choice was applied",
8977                        "type": "object",
8978                        "properties": {
8979                          "statusCode": {
8980                            "type": "integer",
8981                            "example": 200
8982                          },
8983                          "data": {
8984                            "type": "object",
8985                            "properties": {
8986                              "avatarUrl": {
8987                                "type": "string",
8988                                "nullable": true,
8989                                "description": "The picture now in use, or null for initials. Absolute when this deployment knows its own public origin, a bare path otherwise — resolve it against the API base URL you already hold. Immutable, so cache it for as long as you like.\n",
8990                                "example": "/api/v1/account/avatar/6f1e2c34-9b7a-4f10-8f2d-2a0b1c3d4e5f"
8991                              },
8992                              "avatarProvider": {
8993                                "type": "string",
8994                                "nullable": true,
8995                                "enum": [
8996                                  "google",
8997                                  "facebook",
8998                                  "apple",
8999                                  null
9000                                ],
9001                                "example": "google"
9002                              }
9003                            }
9004                          }
9005                        }
9006                      },
9007                      {
9008                        "description": "The `provider` key was missing from the body",
9009                        "type": "object",
9010                        "properties": {
9011                          "statusCode": {
9012                            "type": "integer",
9013                            "example": 400
9014                          },
9015                          "reason": {
9016                            "type": "string",
9017                            "example": "MISSING_FIELD"
9018                          },
9019                          "statusMessage": {
9020                            "type": "string",
9021                            "example": "Send a \"provider\" field. Use a provider name to show that account's picture, or null to go back to initials — omitting the field is not the same as sending null."
9022                          }
9023                        }
9024                      },
9025                      {
9026                        "description": "Not one of the three supported providers",
9027                        "type": "object",
9028                        "properties": {
9029                          "statusCode": {
9030                            "type": "integer",
9031                            "example": 400
9032                          },
9033                          "reason": {
9034                            "type": "string",
9035                            "example": "INVALID_PROVIDER"
9036                          },
9037                          "statusMessage": {
9038                            "type": "string",
9039                            "example": "Unknown provider \"myspace\". Use \"google\", \"facebook\", or \"apple\", or null for initials."
9040                          }
9041                        }
9042                      },
9043                      {
9044                        "description": "No valid bearer token",
9045                        "type": "object",
9046                        "properties": {
9047                          "statusCode": {
9048                            "type": "integer",
9049                            "example": 401
9050                          },
9051                          "reason": {
9052                            "type": "string",
9053                            "example": "UNAUTHORIZED"
9054                          },
9055                          "statusMessage": {
9056                            "type": "string",
9057                            "example": "Your session has expired. Please refresh your token or log in again."
9058                          }
9059                        }
9060                      },
9061                      {
9062                        "description": "That provider is not connected to this account, or it is connected but supplied no picture. The two are different problems: the first is fixed by connecting the account, the second cannot be fixed by connecting anything.\n",
9063                        "type": "object",
9064                        "properties": {
9065                          "statusCode": {
9066                            "type": "integer",
9067                            "example": 404
9068                          },
9069                          "reason": {
9070                            "type": "string",
9071                            "enum": [
9072                              "PROVIDER_NOT_LINKED",
9073                              "NO_PICTURE",
9074                              "NOT_FOUND"
9075                            ],
9076                            "example": "NO_PICTURE"
9077                          },
9078                          "statusMessage": {
9079                            "type": "string",
9080                            "example": "Your google account has no profile picture for us to use. Set one with google, then sign in with it again to bring it across."
9081                          }
9082                        }
9083                      }
9084                    ]
9085                  }
9086                }
9087              }
9088            }
9089          }
9090        }
9091      },
9092      "/api/account/change-email": {
9093        "post": {
9094          "tags": [
9095            "Accounts"
9096          ],
9097          "summary": "Request a change of the account's email address",
9098          "description": "Step one of two. Verifies the caller's current password, then sends a confirmation token to the **new** address. The account keeps its current address, and the user keeps 
9098signing in with it, until `POST /api/account/confirm-email-change` is called with that token.\n\nA notification is also sent to the **current** address so the change cannot happen silently. That message carries no token.\n\nThe token expires after 24 hours. Requesting again replaces any token already outstanding. Changing the account password cancels a pending request.\n\n**Two ways to prove who you are, and which one applies depends on the account.** An account with a password sends `currentPassword`. An account created through Google, Apple or Facebook has none to send, so it sends a **fresh** `provider` + `token` pair instead — running the provider sheet again is the same act as typing the password. The identity in that token must already be linked to this account.\n\nRead `hasPassword` from `GET /api/v1/account/me` to decide which to collect. Sending `currentPassword` on a password-less account is refused with `SOCIAL_REAUTH_REQUIRED`, and sending a provider token on an account that has a password is ignored — the password is checked instead.\n\nThis is how a social account's address stops being frozen at whatever the provider supplied on the first sign-in. Sign-in itself is unaffected: it resolves on the provider's user id, not the address, so the account is still reached with the same button afterwards.\n",
9099          "security": [
9100            {
9101              "bearerAuth": []
9102            }
9103          ],
9104          "requestBody": {
9105            "required": true,
9106            "content": {
9107              "application/json": {
9108                "schema": {
9109                  "type": "object",
9110                  "required": [
9111                    "newEmail"
9112                  ],
9113                  "properties": {
9114                    "newEmail": {
9115                      "type": "string",
9116                      "format": "email",
9117                      "maxLength": 500,
9118                      "example": "[email protected]",
9119                      "description": "The address to move the account to. Must not already be registered."
9120                    },
9121                    "currentPassword": {
9122                      "type": "string",
9123                      "example": "MyPassword123",
9124                      "description": "The signed-in user's current password. Required when the account has one (`me.hasPassword === true`) — this is the check that stops a stolen session moving the account. Omit it for a social-only account and send `provider` + `token` instead.\n"
9125                    },
9126                    "provider": {
9127                      "type": "string",
9128                      "enum": [
9129                        "google",
9130                        "facebook",
9131                        "apple"
9132                      ],
9133                      "example": "google",
9134                      "description": "Only for an account with no password. The provider to re-authenticate against. Must already be linked to this account — see `me.socialProviders`.\n"
9135                    },
9136                    "token": {
9137                      "type": "string",
9138                      "example": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
9139                      "description": "A **fresh** identity token from `provider`, obtained by running the provider sheet again immediately before this call. Same token type `POST /api/v1/account/social-login` takes: the Google/Apple `id_token`, or the Facebook access token.\n"
9140                    }
9141                  }
9142                }
9143              }
9144            }
9145          },
9146          "responses": {
9147            "200": {
9148              "description": "Always HTTP 200 — check `statusCode` in the body",
9149              "content": {
9150                "application/json": {
9151                  "schema": {
9152                    "oneOf": [
9153                      {
9154                        "description": "Confirmation token sent to the new address.",
9155                        "type": "object",
9156                        "properties": {
9157                          "statusCode": {
9158                            "type": "integer",
9159                            "example": 200
9160                          },
9161                          "data": {
9162                            "type": "object",
9163                            "properties": {
9164                              "pendingEmail": {
9165                                "type": "string",
9166                                "example": "[email protected]",
9167                                "description": "The address awaiting confirmation. Show it so the user knows where to look."
9168                              }
9169                            }
9170                          },
9171                          "statusMessage": {
9172                            "type": "string",
9173                            "example": "Check the new address for a confirmation token."
9174                          }
9175                        }
9176                      },
9177                      {
9178                        "description": "A required field was missing, the address was malformed, or it is already this account's address.",
9179                        "type": "object",
9180                        "properties": {
9181                          "statusCode": {
9182                            "type": "integer",
9183                            "example": 400
9184                          },
9185                          "reason": {
9186                            "type": "string",
9187                            "enum": [
9188                              "MISSING_FIELD",
9189                              "VALIDATION_ERROR",
9190                              "SAME_EMAIL"
9191                            ],
9192                            "example": "SAME_EMAIL"
9193                          },
9194                          "statusMessage": {
9195                            "type": "string",
9196                            "example": "That is already the email address on this account."
9197                          }
9198                        }
9199                      },
9200                      {
9201                        "description": "The current password was wrong.",
9202                        "type": "object",
9203                        "properties": {
9204                          "statusCode": {
9205                            "type": "integer",
9206                            "example": 401
9207                          },
9208                          "reason": {
9209                            "type": "string",
9210                            "enum": [
9211                              "INVALID_PASSWORD"
9212                            ],
9213                            "example": "INVALID_PASSWORD"
9214                          },
9215                          "statusMessage": {
9216                            "type": "string",
9217                            "example": "The current password is not correct."
9218                          }
9219                        }
9220                      },
9221                      {
9222                        "description": "The account has no password, and no provider token was supplied to stand in for one. **Needs a screen, not a toast** — send the user back through the provider sheet and retry with the token it returns.\n",
9223                        "type": "object",
9224                        "properties": {
9225                          "statusCode": {
9226                            "type": "integer",
9227                            "example": 401
9228                          },
9229                          "reason": {
9230                            "type": "string",
9231                            "enum": [
9232                              "SOCIAL_REAUTH_REQUIRED"
9233                            ],
9234                            "example": "SOCIAL_REAUTH_REQUIRED"
9235                          },
9236                          "statusMessage": {
9237                            "type": "string",
9238                            "example": "This account signs in with a social provider and has no password to confirm. Sign in with that provider again and send the fresh token as `provider` and `token` to change the email address."
9239                          }
9240                        }
9241                      },
9242                      {
9243                        "description": "The token was valid but belongs to a provider account that is not linked here. Usually the user picked the wrong account in the provider sheet — offer the sheet again rather than treating it as a failure.\n",
9244                        "type": "object",
9245                        "properties": {
9246                          "statusCode": {
9247                            "type": "integer",
9248                            "example": 401
9249                          },
9250                          "reason": {
9251                            "type": "string",
9252                            "enum": [
9253                              "SOCIAL_IDENTITY_MISMATCH"
9254                            ],
9255                            "example": "SOCIAL_IDENTITY_MISMATCH"
9256                          },
9257                          "statusMessage": {
9258                            "type": "string",
9259                            "example": "That google account is not linked to this twodos account. Sign in with the provider this account is linked to and try again."
9260                          }
9261                        }
9262                      },
9263                      {
9264                        "description": "The provider rejected the token, or the provider is switched off on this server. `PROVIDER_DISABLED` is not the user's fault and retrying the sheet will not fix it.\n",
9265                        "type": "object",
9266                        "properties": {
9267                          "statusCode": {
9268                            "type": "integer",
9269                            "example": 401
9270                          },
9271                          "reason": {
9272                            "type": "string",
9273                            "enum": [
9274                              "INVALID_TOKEN",
9275                              "PROVIDER_DISABLED",
9276                              "INVALID_PROVIDER"
9277                            ],
9278                            "example": "INVALID_TOKEN"
9279                          },
9280                          "statusMessage": {
9281                            "type": "string"
9282                          }
9283                        }
9284                      },
9285                      {
9286                        "description": "The requested address already belongs to another account.",
9287                        "type": "object",
9288                        "properties": {
9289                          "statusCode": {
9290                            "type": "integer",
9291                            "example": 409
9292                          },
9293                          "reason": {
9294                            "type": "string",
9295                            "enum": [
9296                              "EMAIL_IN_USE"
9297                            ],
9298                            "example": "EMAIL_IN_USE"
9299                          },
9300                          "statusMessage": {
9301                            "type": "string",
9302                            "example": "That email address is already registered to another account."
9303                          }
9304                        }
9305                      }
9306                    ]
9307                  }
9308                }
9309              }
9310            },
9311            "401": {
9312              "description": "Missing or invalid bearer token",
9313              "content": {
9314                "application/json": {
9315                  "schema": {
9316                    "$ref": "#/components/schemas/ErrorResponse"
9317                  }
9318                }
9319              }
9320            }
9321          }
9322        }
9323      },
9324      "/api/account/confirm-email-change": {
9325        "post": {
9326          "tags": [
9327            "Accounts"
9328          ],
9329          "summary": "Confirm a pending email address change",
9330          "description": "Step two of two. Supply the token that was emailed to the new address by `POST /api/account/change-email`. On success the account's email address becomes the new one and is marked verified, because receiving the token already proved the address.\n\n**Existing access tokens keep working** — the change does not sign the user out. From this point on, sign-in, password reset and OTP all use the new address, so a client caching the email locally must refresh it from the `data.email` field returned here or from `GET /api/account/me`.\n",
9331          "security": [
9332            {
9333              "bearerAuth": []
9334            }
9335          ],
9336          "requestBody": {
9337            "required": true,
9338            "content": {
9339              "application/json": {
9340                "schema": {
9341                  "type": "object",
9342                  "required": [
9343                    "token"
9344                  ],
9345                  "properties": {
9346                    "token": {
9347                      "type": "string",
9348                      "example": "A1B2C3",
9349                      "description": "The confirmation token sent to the new address. Case-insensitive."
9350                    }
9351                  }
9352                }
9353              }
9354            }
9355          },
9356          "responses": {
9357            "200": {
9358              "description": "Always HTTP 200 — check `statusCode` in the body",
9359              "content": {
9360                "application/json": {
9361                  "schema": {
9362                    "oneOf": [
9363                      {
9364                        "description": "Email address changed.",
9365                        "type": "object",
9366                        "properties": {
9367                          "statusCode": {
9368                            "type": "integer",
9369                            "example": 200
9370                          },
9371                          "data": {
9372                            "type": "object",
9373                            "properties": {
9374                              "email": {
9375                                "type": "string",
9376                                "example": "[email protected]",
9377                                "description": "The account's new email address. Replace any cached copy with this."
9378                              }
9379                            }
9380                          },
9381                          "statusMessage": {
9382                            "type": "string",
9383                            "example": "Your email address has been changed."
9384                          }
9385                        }
9386                      },
9387                      {
9388                        "description": "The token was missing, wrong, or expired, or there is no pending change on this account. TOKEN_EXPIRED and a wrong token both require starting over with POST /api/account/change-email.\n",
9389                        "type": "object",
9390                        "properties": {
9391                          "statusCode": {
9392                            "type": "integer",
9393                            "example": 400
9394                          },
9395                          "reason": {
9396                            "type": "string",
9397                            "enum": [
9398                              "MISSING_FIELD",
9399                              "INVALID_TOKEN",
9400                              "TOKEN_EXPIRED",
9401                              "NO_PENDING_CHANGE"
9402                            ],
9403                            "example": "INVALID_TOKEN"
9404                          },
9405                          "statusMessage": {
9406                            "type": "string",
9407                            "example": "That confirmation token is not correct."
9408                          }
9409                        }
9410                      },
9411                      {
9412                        "description": "The address was claimed by another account while this change was pending.",
9413                        "type": "object",
9414                        "properties": {
9415                          "statusCode": {
9416                            "type": "integer",
9417                            "example": 409
9418                          },
9419                          "reason": {
9420                            "type": "string",
9421                            "enum": [
9422                              "EMAIL_IN_USE"
9423                            ],
9424                            "example": "EMAIL_IN_USE"
9425                          },
9426                          "statusMessage": {
9427                            "type": "string"
9428                          }
9429                        }
9430                      }
9431                    ]
9432                  }
9433                }
9434              }
9435            },
9436            "401": {
9437              "description": "Missing or invalid bearer token",
9438              "content": {
9439                "application/json": {
9440                  "schema": {
9441                    "$ref": "#/components/schemas/ErrorResponse"
9442                  }
9443                }
9444              }
9445            }
9446          }
9447        }
9448      },
9449      "/api/account/preferences": {
9450        "get": {
9451          "tags": [
9452            "Accounts"
9453          ],
9454          "summary": "Get the current user's notification preferences",
9455          "description": "Returns the preferences row. A row is created with defaults if it does\nnot exist yet.\n\nReturns **both channels**. Push and email are independent — a user who\nturned off alarm *emails* has said nothing about alarm *pushes* — and\nthe push switches are the ones that gate delivery in `PushService`.\n\n⚠️ **The email switches are recorded but not yet enforced.** They are\nstored, returned and accepted exactly as documented, and nothing\ncurrently consults them: every email the platform sends is\ntransactional (verification, password, account deletion, invitations),\nand transactional mail is not gated on a notification preference.\nClients should not present them as controls that take effect today.\nThe push switches below are enforced on every send.\n",
9456          "security": [
9457            {
9458              "bearerAuth": []
9459            }
9460          ],
9461          "responses": {
9462            "200": {
9463              "description": "Always HTTP 200 — check `statusCode` in the body",
9464              "content": {
9465                "application/json": {
9466                  "schema": {
9467                    "type": "object",
9468                    "properties": {
9469                      "statusCode": {
9470                        "type": "integer",
9471                        "example": 200
9472                      },
9473                      "data": {
9474                        "type": "object",
9475                        "description": "The `default` on each field is what a freshly created\npreferences row is given. Every switch except\n`alarmEmail` starts on — in particular **push is on by\ndefault**, so a user who is not receiving push has either\nturned it off here or has no registered device.\n",
9476                        "properties": {
9477                          "emailEnabled": {
9478                            "type": "boolean",
9479                            "default": true,
9480                            "description": "Master switch for email. **Recorded but not yet\nenforced** — see the note on the email switches above.\n"
9481                          },
9482                          "inviteEmail": {
9483                            "type": "boolean",
9484                            "default": true,
9485                            "description": "Recorded but not yet enforced."
9486                          },
9487                          "deadlineEmail": {
9488                            "type": "boolean",
9489                            "default": true,
9490                            "description": "Recorded but not yet enforced."
9491                          },
9492                          "alarmEmail": {
9493                            "type": "boolean",
9494                            "default": false,
9495                            "description": "Recorded but not yet enforced."
9496                          },
9497                          "reminderEmail": {
9498                            "type": "boolean",
9499                            "default": true,
9500                            "description": "Recorded but not yet enforced."
9501                          },
9502                          "pushEnabled": {
9503                            "type": "boolean",
9504                            "default": true,
9505                            "description": "Master switch for push. False mutes every push."
9506                          },
9507                          "invitePush": {
9508                            "type": "boolean",
9509                            "default": true
9510                          },
9511                          "deadlinePush": {
9512                            "type": "boolean",
9513                            "default": true,
9514                            "description": "Governs deadline reminders — the alarms the server\ncreates from a list or item deadline, which carry\n`autoCreated: true` in the notification payload.\n\nA deadline reminder is pushed only when **both**\n`deadlinePush` and `alarmPush` are on. Turning either\noff silences it. Hand-set alarms are unaffected by\nthis switch.\n"
9515                          },
9516                          "alarmPush": {
9517                            "type": "boolean",
9518                            "default": true,
9519                            "description": "Governs alarms the user set themselves, and is also\nrequired for deadline reminders — see `deadlinePush`.\n"
9520                          },
9521                          "reminderPush": {
9522                            "type": "boolean",
9523                            "default": true,
9524                            "description": "Also gates task assignment, place arrivals and\nchallenge activity — anything without a category\nswitch of its own.\n"
9525                          }
9526                        }
9527                      }
9528                    }
9529                  }
9530                }
9531              }
9532            }
9533          }
9534        },
9535        "patch": {
9536          "tags": [
9537            "Accounts"
9538          ],
9539          "summary": "Update the current user's notification preferences",
9540          "description": "Partial update — only fields present in the request body are changed.\nOmit a field to leave it unchanged.\n\nAccepts both channels. Push and email are independent: a user who\nturned off alarm *emails* has said nothing about alarm *pushes*. The\npush switches are the ones `PushService` checks before every send.\n\n⚠️ **The email switches are recorded but not yet enforced** — stored\nand returned faithfully, consulted by nothing. See `GET /preferences`.\n\n`deadlinePush` governs deadline reminders, which reach the user as\nserver-created alarms; it takes effect alongside `alarmPush` rather\nthan instead of it.\n\nSend at least one field. Any field that is present must be a boolean —\na non-boolean value is rejected with `INVALID_VALUE` and **nothing is\nwritten**, including the other fields in the same request.\n",
9541          "security": [
9542            {
9543              "bearerAuth": []
9544            }
9545          ],
9546          "requestBody": {
9547            "required": true,
9548            "content": {
9549              "application/json": {
9550                "schema": {
9551                  "type": "object",
9552                  "description": "Every field is optional and every field is writable. The\n`default` on each is the value a freshly created preferences\nrow is given.\n",
9553                  "properties": {
9554                    "emailEnabled": {
9555                      "type": "boolean",
9556                      "default": true,
9557                      "description": "Master switch for email. False mutes every email."
9558                    },
9559                    "inviteEmail": {
9560                      "type": "boolean",
9561                      "default": true
9562                    },
9563                    "deadlineEmail": {
9564                      "type": "boolean",
9565                      "default": true
9566                    },
9567                    "alarmEmail": {
9568                      "type": "boolean",
9569                      "default": false,
9570                      "description": "The one switch that starts off — alarms are noisy by email."
9571                    },
9572                    "reminderEmail": {
9573                      "type": "boolean",
9574                      "default": true
9575                    },
9576                    "pushEnabled": {
9577                      "type": "boolean",
9578                      "default": true,
9579                      "description": "Master switch for push. False mutes every push."
9580                    },
9581                    "invitePush": {
9582                      "type": "boolean",
9583                      "default": true
9584                    },
9585                    "deadlinePush": {
9586                      "type": "boolean",
9587                      "default": true
9588                    },
9589                    "alarmPush": {
9590                      "type": "boolean",
9591                      "default": true
9592                    },
9593                    "reminderPush": {
9594                      "type": "boolean",
9595                      "default": true,
9596                      "description": "Also gates task assignment, place arrivals and challenge\nactivity — anything without a category switch of its own.\n"
9597                    }
9598                  },
9599                  "example": {
9600                    "pushEnabled": true,
9601                    "alarmPush": false
9602                  }
9603                }
9604              }
9605            }
9606          },
9607          "responses": {
9608            "200": {
9609              "description": "Always HTTP 200 — check `statusCode` in the body",
9610              "content": {
9611                "application/json": {
9612                  "schema": {
9613                    "oneOf": [
9614                      {
9615                        "description": "Preferences updated. Returns the full row — all ten\nfields, both channels — not just the ones you sent.\n",
9616                        "type": "object",
9617                        "properties": {
9618                          "statusCode": {
9619                            "type": "integer",
9620                            "example": 200
9621                          },
9622                          "data": {
9623                            "type": "object",
9624                            "properties": {
9625                              "emailEnabled": {
9626                                "type": "boolean"
9627                              },
9628                              "inviteEmail": {
9629                                "type": "boolean"
9630                              },
9631                              "deadlineEmail": {
9632                                "type": "boolean"
9633                              },
9634                              "alarmEmail": {
9635                                "type": "boolean"
9636                              },
9637                              "reminderEmail": {
9638                                "type": "boolean"
9639                              },
9640                              "pushEnabled": {
9641                                "type": "boolean"
9642                              },
9643                              "invitePush": {
9644                                "type": "boolean"
9645                              },
9646                              "deadlinePush": {
9647                                "type": "boolean"
9648                              },
9649                              "alarmPush": {
9650                                "type": "boolean"
9651                              },
9652                              "reminderPush": {
9653                                "type": "boolean"
9654                              }
9655                            }
9656                          }
9657                        }
9658                      },
9659                      {
9660                        "description": "The request body contained no recognised preference field",
9661                        "type": "object",
9662                        "properties": {
9663                          "statusCode": {
9664                            "type": "integer",
9665                            "example": 400
9666                          },
9667                          "reason": {
9668                            "type": "string",
9669                            "enum": [
9670                              "MISSING_FIELD"
9671                            ]
9672                          },
9673                          "statusMessage": {
9674                            "type": "string",
9675                            "example": "Provide at least one preference field to update: emailEnabled, inviteEmail, deadlineEmail, alarmEmail, reminderEmail, pushEnabled, invitePush, deadlinePush, alarmPush, reminderPush."
9676                          }
9677                        }
9678                      },
9679                      {
9680                        "description": "A recognised field was present but was not a boolean.\nNo field is written when this is returned.\n",
9681                        "type": "object",
9682                        "properties": {
9683                          "statusCode": {
9684                            "type": "integer",
9685                            "example": 400
9686                          },
9687                          "reason": {
9688                            "type": "string",
9689                            "enum": [
9690                              "INVALID_VALUE"
9691                            ]
9692                          },
9693                          "statusMessage": {
9694                            "type": "string",
9695                            "example": "The 'pushEnabled' field must be a boolean (true or false)."
9696                          }
9697                        }
9698                      }
9699                    ]
9700                  }
9701                }
9702              }
9703            }
9704          }
9705        }
9706      },
9707      "/api/account/sessions": {
9708        "get": {
9709          "tags": [
9710            "Accounts"
9711          ],
9712          "summary": "List all active sessions for the current user",
9713          "description": "Returns one entry per active device session (refresh-token family).\nThe `isCurrent` flag identifies the session making this request.\nUse `familyId` as the handle to revoke a specific session via\n`DELETE /api/account/sessions/{familyId}`.\n",
9714          "security": [
9715            {
9716              "bearerAuth": []
9717            }
9718          ],
9719          "responses": {
9720            "200": {
9721              "description": "Always HTTP 200 — check `statusCode` in the body",
9722              "content": {
9723                "application/json": {
9724                  "schema": {
9725                    "type": "object",
9726                    "properties": {
9727                      "statusCode": {
9728                        "type": "integer",
9729                        "example": 200
9730                      },
9731                      "data": {
9732                        "type": "array",
9733                        "items": {
9734                          "type": "object",
9735                          "properties": {
9736                            "familyId": {
9737                              "type": "string",
9738                              "description": "Opaque session handle — pass to DELETE /sessions/{familyId} to revoke"
9739                            },
9740                            "platform": {
9741                              "type": "string",
9742                              "description": "Platform enum value recorded at login",
9743                              "enum": [
9744                                "PLATFORM::IOS",
9745                                "PLATFORM::ANDROID",
9746                                "PLATFORM::WEB",
9747                                "PLATFORM::MACOS",
9748                                "PLATFORM::WINDOWS",
9749                                "PLATFORM::LINUX",
9750                                "PLATFORM::UNKNOWN"
9751                              ]
9752                            },
9753                            "ipAddress": {
9754                              "type": "string",
9755                              "nullable": true,
9756                              "description": "IP address recorded at session creation"
9757                            },
9758                            "createdAt": {
9759                              "type": "string",
9760                              "format": "date-time",
9761                              "description": "When this session was first created (login time)"
9762                            },
9763                            "lastUsedAt": {
9764                              "type": "string",
9765                              "format": "date-time",
9766                              "nullable": true,
9767                              "description": "When this session last refreshed its tokens. Null if the session has never been refreshed."
9768                            },
9769                            "isCurrent": {
9770                              "type": "boolean",
9771                              "description": "True if this is the session making the current request"
9772                            }
9773                          }
9774                        }
9775                      }
9776                    }
9777                  }
9778                }
9779              }
9780            }
9781          }
9782        },
9783        "delete": {
9784          "tags": [
9785            "Accounts"
9786          ],
9787          "summary": "Sign out all other devices — keep the current session active",
9788          "description": "Revokes every active refresh-token family except the one belonging to the\ncurrent request. The caller stays signed in; all other devices are signed\nout immediately and must log in again.\n",
9789          "security": [
9790            {
9791              "bearerAuth": []
9792            }
9793          ],
9794          "responses": {
9795            "200": {
9796              "description": "Always HTTP 200 — check `statusCode` in the body",
9797              "content": {
9798                "application/json": {
9799                  "schema": {
9800                    "type": "object",
9801                    "properties": {
9802                      "statusCode": {
9803                        "type": "integer",
9804                        "example": 200
9805                      },
9806                      "statusMessage": {
9807                        "type": "string",
9808                        "example": "3 other device(s) have been signed out."
9809                      },
9810                      "data": {
9811                        "type": "object",
9812                        "properties": {
9813                          "revokedCount": {
9814                            "type": "integer",
9815                            "description": "Number of sessions that were revoked"
9816                          }
9817                        }
9818                      }
9819                    }
9820                  }
9821                }
9822              }
9823            }
9824          }
9825        }
9826      },
9827      "/api/account/sessions/{familyId}": {
9828        "delete": {
9829          "tags": [
9830            "Accounts"
9831          ],
9832          "summary": "Revoke a specific session (remote sign-out of one device)",
9833          "description": "Immediately revokes the entire refresh-token family identified by `familyId`,\ndeleting the paired access token so the device cannot make further API calls.\nThe device must log in again to regain access.\n\n**This endpoint does not rotate — it kills the session outright.**\nTo sign out of the current session use `POST /api/account/logout` instead.\n",
9834          "security": [
9835            {
9836              "bearerAuth": []
9837            }
9838          ],
9839          "parameters": [
9840            {
9841              "name": "familyId",
9842              "in": "path",
9843              "required": true,
9844              "schema": {
9845                "type": "string"
9846              },
9847              "description": "The `familyId` from GET /api/account/sessions"
9848            }
9849          ],
9850          "responses": {
9851            "200": {
9852              "description": "Always HTTP 200 — check `statusCode` in the body",
9853              "content": {
9854                "application/json": {
9855                  "schema": {
9856                    "oneOf": [
9857                      {
9858                        "description": "Session revoked",
9859                        "type": "object",
9860                        "properties": {
9861                          "statusCode": {
9862                            "type": "integer",
9863                            "example": 200
9864                          },
9865                          "statusMessage": {
9866                            "type": "string"
9867                          }
9868                        }
9869                      },
9870                      {
9871                        "description": "Cannot revoke current session via this endpoint — use POST /logout",
9872                        "type": "object",
9873                        "properties": {
9874                          "statusCode": {
9875                            "type": "integer",
9876                            "example": 400
9877                          },
9878                          "reason": {
9879                            "type": "string",
9880                            "example": "INVALID_OPERATION"
9881                          },
9882                          "statusMessage": {
9883                            "type": "string"
9884                          }
9885                        }
9886                      },
9887                      {
9888                        "description": "Session not found or does not belong to current user",
9889                        "type": "object",
9890                        "properties": {
9891                          "statusCode": {
9892                            "type": "integer",
9893                            "example": 404
9894                          },
9895                          "reason": {
9896                            "type": "string",
9897                            "example": "NOT_FOUND"
9898                          },
9899                          "statusMessage": {
9900                            "type": "string"
9901                          }
9902                        }
9903                      }
9904                    ]
9905                  }
9906                }
9907              }
9908            }
9909          }
9910        }
9911      }
9912    },
9913    "tags": []
9914  },
9915  "customOptions": {}
9916};
9917  url = options.swaggerUrl || url
9918  var urls = options.swaggerUrls
9919  var customOptions = options.customOptions
9920  var spec1 = options.swaggerDoc
9921  var swaggerOptions = {
9922    spec: spec1,
9923    url: url,
9924    urls: urls,
9925    dom_id: '#swagger-ui',
9926    deepLinking: true,
9927    presets: [
9928      SwaggerUIBundle.presets.apis,
9929      SwaggerUIStandalonePreset
9930    ],
9931    plugins: [
9932      SwaggerUIBundle.plugins.DownloadUrl
9933    ],
9934    layout: "StandaloneLayout"
9935  }
9936  for (var attrname in customOptions) {
9937    swaggerOptions[attrname] = customOptions[attrname];
9938  }
9939  var ui = SwaggerUIBundle(swaggerOptions)
9940
9941  if (customOptions.oauth) {
9942    ui.initOAuth(customOptions.oauth)
9943  }
9944
9945  if (customOptions.preauthorizeApiKey) {
9946    const key = customOptions.preauthorizeApiKey.authDefinitionKey;
9947    const value = customOptions.preauthorizeApiKey.apiKeyValue;
9948    if (!!key && !!value) {
9949      const pid = setInterval(() => {
9950        const authorized = ui.preauthorizeApiKey(key, value);
9951        if(!!authorized) clearInterval(pid);
9952      }, 500)
9953
9954    }
9955  }
9956
9957  if (customOptions.authAction) {
9958    ui.authActions.authorize(customOptions.authAction)
9959  }
9960
9961  window.ui = ui
9962}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.