openapi: 3.1.0 info: title: Hazydo API version: 1.0.0 description: | HTTP/JSON API exposed by `hazydod` for Hazydo. Every endpoint lives under `/api/v1/`; this document covers the non-admin surface used by the SPA, CLI, and personal-API-key scripts. The admin-only endpoints under `/admin` are out of scope here. Authentication: most routes accept either a session cookie (`hazy_jwt`) or a `Authorization: Bearer ` header where the token is either a short-lived login JWT or a personal API key minted at `POST /users/me/api-keys`. The two schemes are interchangeable; the SPA uses the cookie, scripts typically use a bearer key. Errors share the envelope `{ code: string, error: string }` — `code` follows `area.subkey` (`auth.invalid_credentials`, `billing.not_configured`, …). Some paths omit `code`; the `error` field is always present. license: name: AGPL-3.0-or-later identifier: AGPL-3.0-or-later servers: - url: /api/v1 tags: - name: Auth description: Sign-in, sign-up, password reset, OIDC, and session lifecycle. - name: TOTP description: Two-factor authentication setup, verification, and recovery codes. - name: Users description: The caller's own profile, password, export, audit log, account deletion, and personal API keys. - name: Lists description: Top-level work containers ("lists" in the URL space) — CRUD and per-list resources. - name: Tasks description: Individual units of work inside a list — CRUD and archive. - name: Comments description: Threaded discussion on a task. - name: Members description: Membership and access control on a list. - name: Invites description: Outbound and inbound list invitations. - name: Portraits description: Per-user avatar uploads. - name: Billing description: Stripe-backed subscription, plans, charges, checkout, and customer portal. - name: PublicLink description: Enable / disable the unguessable read-only token on a list. - name: PublicViewer description: Token-scoped read of a list (no auth) and subscribe-into-drawer. - name: Analytics description: Per-user aggregate metrics over the caller's lists and tasks. - name: Events description: Per-list change-event stream (SSE). - name: Setup description: First-run install bootstrap (admin user, base settings). - name: Meta description: Daemon build identity and similar unauthenticated metadata. security: - bearerAuth: [] - cookieAuth: [] paths: # --------------------------------------------------------------------------- # Auth + session /auth/login: post: operationId: login tags: [Auth] summary: Sign in with email and password description: | Verifies credentials, mints a session row (returned as the `hazy_jwt` cookie), and emits a JWT in the response body for Bearer-using callers. When the user has TOTP enabled, returns a short-lived challenge instead — call `POST /auth/totp/verify` with the code to finish login. security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/LoginRequest' } responses: '200': description: Signed in, or TOTP challenge pending. content: application/json: schema: oneOf: - $ref: '#/components/schemas/LoginResponse' - $ref: '#/components/schemas/LoginTOTPRequired' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '409': description: SSO required for this address (hosted-domain lock). content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: login_url: { type: string } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/ServerError' } /auth/demo: post: operationId: demoLogin tags: [Auth] summary: Sign in to the shared demo account description: | Public autologin into the demo user, when `HAZY_DEMO_ENABLED` is set on the server. 404 when disabled or unseeded. The session cookie is set on success. security: [] responses: '200': description: Demo session created. content: application/json: schema: allOf: - $ref: '#/components/schemas/LoginResponse' - type: object properties: email: { type: string } email_verified: { type: boolean } demo: { type: boolean } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /auth/logout: post: operationId: logout tags: [Auth] summary: Drop the caller's session row + cookie description: | Idempotent. The cookie is always cleared in the response. If a session row was actually deleted, an `auth.logout` audit entry lands. responses: '204': { description: Logged out. } '401': { $ref: '#/components/responses/Unauthorized' } /auth/config: get: operationId: getAuthConfig tags: [Auth] summary: Pre-auth configuration the login screen needs description: | Reports whether self-service signup is enabled, what SSO option (if any) is wired up, and which i18n locales the server can render mail in. Public. security: [] responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/AuthConfig' } '4XX': description: | Any client-side error follows the shared Error envelope. In practice this endpoint has no 4xx mode today — it's declared here so generated clients have a typed branch ready if one ever appears. content: application/json: schema: { $ref: '#/components/schemas/Error' } /auth/register: post: operationId: register tags: [Auth] summary: Create an account description: | Creates a user, mints a session, sends the verification mail. Refused with `auth.signup_disabled` when public signup is off unless `invite_token` resolves to a valid pending list invite for the same email. Rate-limited per (IP, email). security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/SignupRequest' } responses: '201': description: Account created. content: application/json: schema: { $ref: '#/components/schemas/RegisterResponse' } '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/ServerError' } /auth/verify: get: operationId: verifyEmail tags: [Auth] summary: Consume an email-verification token (link in mail) description: | Single-use. Always redirects to the SPA with `#verify-ok` or `#verify-failed`; no JSON body is returned. security: [] parameters: - in: query name: token required: true schema: { type: string } responses: '200': description: | Reserved — current implementation always redirects, but the response key is declared so codegen tools that require a 2xx entry don't choke. '302': description: Redirect to the SPA with a flash flag. '400': description: Missing or malformed `token` query parameter. content: application/json: schema: { $ref: '#/components/schemas/Error' } /auth/password/forgot: post: operationId: forgotPassword tags: [Auth] summary: Issue a password-reset link description: | Always 204 — hit and miss are indistinguishable on the wire so the endpoint can't be used to enumerate registered addresses. Rate-limited per (IP, email). security: [] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string } responses: '204': { description: Token (maybe) issued. } '400': { $ref: '#/components/responses/BadRequest' } '429': { $ref: '#/components/responses/RateLimited' } /auth/password/reset: post: operationId: resetPassword tags: [Auth] summary: Consume a reset token and set a new password description: | On success: all prior sessions are dropped, a new session is minted, and the `hazy_jwt` cookie is updated. security: [] requestBody: required: true content: application/json: schema: type: object required: [token, password] properties: token: { type: string } password: { type: string } responses: '204': { description: Password reset. } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } /auth/verification/resend: post: operationId: resendVerification tags: [Auth] summary: Re-send the verification email description: | Login-required so the recipient address comes from the signed-in row (not request input). 409 when the user is already verified. responses: '204': { description: Mail dispatched. } '401': { $ref: '#/components/responses/Unauthorized' } '409': { $ref: '#/components/responses/Conflict' } '429': { $ref: '#/components/responses/RateLimited' } '502': { $ref: '#/components/responses/BadGateway' } /auth/oidc/login: get: operationId: oidcLogin tags: [Auth] summary: Start the SSO leg description: | Mints state + nonce cookies and 302s to the configured OpenID Connect provider. 404 when SSO isn't enabled. security: [] responses: '200': description: | Reserved — current implementation always redirects, but the response key is declared so codegen tools that require a 2xx entry don't choke. '302': { description: Redirect to the IdP. } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /auth/oidc/callback: get: operationId: oidcCallback tags: [Auth] summary: Finish the SSO leg description: | Verifies state + nonce, exchanges the code, JIT-provisions or links the user, mints a session, and redirects to `/` (or `/#sso-error=` on failure). security: [] parameters: - in: query name: code schema: { type: string } - in: query name: state schema: { type: string } responses: '200': description: | Reserved — current implementation always redirects, but the response key is declared so codegen tools that require a 2xx entry don't choke. '302': { description: Redirect back into the SPA. } '404': { $ref: '#/components/responses/NotFound' } # --------------------------------------------------------------------------- # TOTP /users/me/totp: get: operationId: getTOTPStatus tags: [TOTP] summary: Report 2FA enrolment state responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/TOTPStatus' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } /users/me/totp/setup: post: operationId: setupTOTP tags: [TOTP] summary: Start TOTP enrolment description: | Mints a fresh secret and returns its provisioning URL, QR PNG data URL, and the manual base32 fallback. Re-calling before confirm replaces the pending secret. responses: '200': description: Provisioning data. content: application/json: schema: { $ref: '#/components/schemas/TOTPSetupResponse' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /users/me/totp/confirm: post: operationId: confirmTOTP tags: [TOTP] summary: Finalise TOTP enrolment description: | Validates the first code and surfaces the freshly-minted recovery codes ONCE. Save them off-server — the API never returns the same codes again. requestBody: required: true content: application/json: schema: type: object required: [code] properties: code: { type: string } responses: '200': description: Enrolment complete. content: application/json: schema: { $ref: '#/components/schemas/RecoveryCodesResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /users/me/totp/disable: post: operationId: disableTOTP tags: [TOTP] summary: Turn off 2FA after re-auth description: | Requires the user's current password. Clears trusted-device cookies on success. requestBody: required: true content: application/json: schema: type: object required: [password] properties: password: { type: string } responses: '204': { description: Disabled. } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /users/me/totp/recovery-codes/regenerate: post: operationId: regenerateRecoveryCodes tags: [TOTP] summary: Mint a fresh set of recovery codes description: Invalidates the previous codes; shown once. responses: '200': description: Codes minted. content: application/json: schema: { $ref: '#/components/schemas/RecoveryCodesResponse' } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /auth/totp/verify: post: operationId: verifyTOTP tags: [TOTP] summary: Finish the TOTP step of login description: | Consumes the challenge token returned by `/auth/login`, accepts either a 6-digit code or a recovery code. Setting `remember=true` mints a 30-day trusted-device bypass cookie. Mints the session cookie + JWT on success. security: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TOTPVerifyRequest' } responses: '200': description: Signed in. content: application/json: schema: { $ref: '#/components/schemas/LoginResponse' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } # --------------------------------------------------------------------------- # Users (self) /users/me: get: operationId: getMe tags: [Users] summary: Return the signed-in user's profile responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/MeUser' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } patch: operationId: patchMe tags: [Users] summary: Update display name and/or locale requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PatchMeRequest' } responses: '204': { description: Updated. } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } /users/me/password: post: operationId: changePassword tags: [Users] summary: Change the signed-in user's password description: | Verifies the current password (bcrypt), writes the new hash, drops every other session, and refreshes the cookie. Escape- hatch route: works even when the account is read-only. requestBody: required: true content: application/json: schema: type: object required: [current_password, new_password] properties: current_password: { type: string } new_password: { type: string, minLength: 8 } responses: '204': { description: Password updated. } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } /users/me/portrait: put: operationId: putPortrait tags: [Portraits] summary: Upload a portrait description: | Multipart form upload (`file` field, JPEG or PNG, ≤ 1 MB). Server centre-crops to square, resizes to 256×256, re-encodes as JPEG (quality 82) before storing. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: '200': description: Saved. content: application/json: schema: { $ref: '#/components/schemas/PortraitMeta' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '500': { $ref: '#/components/responses/ServerError' } delete: operationId: deletePortrait tags: [Portraits] summary: Clear the caller's portrait description: Idempotent. responses: '204': { description: Removed. } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } /users/{userID}/portrait: get: operationId: getPortrait tags: [Portraits] summary: Fetch any user's portrait as JPEG description: | Cross-workspace by design (so comment authors / shared-list members render with the right face). Strong ETag from the stored sha256; supports `If-None-Match`. parameters: - $ref: '#/components/parameters/UserIDPath' responses: '200': description: JPEG bytes. content: image/jpeg: schema: type: string format: binary '304': { description: Not modified. } '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /users/me/export: get: operationId: exportMe tags: [Users] summary: GDPR self-service data export description: | Returns one JSON document covering the caller's profile, sessions, audit, API keys (metadata only — never plaintexts), memberships, comments they authored, and 2FA presence. responses: '200': description: Export payload. content: application/json: schema: { $ref: '#/components/schemas/UserExport' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/ServerError' } /users/me/audit: get: operationId: listMyAudit tags: [Users] summary: Recent sign-in / security events for the caller parameters: - in: query name: limit schema: { type: integer, minimum: 1, maximum: 200, default: 50 } responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/AuditEvent' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } /users/me/delete: post: operationId: deleteAccount tags: [Users] summary: Self-service GDPR Art. 17 erasure description: | Requires the current password and the exact confirmation phrase "delete my account" (case-insensitive). Refused with code `last_admin` (single-admin install) or `shared_project` (caller owns lists with other members; offending IDs returned in `blocked_project_ids`). requestBody: required: true content: application/json: schema: type: object required: [current_password, confirmation] properties: current_password: { type: string } confirmation: { type: string } responses: '204': { description: Account deleted; session cookie cleared. } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '409': description: Refused — `last_admin` or `shared_project`. content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: blocked_project_ids: type: array items: { $ref: '#/components/schemas/UUID' } '500': { $ref: '#/components/responses/ServerError' } /users/me/analytics: get: operationId: getAnalytics tags: [Analytics] summary: Per-user analytics snapshot parameters: - in: query name: days schema: { type: integer, minimum: 1, maximum: 365, default: 30 } responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/AnalyticsSnapshot' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } /users/me/api-keys: get: operationId: listAPIKeys tags: [Users] summary: List the caller's active personal API keys responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/APIKey' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: createAPIKey tags: [Users] summary: Mint a new personal API key description: | Returns the plaintext exactly once. After this response the server keeps only the sha256 — the plaintext is unrecoverable. Limited to 25 active keys per user. requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string, maxLength: 80 } expires_at: { type: string, format: date-time } read_only: { type: boolean } responses: '201': description: Key minted. content: application/json: schema: { $ref: '#/components/schemas/APIKeyCreated' } '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /users/me/api-keys/{id}: delete: operationId: revokeAPIKey tags: [Users] summary: Revoke a personal API key description: Password-confirmed. Idempotent on a revoked key. parameters: - $ref: '#/components/parameters/IDPath' requestBody: required: true content: application/json: schema: type: object required: [password] properties: password: { type: string } responses: '204': { description: Revoked. } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } # --------------------------------------------------------------------------- # Lists ("lists" in the URL space) /lists: get: operationId: listProjects tags: [Lists] summary: List the caller's visible lists description: | Returns every list the caller owns or is a member of, with aggregate task-status counts per row so the drawer can render progress without a per-list events subscription. responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/Project' } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: createProject tags: [Lists] summary: Create a new list requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } description: { type: string, maxLength: 4096 } git_repo: { type: string, maxLength: 2048, description: "Git remote url; empty if none." } responses: '201': description: Created. content: application/json: schema: { $ref: '#/components/schemas/Project' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}: patch: operationId: patchProject tags: [Lists] summary: Rename, re-describe, re-icon, or set the git repo of a list parameters: [{ $ref: '#/components/parameters/ListIDPath' }] requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } description: { type: string, maxLength: 4096 } icon: { type: string } git_repo: { type: string, maxLength: 2048, description: "Git remote url, or empty string to clear." } responses: '200': description: Updated. content: application/json: schema: { $ref: '#/components/schemas/Project' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } delete: operationId: deleteProject tags: [Lists] summary: Drop a list and cascade its tasks description: | Deletes the list and cascades its tasks, comments, and members. parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '204': { description: Deleted. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/tasks: get: operationId: listTasks tags: [Tasks] summary: List tasks in a list parameters: - $ref: '#/components/parameters/ListIDPath' - in: query name: archived description: When `true`, includes archived tasks. schema: { type: string, enum: ["true", "false"] } responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/Task' } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: createTask tags: [Tasks] summary: Append a task to a list description: | Position is auto-assigned as `max(position) + 1`. Refused with `project_ready` if the list is currently dispatching; move it to Draft first. parameters: [{ $ref: '#/components/parameters/ListIDPath' }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CreateTaskRequest' } responses: '201': description: Created. content: application/json: schema: { $ref: '#/components/schemas/Task' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/members: get: operationId: listMembers tags: [Members] summary: List members of a list parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/Member' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: addMember tags: [Members] summary: Invite a user as editor or viewer description: | If the email resolves to a known user, the membership is created immediately. Otherwise a pending invite is persisted and an email link is sent — the recipient redeems via `/invites/{token}/accept`. parameters: [{ $ref: '#/components/parameters/ListIDPath' }] requestBody: required: true content: application/json: schema: type: object required: [email, role] properties: email: { type: string } role: { type: string, enum: [editor, viewer] } responses: '201': description: Member added (or invite mailed). content: application/json: schema: oneOf: - $ref: '#/components/schemas/Member' - type: object properties: status: { type: string } email: { type: string } role: { type: string } expires_at: { $ref: '#/components/schemas/Timestamp' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } '502': { $ref: '#/components/responses/BadGateway' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /lists/{listID}/members/{userID}: delete: operationId: removeMember tags: [Members] summary: Remove a member (kick or self-leave) description: | Owner-only when removing someone else. Members removing themselves use the same endpoint with their own user id; owners cannot self-leave (delete the list instead). parameters: - $ref: '#/components/parameters/ListIDPath' - $ref: '#/components/parameters/UserIDPath' responses: '204': { description: Removed. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/invites: get: operationId: listInvites tags: [Invites] summary: Pending invites on a list (owner-only) parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/Invite' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/invites/{inviteID}: delete: operationId: revokeInvite tags: [Invites] summary: Revoke a pending invite (owner-only) parameters: - $ref: '#/components/parameters/ListIDPath' - in: path name: inviteID required: true schema: { $ref: '#/components/schemas/UUID' } responses: '204': { description: Revoked. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/public: get: operationId: getPublicLink tags: [PublicLink] summary: Inspect / fetch the list's public-share token parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '200': description: Public link state. content: application/json: schema: type: object properties: enabled: { type: boolean } token: { type: string } url: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: enablePublicLink tags: [PublicLink] summary: Enable (or rotate) the list's public-share token parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '200': description: Token minted. content: application/json: schema: type: object properties: token: { type: string } url: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } delete: operationId: disablePublicLink tags: [PublicLink] summary: Revoke the list's public-share token description: Idempotent — disabling a private list is a no-op. parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '204': { description: Disabled. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /lists/{listID}/events: get: operationId: listProjectEvents tags: [Events] summary: Server-sent event stream for a list description: | Replaces UI polling. Frames: * `event: task` — payload is the full task snapshot. * `event: task_deleted` — payload is `{ id: }`. * `event: comment` / `event: comment_deleted` — discussion thread. * `event: members_changed` — drawer should refetch `/members`. * `event: synced` — baseline replay complete. * `: keepalive` comment every 15s. parameters: [{ $ref: '#/components/parameters/ListIDPath' }] responses: '200': description: SSE stream. content: text/event-stream: schema: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } # --------------------------------------------------------------------------- # Tasks /tasks/{taskID}: patch: operationId: patchTask tags: [Tasks] summary: Update mutable task fields description: | Position, name, description, due_at, outputs, and status (one of `pending`, `in_progress`, `completed`, `failed`). `due_at` accepts JSON `null` to clear. parameters: [{ $ref: '#/components/parameters/TaskIDPath' }] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PatchTaskRequest' } responses: '200': description: Updated. content: application/json: schema: { $ref: '#/components/schemas/Task' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } delete: operationId: deleteTask tags: [Tasks] summary: Remove a task description: Refused with `project_ready` if dispatching; with 409 if the task is running. parameters: [{ $ref: '#/components/parameters/TaskIDPath' }] responses: '204': { description: Deleted. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /tasks/{taskID}/archive: post: operationId: archiveTask tags: [Tasks] summary: Soft-hide a task description: | Stamps `archived_at`. The row stays addressable but drops out of default listings. parameters: [{ $ref: '#/components/parameters/TaskIDPath' }] responses: '200': description: Archived. content: application/json: schema: { $ref: '#/components/schemas/Task' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /tasks/{taskID}/comments: get: operationId: listComments tags: [Comments] summary: Read a task's comment thread parameters: [{ $ref: '#/components/parameters/TaskIDPath' }] responses: '200': description: OK content: application/json: schema: type: array items: { $ref: '#/components/schemas/Comment' } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } post: operationId: createComment tags: [Comments] summary: Post a new comment description: | Adds a comment to the task's discussion thread. parameters: [{ $ref: '#/components/parameters/TaskIDPath' }] requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 10000 } responses: '201': description: Posted. content: application/json: schema: { $ref: '#/components/schemas/Comment' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /comments/{commentID}: patch: operationId: patchComment tags: [Comments] summary: Edit your own comment description: Author-only at the SQL layer. 404 masks "not yours". parameters: - in: path name: commentID required: true schema: { type: integer, format: int64 } requestBody: required: true content: application/json: schema: type: object required: [body] properties: body: { type: string, maxLength: 10000 } responses: '200': description: Updated. content: application/json: schema: { $ref: '#/components/schemas/Comment' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } delete: operationId: deleteComment tags: [Comments] summary: Delete your own comment parameters: - in: path name: commentID required: true schema: { type: integer, format: int64 } responses: '204': { description: Deleted. } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } # --------------------------------------------------------------------------- # Billing /billing/plans: get: operationId: listBillingPlans tags: [Billing] summary: Public catalogue of operator-configured plans security: [] responses: '200': description: OK content: application/json: schema: type: object properties: plans: type: array items: { $ref: '#/components/schemas/BillingPlan' } '400': { $ref: '#/components/responses/BadRequest' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /billing/me: get: operationId: getBillingMe tags: [Billing] summary: Caller's subscription snapshot responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/BillingMe' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /billing/charges: get: operationId: listBillingCharges tags: [Billing] summary: Caller's recent Stripe charges description: | Returns the 25 most recent charges. Stripe outage degrades to an empty list + `charges_error` rather than a 5xx. responses: '200': description: OK content: application/json: schema: type: object properties: charges: type: array items: { $ref: '#/components/schemas/Charge' } charges_error: { type: string } '401': { $ref: '#/components/responses/Unauthorized' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /billing/checkout: post: operationId: startCheckout tags: [Billing] summary: Start a Stripe Checkout Session requestBody: required: false content: application/json: schema: type: object properties: plan_id: { type: string } kind: { type: string, description: "Legacy alias; resolves to the first plan whose Kind matches." } responses: '200': description: OK content: application/json: schema: type: object properties: url: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '502': { $ref: '#/components/responses/BadGateway' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /billing/portal: post: operationId: openBillingPortal tags: [Billing] summary: Open the Stripe Customer Portal responses: '200': description: OK content: application/json: schema: type: object properties: url: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } '502': { $ref: '#/components/responses/BadGateway' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /webhooks/stripe: post: operationId: stripeWebhook tags: [Billing] summary: Inbound Stripe events description: | Unauthenticated by JWT; the `Stripe-Signature` header verifies the body. Body is capped at 256 KiB. security: - stripeSignature: [] requestBody: required: true content: application/json: schema: type: object additionalProperties: true responses: '200': { description: Accepted. } '400': { $ref: '#/components/responses/BadRequest' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '500': { $ref: '#/components/responses/ServerError' } '503': { $ref: '#/components/responses/ServiceUnavailable' } # --------------------------------------------------------------------------- # Public viewer + invites /public/lists/{token}: get: operationId: viewPublicProject tags: [PublicViewer] summary: Read-only public list view (no auth) security: [] parameters: - in: path name: token required: true schema: { type: string } responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/PublicProjectView' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /public/lists/{token}/rss: get: operationId: viewPublicProjectRSS tags: [PublicViewer] summary: RSS 2.0 feed of a shared list's completed tasks (no auth) description: > Reuses the same public token as the read-only viewer. Each completed (non-archived) task is one entry: the task name is the title and its completion note (outputs.result), rendered from markdown to sanitized HTML, is the body — ordered newest-completion first. security: [] parameters: - in: path name: token required: true schema: { type: string } responses: '200': description: OK content: application/rss+xml: schema: { type: string } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/ServerError' } /public/lists/{token}/subscribe: post: operationId: subscribePublicProject tags: [PublicViewer] summary: Convert public-link access into a persistent viewer membership parameters: - in: path name: token required: true schema: { type: string } responses: '200': description: Subscribed. content: application/json: schema: type: object properties: id: { type: string } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /invites/{token}: get: operationId: showInvite tags: [Invites] summary: Pre-auth invite preview (renders "You've been invited") security: [] parameters: - in: path name: token required: true schema: { type: string } responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/InviteSummary' } '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/ServerError' } /invites/{token}/accept: post: operationId: acceptInvite tags: [Invites] summary: Redeem a list invite for the signed-in user description: | The caller's email must match the invite's target. Returns the joined list id. parameters: - in: path name: token required: true schema: { type: string } responses: '200': description: Joined. content: application/json: schema: type: object properties: project_id: { $ref: '#/components/schemas/UUID' } '400': { $ref: '#/components/responses/BadRequest' } '402': { $ref: '#/components/responses/PaymentRequired' } '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/ServerError' } # --------------------------------------------------------------------------- # Setup + meta /setup/state: get: operationId: getSetupState tags: [Setup] summary: Does this install still need first-run bootstrapping? security: [] responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/SetupState' } '400': { $ref: '#/components/responses/BadRequest' } '500': { $ref: '#/components/responses/ServerError' } /setup: post: operationId: doSetup tags: [Setup] summary: Create the first admin user on a blank install description: | Refused with 409 if any admin user already exists. On success the caller is logged in immediately (cookie + JWT). security: [] requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string } password: { type: string, minLength: 8 } responses: '201': description: Admin created. content: application/json: schema: type: object properties: token: { type: string } user_id: { $ref: '#/components/schemas/UUID' } role: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Conflict' } '500': { $ref: '#/components/responses/ServerError' } /version: get: operationId: getVersion tags: [Meta] summary: Build identity (unauthenticated) security: [] responses: '200': description: OK content: application/json: schema: { $ref: '#/components/schemas/Version' } '4XX': description: | Reserved — the daemon never returns a 4xx from this route (it just serializes a static `version.Info`), but a typed error branch keeps generated clients consistent with the rest of the API. content: application/json: schema: { $ref: '#/components/schemas/Error' } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | JWT issued by `POST /auth/login` or sent via the `hazy_jwt` cookie; for service callers, personal API keys created via `POST /users/me/api-keys` use the same `Authorization: Bearer …` header. cookieAuth: type: apiKey in: cookie name: hazy_jwt description: | Browser callers use this in practice; the bearer header and cookie are interchangeable. stripeSignature: type: apiKey in: header name: Stripe-Signature description: HMAC signature over the body — only on `POST /webhooks/stripe`. parameters: ListIDPath: in: path name: listID required: true schema: { $ref: '#/components/schemas/UUID' } TaskIDPath: in: path name: taskID required: true schema: { $ref: '#/components/schemas/UUID' } UserIDPath: in: path name: userID required: true schema: { $ref: '#/components/schemas/UUID' } IDPath: in: path name: id required: true schema: { $ref: '#/components/schemas/UUID' } VarSecretName: in: path name: name required: true description: | Variable / secret name. Shell-env shape: uppercase letter first, then uppercase letters, digits, or underscores. Up to 64 chars. schema: type: string pattern: '^[A-Z][A-Z0-9_]{0,63}$' responses: BadRequest: description: Bad request. content: application/json: schema: { $ref: '#/components/schemas/Error' } Unauthorized: description: Authentication required or failed. content: application/json: schema: { $ref: '#/components/schemas/Error' } PaymentRequired: description: Subscription expired or read-only. content: application/json: schema: { $ref: '#/components/schemas/Error' } Forbidden: description: Caller lacks the required role. content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: Row not found or hidden by RLS. content: application/json: schema: { $ref: '#/components/schemas/Error' } Conflict: description: State conflict (e.g. project_ready, already-decided). content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: Too many requests; check the `Retry-After` header. headers: Retry-After: schema: { type: integer } content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: retry_after: { type: integer } PayloadTooLarge: description: Request body exceeded the configured limit. content: application/json: schema: { $ref: '#/components/schemas/Error' } BadGateway: description: Upstream provider failure (mailer, Stripe, Slack, Favro). content: application/json: schema: { $ref: '#/components/schemas/Error' } ServerError: description: Internal server error. content: application/json: schema: { $ref: '#/components/schemas/Error' } ServiceUnavailable: description: Subsystem not configured / temporarily unavailable. content: application/json: schema: { $ref: '#/components/schemas/Error' } schemas: UUID: type: string format: uuid Timestamp: type: string format: date-time Error: type: object description: Structured error envelope. `code` follows `area.subkey`. properties: code: { type: string } error: { type: string } required: [error] LoginRequest: type: object required: [email, password] properties: email: { type: string } password: { type: string } LoginResponse: type: object properties: token: { type: string } user_id: { $ref: '#/components/schemas/UUID' } role: { type: string } must_change_password: { type: boolean } LoginTOTPRequired: type: object properties: requires_totp: { type: boolean } challenge: { type: string } SignupRequest: type: object required: [email, password, terms_accepted] properties: email: { type: string } password: { type: string } terms_accepted: { type: boolean } invite_token: { type: string } RegisterResponse: type: object properties: token: { type: string } user_id: { $ref: '#/components/schemas/UUID' } role: { type: string } email: { type: string } email_verified: { type: boolean } AuthConfig: type: object properties: signup_enabled: { type: boolean } locales: type: array items: { type: string } sso: type: object properties: enabled: { type: boolean } button_label: { type: string } hosted_domain: { type: string } login_url: { type: string } provider_name: { type: string } TOTPStatus: type: object properties: enabled: { type: boolean } enrolled_at: { $ref: '#/components/schemas/Timestamp' } TOTPSetupResponse: type: object properties: otp_auth_url: { type: string } secret_base32: { type: string } qr_png_data_url: { type: string } RecoveryCodesResponse: type: object properties: recovery_codes: type: array items: { type: string } TOTPVerifyRequest: type: object required: [challenge] properties: challenge: { type: string } code: { type: string } recovery_code: { type: string } remember: { type: boolean } MeUser: type: object properties: user_id: { $ref: '#/components/schemas/UUID' } email: { type: string } display_name: { type: string } role: { type: string } demo: { type: boolean } email_verified: { type: boolean } must_change_password: { type: boolean } locale: { type: string } PatchMeRequest: type: object properties: display_name: { type: string, maxLength: 40 } locale: { type: string } AuditEvent: type: object properties: event: { type: string } ok: { type: boolean } ip: { type: string } user_agent: { type: string } reason: { type: string } created_at: { $ref: '#/components/schemas/Timestamp' } AnalyticsSnapshot: type: object description: | Aggregated stats backing the per-user analytics dashboard. Status counts are open-ended (per task status), so additional properties are allowed. properties: days: { type: integer } series: type: array items: type: object properties: date: { type: string, description: "YYYY-MM-DD" } completed: { type: integer } failed: { type: integer } status: type: object additionalProperties: { type: integer } lists: type: array items: type: object properties: id: { $ref: '#/components/schemas/UUID' } name: { type: string } count: { type: integer } APIKey: type: object properties: id: { $ref: '#/components/schemas/UUID' } name: { type: string } prefix: { type: string } read_only: { type: boolean } last_used_at: { $ref: '#/components/schemas/Timestamp' } expires_at: { $ref: '#/components/schemas/Timestamp' } created_at: { $ref: '#/components/schemas/Timestamp' } APIKeyCreated: allOf: - $ref: '#/components/schemas/APIKey' - type: object properties: key: type: string description: One-time plaintext. Never returned again. PortraitMeta: type: object properties: sha256: { type: string } bytes: { type: integer } mime: { type: string } updated_at: { $ref: '#/components/schemas/Timestamp' } Project: type: object description: A list (URL alias "list"). MyRole is the caller's effective role. properties: id: { $ref: '#/components/schemas/UUID' } name: { type: string } description: { type: string } owner_id: { $ref: '#/components/schemas/UUID' } created_at: { $ref: '#/components/schemas/Timestamp' } icon: { type: string } git_repo: { type: string, description: "Associated git remote url; empty if none." } my_role: { type: string, enum: [owner, editor, viewer] } total: { type: integer } completed: { type: integer } failed: { type: integer } pending: { type: integer } overdue: { type: integer } Task: type: object properties: id: { $ref: '#/components/schemas/UUID' } list_id: { $ref: '#/components/schemas/UUID' } name: { type: string } description: { type: string } status: { type: string } position: { type: number, format: double } icon: { type: string } color: { type: string } outputs: type: object additionalProperties: true comment_count: { type: integer } archived_at: { $ref: '#/components/schemas/Timestamp' } due_at: { $ref: '#/components/schemas/Timestamp' } created_at: { $ref: '#/components/schemas/Timestamp' } updated_at: { $ref: '#/components/schemas/Timestamp' } CreateTaskRequest: type: object required: [name] properties: name: { type: string } description: { type: string } icon: { type: string, maxLength: 16 } color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" } due_at: { $ref: '#/components/schemas/Timestamp' } PatchTaskRequest: type: object description: | All fields optional. `due_at` accepts JSON `null` to clear the column; omitting it leaves the existing value. properties: name: { type: string } description: { type: string } position: { type: number, format: double, exclusiveMinimum: 0 } status: { type: string, enum: [pending, in_progress, completed, failed] } due_at: oneOf: - { $ref: '#/components/schemas/Timestamp' } - { type: "null" } outputs: type: object additionalProperties: true Comment: type: object properties: id: { type: integer, format: int64 } task_id: { $ref: '#/components/schemas/UUID' } list_id: { $ref: '#/components/schemas/UUID' } kind: { type: string, enum: [user] } author_id: { $ref: '#/components/schemas/UUID' } author: { type: string } body: { type: string } created_at: { $ref: '#/components/schemas/Timestamp' } edited_at: { $ref: '#/components/schemas/Timestamp' } Member: type: object properties: user_id: { $ref: '#/components/schemas/UUID' } email: { type: string } role: { type: string, enum: [editor, viewer] } created_at: { $ref: '#/components/schemas/Timestamp' } Invite: type: object properties: id: { $ref: '#/components/schemas/UUID' } email: { type: string } role: { type: string } created_at: { $ref: '#/components/schemas/Timestamp' } expires_at: { $ref: '#/components/schemas/Timestamp' } InviteSummary: type: object properties: project_id: { $ref: '#/components/schemas/UUID' } project_name: { type: string } email: { type: string } role: { type: string } expires_at: { $ref: '#/components/schemas/Timestamp' } accepted: { type: boolean } expired: { type: boolean } BillingPlan: type: object properties: id: { type: string } kind: { type: string } label: { type: string } tagline: { type: string } description: { type: string } highlight: { type: boolean } BillingMe: type: object properties: billing_enabled: { type: boolean } trial_ends_at: { $ref: '#/components/schemas/Timestamp' } in_trial: { type: boolean } status: { type: string } tier: { type: string } current_period_end: { $ref: '#/components/schemas/Timestamp' } financial_aid: { type: boolean } is_active: { type: boolean } note: { type: string } Charge: type: object description: Stripe charge snapshot; shape mirrors Stripe's `charges` object. additionalProperties: true PublicProjectView: type: object properties: name: { type: string } description: { type: string } state: { type: string } icon: { type: string } tasks: type: array items: type: object properties: id: { $ref: '#/components/schemas/UUID' } name: { type: string } description: { type: string } status: { type: string } position: { type: number, format: double } icon: { type: string } UserExport: type: object properties: generated_at: { $ref: '#/components/schemas/Timestamp' } format: { type: string } profile: type: object additionalProperties: true sessions: type: array items: type: object additionalProperties: true auth_audit: type: array items: { $ref: '#/components/schemas/AuditEvent' } api_keys: type: array items: type: object additionalProperties: true project_memberships: type: array items: type: object additionalProperties: true comments_authored: type: array items: type: object additionalProperties: true two_factor: type: object additionalProperties: true SetupState: type: object properties: setup_required: { type: boolean } Version: type: object properties: version: { type: string } revision: { type: string } build_time: { type: string } modified: { type: boolean } go_version: { type: string }