openapi: 3.1.0 info: title: Craaft API version: "1.0.0" description: | Personal-access-token API for Craaft. See `docs/api.md` for the narrative guide, rate limits, and worked examples. contact: name: Craaft servers: - url: https://craaft.io/api/v1 description: Production - url: http://localhost:8080/api/v1 description: Local security: - bearerAuth: [] components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: cra_<24 base64url chars> schemas: Error: type: object required: [error] properties: error: { type: string } User: type: object required: [id, email, name, username, avatarUrl, hasPassword, emailVerified, newsletterSubscribed, newsletterAvailable, isSuperAdmin] properties: id: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } username: { type: string } avatarUrl: { type: string, description: "Empty when no upload; otherwise /api/v1/users/{id}/avatar" } hasPassword: type: boolean description: "False for OAuth-only users (no password set). The hash itself is never returned." emailVerified: type: boolean description: | Whether the address has been confirmed. Currently a soft gate - every endpoint still works for an unverified user - so treat it as a prompt to nudge, not as an authorization signal. newsletterSubscribed: { type: boolean } newsletterAvailable: type: boolean description: | False when the newsletter feature isn't configured on the deployment at all, which is different from being unsubscribed. isSuperAdmin: type: boolean description: | Whether the user can reach the `/admin` dashboard. Informational only - the real gate is server-side, and the admin endpoints are session-authenticated, so a token cannot use them. csrfToken: type: string description: | Present only for cookie/session callers, who need it to send mutations. Omitted for bearer-token callers, since CSRF does not apply to a credential that isn't attached automatically. Project: type: object required: [id, workspaceId, name, createdAt, updatedAt] properties: id: { type: string, format: uuid } workspaceId: { type: string, format: uuid } name: { type: string, maxLength: 200 } description: { type: string } isFavorite: { type: boolean } publicToken: { type: string, description: "Non-empty when public sharing is on" } backgroundImage: { type: string } backgroundColor: { type: string, description: "Board background hex color (#rrggbb), empty when unset; mutually exclusive with backgroundImage" } colorScheme: { type: string } textColor: { type: string, enum: [dark, light] } myRole: type: string enum: [owner, admin, member] description: "Caller's role in the project's *workspace* (distinct from myBoardRole)." visibility: type: string enum: [private, workspace] description: | `private` (default): only users with an explicit grant (plus workspace owners/admins) see the board. `workspace`: every workspace member is an implicit contributor. myBoardRole: type: string enum: [admin, contributor] description: | Caller's effective role on the board. Workspace owners and admins are always `admin`; explicit grants resolve to `admin` or `contributor`; on a `workspace`-visibility board, plain workspace members default to `contributor`. canUploadAttachments: type: boolean description: | Whether cards on this board accept file attachments. Reflects the board's *workspace* plan (Pro), NOT the caller's own plan - so a Free user collaborating on a Pro workspace's board sees `true`. Matches the server's upload gate; the SPA uses it to show/hide the attachment dropzone. totalCards: { type: integer } columnCounts: { type: object, additionalProperties: { type: integer } } workspaceName: type: string description: "Workspace display name; populated on list responses." members: type: array description: "Capped avatar-stack preview on list responses only." items: { $ref: "#/components/schemas/ProjectMemberPreview" } columns: type: array items: { $ref: "#/components/schemas/Column" } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } ProjectMemberPreview: type: object required: [id, name, avatarUrl] properties: id: { type: string, format: uuid } name: { type: string } avatarUrl: { type: string, description: "Empty when no upload; otherwise /api/v1/users/{id}/avatar" } BoardMember: type: object required: [userId, name, email, username, avatarUrl, role, source, createdAt] properties: userId: { type: string, format: uuid } name: { type: string } email: { type: string, format: email } username: { type: string } avatarUrl: { type: string, description: "Empty when no upload; otherwise /api/v1/users/{id}/avatar" } role: { type: string, enum: [admin, contributor] } source: type: string enum: [explicit, workspace-admin, workspace-visible] description: | How this row gets access. `explicit` = stored `project_members` row. `workspace-admin` = implicit because they're a workspace owner/admin. `workspace-visible` = implicit because the project is `visibility=workspace`. createdAt: { type: string, format: date-time } BoardMemberInput: type: object required: [userId, role] properties: userId: { type: string, format: uuid } role: { type: string, enum: [admin, contributor] } BoardAccess: type: object required: [projectId, name, role] properties: projectId: { type: string, format: uuid } name: { type: string, maxLength: 200 } role: { type: string, enum: [admin, contributor] } InvitationGrant: type: object required: [projectId, name, role] properties: projectId: { type: string, format: uuid } name: { type: string, maxLength: 200 } role: { type: string, enum: [admin, contributor] } Member: type: object required: [userId, email, name, role, avatarUrl, joinedAt] properties: userId: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } role: { type: string, enum: [owner, admin, member] } avatarUrl: { type: string, description: "Empty when no upload; otherwise /api/v1/users/{id}/avatar" } joinedAt: { type: string, format: date-time } boardAccess: type: array description: | Explicit board grants the caller is allowed to see. Omitted when the row is a workspace owner/admin (implicit access to every board) OR when a non-admin caller is reading someone else's row. Empty array means "this member has no explicit grants". items: { $ref: "#/components/schemas/BoardAccess" } InvitationCreate: type: object required: [email, role] properties: email: { type: string, format: email } role: { type: string, enum: [admin, member] } boardGrants: type: array description: | Optional, only honoured for `role: "member"` invitations (admins auto-see every board). Persisted alongside the invitation and applied atomically on accept. items: type: object required: [projectId, role] properties: projectId: { type: string, format: uuid } role: { type: string, enum: [admin, contributor] } Invitation: type: object required: [id, email, role, invitedBy, invitedByName, createdAt, expiresAt, boardGrants] properties: id: { type: string, format: uuid } email: { type: string, format: email } role: { type: string, enum: [admin, member] } invitedBy: { type: string, format: uuid } invitedByName: { type: string } createdAt: { type: string, format: date-time } expiresAt: { type: string, format: date-time } boardGrants: type: array items: { $ref: "#/components/schemas/InvitationGrant" } Column: type: object required: [id, key, title, position] properties: id: { type: string, format: uuid } key: { type: string, description: "Stable identifier for cards.column" } title: { type: string, maxLength: 80 } color: { type: string, description: "Optional palette token; empty for default" } position: { type: number } isDone: { type: boolean } cardLimit: { type: integer, nullable: true } Card: type: object required: [id, projectId, column, title, position, createdAt, updatedAt] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } column: { type: string } title: { type: string, maxLength: 500 } description: type: string description: | Full body on GET /cards/{id}, PATCH, create, and /detail. Omitted from GET /projects/{id}/cards and lean SSE card.updated events (position-only moves). position: { type: number } dueDate: { type: string, format: date-time, nullable: true } assignedUserId: { type: string, format: uuid, nullable: true } assignedUserName: { type: string, nullable: true } size: { type: integer, nullable: true, description: "Optional integer size estimate; null when unset" } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } createdBy: { type: string, format: uuid, nullable: true } createdByName: { type: string, nullable: true } updatedBy: { type: string, format: uuid, nullable: true } updatedByName: { type: string, nullable: true } attachmentCount: { type: integer } checklistDone: { type: integer, description: "Completed checklist items (denormalized count)" } checklistTotal: { type: integer, description: "Total checklist items (denormalized count)" } tags: type: array maxItems: 12 items: { type: string, maxLength: 32 } following: { type: boolean, description: "Whether the authenticated caller follows this card" } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } CardSummary: type: object required: [id, projectId, projectName, columnKey, columnTitle, title] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } projectName: { type: string } columnKey: { type: string } columnTitle: { type: string } title: { type: string, maxLength: 500 } description: type: string description: Snippet of at most 180 characters (not the full body). dueDate: { type: string, format: date-time, nullable: true } assignedUserId: { type: string, format: uuid, nullable: true } assignedUserName: { type: string, nullable: true } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } updatedAt: { type: string, format: date-time, nullable: true } archived: { type: boolean } Comment: type: object required: [id, cardId, authorId, body, createdAt] properties: id: { type: string, format: uuid } cardId: { type: string, format: uuid } authorId: { type: string, format: uuid } body: { type: string, maxLength: 5000 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } ChecklistItem: type: object required: [id, cardId, text, done, position, createdAt, updatedAt] properties: id: { type: string, format: uuid } cardId: { type: string, format: uuid } text: { type: string } done: { type: boolean } position: { type: number, format: double } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } Milestone: type: object required: [id, projectId, name, dueOn, createdAt, updatedAt] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } name: { type: string, maxLength: 200 } dueOn: { type: string, format: date } achievedAt: { type: string, format: date-time, nullable: true } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } VersionInfo: type: object description: | Build-info triplet stamped in at link time via `-X` ldflags. A plain `go run` leaves the defaults in place, so `dev` / `unknown` means an unstamped build rather than a broken one. required: [version, commit, buildTime] properties: version: { type: string, description: "Release tag, or `dev` when unstamped" } commit: { type: string, description: "Short commit SHA, or `unknown` when unstamped" } buildTime: { type: string, description: "RFC3339 build timestamp, or `unknown` when unstamped" } PublicBoard: type: object description: | Read-only snapshot returned by the unauthenticated `GET /public/projects/{token}` endpoint. A trimmed projection of the board: no workspace/ownership fields, no card metadata beyond priority + assignee. required: [project, columns, cards] properties: project: type: object required: [id, name, updatedAt] properties: id: { type: string, format: uuid } name: { type: string, maxLength: 200 } description: { type: string } backgroundImage: { type: string } backgroundColor: { type: string } colorScheme: { type: string } textColor: { type: string, enum: [dark, light] } updatedAt: { type: string, format: date-time } columns: type: array items: type: object required: [key, title, position] properties: key: { type: string } title: { type: string, maxLength: 80 } color: { type: string } position: { type: number, format: double } isDone: { type: boolean } cards: type: array items: type: object required: [id, column, position, title] properties: id: { type: string, format: uuid } column: { type: string, description: "column.key" } position: { type: number, format: double } title: { type: string, maxLength: 500 } description: { type: string } priority: { type: string, enum: [low, medium, high, urgent], nullable: true } assignedUserId: { type: string, format: uuid, nullable: true } assignedUserName: { type: string, nullable: true } assignedUserAvatarUrl: { type: string } BulkCardsResponse: type: object required: [cards] properties: cards: type: array description: "Affected cards, in request order" items: { $ref: "#/components/schemas/Card" } Attachment: type: object required: [id, cardId, name, size, contentType, uploadedBy, uploadedByName, createdAt] properties: id: { type: string, format: uuid } cardId: { type: string, format: uuid } name: { type: string, description: "Original filename" } size: { type: integer, format: int64, description: "Byte size" } contentType: { type: string } uploadedBy: { type: string, format: uuid } uploadedByName: { type: string } createdAt: { type: string, format: date-time } FocusDueCard: type: object required: [id, projectId, projectName, columnKey, columnTitle, title, dueDate] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } projectName: { type: string } columnKey: { type: string } columnTitle: { type: string } title: { type: string, maxLength: 500 } dueDate: { type: string, format: date-time } assignedUserId: { type: string, format: uuid, nullable: true } assignedUserName: { type: string, nullable: true } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } AttentionCard: type: object required: [id, projectId, projectName, columnKey, columnTitle, title, updatedAt, staleInProgress, highPriority, idleByMe] properties: id: { type: string, format: uuid } projectId: { type: string, format: uuid } projectName: { type: string } columnKey: { type: string } columnTitle: { type: string } title: { type: string, maxLength: 500 } updatedAt: { type: string, format: date-time } assignedUserId: { type: string, format: uuid, nullable: true } assignedUserName: { type: string, nullable: true } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } staleInProgress: { type: boolean } highPriority: { type: boolean } idleByMe: { type: boolean } CardEvent: type: object required: [id, type, createdAt] properties: id: { type: string, format: uuid } type: { type: string, enum: [moved, priority, assignee] } fromValue: { type: string, nullable: true } toValue: { type: string, nullable: true } fromName: { type: string, nullable: true } toName: { type: string, nullable: true } actorId: { type: string, format: uuid, nullable: true } actorName: { type: string, nullable: true } createdAt: { type: string, format: date-time } CardDetail: type: object description: Envelope for GET /cards/{id}/detail (one authorised round-trip for the card modal). required: [card, comments, events, checklist, attachments] properties: card: { $ref: "#/components/schemas/Card" } comments: type: array items: { $ref: "#/components/schemas/Comment" } events: type: array description: Newest 100 events, oldest-first items: { $ref: "#/components/schemas/CardEvent" } checklist: type: array items: { $ref: "#/components/schemas/ChecklistItem" } attachments: type: array items: { $ref: "#/components/schemas/Attachment" } ProjectExport: type: object required: [version, exportedAt, project, columns, cards] properties: version: { type: integer, example: 1 } exportedAt: { type: string, format: date-time } project: type: object required: [id, name, isFavorite, createdAt, updatedAt] properties: id: { type: string } name: { type: string, maxLength: 200 } description: { type: string } isFavorite: { type: boolean } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } columns: type: array items: type: object required: [key, name, position] properties: key: { type: string } name: { type: string, maxLength: 80 } color: { type: string } position: { type: number, format: double } cards: type: array items: type: object required: [id, title, columnKey, position, createdAt, updatedAt] properties: id: { type: string } title: { type: string, maxLength: 500 } description: { type: string } columnKey: { type: string } position: { type: number, format: double } dueDate: { type: string, format: date-time, nullable: true } priority: { type: string } size: { type: integer, nullable: true } assignee: { $ref: "#/components/schemas/ProjectExportUser" } createdBy: { $ref: "#/components/schemas/ProjectExportUser" } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } comments: type: array items: type: object required: [author, body, createdAt] properties: author: { $ref: "#/components/schemas/ProjectExportUser" } body: { type: string } createdAt: { type: string, format: date-time } attachments: type: array items: type: object required: [filename, size, uploadedAt] properties: filename: { type: string } size: { type: integer, format: int64 } uploader: { $ref: "#/components/schemas/ProjectExportUser" } uploadedAt: { type: string, format: date-time } ProjectExportUser: type: object required: [username, name] properties: username: { type: string } name: { type: string } responses: Unauthorized: description: Missing or invalid token content: application/json: schema: { $ref: "#/components/schemas/Error" } NotFound: description: Resource doesn't exist or caller isn't a member of its workspace content: application/json: schema: { $ref: "#/components/schemas/Error" } RateLimited: description: Per-token rate limit exceeded headers: Retry-After: schema: { type: integer } description: Seconds until the next allowed request content: application/json: schema: { $ref: "#/components/schemas/Error" } paths: /me: get: summary: Current user responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } patch: summary: Update profile (partial) requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } email: { type: string, format: email } username: { type: string } responses: "200": description: Updated user content: application/json: schema: { $ref: "#/components/schemas/User" } /users/{id}/avatar: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Download a user's avatar description: | Not gated on workspace membership: avatars are inherently public within the app (assignee badges, mention rows, public boards), so anyone holding the user id can fetch theirs. Served with `X-Content-Type-Options: nosniff`. Bare `404` when the user has never uploaded one - that is the cue to render the initial-and-colour placeholder rather than an error. security: [] responses: "200": description: Image bytes content: application/octet-stream: schema: { type: string, format: binary } "404": description: No uploaded avatar for this user /projects: get: summary: List projects responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/Project" } post: summary: Create a project requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, maxLength: 200 } description: { type: string } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Project" } "402": description: Plan limit reached (Free tier project cap) /projects/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Get a project (with columns + counts) responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Project" } "404": { $ref: "#/components/responses/NotFound" } patch: summary: Update a project (partial) requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, maxLength: 200 } description: { type: string } isFavorite: { type: boolean } backgroundImage: { type: string } backgroundColor: { type: string, description: "Hex colour (#rrggbb); mutually exclusive with backgroundImage" } colorScheme: { type: string } textColor: { type: string, enum: [dark, light] } visibility: type: string enum: [private, workspace] description: | Flip the board between explicit-grant access and workspace-wide implicit access. Board-admin only. responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Project" } delete: summary: Delete a project responses: "204": { description: Deleted } /projects/{id}/export: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Export a project as JSON or CSV description: | Returns a versioned JSON envelope containing the project, its columns, cards (with metadata), comments, and attachment metadata. Attachment bytes are not included. Available on every plan. With `format=csv` the response is `text/csv` with one row per card, prefixed with a UTF-8 BOM so Excel decodes it as UTF-8 rather than the system ANSI codepage. Parse it as `utf-8-sig` or strip the three leading bytes. CSV can't carry the envelope's nesting, so comments and attachments collapse to `comment_count` / `attachment_count` - JSON stays the lossless format. Timestamps are RFC3339 UTC, `tags` is comma-joined, and unset optional fields are empty cells. Cells starting with `=`, `+`, `-` or `@` are prefixed with a single quote so spreadsheets treat user-entered card text as text rather than a formula. parameters: - name: format in: query required: false description: Output format. Defaults to `json`. schema: type: string enum: [json, csv] default: json responses: "200": description: Export payload headers: Cache-Control: schema: { type: string, example: "no-store" } description: Always `no-store` - exports are per-user and shouldn't be cached. Content-Disposition: schema: { type: string, example: 'attachment; filename="my-project-2026-05-08.json"' } description: Triggers a file download in browsers. The extension follows `format`. content: application/json: schema: { $ref: "#/components/schemas/ProjectExport" } text/csv: schema: type: string example: | card_id,title,description,column_key,column_name,position,due_date,priority,size,tags,assignee_username,assignee_name,created_by_username,created_by_name,created_at,updated_at,archived_at,comment_count,attachment_count 8f1c…,Write spec,,todo,To Do,1.5,2026-06-01T09:30:00Z,high,3,"backend,spec",alice,Alice,bob,Bob,2026-04-01T00:00:00Z,2026-05-01T00:00:00Z,,2,1 "400": description: Unsupported `format` - expected `json` or `csv` content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/cards: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List cards in a project responses: "200": description: Ordered by (column, position, createdAt). Description is omitted; fetch GET /cards/{id} or /detail for the body. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Card" } post: summary: Create a card requestBody: required: true content: application/json: schema: type: object required: [title, column, position] properties: title: { type: string, maxLength: 500 } column: { type: string, description: "column.key" } position: { type: number, description: "Fractional - drop midpoint between siblings" } description: { type: string } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Card" } /projects/{id}/cards/bulk: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Create up to 100 cards in one transaction description: | All-or-nothing batch create - one invalid item rolls back the whole batch and the error names the offending index (`cards[3]: …`). Cards without an explicit `position` append to the end of their column in request order. Unlike the single-card create, the assignee is NOT defaulted to the caller. Bulk requests never send notification emails; activity events and SSE broadcasts fire normally. Body limit 1 MiB. requestBody: required: true content: application/json: schema: type: object required: [cards] properties: cards: type: array maxItems: 100 items: type: object required: [title, column] properties: title: { type: string, maxLength: 500 } column: { type: string, description: "column.key" } description: { type: string } position: { type: number, description: "Omit to append to the end of the column" } dueDate: { type: string, format: date-time } assignedUserId: { type: string, format: uuid } size: { type: integer } priority: { type: string, enum: [low, medium, high, urgent] } tags: type: array maxItems: 12 items: { type: string, maxLength: 32 } responses: "201": description: Created cards, in request order content: application/json: schema: { $ref: "#/components/schemas/BulkCardsResponse" } "400": description: Invalid item (message names the index), too many items, or invalid column/assignee content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/cards/rebalance: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Rewrite a column's card order in one transaction description: | Renumbers the listed cards onto `column` with positions 1, 2, 3, … in request order. Sized for a real column (up to 10 000 ids), not an import batch - the board uses this when a drop can no longer find a representable midpoint between two neighbours. All ids must already belong to this board (`404` otherwise). Within-board column changes are recorded to each card's activity log. All-or- nothing; no notification emails. Body limit 1 MiB. requestBody: required: true content: application/json: schema: type: object required: [ids, column] properties: ids: type: array maxItems: 10000 items: { type: string, format: uuid } column: { type: string, description: "Target column.key" } responses: "200": description: Rewritten cards, in request order content: application/json: schema: { $ref: "#/components/schemas/BulkCardsResponse" } "400": description: Missing fields or too many items content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": description: A referenced card isn't on this board or isn't accessible content: application/json: schema: { $ref: "#/components/schemas/Error" } "422": description: The target column no longer exists content: application/json: schema: { $ref: "#/components/schemas/Error" } /projects/{id}/background-image: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Upload a board background image description: | Board admins only (workspace owners/admins, or explicit board admins). `multipart/form-data` with a single `file` part, max 10 MiB. The declared part `Content-Type` must be one of `image/png`, `image/jpeg`, `image/webp`, `image/gif` AND must match what the leading bytes actually sniff as, so a renamed file is rejected rather than stored. Returns the updated project. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: { type: string, format: binary } responses: "200": description: Updated project content: application/json: schema: { $ref: "#/components/schemas/Project" } "400": description: | Unparseable multipart body, a missing or empty `file` part, a type outside the PNG/JPEG/WebP/GIF set, or a declared type that does not match what the leading bytes sniff as. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "413": description: File exceeds the 10 MiB limit get: summary: Download the board background image description: | Streams the stored bytes to a caller with board access. Bare `404` (no JSON body) when the board has no background set. responses: "200": description: Image bytes content: application/octet-stream: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: No background set, or no board access delete: summary: Remove the board background image description: Board admins only. Returns the updated project. responses: "200": description: Updated project content: application/json: schema: { $ref: "#/components/schemas/Project" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/tags: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Distinct tags used on a project's cards description: Sorted, de-duplicated union of tags across the board's cards (including archived). responses: "200": description: OK content: application/json: schema: type: array items: { type: string } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/share: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Enable public sharing (mint a read-only share token) description: | Generates a fresh public token; revoking and re-enabling mints a new one. The read-only board is then reachable at `/share/{publicToken}`. responses: "200": description: Sharing enabled content: application/json: schema: type: object required: [publicToken] properties: publicToken: { type: string } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Revoke public sharing description: Clears the share token; existing links stop working immediately. responses: "204": { description: Sharing disabled } "404": { $ref: "#/components/responses/NotFound" } /cards/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Get a single card description: | Returns the same shape `PATCH /cards/{id}` responds with, including `following`. `404` when the card does not exist OR the caller has no access to its board - the two are intentionally indistinguishable. responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Card" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } patch: summary: Update a card (metadata + drag-drop) requestBody: required: true content: application/json: schema: type: object properties: title: { type: string, maxLength: 500 } description: { type: string } column: { type: string } position: { type: number } dueDate: { type: string, format: date-time, nullable: true } assignedUserId: { type: string, format: uuid, nullable: true } size: { type: integer, nullable: true } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } tags: type: array maxItems: 12 items: { type: string, maxLength: 32 } description: "Replaces the card's tag set when present. Send `[]` to clear." responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Card" } delete: summary: Delete a card responses: "204": { description: Deleted } /cards/{id}/detail: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Card plus nested collections in one envelope description: | Returns the card (with description) plus comments, events (newest 100, oldest-first), checklist, and attachments. Prefer this over five sequential GETs when opening a card modal. responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/CardDetail" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /cards/bulk: patch: summary: Apply up to 100 partial card updates in one transaction description: | All-or-nothing batch update. Each item is `{ "id": … }` plus any fields the single-card PATCH accepts, with identical semantics (present = apply, `null` = clear, absent = leave alone). Column / priority / assignee changes are recorded to each card's activity log. One invalid item or missing card fails the whole batch and the error names the offending index. Bulk requests never send notification emails. Body limit 1 MiB. requestBody: required: true content: application/json: schema: type: object required: [cards] properties: cards: type: array maxItems: 100 items: type: object required: [id] properties: id: { type: string, format: uuid } title: { type: string, maxLength: 500 } description: { type: string } column: { type: string } position: { type: number } dueDate: { type: string, format: date-time, nullable: true } assignedUserId: { type: string, format: uuid, nullable: true } size: { type: integer, nullable: true } priority: { type: string, nullable: true, enum: [low, medium, high, urgent, null] } tags: type: array maxItems: 12 items: { type: string, maxLength: 32 } responses: "200": description: Updated cards, in request order content: application/json: schema: { $ref: "#/components/schemas/BulkCardsResponse" } "400": description: Invalid item - message names the index content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": description: A referenced card doesn't exist or isn't accessible; nothing was applied content: application/json: schema: { $ref: "#/components/schemas/Error" } /cards/bulk/move: post: summary: Move up to 100 cards to a column in one transaction description: | Without `targetProjectId`, sweeps the cards to a column on their own board - every id must belong to the same board (400 otherwise). With `targetProjectId`, moves the batch to that board instead (same workspace only, mirroring the single-card move). Moved cards append to the end of the target column in request order. Within-board column changes are recorded to each card's activity log. All-or-nothing; no notification emails. requestBody: required: true content: application/json: schema: type: object required: [ids, column] properties: ids: type: array maxItems: 100 items: { type: string, format: uuid } column: { type: string, description: "Target column.key" } targetProjectId: type: string format: uuid description: "Optional destination board (same workspace)" responses: "200": description: Moved cards, in request order content: application/json: schema: { $ref: "#/components/schemas/BulkCardsResponse" } "400": description: Missing fields, too many items, or ids span multiple boards without a targetProjectId content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": description: A card or the target board doesn't exist / isn't accessible; nothing was moved content: application/json: schema: { $ref: "#/components/schemas/Error" } "422": description: Target column doesn't exist content: application/json: schema: { $ref: "#/components/schemas/Error" } /cards/upcoming: get: summary: Every due-dated card you can see responses: "200": description: Flat list with project/column info content: application/json: schema: type: array items: { $ref: "#/components/schemas/CardSummary" } /cards/focus: get: summary: Focus view - due-dated cards + cards needing attention description: | Two buckets across every board you can see: `due` (cards with a due date) and `attention` (cards flagged stale, high priority, or idle-and-assigned-to-you). responses: "200": description: OK content: application/json: schema: type: object required: [due, attention, hygiene] properties: due: type: array items: { $ref: "#/components/schemas/FocusDueCard" } attention: type: array items: { $ref: "#/components/schemas/AttentionCard" } hygiene: type: object description: Per-category counts for the hygiene drill-downs. required: [ghosts, longInProgress, mineNoDate] properties: ghosts: { type: integer } longInProgress: { type: integer } mineNoDate: { type: integer } /cards/hygiene: get: summary: Board-hygiene drill-down parameters: - name: type in: query required: true schema: { type: string, enum: [ghosts, stuck, mine_no_date] } description: | `ghosts` = cards with no due date, priority, or assignee; `stuck` = not updated in 14+ days; `mine_no_date` = assigned to you with no due date. responses: "200": description: Cards in the chosen hygiene category content: application/json: schema: type: array items: { $ref: "#/components/schemas/AttentionCard" } "400": description: Unknown hygiene type content: application/json: schema: { $ref: "#/components/schemas/Error" } /cards/{id}/move: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Move a card to another board description: | Relocates a card to another board in the same workspace that you can edit, into the chosen column (appended to the end). Keeps all card metadata, comments, and attachments. requestBody: required: true content: application/json: schema: type: object required: [targetProjectId, column] properties: targetProjectId: { type: string, format: uuid } column: { type: string, description: "Target column.key on the destination board" } responses: "200": description: The moved card content: application/json: schema: { $ref: "#/components/schemas/Card" } "400": description: Missing fields, or the target is the card's current board content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } "422": description: Target column doesn't exist on the destination board content: application/json: schema: { $ref: "#/components/schemas/Error" } /cards/{id}/events: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Card activity log (moves, priority + assignee changes) responses: "200": description: Events, oldest first content: application/json: schema: type: array items: { $ref: "#/components/schemas/CardEvent" } "404": { $ref: "#/components/responses/NotFound" } /cards/{id}/comments: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List comments (oldest first) responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/Comment" } post: summary: Add a comment requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: type: string maxLength: 5000 description: Markdown. Capped at 5000 characters; longer bodies return 400. responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Comment" } /cards/{id}/follow: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Follow a card description: | Subscribe to future notifications for this card. Idempotent - already following returns 204. API keys may call this endpoint. responses: "204": { description: Following } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Unfollow a card description: | Unsubscribe from future notifications for this card. Idempotent when not following. 404 when the card doesn't exist or the caller has no board access. responses: "204": { description: Unfollowed } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /cards/{id}/checklist: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List a card's checklist items description: Flat list of `{text, done}` items, ordered by position. responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/ChecklistItem" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: summary: Add a checklist item description: Any board member may add an item; it's appended to the end. requestBody: required: true content: application/json: schema: type: object required: [text] properties: text: { type: string, maxLength: 1000 } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/ChecklistItem" } "400": description: Missing/blank text or text too long content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /checklist/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } patch: summary: Update a checklist item (partial) description: Any board member may edit an item's `text` and/or `done` state. requestBody: required: true content: application/json: schema: type: object properties: text: { type: string, maxLength: 1000 } done: { type: boolean } responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/ChecklistItem" } "400": description: Blank text or text too long content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Delete a checklist item description: Any board member may delete an item. responses: "204": { description: Deleted } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/milestones: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List a project's milestones description: Ordered by dueOn ascending, then createdAt as a tie-break. Any board member may read. responses: "200": description: Milestones for the project content: application/json: schema: type: array items: { $ref: "#/components/schemas/Milestone" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: summary: Add a milestone description: Board admins only (workspace owners/admins, or explicit board admins). requestBody: required: true content: application/json: schema: type: object required: [name, dueOn] properties: name: { type: string, maxLength: 200 } dueOn: { type: string, format: date } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Milestone" } "400": description: Missing/blank name, name too long, or invalid dueOn content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller is a board member but not an admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /milestones/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } patch: summary: Update a milestone (partial) description: | Board admins only. Setting `achieved: true` stamps `achievedAt` once (re-sending `true` keeps the original stamp); `achieved: false` clears it. requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, maxLength: 200 } dueOn: { type: string, format: date } achieved: { type: boolean } responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Milestone" } "400": description: Blank name, name too long, or invalid dueOn content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller is a board member but not an admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Delete a milestone description: Board admins only. responses: "204": { description: Deleted } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller is a board member but not an admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/events: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Server-Sent Events stream for a board (realtime) description: | Opens a long-lived `text/event-stream` of `card.created` / `card.updated` / `card.deleted` (and checklist) events scoped to one project, plus a `: ping` comment every 30s to keep the connection warm. Token-authenticated callers can subscribe, but typical API workflows poll the REST endpoints - the stream exists primarily for the SPA. responses: "200": description: An SSE stream (connection stays open). content: text/event-stream: schema: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /public/projects/{token}: parameters: - name: token in: path required: true schema: { type: string } get: summary: Read-only public board snapshot (no auth) operationId: getPublicBoard description: | Unauthenticated read of a board that has public sharing enabled. The share token is the access check; revoking sharing (or minting a new token) invalidates old links immediately. Returns a trimmed projection - no workspace, ownership, or private metadata. security: [] responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PublicBoard" } "404": description: No board with this share token (or sharing revoked) content: application/json: schema: { $ref: "#/components/schemas/Error" } /public/projects/{token}/background-image: parameters: - name: token in: path required: true schema: { type: string } get: summary: Download a shared board's background image description: | Unauthenticated, gated entirely by the share token in the URL - the same blob `/projects/{id}/background-image` serves to members. Bare `404` when the token is unknown or the board has no background set. security: [] responses: "200": description: Image bytes content: application/octet-stream: schema: { type: string, format: binary } "404": description: Unknown token, or no background set /cards/{id}/attachments: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List attachments on a card responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/Attachment" } "404": { $ref: "#/components/responses/NotFound" } post: summary: Upload a file to a card description: | Multipart upload with a single `file` part. Bytes flow through the API to object storage (max **25 MiB** per file). Requires the card's workspace to be on a paid plan - Free workspaces receive `402`. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary description: The file to attach responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Attachment" } "400": description: Missing/empty file or malformed multipart body content: application/json: schema: { $ref: "#/components/schemas/Error" } "402": description: Workspace plan does not allow uploads content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } "413": description: File exceeds the 25 MiB limit content: application/json: schema: { $ref: "#/components/schemas/Error" } /attachments/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: Download attachment bytes parameters: - name: inline in: query required: false schema: { type: string, enum: ["1"] } description: | Request inline rendering rather than download. Honoured only for playable audio/video content types (audio/webm, audio/ogg, audio/mpeg, audio/mp4, audio/wav, video/webm, video/mp4); every other type is served as an attachment regardless, so this can never be used to render an uploaded document in the app's origin. description: | Streams the file with its stored `Content-Type` and a `Content-Disposition: attachment` header (or `inline` - see the `inline` parameter). Returns bare `404` (no JSON body) when the attachment or its bytes are missing. responses: "200": description: File bytes content: application/octet-stream: schema: type: string format: binary "404": description: Attachment not found or bytes unavailable delete: summary: Delete an attachment description: Removes metadata and storage bytes (best-effort on storage). responses: "204": { description: Deleted } "404": { $ref: "#/components/responses/NotFound" } /comments/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } patch: summary: Edit (author only) requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: type: string maxLength: 5000 description: Markdown. Capped at 5000 characters; longer bodies return 400. responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Comment" } delete: summary: Delete (author or workspace owner/admin) responses: "204": { description: Deleted } /search: get: summary: Cross-project card search parameters: - name: q in: query required: true schema: { type: string } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 50, default: 20 } description: | Out-of-range values fall back to the default of 20 rather than clamping to 50 or erroring, so `limit=100` returns 20 rows. responses: "200": description: OK content: application/json: schema: type: object properties: cards: type: array items: { $ref: "#/components/schemas/CardSummary" } /columns/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } patch: summary: Update a column requestBody: required: true content: application/json: schema: type: object properties: title: { type: string, maxLength: 80 } color: { type: string, nullable: true } position: { type: number } isDone: { type: boolean } cardLimit: { type: integer, nullable: true } responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Column" } delete: summary: Delete a column (409 if it holds live cards) description: | Only LIVE cards block the delete. Archived cards in the column are reassigned to the board's first column (by position) and the delete proceeds - they keep existing and stay archived, so nothing that is still searchable is destroyed. Their recorded column changes, which is unavoidable once their real column is gone. A column that is the board's only column and holds archived cards has nowhere to reassign them to, so it still returns 409. responses: "204": { description: Deleted } "409": description: Column still has live cards content: application/json: schema: { $ref: "#/components/schemas/Error" } /columns/{id}/archive: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Archive all cards in a column description: | Soft-archives every live card in the column - they leave the board but stay searchable. Only applies to a column flagged `isDone`. On any other column this still answers `200` with `{"archived": 0}` rather than an error, so check the count instead of relying on the status code. responses: "200": description: Count of cards archived content: application/json: schema: type: object required: [archived] properties: archived: { type: integer } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/columns: parameters: - name: id in: path required: true schema: { type: string, format: uuid } post: summary: Add a column to a project requestBody: required: true content: application/json: schema: type: object required: [title] properties: title: { type: string, maxLength: 80 } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Column" } /projects/{id}/members: parameters: - name: id in: path required: true schema: { type: string, format: uuid } get: summary: List everyone who can see the board operationId: listBoardMembers description: | Returns explicit `project_members` grants alongside implicit rows for workspace owners/admins and (when the board is workspace-visible) every workspace member. The `source` field discriminates the three kinds. Any caller who can see the board may read this. responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/BoardMember" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } post: summary: Add a workspace member to the board operationId: addBoardMember description: | Board-admin only. The target must already be a workspace member; otherwise 404. Roles for workspace owners/admins are implicit, so explicit grants are only stored for workspace `member`s. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/BoardMemberInput" } responses: "201": description: Created content: application/json: schema: type: object required: [userId, role, createdAt] properties: userId: { type: string, format: uuid } role: { type: string, enum: [admin, contributor] } createdAt: { type: string, format: date-time } "400": description: Missing / invalid role content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /projects/{id}/members/{userId}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } - name: userId in: path required: true schema: { type: string, format: uuid } patch: summary: Change an explicit board grant's role operationId: updateBoardMember description: | Board-admin only. 404 if there's no explicit `project_members` row for the target (i.e. their access is implicit via workspace role or workspace-visibility). requestBody: required: true content: application/json: schema: type: object required: [role] properties: role: { type: string, enum: [admin, contributor] } responses: "200": description: Updated content: application/json: schema: type: object required: [userId, role, createdAt] properties: userId: { type: string, format: uuid } role: { type: string, enum: [admin, contributor] } createdAt: { type: string, format: date-time } "400": description: Missing / invalid role content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Remove an explicit board grant operationId: removeBoardMember description: | Board-admin only, except a contributor may remove themselves. 404 if no explicit grant exists for the target. responses: "204": { description: Removed } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /members: get: summary: List workspace members operationId: listMembers responses: "200": description: | Workspace members. `boardAccess` is attached on rows the caller is allowed to see (their own row, or every row when the caller is owner/admin); omitted on owner/admin rows since they have implicit access to every board. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Member" } "401": { $ref: "#/components/responses/Unauthorized" } /members/{userId}: parameters: - name: userId in: path required: true schema: { type: string, format: uuid } patch: summary: Change a member's workspace role operationId: updateMemberRole description: | Owner/admin only. Flips a member between `admin` and `member`. The owner role can't be set or changed here. requestBody: required: true content: application/json: schema: type: object required: [role] properties: role: { type: string, enum: [admin, member] } responses: "200": description: Updated content: application/json: schema: { $ref: "#/components/schemas/Member" } "400": description: Missing / invalid role content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller isn't a workspace owner/admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } delete: summary: Remove a member from the workspace operationId: removeMember description: Admin/owner only. Revokes membership and closes the member's open SSE streams. responses: "204": { description: Removed } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller isn't a workspace owner/admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /invitations: get: summary: List pending invitations operationId: listInvitations responses: "200": description: Pending invitations, each with its `boardGrants` array. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Invitation" } "401": { $ref: "#/components/responses/Unauthorized" } post: summary: Invite a user by email operationId: createInvitation description: | `boardGrants` is optional and only honoured for `role: "member"` invitations - admin invitations auto-see every board. Grants are persisted alongside the invitation and applied atomically when the invitee accepts. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/InvitationCreate" } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/Invitation" } "400": description: Malformed body or invalid role content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "409": description: Email already a member or has a pending invitation content: application/json: schema: { $ref: "#/components/schemas/Error" } /invitations/{id}: parameters: - name: id in: path required: true schema: { type: string, format: uuid } delete: summary: Revoke a pending invitation operationId: revokeInvitation description: Owner/admin only. Deletes a pending invite so its accept link stops working. responses: "204": { description: Revoked } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: Caller isn't a workspace owner/admin content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /version: get: summary: Build information for the running server operationId: getVersion description: | Unauthenticated so monitoring can poll it without managing credentials, and cheap enough to use as a liveness probe: it touches neither the database nor the session store. Use it to confirm which artifact is live without shelling into a container. security: [] responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/VersionInfo" }