openapi: 3.1.0

# The machine-readable half of docs/api.md.
#
# Hand-written, and kept honest by a test: internal/httpapi/openapi_test.go walks
# the router and fails if a route is missing here or described here but not
# routed. Generating it from annotations was the alternative, and it would have
# meant every handler carrying a second copy of itself in a comment.

info:
  title: verdande
  version: "0.1.0"
  summary: Self-hosted tasks and projects, shared with other people.
  description: |
    REST under `/api/v1`, JSON in and out. Everything the web interface does goes
    through this API, so anything you can do there you can script.

    Authenticate with a session cookie or with a personal API token. Both are
    accepted on every endpoint except `/tokens`, which needs a session — a token
    that can mint another token is a leak that cannot be revoked.

    Anything you may not see answers `404`, never `403`. A `403` would confirm
    the thing exists, which is what somebody trying ids is hoping to learn.
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://todo.example.dk
    description: Your own instance.

security:
  - session: []
  - bearer: []

tags:
  - name: auth
    description: Signing in, the second factor, and the account itself.
  - name: tasks
    description: Tasks, sub-tasks, comments, attachments and reminders.
  - name: projects
    description: Projects, sections, members and activity.
  - name: views
    description: Today, upcoming, search, labels and saved filters.
  - name: integrations
    description: Calendar feed, mail-to-task, Gmail, AI and Web Push.
  - name: data
    description: Import, export and project templates.

paths:
  # --- auth -----------------------------------------------------------------

  /api/v1/auth/setup:
    get:
      tags: [auth]
      summary: Whether this instance still needs its first account.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  needs_setup: { type: boolean }
    post:
      tags: [auth]
      summary: Create the first account, which becomes the administrator.
      description: Refused once any account exists. Rate limited.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, name, password]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                password: { type: string, minLength: 10 }
                timezone: { type: string, example: Europe/Copenhagen }
                locale: { type: string, enum: [da, en] }
      responses:
        "201":
          description: Created, and signed in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/auth/login:
    post:
      tags: [auth]
      summary: Sign in.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: |
            Signed in, or waiting for a code. When `totp_required` is true the
            session exists but reaches nothing until `/auth/login/totp`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  totp_required: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/auth/login/totp:
    post:
      tags: [auth]
      summary: Complete a login with the code from an authenticator.
      description: Accepts a recovery code in place of a generated one.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/auth/signup:
    post:
      tags: [auth]
      summary: Create an account from an invite.
      description: |
        There is no open registration. The address comes from the invite rather
        than from the request, so an invite cannot be redirected to somebody else.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, name, password]
              properties:
                token: { type: string }
                name: { type: string }
                password: { type: string, minLength: 10 }
                timezone: { type: string }
                locale: { type: string, enum: [da, en] }
      responses:
        "201":
          description: Created, and signed in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/auth/password/forgot:
    post:
      tags: [auth]
      summary: Ask for a reset link.
      description: |
        Always answers 204, whether or not the address has an account. Answering
        differently would turn this into a way to test which addresses exist.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "204": { description: Accepted. }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/auth/password/reset:
    post:
      tags: [auth]
      summary: Set a new password with a reset token.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, minLength: 10 }
      responses:
        "204": { description: Changed. Every other session is ended. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/auth/sidebar-sections:
    put:
      tags: [auth]
      summary: Which sidebar headings are folded away.
      description: |
        On the account rather than in the browser. A project group already carries
        `collapsed` on its own row for the same reason: folding a heading is a
        statement about the work — "I am not in Etiketter at the moment" — and that
        is as true on a laptop as on a desktop. The sidebar's *width* is the
        opposite case and stays in the browser, because a narrow laptop and a wide
        monitor deserve different answers.

        Its own route rather than a field on `PATCH /auth/me`. That one backs a
        form with a save button and validates three fields together; this writes on
        every click of a chevron, and sharing the path would mean a fold sending a
        name and a timezone it was never asked to change.

        The keys belong to the interface and are not checked here. An unknown one
        costs nothing — the sidebar folds what it recognises and ignores the rest —
        so renaming or removing a heading leaves a harmless entry rather than
        needing a migration.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sections:
                  type: array
                  maxItems: 20
                  items: { type: string }
                  example: ["labels", "filters"]
      responses:
        "204": { description: Saved. }
        "400": { $ref: "#/components/responses/BadRequest" }

  /api/v1/auth/nav-order:
    put:
      tags: [auth]
      summary: The order of the fixed views in the sidebar.
      description: |
        I dag, Kommende, Venter and the inbox, in whatever order this person wants
        them. On the account rather than in the browser: which order you want them
        in is a fact about you and should be the same on the phone as on the laptop.

        A list of keys rather than a number per view. The set of views changes with
        the program, and a key that no longer exists costs nothing — the sidebar
        draws what it recognises and appends anything new at the end, which is what
        makes adding a view later not a migration. An empty list means the order the
        program ships with.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                order:
                  type: array
                  maxItems: 20
                  items: { type: string }
                  example: ["inbox", "today", "upcoming", "delegated"]
      responses:
        "204": { description: Saved. }
        "400": { $ref: "#/components/responses/BadRequest" }

  /api/v1/auth/me:
    get:
      tags: [auth]
      summary: Who the caller is.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [auth]
      summary: Change your own name, timezone or language.
      description: |
        The email address is not among them: it identifies the account and is what
        invites were sent to. The timezone is not cosmetic — every date is
        resolved in it, so "tomorrow at 9" means a different instant afterwards.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                timezone: { type: string, example: Europe/Copenhagen }
                locale: { type: string, enum: [da, en] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/auth/logout:
    post:
      tags: [auth]
      summary: End this session.
      responses:
        "204": { description: Signed out. }

  /api/v1/auth/password/change:
    post:
      tags: [auth]
      summary: Change your password.
      description: Ends every other session, which is most of the reason to do it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string }
                new_password: { type: string, minLength: 10 }
      responses:
        "204": { description: Changed. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/auth/totp/setup:
    post:
      tags: [auth]
      summary: Begin two-factor enrolment.
      description: |
        Stores the secret but does not switch anything on. `/auth/totp/confirm`
        does that, once a code has proved the authenticator really has it.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string }
                  uri: { type: string, description: otpauth:// URI for a QR code. }
        "409": { $ref: "#/components/responses/Conflict" }

  /api/v1/auth/totp/confirm:
    post:
      tags: [auth]
      summary: Finish enrolment and switch two-factor on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          description: |
            On. The recovery codes are returned once and never again — only their
            hashes are kept.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecoveryCodes" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/auth/totp/disable:
    post:
      tags: [auth]
      summary: Switch two-factor off.
      description: |
        Needs the password again: switching off a second factor is exactly what
        somebody with a borrowed session would do first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "204": { description: Off. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/auth/passkeys:
    get:
      tags: [auth]
      summary: The keys registered on this account.
      description: |
        A passkey is a key on a device rather than a secret in a head. The private
        half never leaves the device, and the signature is bound to the origin that
        asked for it — so a convincing copy of the sign-in page at another address
        gets a signature it cannot use.

        One row per credential, because a person has a laptop and a phone and losing
        one must not mean losing the way in. The name is theirs to write: a list
        nobody can read is a list nobody revokes from.

        Sessions only, like `/tokens` — a leaked API token must not be able to add
        its own way in, which would turn a theft into a tenancy.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  passkeys:
                    type: array
                    items: { $ref: "#/components/schemas/Passkey" }
                  available:
                    type: boolean
                    description: |
                      Whether this deployment can offer passkeys at all. False when
                      `VERDANDE_BASE_URL` is an IP address or otherwise not a
                      domain an authenticator will accept — the interface asks once
                      rather than discovering it from a 503 it caused by offering a
                      button.
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/auth/passkeys/register/begin:
    post:
      tags: [auth]
      summary: Start registering a key. Returns the options and a challenge id.
      description: |
        Registration is done by somebody already signed in: you cannot be handed a
        way into an account you have not proved you own.

        The challenge is kept server-side rather than in a cookie, because the whole
        point of it is that the server chose it and remembers choosing it. It is
        answerable once and expires after five minutes.
      responses:
        "200":
          description: Pass `options` to `navigator.credentials.create()`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  challenge_id: { type: string }
                  options: { type: object, additionalProperties: true }
        "503":
          description: |
            `VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
            rather than 404: this version has passkeys, this deployment cannot offer
            them, and those are different things to fix.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/auth/passkeys/register/finish:
    post:
      tags: [auth]
      summary: Finish registering a key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                challenge_id: { type: string }
                name:
                  type: string
                  description: What to call it in the list — "min bærbare".
                credential:
                  type: object
                  additionalProperties: true
                  description: The response from `navigator.credentials.create()`.
      responses:
        "201":
          description: Registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Passkey" }
        "400":
          description: The attempt expired, was already used, or did not verify.
        "503":
          description: Passkeys are unavailable on this instance.

  /api/v1/auth/passkeys/{passkeyID}:
    parameters:
      - { name: passkeyID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [auth]
      summary: Rename a key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        "204": { description: Renamed. }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [auth]
      summary: Remove a key.
      description: |
        Scoped in the SQL rather than checked first: an id read in one step and
        deleted in the next is a window, and the reason somebody is on this screen
        is usually that they think a device is gone.
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/auth/passkey/login/begin:
    post:
      tags: [auth]
      summary: Start signing in with a key. No session, and no email.
      description: |
        A discoverable login: nobody has said who they are yet, and that is the
        point. The device knows which account its key belongs to, so there is no
        address to type and no list of accounts for anybody to probe.
      responses:
        "200":
          description: Pass `options` to `navigator.credentials.get()`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  challenge_id: { type: string }
                  options: { type: object, additionalProperties: true }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Passkeys are unavailable on this instance.

  /api/v1/auth/passkey/login/finish:
    post:
      tags: [auth]
      summary: Finish signing in with a key.
      description: |
        A key that verified its owner — a PIN, a fingerprint, a face — is both
        factors at once, and completes the login outright. One that only proved
        possession still stops at the TOTP step, exactly as a password does.

        A counter that has gone backwards means the credential has been cloned. It
        is refused rather than logged: that counter is the only signal this design
        gives.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                challenge_id: { type: string }
                credential:
                  type: object
                  additionalProperties: true
                  description: The response from `navigator.credentials.get()`.
      responses:
        "200":
          description: Signed in, or `totp_required` when the key was possession only.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  totp_required: { type: boolean }
        "401":
          description: |
            One message for an unknown key and for a bad signature. Telling them
            apart is a way to learn which credentials exist here.
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Passkeys are unavailable on this instance.

  /api/v1/auth/sessions:
    get:
      tags: [auth]
      summary: Where you are signed in.
      description: |
        Most recently used first. `id` is the stored hash of the cookie and never
        the cookie itself — it cannot be pasted into a browser, and it is what the
        delete below takes.

        Session only: an API token cannot list the devices of the person it was
        stolen from.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items: { $ref: "#/components/schemas/Session" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/auth/sessions/{sessionID}:
    parameters:
      - { name: sessionID, in: path, required: true, schema: { type: string } }
    delete:
      tags: [auth]
      summary: Sign one device out.
      description: |
        Ending the current session is allowed and is simply logging out —
        somebody clearing every device wants this one gone too.
      responses:
        "204": { description: Ended. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/auth/recovery-codes:
    get:
      tags: [auth]
      summary: How many recovery codes are left.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  remaining: { type: integer }
    post:
      tags: [auth]
      summary: Issue a fresh set and retire the old one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecoveryCodes" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }

  # --- tasks ----------------------------------------------------------------

  /api/v1/tasks:
    get:
      tags: [tasks]
      summary: List tasks.
      parameters:
        - { name: project_id, in: query, schema: { type: string } }
        - { name: section_id, in: query, schema: { type: string } }
        - { name: parent_id, in: query, schema: { type: string }, description: Sub-tasks of one task. }
        - { name: label_id, in: query, schema: { type: string } }
        - { name: priority, in: query, schema: { type: integer, minimum: 1, maximum: 4 } }
        - { name: assignee_id, in: query, schema: { type: string }, description: "`me` resolves to the caller." }
        - { name: due_before, in: query, schema: { type: string, format: date } }
        - { name: due_from, in: query, schema: { type: string, format: date } }
        - { name: no_date, in: query, schema: { type: boolean } }
        - { name: q, in: query, schema: { type: string }, description: "Full-text search, diacritic-insensitive for Danish." }
        - name: completed
          in: query
          schema: { type: string, enum: [include, only] }
          description: Omitted, only open tasks are returned.
        - { name: limit, in: query, schema: { type: integer } }
      responses:
        "200": { $ref: "#/components/responses/TaskList" }
    post:
      tags: [tasks]
      summary: Create a task.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskInput" }
      responses:
        "201": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/tasks/{taskID}:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    get:
      tags: [tasks]
      summary: One task.
      responses:
        "200": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [tasks]
      summary: Change a task.
      description: Only the fields present are changed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskInput" }
      responses:
        "200": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [tasks]
      summary: Delete a task.
      description: Soft-deleted, and purged from the trash after 30 days.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/complete:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [tasks]
      summary: Tick a task off.
      description: |
        A repeating task is not closed: it moves forward in the same row, keeping
        its id, so sub-tasks and comments stay attached. `recurred` and `next_due`
        say when that happened.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Task"
                  - type: object
                    properties:
                      recurred: { type: boolean }
                      next_due: { type: string, format: date }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/reopen:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [tasks]
      summary: Un-tick a task.
      responses:
        "200": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/move:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [tasks]
      summary: Move a task, and place it between two others.
      description: |
        `after_id` and `before_id` are the tasks either side of the gap it lands
        in. Give both to place between them, one to place at an end, neither to
        place first.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id: { type: string, description: "Omitted, the task stays in its project." }
                section_id: { type: string, description: Empty string means no section. }
                after_id: { type: string }
                before_id: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/snooze:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [tasks]
      summary: Snooze a task until a time, or wake it.
      description: |
        Parks the task at the bottom of its list, greyed, until `until` passes —
        nothing else about it moves. An empty `until` wakes it now.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
      responses:
        "200": { $ref: "#/components/responses/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/tasks/quick-add:
    post:
      tags: [tasks]
      summary: Create a task from one line of natural language.
      description: |
        Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
        becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
        labelled tax. An unknown `#project` is not an error — the task goes to the
        Inbox rather than a thought being thrown away over a typo.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                project_id:
                  type: string
                  description: Where it goes when the text names no project.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuickAddResult" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/tasks/quick-add/preview:
    get:
      tags: [tasks]
      summary: Show what a line would become, without creating anything.
      parameters:
        - { name: text, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ParsedTask" }

  /api/v1/tasks/{taskID}/comments:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    get:
      tags: [tasks]
      summary: Comments on a task, and the files attached to the task itself.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  comments:
                    type: array
                    items: { $ref: "#/components/schemas/Comment" }
                  attachments:
                    type: array
                    items: { $ref: "#/components/schemas/Attachment" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [tasks]
      summary: Write a comment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 10000 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Comment" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/comments/{commentID}:
    parameters:
      - { name: commentID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [tasks]
      summary: Edit a comment. Its author only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 10000 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Comment" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [tasks]
      summary: Delete a comment. Its author only.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/attachments:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [tasks]
      summary: Attach a file, up to 25 MB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                comment_id:
                  type: string
                  description: Hang it on a comment instead of on the task.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Attachment" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/attachments/{attachmentID}:
    parameters:
      - { name: attachmentID, in: path, required: true, schema: { type: string } }
    get:
      tags: [tasks]
      summary: Download a file.
      description: |
        Always `application/octet-stream` and always an attachment, whatever the
        file is. An uploaded SVG rendered inline would run its own script on this
        origin with the session cookie attached.
      responses:
        "200":
          description: The bytes.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [tasks]
      summary: Delete a file.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/tasks/{taskID}/reminders:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    get:
      tags: [tasks]
      summary: Reminders on a task.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  reminders:
                    type: array
                    items: { $ref: "#/components/schemas/Reminder" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [tasks]
      summary: Add a reminder.
      description: |
        Exactly one of `remind_at` and `offset_min`. The offset is relative to the
        task's due time and negative for "before", which is the only kind anybody
        wants.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                remind_at: { type: string, format: date-time }
                offset_min: { type: integer }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reminder" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/reminders/{reminderID}:
    parameters:
      - { name: reminderID, in: path, required: true, schema: { type: string } }
    delete:
      tags: [tasks]
      summary: Delete a reminder.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  # --- projects -------------------------------------------------------------

  /api/v1/projects:
    get:
      tags: [projects]
      summary: Projects you can see.
      parameters:
        - { name: archived, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
    post:
      tags: [projects]
      summary: Create a project.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectInput" }
      responses:
        "201": { $ref: "#/components/responses/Project" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/projects/reorder:
    post:
      tags: [projects]
      summary: Set the order projects appear in.
      description: |
        The whole list at once rather than one move at a time. A person has a
        handful of projects, so sending the order you want is simpler than
        midpoints and cannot land half-applied.

        Projects you do not own are skipped: `sort_order` is a column on the
        project rather than a preference per viewer, so a shared one stays where
        its owner put it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  maxItems: 500
                  items: { type: string }
                  description: Project ids, top to bottom.
      responses:
        "204": { description: Reordered. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/projects/{projectID}:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [projects]
      summary: One project.
      responses:
        "200": { $ref: "#/components/responses/Project" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [projects]
      summary: Change a project. Its owner only.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectInput" }
      responses:
        "200": { $ref: "#/components/responses/Project" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [projects]
      summary: Delete a project. Its owner only.
      description: Soft-deleted, and restorable from the trash for 30 days.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/activity:
    get:
      tags: [auth]
      summary: What has been done on this instance. Administrators only.
      description: |
        The same rows as `/projects/{projectID}/activity`, read across every
        project instead of one at a time.

        The per-project history is a question a project's members ask about their
        own work. This is a different question — "what has happened on this
        server" — and nothing could answer it: the per-project endpoint takes one
        project id, and an administrator is not necessarily a member of the
        project they need to look into.

        It is the other half of `/errors`. That one is what broke; this is what was
        done.

        Rows outlive the accounts that wrote them. `activity.user_id` is
        `ON DELETE SET NULL`, so a deleted account leaves its trail behind with
        `user_id` and `user_name` absent — a log that lost those rows would be
        missing the part most worth keeping.

        Paged with a keyset cursor rather than an offset. The log is written to
        constantly and read rarely, so a page fetched a minute after the one
        before it would be shifted by everything written in between: a row shown
        twice and the row behind it skipped.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - name: before
          in: query
          schema: { type: string }
          description: |
            `next_cursor` from the previous page, as `<unix>:<id>`. Both halves
            are required: the log is written in whole seconds, so a timestamp
            alone cannot separate two rows written in the same one.
          example: "1755432000:01J8Z9"
        - { name: user_id, in: query, schema: { type: string } }
        - { name: project_id, in: query, schema: { type: string } }
        - name: event
          in: query
          schema: { type: string }
          description: An exact event name, as listed by `/activity/events`.
          example: task.completed
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        at: { type: string, format: date-time }
                        project_id: { type: string }
                        project_name: { type: string }
                        task_id: { type: string }
                        user_id:
                          type: string
                          description: Absent when the account has been deleted.
                        user_name:
                          type: string
                          description: Absent when the account has been deleted.
                        event: { type: string, example: task.completed }
                        payload: { type: object, additionalProperties: true }
                  next_cursor:
                    type: string
                    description: |
                      Absent on the last page. Present only when the page came
                      back full — a short page is the end of the log, and a
                      cursor there invites one more request that can only come
                      back empty.
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/activity/events:
    get:
      tags: [auth]
      summary: The event names the log contains, with counts. Administrators only.
      description: |
        What the `event` filter is drawn from, most frequent first.

        Offered rather than typed. Event names are an internal vocabulary —
        `member.role_changed`, not a sentence anybody would guess — so a text
        field for them is a field that returns nothing until you have read the
        source. The counts are worth having on their own: they are the shape of
        what the instance does.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        event: { type: string, example: task.completed }
                        count: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/gmail/client:
    get:
      tags: [integrations]
      summary: The Gmail OAuth client this instance uses. Administrators only.
      description: |
        The registration Google requires, enterable rather than deployed.

        The one click that opens Google's consent screen already exists. What it
        needs behind it is a registered client, and no app can conjure one: Google
        issues no Gmail access to an unregistered client, and `gmail.modify` is a
        restricted scope, so an id shipped inside a public image would be both
        extractable from it and unusable without Google's security review of every
        deployment that used it.

        The registration is a one-off. Having to edit a Rune's manifest and
        recreate the container to paste the result was not, which is what this
        replaces.

        `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when they are set. A
        value in the environment is a deliberate deployment decision — under
        version control, reapplied on every recreate — and a form that could
        quietly override it would make the manifest a lie. `from_env` says so, and
        `PUT` answers 409.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GmailClient" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [integrations]
      summary: Set the Gmail OAuth client. Administrators only.
      description: |
        An empty `client_secret` means "leave the stored one alone", not "delete
        it" — otherwise correcting a typo in the id would silently clear the
        secret. The secret is never read back, for the same reason the AI key is
        not: a settings page that repopulates a password field is one that will
        eventually leak into a screenshot.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id: { type: string }
                client_secret: { type: string }
      responses:
        "204": { description: Saved. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The environment sets it, so this cannot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/backups:
    get:
      tags: [auth]
      summary: The nightly backups. Administrators only.
      description: |
        The backup job has run since the beginning and nothing showed it. A backup
        nobody can see is a backup nobody knows has been failing, and the failure
        is quiet: a volume that went read-only, a disk that filled, a container
        that has not been up at midnight all week. The first anybody hears of it
        is the day they need one.

        The most recent fourteen files are kept — counted by file rather than by
        age, so a container that was off for a month does not come back and delete
        every backup for being too old. Rows older than that stay in the list with
        `present: false`: a true record of a backup that was made and a file that
        is no longer there.

        Sessions-only as well as administrators-only. A backup is a complete copy
        of the database, so this is everybody's data in one file.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 30, maximum: 100 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  backups:
                    type: array
                    items: { $ref: "#/components/schemas/BackupRun" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [auth]
      summary: Take a backup now. Administrators only.
      description: |
        The same work the nightly job does, without its "one a day" check.

        Two reasons it exists: waiting until tonight to find out whether backups
        work at all is how somebody finds out on the day they need one, and it is
        the only way to get a snapshot from *before* something you are about to
        do.

        Synchronous — a homelab database is a second or two, and a button that
        returns immediately and says nothing is a button people press twice.
      responses:
        "201":
          description: Written.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackupRun" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500":
          description: |
            The snapshot failed — a full volume, or one that has gone read-only,
            which is precisely the condition this button exists to surface.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/backups/{backupID}:
    parameters:
      - { name: backupID, in: path, required: true, schema: { type: string } }
    get:
      tags: [auth]
      summary: Download one backup file. Administrators only.
      description: |
        A SQLite database, served as `application/octet-stream` with
        `Content-Disposition: attachment` — nothing should ever be tempted to
        render it inline.

        Restoring is deliberately not an API call. Putting a database back under a
        running server means stopping every writer, swapping the file and
        reopening, and a half-done version of that corrupts the thing it was
        restoring. Stop the container, put the file in place of `verdande.db` in
        the data volume, delete the `-wal` and `-shm` beside it, and start it
        again.

        A row whose file has been rotated away answers 404. That is the honest
        answer to "send me that one".
      responses:
        "200":
          description: The database file.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/panel:
    get:
      tags: [auth]
      summary: Whether this instance can restart itself. Administrators only.
      description: |
        A container cannot replace its own image from the inside, so "update" is
        really "ask whoever owns the container to recreate it" — Yggdrasil pulls
        `:latest` on the way, which is what makes a restart an update at all.

        This says whether the three settings are present. The token is never sent:
        the page needs to know whether the button can work, not what the credential
        is. When it cannot, the missing variables are named — an operator reading
        "not configured" should not have to go and find out which of three.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  configured: { type: boolean }
                  panel_url: { type: string }
                  missing:
                    type: array
                    items: { type: string }
                    description: The environment variables that are not set.
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/notes/bulk:
    post:
      tags: [notes]
      summary: Archive, unarchive or delete a set of notes in one call.
      description: |
        One call rather than one per note, because the thing being asked is one
        thing: "put these away". Fifty separate requests would be fifty round
        trips, fifty chances for half of them to fail, and a list that redraws
        fifty times — and after an import of twelve hundred notes, selecting fifty
        is the ordinary case rather than the extreme one.

        Each note is still checked on its own. A set is a convenience for the
        caller and never a way to touch something they could not have touched one
        at a time; anything they may not write to is counted in `skipped` rather
        than failing the whole call, so one note somebody else just deleted does
        not stop the other forty-nine.

        Archiving is not deleting. The trash says "this was a mistake, and in
        thirty days it is gone"; the archive says "this is finished, and I want to
        keep it".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: string }
                  description: At most 500.
                archived:
                  type: boolean
                  description: Put away, or bring back out.
                delete:
                  type: boolean
                  description: Into the trash instead. Wins over `archived`.
      responses:
        "200":
          description: How many were changed, and how many were passed over.
          content:
            application/json:
              schema:
                type: object
                properties:
                  done: { type: integer }
                  skipped: { type: integer }
        "422":
          description: No ids, too many, or neither archived nor delete was asked for.

  /api/v1/notes/{noteID}/attachments:
    post:
      tags: [notes]
      summary: Put a file into a note.
      description: |
        A picture pasted or dropped into a note. Until this existed the import
        could bring files in and nothing else could, so the most ordinary act in
        a notes app — paste a screenshot — did nothing at all.

        Editor rights on the note. The same answer for "no such note" and "not
        yours", so an id cannot be probed.

        The response carries the attachment's URL; the editor writes that into the
        note's text as a Markdown image. The file is never inlined as a data URL —
        a photograph as base64 would make the note itself megabytes of text.
      parameters:
        - name: noteID
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Stored, and hung on the note.
        "404":
          description: No such note, or not yours to write in.
        "413":
          description: Larger than 25 MB.

  /api/v1/beacon:
    post:
      tags: [auth]
      summary: Report that an installation exists. Unauthenticated.
      description: |
        The receiving end of the beacon. Open on purpose: the caller is a
        stranger's installation, which is the whole feature.

        Answers 404 unless this instance has been told it is the collector, so an
        ordinary installation is not a spam sink that answers 200 to anything.
        Rate limited like the login routes, and the table behind it has a ceiling:
        the endpoint inserts a row keyed on an id the caller invented, so without
        one anybody could grow it until the volume is full.

        Nothing about the request is recorded but the two values in the body. No
        source address is read or written here.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instance_id]
              properties:
                instance_id:
                  type: string
                  description: A random id the installation generated for itself.
                version:
                  type: string
      responses:
        "204":
          description: Recorded.
        "400":
          description: The id or the version is not the expected shape.
        "404":
          description: This instance is not a collector.

  /api/v1/beacon/count:
    get:
      tags: [auth]
      summary: How many installations reported in the last thirty days.
      description: |
        Unauthenticated and cached for a minute, and only answered when the
        collector has been told to publish it.

        A window rather than a running total: a total only climbs, so it stops
        meaning "how many people use this" and starts meaning "how many ever
        tried it".
      responses:
        "200":
          description: The count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  installs:
                    type: [integer, "null"]
                    description: |
                      Null below the collector's floor (`publish_min`, 25 by
                      default). A number that *falls* cannot be published as a
                      fact — a bad release, a DNS change that quietly stops the
                      pings, or lost rows all look like "fewer people use this
                      now" — and a small number mostly says how little it takes
                      to move it.
                  window_days: { type: integer }
        "404":
          description: Not a collector, or not published.

  /api/v1/beacon/settings:
    get:
      tags: [auth]
      summary: What this instance reports, and to whom. Administrators only.
      description: |
        Includes the exact payload that will be sent, so the settings page can
        print it in full rather than describe it.
      responses:
        "200":
          description: The current configuration, and the counts if this is the collector.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  collector_url: { type: string }
                  instance_id: { type: string }
                  version: { type: string }
                  is_collector: { type: boolean }
                  publish_count: { type: boolean }
                  publish_min:
                    type: integer
                    description: The floor under the published count.
                  last_ping_at: { type: string, format: date-time }
                  last_error:
                    type: string
                    description: |
                      Why the last attempt did not arrive; absent when the last
                      one did. Shown rather than only logged: a beacon that
                      cannot reach its collector otherwise fails in complete
                      silence, and the only trace is a "last sent" date that
                      quietly gets older.
                  last_error_at: { type: string, format: date-time }
    put:
      tags: [auth]
      summary: Turn the beacon off, or point it somewhere else. Administrators only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                collector_url:
                  type: string
                  description: |
                    Where reports go. Fetched through a dialer that refuses
                    loopback and private address space, so this cannot be used to
                    make the server reach inside its own network.
                is_collector: { type: boolean }
                publish_count: { type: boolean }
                publish_min: { type: integer, minimum: 0 }
      responses:
        "200":
          description: The configuration as it now stands.
        "422":
          description: The collector address is not an http or https URL.

  /api/v1/beacon/notice:
    get:
      tags: [auth]
      summary: Whether the disclosure is still owed, and what it says. Administrators only.
      description: |
        The beacon is on by default, and that is only defensible if it is said
        unprompted. A settings page where it is stated fully is not the same as
        saying it: that page is opened by people who already suspect there is
        something to read.

        The three values are in the response — the real id, the real version, the
        real collector address — so the notice can show what is sent rather than
        say "anonymous data" and ask to be believed.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  pending:
                    type: boolean
                    description: False once anybody has answered, panel-wide.
                  enabled: { type: boolean }
                  instance_id: { type: string }
                  version: { type: string }
                  collector_url: { type: string }
    post:
      tags: [auth]
      summary: Answer the notice. Administrators only.
      description: |
        One call for both answers — "keep it on" and "turn it off" are the same
        act, somebody deciding, and two endpoints would mean one of them could
        forget to record that a decision was made.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keep]
              properties:
                keep:
                  type: boolean
                  description: |
                    Required, and rejected when absent. An empty body must not be
                    able to mean "turn it off": an opt-out nobody chose is as much
                    a broken promise as an opt-in nobody chose.
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
        "422":
          description: No answer was given.

  /api/v1/panel/restart:
    post:
      tags: [auth]
      summary: Ask the panel to recreate this container. Administrators only.
      description: |
        The panel does the work and records who asked; this removes the second
        browser tab, not the panel's authority.

        Answers 202 rather than waiting: the panel stops this container as part of
        answering, so the reply may never arrive. A request cut off mid-flight is
        the successful case wearing a failure's clothes — the interface polls
        `/healthz` rather than believing either answer.

        Sessions only, like the rest of this group. A leaked API token that could
        restart the server is a denial of service in one request.
      responses:
        "202":
          description: The panel accepted, or was already restarting this container.
          content:
            application/json:
              schema:
                type: object
                properties:
                  restarting: { type: boolean }
                  note: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "502":
          description: |
            The panel refused. Usually a token without control of this server.
        "503":
          description: The three settings are not all present.

  /api/v1/errors:
    get:
      tags: [auth]
      summary: What has gone wrong lately. Administrators only.
      description: |
        Every 500 the API has answered, newest first, with what the handler was
        attempting and the error it hit.

        These are written to the database rather than only to the log because the
        log does not survive. On a Rune the log belongs to the container, and the
        container is replaced on every restart — so a panel watcher reports "HTTP
        5xx, twice, at 11:49" long after the line explaining it has gone, which
        tells an operator that something broke and gives them no way to find out
        what.

        Kept for thirty days by the nightly sweep: long enough to answer "what
        happened last week", short enough that a fault loop cannot fill the volume
        the tasks live on.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        at: { type: string, format: date-time }
                        method: { type: string }
                        path: { type: string }
                        status: { type: integer }
                        what:
                          type: string
                          description: What the handler was attempting, in its own words.
                          example: list projects
                        message: { type: string }
                        user_name:
                          type: string
                          description: |
                            Who was asking, when there was somebody. A fault only
                            one account hits is a different problem from one
                            everybody hits.
                        request_id:
                          type: string
                          description: Ties the row back to the log line, for whoever still has it.
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/users:
    get:
      tags: [auth]
      summary: Everybody on the instance, and the invites not yet accepted.
      description: |
        Administrators only. Each user carries what deleting them would destroy,
        so the confirmation can name real numbers without a second request in the
        middle of a decision.

        Session only: an API token cannot enumerate the instance's membership.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items: { $ref: "#/components/schemas/AdminUser" }
                  invites:
                    type: array
                    items: { $ref: "#/components/schemas/PendingInvite" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [auth]
      summary: Invite somebody to the instance.
      description: |
        There is no open registration and no account created with a password
        somebody else chose: this issues an invite with no project attached, and
        the link ends with a password only its owner has seen. The same link and
        the same `/auth/signup` as a project invite — an invite whose project is
        empty simply grants no membership when it is accepted.

        No name is asked for. The signup form asks the person themselves, and a
        name an administrator guesses is a name somebody has to correct.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  link:
                    type: string
                    description: |
                      Returned so the interface can offer to copy it. On an
                      instance with no mail server that is the only way the invite
                      reaches anybody.
                  emailed: { type: boolean }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: That address already has an account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/users/{userID}:
    parameters:
      - { name: userID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [auth]
      summary: Make somebody an administrator, or stop them being one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                is_admin: { type: boolean }
      responses:
        "204": { description: Changed. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            The last administrator cannot be demoted — `code` is `last_admin`.
            An instance with no administrator has no way back: there is no
            console, and setup refuses to run once an account exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [auth]
      summary: Remove an account and everything the foreign keys take with it.
      description: |
        The only hard delete in this API. Everything else is soft-deleted with a
        trash behind it; this is not recoverable.

        What goes: their projects, and every task in those projects.

        What stays: everything they wrote in **somebody else's** project.
        `tasks.created_by` is `ON DELETE SET NULL`, so that work survives and
        loses its author rather than disappearing with the account. Tasks merely
        assigned to them are unassigned and survive by the same rule.

        Refused for your own account, and for the last administrator.
      responses:
        "204": { description: Deleted. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Your own account, or the last administrator.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/invites/{inviteID}:
    parameters:
      - { name: inviteID, in: path, required: true, schema: { type: string } }
    delete:
      tags: [auth]
      summary: Withdraw an invite that has not been accepted.
      description: |
        Only the token's hash is stored, so a link sent to the wrong address
        cannot be looked up and resent — withdrawing it is the only way to stop
        it working before it expires.
      responses:
        "204": { description: Withdrawn. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/project-groups:
    get:
      tags: [projects]
      summary: Your foldable sidebar groups.
      description: |
        A group is a heading over some of your projects. It is yours rather than
        the project's: it has no members and no roles, and a project shared with
        you can be filed under one of your groups without the owner seeing it.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items: { $ref: "#/components/schemas/ProjectGroup" }
    post:
      tags: [projects]
      summary: Make a group.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectGroupInput" }
      responses:
        "201": { $ref: "#/components/responses/ProjectGroup" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/project-groups/reorder:
    post:
      tags: [projects]
      summary: Set the order groups appear in.
      description: The whole list at once, for the same reasons as `/projects/reorder`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  maxItems: 500
                  items: { type: string }
                  description: Group ids, top to bottom.
      responses:
        "204": { description: Reordered. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/project-groups/{groupID}:
    parameters:
      - { name: groupID, in: path, required: true, schema: { type: string } }
    get:
      tags: [projects]
      summary: The group as a place, with what is in it.
      description: |
        A heading can carry a name. A page carries what the group *is*: the
        sentence saying why these projects are one body of work, the documents
        that belong to all of them rather than to any one, and the projects
        themselves.

        The projects come with the group rather than being fetched per row — the
        page is nothing without them, and two round trips to draw one list is one
        too many.

        A group that is not yours is not found. That is the rule for every route
        here, and it is enforced by the lookup rather than by a separate check.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  group: { $ref: "#/components/schemas/ProjectGroup" }
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
                  attachments:
                    type: array
                    items: { $ref: "#/components/schemas/Attachment" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [projects]
      summary: Rename a group, or fold it.
      description: |
        `collapsed` is stored on the account rather than in the browser. The
        sidebar's width is a property of the screen you are at; a folded group is
        a statement about the group, and is true wherever you sign in.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProjectGroupInput" }
      responses:
        "200": { $ref: "#/components/responses/ProjectGroup" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [projects]
      summary: Delete a group.
      description: |
        The heading goes; the projects under it come back out as ungrouped. A
        group holds no work of its own, so this is not soft-deleted — there is
        nothing in it to recover.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/project-groups/{groupID}/attachments:
    parameters:
      - { name: groupID, in: path, required: true, schema: { type: string } }
    post:
      tags: [projects]
      summary: Put a document on the group itself.
      description: |
        A file that belongs to the whole group rather than to any one task in it —
        the contract that governs all of "Arbejde", not a step in it.

        The same storage as every other attachment: content-addressed by hash, so
        the path never contains anything the uploader chose, and the same file
        uploaded twice is stored once. 25 MB maximum.

        Downloading and deleting go through `/attachments/{attachmentID}` like any
        other. A group's attachment is reachable by its owner and nobody else,
        because a group belongs to one person and is never shared.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Attachment" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/trash/projects:
    get:
      tags: [projects]
      summary: Projects you have deleted and can still bring back.
      description: |
        Only your own. Restoring has been possible since the trash existed, but
        the endpoint takes an id — and the id is what you no longer have once the
        project has gone from the interface.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Project"
                        - type: object
                          properties:
                            deleted_at: { type: string, format: date-time }
                            purge_after:
                              type: string
                              format: date-time
                              description: |
                                When it stops being recoverable. Sent rather than
                                left for the client to work out, because the
                                retention window is the server's and a second
                                copy of it will one day disagree.
                            task_count:
                              type: integer
                              description: Tasks that went with it, and would come back with it.

  /api/v1/trash/projects/{projectID}/restore:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    post:
      tags: [projects]
      summary: Restore a deleted project.
      responses:
        "200": { $ref: "#/components/responses/Project" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectID}/sections:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [projects]
      summary: Sections in a project.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  sections:
                    type: array
                    items: { $ref: "#/components/schemas/Section" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [projects]
      summary: Add a section. Editors and owners.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Section" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/projects/{projectID}/sections/reorder:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    post:
      tags: [projects]
      summary: Set the order the project's sections appear in. Editors and owners.
      description: |
        The whole list at once, for the same reasons as `/projects/reorder`: a
        project has a handful of sections, so sending the order you want is
        simpler than midpoints and cannot land half-applied.

        Ids belonging to another project are skipped rather than refused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  maxItems: 500
                  items: { type: string }
                  description: Section ids, top to bottom.
      responses:
        "204": { description: Reordered. }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/calendar/subscriptions:
    post:
      tags: [calendar]
      summary: Subscribe to a calendar you only have an address to.
      description: |
        An ICS or `webcal://` address. Several per person, unlike the Google
        connection, which is one — the address is the identity here, the way the
        token is for OAuth.

        The address is fetched once before it is stored: an address that cannot be
        read is a typo far more often than a server that happens to be down, and
        saying so while somebody is still looking at the field is worth two
        seconds. `webcal://` is rewritten to `https://`, and the destination is
        checked against the private-network wall both when it is written down and
        again on the resolved address every time it is fetched.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: "https:// or webcal://" }
                name:
                  type: string
                  description: Optional. The calendar's own name is used when this is empty.
      responses:
        "201":
          description: Subscribed, and fetched once.
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/calendar/subscriptions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [calendar]
      summary: Stop subscribing. The cached events go with it.
      responses:
        "204": { description: Gone. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/push/test:
    post:
      tags: [notifications]
      summary: Send one notification to your own devices, and say what happened.
      description: |
        The answer to "no notifications arrive". A push that a service refuses is
        the ordinary failure — a stale subscription, a browser that revoked
        permission without telling the server — and every one of them looks
        exactly like silence from the outside.

        It goes through the same path a real notification takes, so what it proves
        is the thing that has to work. The title and body come from the caller
        because the dictionaries live in the interface.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 200 }
                body: { type: string, maxLength: 500 }
      responses:
        "200":
          description: What each device did with it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: integer
                    description: Devices registered for this account.
                  sent:
                    type: integer
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        service:
                          type: string
                          description: The push service's host — never the endpoint, which is a credential.
                        reason:
                          type: string
                          description: What the service said.

  /api/v1/sections/{sectionID}:
    parameters:
      - { name: sectionID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [projects]
      summary: Rename or reorder a section.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                sort_order: { type: number }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Section" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [projects]
      summary: Delete a section.
      description: Its tasks are not deleted; they lose their section.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectID}/members:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [projects]
      summary: Who the project is shared with, who can be added, and who has been invited.
      description: >
        Three lists in one answer, so the share panel opens complete in a single
        round-trip. `candidates` is everybody with an account here who is not
        already on the project — the instance's accounts are a closed, invited set,
        so listing them to each other is the address book the feature needs, not a
        disclosure. `pending` is the invitations sent to people who have no account
        yet: without them an invitation sent yesterday is in neither of the other
        two lists, and gets sent a second time.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
                  candidates:
                    type: array
                    items: { $ref: "#/components/schemas/Person" }
                  pending:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        email: { type: string, format: email }
                        role: { type: string, enum: [editor, viewer] }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectID}/members/{userID}:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
      - { name: userID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [projects]
      summary: Change what an existing member may do. The project's owner only.
      description: |
        Editor or viewer. An UPDATE against a membership that already exists —
        somebody who is not a member is not silently made one, because that is an
        invite and belongs to the invite flow.

        Not the owner: ownership is transferred, not granted, and two owners is a
        state with no way to settle a disagreement about who may remove whom. The
        owner also has no membership row at all — ownership lives on the project —
        so this answers 409 with the real reason rather than "not a member".

        Correcting a role used to mean removing the person and inviting them
        again, which also unassigns every task they were responsible for.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [editor, viewer] }
      responses:
        "204": { description: Changed. }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The owner's standing cannot be changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [projects]
      summary: Remove somebody from a project — its owner, or yourself.
      description: |
        Two things through one route: the owner taking somebody off, and anybody
        taking themselves off. The second is what makes a share something you can
        undo. You are let into a project without being asked, so a way out that
        does not go through asking its owner is the other half of that.

        The owner cannot leave their own project. That is a transfer of ownership,
        not an exit, and there is no transfer yet.

        Pointing at somebody else's membership without owning the project answers
        404 rather than 403: saying "you may not" confirms the membership exists.

        Their tasks are unassigned rather than left pointing at somebody who can
        no longer see the project — which is why changing a role is a PATCH and
        not a remove-and-reinvite.
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectID}/invites:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    post:
      tags: [projects]
      summary: Invite somebody. Its owner only.
      description: |
        With no mail server configured the response carries the link instead of
        emailing it, so the invite is not silently lost.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: string
                  description: >
                    Somebody who already has an account here, picked from the
                    candidates in GET /members. One of user_id or email.
                email:
                  type: string
                  format: email
                  description: >
                    An address. If it belongs to an account they are added at
                    once; otherwise an invitation is sent.
                role: { type: string, enum: [editor, viewer] }
      responses:
        "200":
          description: Added straight away — the person already had an account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  added: { type: boolean }
                  user: { $ref: "#/components/schemas/Person" }
                  role: { type: string, enum: [editor, viewer] }
        "201":
          description: Invited. No account yet, so a link was made.
          content:
            application/json:
              schema:
                type: object
                properties:
                  link: { type: string }
                  emailed: { type: boolean }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/projects/{projectID}/invites/{inviteID}:
    delete:
      tags: [projects]
      summary: Withdraw an invitation to a project before it is used. Its owner only.
      description: >
        The only way back. Only the token's hash is stored, so a link sent to the
        wrong address cannot be looked up and invalidated — only the row it belongs
        to can.
      parameters:
        - name: projectID
          in: path
          required: true
          schema: { type: string }
        - name: inviteID
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Withdrawn }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectID}/activity:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [projects]
      summary: What has happened in a project.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity:
                    type: array
                    items: { $ref: "#/components/schemas/Activity" }
        "404": { $ref: "#/components/responses/NotFound" }

  # --- views ----------------------------------------------------------------

  /api/v1/today:
    get:
      tags: [views]
      summary: What is overdue, and what is due today.
      description: Resolved in the caller's timezone, not the server's.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  overdue:
                    type: array
                    items: { $ref: "#/components/schemas/Task" }
                  today:
                    type: array
                    items: { $ref: "#/components/schemas/Task" }

  /api/v1/upcoming:
    get:
      tags: [views]
      summary: The next few days, empty ones included.
      parameters:
        - {
            name: days,
            in: query,
            description: "How many days to return, counting today. Above the maximum is capped, not refused.",
            schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
          }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        tasks:
                          type: array
                          items: { $ref: "#/components/schemas/Task" }

  /api/v1/people:
    get:
      tags: [views]
      summary: Everybody you share a project with, and yourself.
      description: |
        What an interface needs to put a name and a face on an `assignee_id` — in
        a task row, in a filter, anywhere a person appears beside work. One
        request per session rather than a lookup per row, and per project members
        would mean knowing which project every task belongs to first.

        Deliberately not the instance's user list: that is
        `/users`, administrators only. A task list has no business enumerating
        everybody with an account here.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        avatar_color: { type: string }

  /api/v1/delegated:
    get:
      tags: [views]
      summary: What you are waiting on other people for.
      description: |
        Everything assigned to somebody who is not you, in the projects you can
        see, grouped by the person it is assigned to.

        Grouped rather than flat because the question is about people, and a
        client grouping it itself would need every assignee's name — which it has
        no way to ask for. The names come back with the tasks, so one request is
        one screen.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id: { type: string }
                        name: { type: string }
                        avatar_color: { type: string }
                        tasks:
                          type: array
                          items: { $ref: "#/components/schemas/Task" }

  /api/v1/search:
    get:
      tags: [views]
      summary: Full-text search over tasks.
      description: |
        Diacritic-folded, so "gron" finds "grøn". Danish treats ø and æ as letters
        of their own rather than as accented forms, which is why the index carries
        a folded copy of the text beside the original.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { $ref: "#/components/responses/TaskList" }

  /api/v1/labels:
    get:
      tags: [views]
      summary: Your labels, with how many tasks carry each.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  labels:
                    type: array
                    items: { $ref: "#/components/schemas/Label" }
    post:
      tags: [views]
      summary: Create a label.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Label" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/labels/{labelID}:
    parameters:
      - { name: labelID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [views]
      summary: Rename or recolour a label.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
                sort_order: { type: number }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Label" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [views]
      summary: Delete a label.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/filters:
    get:
      tags: [views]
      summary: Saved filters.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  filters:
                    type: array
                    items: { $ref: "#/components/schemas/Filter" }
    post:
      tags: [views]
      summary: Save a filter.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FilterInput" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Filter" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/filters/{filterID}:
    parameters:
      - { name: filterID, in: path, required: true, schema: { type: string } }
    patch:
      tags: [views]
      summary: Change a saved filter.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FilterInput" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Filter" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
    delete:
      tags: [views]
      summary: Delete a saved filter.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/filters/{filterID}/tasks:
    parameters:
      - { name: filterID, in: path, required: true, schema: { type: string } }
    get:
      tags: [views]
      summary: Run a saved filter.
      responses:
        "200": { $ref: "#/components/responses/TaskList" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/filters/preview:
    get:
      tags: [views]
      summary: Run a filter expression without saving it.
      description: |
        The expression is compiled to a bound SQL fragment, never assembled from
        the text — see the filter language in the documentation.
      parameters:
        - { name: query, in: query, required: true, schema: { type: string, example: "i dag & p1 | @arbejde" } }
      responses:
        "200": { $ref: "#/components/responses/TaskList" }
        "422": { $ref: "#/components/responses/Validation" }

  # --- integrations ---------------------------------------------------------

  /api/v1/tokens:
    get:
      tags: [integrations]
      summary: Your API tokens.
      description: Needs a session. A bearer token is refused with 403.
      security:
        - session: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items: { $ref: "#/components/schemas/ApiToken" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [integrations]
      summary: Mint an API token.
      description: |
        The plaintext comes back here and never again — only its hash is stored.
        Needs a session: a token that can mint another token is a leak that
        revoking the first one does not close.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                expires_in_days:
                  type: integer
                  minimum: 0
                  maximum: 3650
                  default: 0
                  description: 0 means it never expires.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiToken"
                  - type: object
                    properties:
                      token: { type: string, example: vrd_… }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/tokens/{tokenID}:
    parameters:
      - { name: tokenID, in: path, required: true, schema: { type: string } }
    delete:
      tags: [integrations]
      summary: Revoke an API token, immediately.
      security:
        - session: []
      responses:
        "204": { description: Revoked. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/feed:
    get:
      tags: [integrations]
      summary: Your calendar subscription URL.
      description: |
        Minted on first ask. The token in the URL is the whole credential — a
        calendar client cannot log in — so treat the address as a password.
      responses:
        "200": { $ref: "#/components/responses/FeedURL" }

  /api/v1/feed/rotate:
    post:
      tags: [integrations]
      summary: Issue a new feed URL.
      description: |
        Breaks every existing subscription at once. That is the point: it is what
        you do when the old address has ended up somewhere it should not.
      responses:
        "200": { $ref: "#/components/responses/FeedURL" }

  /api/v1/mail-address:
    get:
      tags: [integrations]
      summary: Your personal mail-to-task address.
      responses:
        "200": { $ref: "#/components/responses/MailAddress" }

  /api/v1/mail-address/rotate:
    post:
      tags: [integrations]
      summary: Issue a new mail-to-task address.
      responses:
        "200": { $ref: "#/components/responses/MailAddress" }

  /api/v1/hook-url:
    get:
      tags: [integrations]
      summary: Your personal address for pushing a task in from somewhere else.
      description: >
        Minted on first ask. Its own token rather than the mail one: the two are
        the same kind of secret, but they leak separately — a URL that has been
        sitting in a shortcut on a lost phone has to be replaceable without
        changing the address other people have in their address books.
      responses:
        "200": { $ref: "#/components/responses/HookURL" }

  /api/v1/hook-url/rotate:
    post:
      tags: [integrations]
      summary: Issue a new push address, retiring the old one at once.
      responses:
        "200": { $ref: "#/components/responses/HookURL" }

  /api/v1/notifications:
    get:
      tags: [integrations]
      summary: Recent notifications, and how many are unread.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  notifications:
                    type: array
                    items: { $ref: "#/components/schemas/Notification" }
                  unread: { type: integer }

  /api/v1/notifications/read:
    post:
      tags: [integrations]
      summary: Mark every notification as read.
      responses:
        "204": { description: Marked. }

  /api/v1/notifications/{notificationID}/read:
    parameters:
      - { name: notificationID, in: path, required: true, schema: { type: string } }
    post:
      tags: [integrations]
      summary: Mark one notification as read.
      responses:
        "204": { description: Marked. }

  /api/v1/push/key:
    get:
      tags: [integrations]
      summary: The instance's VAPID public key.
      description: What `PushManager.subscribe` needs before it will produce a subscription.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  public_key: { type: string, description: base64url }

  /api/v1/push/subscribe:
    post:
      tags: [integrations]
      summary: Register a Web Push subscription for this device.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushSubscription" }
      responses:
        "204": { description: Registered. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/push/unsubscribe:
    post:
      tags: [integrations]
      summary: Forget a subscription.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PushSubscription" }
      responses:
        "204": { description: Forgotten. }

  /api/v1/gmail:
    get:
      tags: [integrations]
      summary: The Gmail connection and what triggers a task.
      responses:
        "200": { $ref: "#/components/responses/Gmail" }
    put:
      tags: [integrations]
      summary: Change what triggers a task.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                trigger: { type: string, enum: [starred, label, both] }
                label: { type: string }
      responses:
        "204": { description: Saved. }
    delete:
      tags: [integrations]
      summary: Disconnect Gmail and forget its tokens.
      responses:
        "204": { description: Disconnected. }

  /api/v1/gmail/authorize:
    post:
      tags: [integrations]
      summary: Begin the OAuth flow.
      description: |
        Returns the Google URL to send the browser to. The PKCE verifier and state
        are kept server-side and expire in ten minutes.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/gmail/sync:
    post:
      tags: [integrations]
      summary: Poll Gmail now.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/calendar:
    get:
      tags: [integrations]
      summary: The Google Calendar connection and the calendars in it.
      description: |
        Read-only. verdande shows a Google calendar beside its own dated tasks and
        never writes one back — writing needs the `calendar.events` scope and an
        answer to what happens when both sides moved the same meeting, which is a
        synchronisation model rather than a scope.

        The list of calendars is fetched from Google when it is empty, so an
        account that connected during a hiccup corrects itself on the next look
        rather than showing an empty panel for ever.
      responses:
        "200": { $ref: "#/components/responses/Calendar" }
    delete:
      tags: [integrations]
      summary: Disconnect the calendar and forget its tokens.
      description: >
        The cached events go too. Unlike the tasks a mailbox made, none of this is
        the person's own work — it is a copy of somebody else's calendar, and it
        means nothing without the account.
      responses:
        "204": { description: Disconnected. }

  /api/v1/calendar/calendars:
    put:
      tags: [integrations]
      summary: Choose which calendars are drawn.
      description: >
        The whole set in one write rather than one flip at a time, for the reason
        the projects' order is written the same way: the question is "these ones",
        and a list built out of several requests can land half applied. A calendar
        turned off has its cached events removed; they come back if it is turned
        on again.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                shown:
                  type: array
                  items: { type: string }
      responses:
        "204": { description: Saved. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/calendar/authorize:
    post:
      tags: [integrations]
      summary: Begin the OAuth flow for a calendar.
      description: |
        The same registration Gmail signs in through, asking for
        `calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
        issues a refresh token per authorisation, so connecting a calendar does not
        disturb a working Gmail connection.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        "409":
          $ref: "#/components/responses/Conflict"

  /api/v1/calendar/sync:
    post:
      tags: [integrations]
      summary: Refresh the chosen calendars now.
      description: >
        Refreshes the list of calendars and replaces the cached window of events
        for the ones that are shown. `partial` means the budget ran out before the
        last calendar; what was read is kept, and the rest arrive on the next run.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  events: { type: integer }
                  partial: { type: boolean }
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/calendar/events:
    get:
      tags: [integrations]
      summary: The events of the chosen calendars, between two days.
      description: |
        Both ends inclusive, and an event that merely overlaps the range comes
        back — a grid asks which cells an event covers, not where it begins.

        `from` and `to` in the answer are the window verdande holds a copy of,
        which is not the window that was asked for. A grid paged past it gets no
        events, and an empty answer with no explanation is a calendar quietly
        claiming a day is clear.
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/CalendarEvent" }
                  from: { type: string, format: date }
                  to: { type: string, format: date }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/export/notes.zip:
    get:
      tags: [notes]
      summary: Every note as a Markdown file, in a zip.
      description: >
        One file per note, named after its title, containing exactly what is
        stored. Nothing is converted on the way out — the note on disk is already
        the file you would export — so it opens in Obsidian, in a text editor, or
        in anything else that reads Markdown.
      responses:
        "200":
          description: A zip archive.
          content:
            application/zip:
              schema: { type: string, format: binary }

  /api/v1/notes:
    get:
      tags: [notes]
      summary: Notes — listed, narrowed to a project, or searched.
      description: >
        With no parameters: the notes filed under no project, which are the
        person's own. With `project`: that project's, which needs read access to
        it. With `q`: a full-text search across every note they can see, matching
        Danish spelling either way round — "grøn" finds "gron" and the reverse.
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - name: project
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  notes:
                    type: array
                    items: { $ref: "#/components/schemas/Note" }
    post:
      tags: [notes]
      summary: Write a note.
      description: >
        `#Projekt` files the note against a project and `[[Another note]]` links to
        one. A third, `@Name`, shares the note with that person as a viewer and
        tells them — only the owner's mentions share, and only a name that has just
        appeared in the text counts, so a note saved on every typing pause does not
        share itself twice.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NoteInput" }
      responses:
        "201":
          description: Written
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Note" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/v1/notes/import:
    post:
      tags: [notes]
      summary: A zip of Markdown files becomes notes.
      description: >
        The mirror of the export, and the way in from anywhere else: a folder of
        Markdown is what Obsidian, Bear, iA Writer and a shell script all produce.
        Filenames are ignored — the title is the first line of the body, here as
        everywhere, and taking it from the filename would be a second rule for the
        same thing. Directories, macOS resource forks and anything that is not .md
        are passed over.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Read
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  skipped: { type: integer, description: Files that were not usable as a note. }
        "413": { description: The archive is too large. }

  /api/v1/notes/{noteID}:
    parameters:
      - name: noteID
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [notes]
      summary: One note.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Note" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [notes]
      summary: Change a note. Every field is optional; what is sent is what changes.
      description: >
        A name written with an `@` that was not in the text before shares the note
        with that person as a viewer and tells them. Only the owner's mentions
        share — an editor may write the name, but giving somebody else's note away
        is the owner's alone — and a role already granted is never lowered by one.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NoteInput" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Note" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [notes]
      summary: Put a note in the trash. It can be brought back.
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notes/{noteID}/shares:
    parameters:
      - name: noteID
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [notes]
      summary: Who a note is shared with, and who it can be shared with.
      description: >
        Owner only. Returns the people the note is already shared with, each with
        their role, and the candidates the owner may add — everyone on the instance
        who is not already on the note.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shares:
                    type: array
                    items: { $ref: "#/components/schemas/NoteShare" }
                  candidates:
                    type: array
                    items: { $ref: "#/components/schemas/Person" }
                  invites:
                    description: >
                      Addresses invited to the note that have not been taken up yet.
                      There is no account behind them, so there is no name or colour
                      to show — the address is all there is until they arrive.
                    type: array
                    items: { $ref: "#/components/schemas/NoteInvite" }
                  follows:
                    description: >
                      The notes this one points at with [[links]], which are shared
                      alongside it. Owner's own notes only.
                    type: array
                    items: { $ref: "#/components/schemas/NoteFollow" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [notes]
      summary: Share a note with a person, or change the role they hold.
      description: >
        The person is named either by `user_id` — somebody picked from the
        candidates — or by `email`. An address that already has an account is
        shared with straight away; one that does not gets an invitation, which
        becomes the share the moment the account is created (201).

        The notes this one points at with [[links]] are shared with the same person
        and the same role, so a link in a shared note leads somewhere. Only notes
        the sharer owns follow, and the response lists them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: One of user_id or email.
              properties:
                user_id: { type: string }
                email: { type: string, format: email }
                role: { type: string, enum: [viewer, editor] }
      responses:
        "200":
          description: Shared
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    description: >
                      Who it was shared with. Sent back because a caller that shared
                      by address does not otherwise know who it reached.
                    $ref: "#/components/schemas/Person"
                  role: { type: string, enum: [viewer, editor] }
                  follows:
                    type: array
                    items: { $ref: "#/components/schemas/NoteFollow" }
        "201":
          description: >
            Invited. The address has no account yet, so a link that creates one and
            the share together has been issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invited: { $ref: "#/components/schemas/NoteInvite" }
                  link:
                    description: The signup link, shown so it can be passed on.
                    type: string
                  emailed:
                    description: >
                      False when the instance has no mail configured, which is when
                      the link is the only way the invitation reaches anybody.
                    type: boolean
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/notes/{noteID}/invites/{inviteID}:
    delete:
      tags: [notes]
      summary: Withdraw an invitation to a note before it is used.
      description: >
        The only way back. Only the token's hash is stored, so a link sent to the
        wrong address cannot be looked up and invalidated — only the row it belongs
        to can.
      parameters:
        - name: noteID
          in: path
          required: true
          schema: { type: string }
        - name: inviteID
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Withdrawn }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notes/{noteID}/shares/{userID}:
    delete:
      tags: [notes]
      summary: Take a person's access to a note away again.
      parameters:
        - name: noteID
          in: path
          required: true
          schema: { type: string }
        - name: userID
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Unshared }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/notes/linking/{kind}/{targetID}:
    get:
      tags: [notes]
      summary: What has been written about this.
      description: >
        The backwards direction. A task's id gives every note that mentions it,
        a project's name gives every note carrying that #tag.
      parameters:
        - name: kind
          in: path
          required: true
          schema: { type: string, enum: [task, note, project] }
        - name: targetID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  notes:
                    type: array
                    items: { $ref: "#/components/schemas/Note" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /api/v1/mailboxes:
    get:
      tags: [integrations]
      summary: The mailboxes this person has connected.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  mailboxes:
                    type: array
                    items: { $ref: "#/components/schemas/Mailbox" }
    post:
      tags: [integrations]
      summary: Connect a mailbox over IMAP.
      description: >
        The connection is tested before it is saved, so a mailbox that cannot be
        read is refused here rather than failing quietly every ten minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [host, username, password]
              properties:
                name: { type: string }
                host: { type: string, example: "imap.mail.me.com:993" }
                username: { type: string }
                password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." }
                folder: { type: string, default: INBOX }
      responses:
        "201":
          description: Connected
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Mailbox" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409": { description: That mailbox is already connected }
        "422": { $ref: "#/components/responses/Upstream" }

  /api/v1/mailboxes/{mailboxID}:
    delete:
      tags: [integrations]
      summary: Disconnect a mailbox. The tasks it made stay.
      parameters:
        - name: mailboxID
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Disconnected }

  /api/v1/mailboxes/{mailboxID}/sync:
    post:
      tags: [integrations]
      summary: Read one mailbox now.
      parameters:
        - name: mailboxID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  partial:
                    type: boolean
                    description: The run hit its time budget. What it made is kept.
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Upstream" }

  /api/v1/ai/settings:
    get:
      tags: [integrations]
      summary: The AI provider in use.
      description: |
        The key itself is never sent back; `has_key` says whether one is stored.
        A settings page that repopulates a password field is one that will
        eventually leak a key into a screenshot.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AISettings" }
    put:
      tags: [integrations]
      summary: Set the AI provider.
      description: An empty `api_key` leaves the stored key alone rather than clearing it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/AISettings"
                - type: object
                  properties:
                    api_key: { type: string }
      responses:
        "204": { description: Saved. }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/ai/summary:
    post:
      tags: [integrations]
      summary: A short prioritisation note over what is outstanding.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { type: string }
        "409":
          $ref: "#/components/responses/Conflict"
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/ai/tasks/{taskID}/split:
    parameters:
      - { name: taskID, in: path, required: true, schema: { type: string } }
    post:
      tags: [integrations]
      summary: Break a task into sub-tasks and create them.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  subtasks:
                    type: array
                    items: { $ref: "#/components/schemas/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          $ref: "#/components/responses/Conflict"
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/ai/inbox/tidy:
    post:
      tags: [integrations]
      summary: Suggest how the loose tasks in the inbox could have been written.
      description: |
        Suggests, never writes. Each suggestion is the line the person would have
        typed — quick-add syntax, the same one parser as everywhere else — so it
        can be read and edited by whoever it is put to, rather than being a set of
        fields a model filled in that you take or leave.

        The model is given the projects that exist and may name no other: a
        `#Project` it invented would create one by accident the first time
        somebody said yes.
      responses:
        "200":
          description: The suggestions, one per task, and none for a task already filed well.
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items: { $ref: "#/components/schemas/AISuggestion" }
        "409": { $ref: "#/components/responses/Conflict" }
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/ai/inbox/tidy/apply:
    post:
      tags: [integrations]
      summary: Write one accepted suggestion into the task it is about.
      description: |
        The line is parsed here rather than in the browser, so "tomorrow" means
        what it means everywhere else — and a suggestion edited by hand before it
        was accepted is read exactly like one that was not.

        Only what the line actually says is applied. A missing date means "the
        line said nothing about a date", never "clear the one that is there". A
        project the model invented moves nothing.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, line]
              properties:
                task_id: { type: string }
                line: { type: string }
      responses:
        "200":
          description: The task as it now stands.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/ai/notes/{noteID}/actions:
    parameters:
      - { name: noteID, in: path, required: true, schema: { type: string } }
    post:
      tags: [integrations]
      summary: Pull what somebody committed to out of a note.
      description: |
        Only things somebody committed to or was asked to do — a heading is not a
        task, a topic discussed is not a task. An empty array is a useful answer
        and a made-up list is not.

        Each suggestion quotes the words it came from, so a wrong one can be seen
        through rather than merely being wrong.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items: { $ref: "#/components/schemas/AISuggestion" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/ai/notes/{noteID}/actions/apply:
    parameters:
      - { name: noteID, in: path, required: true, schema: { type: string } }
    post:
      tags: [integrations]
      summary: Create one accepted task from a note.
      description: |
        The note is not touched. Writing the link back into it would make the two
        point at each other, and a text a program edits without being asked is a
        text people stop trusting — so the task carries where it came from, as a
        `[[note title]]` in its description, and the note is left alone.

        The note's own project is the default; a `#Project` in the line wins,
        because typing it is a choice and inheriting is a circumstance.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [line]
              properties:
                line: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/ai/plan:
    get:
      tags: [integrations]
      summary: Whether a morning plan is sent, and at what hour.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DailyPlanSettings" }
    put:
      tags: [integrations]
      summary: Turn the morning plan on or off, or move the hour.
      description: |
        The hour is local to the person. Moving it clears the "already sent today"
        mark, so somebody who moves the plan from 07 to 16 gets it this afternoon
        rather than tomorrow — a setting whose effect you have to wait a day to
        see is a setting nobody dares turn on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                hour: { type: integer, minimum: 0, maximum: 23 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DailyPlanSettings" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/ai/plan/now:
    post:
      tags: [integrations]
      summary: Make today's plan now, to whoever asks.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                silent:
                  type: boolean
                  description: >
                    Make and store it without sending a notification — the card on
                    Today asking for a plan while somebody is looking at it. A
                    notification about something in front of you is the kind that
                    teaches people to ignore the bell.
      description: |
        The counts come from the database and the sentence from the model, in that
        order: a model that counted for itself would sometimes count wrong, and a
        plan that says four tasks where there are three is worse than no plan.
        With no model configured the counts stand on their own, which is what you
        needed to know at seven in the morning anyway.

        Nothing due and nothing overdue sends nothing. A morning with nothing to
        do is a good morning, and a notification saying "there is nothing" teaches
        people to stop opening notifications.
      responses:
        "200":
          description: The plan, and whether it was sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { type: string }
                  sent: { type: boolean }

  /api/v1/ai/ask:
    post:
      tags: [integrations]
      summary: Ask a question of your own notes and tasks.
      description: |
        Search finds what contains the words. This answers the question — "what
        did I promise Anders in August?" — which is not the same: the answer is
        spread over three notes and a task, and none of them contains the word
        "promise".

        Two halves, and the order is the whole of the safety in it. The database
        finds the candidates with the search that already exists and that already
        only sees what this person may see. The model is given those and nothing
        else — it does not look anything up and cannot ask for more.

        The answer carries what it rests on. A model that says "you did, on the
        14th" is only useful if you can open the note it read that in.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
      responses:
        "200":
          description: |
            The answer, and the passages it rests on — only those, not everything
            that was looked at. An empty answer means nothing matched.
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer: { type: string }
                  sources:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string, enum: [note, task] }
                        id: { type: string }
                        title: { type: string }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Validation" }
        "502": { $ref: "#/components/responses/Upstream" }

  /api/v1/mcp:
    post:
      tags: [integrations]
      summary: The Model Context Protocol endpoint.
      description: |
        JSON-RPC 2.0. A connector authenticating with an API token sees exactly
        what its owner sees. A notification — a message with no `id` — is answered
        with 202 and no body, as the specification requires.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jsonrpc, method]
              properties:
                jsonrpc: { type: string, const: "2.0" }
                id: { oneOf: [{ type: string }, { type: integer }] }
                method: { type: string }
                params: { type: object }
      responses:
        "200":
          description: A JSON-RPC response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jsonrpc: { type: string, const: "2.0" }
                  id: { oneOf: [{ type: string }, { type: integer }] }
                  result: { type: object }
                  error: { type: object }
        "202": { description: A notification. No body. }

  /api/v1/version:
    get:
      tags: [integrations]
      summary: The running version, and whether a newer one exists.
      description: |
        The update notice is only filled in for an administrator — telling an
        ordinary member the server is out of date is telling them about somebody
        else's job.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Version" }

  /api/v1/ws:
    get:
      tags: [integrations]
      summary: Live changes, over a WebSocket.
      description: |
        One-way by design: nothing a client sends over the socket changes
        anything. Writes go through the REST API, where the permission checks are.
        Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
        `task.moved`, `task.deleted`, `comment.created`, `notification`,
        `reminder`.
      responses:
        "101": { description: Switching protocols. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/v1/ping:
    get:
      tags: [integrations]
      summary: A liveness check that does not touch the database.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }

  # --- data -----------------------------------------------------------------

  /api/v1/import/todoist:
    post:
      tags: [data]
      summary: Import a Todoist CSV export.
      description: |
        Priorities are converted, not copied: Todoist's CSV writes 4 for what its
        interface calls P1. What could not be brought across exactly comes back in
        `warnings` rather than being dropped silently.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                name: { type: string, description: Defaults to the file's own name. }
      responses:
        "201": { $ref: "#/components/responses/ImportResult" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/import/csv:
    post:
      tags: [data]
      summary: Import mapped rows from any CSV.
      description: |
        The file is parsed in the browser and the mapped rows arrive as JSON, so
        the column-mapping interface does not have to upload the file twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mapping, rows]
              properties:
                name: { type: string }
                mapping:
                  type: object
                  additionalProperties: { type: string }
                  example: { content: Opgave, due: Frist }
                rows:
                  type: array
                  maxItems: 5000
                  items:
                    type: object
                    additionalProperties: { type: string }
      responses:
        "201": { $ref: "#/components/responses/ImportResult" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/export/account:
    get:
      tags: [data]
      summary: Everything, as JSON.
      description: |
        The "you can leave" guarantee, and deliberately the raw shape: an export
        that has been tidied is an export that has lost something.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object }

  /api/v1/export/projects/{projectID}.csv:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [data]
      summary: One project as a Todoist-compatible CSV.
      responses:
        "200":
          description: OK
          content:
            text/csv:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/export/projects/{projectID}.ics:
    parameters:
      - { name: projectID, in: path, required: true, schema: { type: string } }
    get:
      tags: [data]
      summary: One project as a calendar file.
      responses:
        "200":
          description: OK
          content:
            text/calendar:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/templates:
    get:
      tags: [data]
      summary: Your project templates.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
    post:
      tags: [data]
      summary: Save a project as a template.
      description: Due dates are stored relative to the project's start.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id]
              properties:
                project_id: { type: string }
                name: { type: string }
                description: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/templates/{templateID}:
    parameters:
      - { name: templateID, in: path, required: true, schema: { type: string } }
    delete:
      tags: [data]
      summary: Delete a template.
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/templates/{templateID}/use:
    parameters:
      - { name: templateID, in: path, required: true, schema: { type: string } }
    post:
      tags: [data]
      summary: Create a project from a template.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                start_date:
                  type: string
                  format: date
                  description: Day zero for the template's relative dates. Defaults to today.
      responses:
        "201": { $ref: "#/components/responses/Project" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  # --- outside /api/v1 -------------------------------------------------------

  /healthz:
    get:
      tags: [integrations]
      summary: Health check.
      description: |
        Reads the database rather than merely answering, so a container with a
        broken volume reports unhealthy instead of pretending to work.
      security: []
      responses:
        "200":
          description: Healthy. Carries the running version — the one thing you can ask without a session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  version: { type: string, example: v0.3.1 }
        "503": { description: Unhealthy. }

  /ics/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      tags: [integrations]
      summary: The calendar feed itself.
      description: |
        Unauthenticated by necessity: a calendar client fetches a URL with no
        credentials and no way to prompt for any, so the token in the path is the
        credential. A missing feed answers 404 rather than 401, because a
        calendar client would show a password prompt nobody can answer.
      security: []
      responses:
        "200":
          description: OK
          content:
            text/calendar:
              schema: { type: string }
        "404": { description: No such feed. }

  /inbound/mail:
    post:
      tags: [integrations]
      summary: Turn a delivered email into a task.
      description: |
        Called by the mail server, not by a browser. Authenticated by the token in
        the recipient address, which is the only credential inbound mail carries.
        The subject line goes through the quick-add parser.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string }
                from: { type: string }
                subject: { type: string }
                body: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  task_id: { type: string }
        "404": { description: "No such address. Identical to a malformed one, deliberately." }

  /inbound/hook/{token}:
    post:
      tags: [integrations]
      summary: Push a task into somebody's inbox from another program.
      description: |
        The token in the path is the whole credential — the caller is a shortcut on
        a phone or a script, and neither can sign in. A token here can do one
        thing: create a task in that person's inbox. An API token can do everything,
        which is why this is not one.

        The body is read three ways, because those are the three you meet: JSON from
        a service, a form field from a webhook form, and plain text from a shortcut
        that just sends what was selected. The first line becomes the task and the
        rest sits underneath it, and the line goes through the quick-add parser — so
        "Ring til Anders i morgen p1 #Firma" means here what it means in the box at
        the top of the app.

        POST only. A GET would be easier to call, and that is exactly the problem:
        a browser prefetch, a link check in a chat, and a revisited history entry
        would all create tasks nobody asked for.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              type: string
              description: First line the task, the rest its description.
          application/json:
            schema:
              type: object
              properties:
                text: { type: string }
                title: { description: Same as text. }
                content: { description: Same as text. }
                note: { type: string }
                body: { description: Same as note. }
                description: { description: Same as note. }
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                text: { type: string }
                note: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  task_id: { type: string }
                  content: { type: string }
        "404": { description: "No such address. Identical to a malformed one, deliberately." }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { description: Too many pushes; the address is rate limited. }

  /oauth/gmail/callback:
    get:
      tags: [integrations]
      summary: Where Google sends the browser after consent.
      description: |
        Answers with a redirect to `/indstillinger`, not with JSON: the person is
        looking at a browser tab, not at a fetch response.
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
      responses:
        "302": { description: "Back into the app, with the outcome as a query parameter." }

  /oauth/calendar/callback:
    get:
      tags: [integrations]
      summary: Where Google sends the browser after consent to a calendar.
      description: |
        A URI of its own rather than a reuse of Gmail's. One callback deciding from
        stored state which of the two flows had come back would put the choice of
        which token store to write behind a lookup — and when that goes wrong it
        goes wrong by overwriting a working Gmail connection with calendar tokens.

        Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
        An `org_internal` error here means the OAuth app is registered as Internal
        and the account is outside the organisation.
      parameters:
        - { name: code, in: query, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
        - { name: error, in: query, schema: { type: string } }
      responses:
        "302": { description: "Back into the app, with the outcome as a query parameter." }

  /mcp:
    post:
      tags: [integrations]
      summary: The MCP endpoint, with the token in the query string.
      description: |
        The same server as `/api/v1/mcp`, for a client that can only be given a
        URL. Claude's custom-connector dialog asks for a name, an address and
        OAuth client credentials — there is no field for a bearer token — so the
        API path cannot be configured from it.

        The key is the only credential accepted here. A session cookie is
        ignored: this route sits outside the CSRF check, and honouring a cookie
        would make it a cross-site request that acts as whoever is signed in.
      security: []
      parameters:
        - name: key
          in: query
          required: true
          schema: { type: string, example: vrd_… }
          description: A personal API token. Treat the whole address as a password.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jsonrpc, method]
              properties:
                jsonrpc: { type: string, const: "2.0" }
                id: { oneOf: [{ type: string }, { type: integer }] }
                method: { type: string }
                params: { type: object }
      responses:
        "200":
          description: A JSON-RPC response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jsonrpc: { type: string, const: "2.0" }
                  id: { oneOf: [{ type: string }, { type: integer }] }
                  result: { type: object }
                  error: { type: object }
        "202": { description: A notification. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /.well-known/caldav:
    get:
      tags: [integrations]
      summary: CalDAV discovery.
      description: |
        The rest of the CalDAV surface lives under `/caldav` and speaks WebDAV
        verbs — PROPFIND, REPORT — which are outside what OpenAPI describes. See
        the CalDAV page in the documentation.
      security: []
      responses:
        "301": { description: To the CalDAV root. }

components:
  securitySchemes:
    session:
      type: apiKey
      in: cookie
      name: __Host-verdande_session
      description: |
        Set by `/auth/login`. `httpOnly`, `SameSite=Lax`, and prefixed `__Host-`
        when served over HTTPS. Over plain HTTP the prefix is invalid and the
        cookie is named `verdande_session`.
    bearer:
      type: http
      scheme: bearer
      description: |
        A personal API token, `vrd_…`. Carries its owner's permissions exactly —
        there is no separate service identity — and is refused on `/tokens`.

  responses:
    BadRequest:
      description: The body could not be read.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Not signed in, or the credential is wrong.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: A cross-site request, or a token where a session is required.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Does not exist, or you cannot see it. The two are not distinguished.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Already exists, or the state does not allow it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooLarge:
      description: The body or the file is too large.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Validation:
      description: One or more fields are not valid. See `fields`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many attempts. Carries `Retry-After`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Upstream:
      description: An external service failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    Task:
      description: OK
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Task" }
    TaskList:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              tasks:
                type: array
                items: { $ref: "#/components/schemas/Task" }
    Project:
      description: OK
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Project" }
    ProjectGroup:
      description: OK
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ProjectGroup" }
    FeedURL:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              url: { type: string }
    MailAddress:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              address: { type: string, example: todo+abc123@example.dk }
              configured:
                type: boolean
                description: |
                  Whether a mail server is set up at all. The address is real
                  either way, but with no mail server nothing on the internet
                  routes to it — and an interface that does not say so is
                  promising something that will not happen.
    Gmail:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              connected: { type: boolean }
              email: { type: string }
              trigger: { type: string, enum: ["", starred, label, both] }
              label: { type: string }
    Calendar:
      description: OK
      content:
        application/json:
          schema:
            type: object
            properties:
              connected: { type: boolean }
              account:
                type: string
                description: >
                  The address of the connected account, taken from the id of its
                  primary calendar — which is where Google puts it, so no profile
                  scope has to be asked for to learn it.
              read_only:
                type: boolean
                description: >
                  Always true today. Stated rather than assumed: an interface that
                  quietly looks editable is one somebody will try to edit.
              has_client:
                type: boolean
                description: >
                  Whether an OAuth client is registered on this instance at all, so
                  the page can explain rather than offer a button that answers 409.
              redirect_uri:
                type: string
                description: >
                  What has to be registered with Google, spelled out. Derived from
                  VERDANDE_BASE_URL and matched exactly — the single most likely
                  thing to be wrong, and the error Google gives names neither value.
              last_sync_at: { type: string, format: date-time }
              calendars:
                type: array
                items: { $ref: "#/components/schemas/Calendar" }
    ImportResult:
      description: Created
      content:
        application/json:
          schema:
            type: object
            properties:
              project_id: { type: string }
              projects: { type: integer }
              tasks: { type: integer }
              sections: { type: integer }
              comments: { type: integer }
              warnings:
                type: array
                items: { type: string }

    HookURL:
      description: The address another program pushes to.
      content:
        application/json:
          schema:
            type: object
            properties:
              url: { type: string }

  schemas:
    Calendar:
      type: object
      description: >
        One calendar inside a connected account. Kept whether or not it is shown,
        so turning one off and on again gives back the same row.
      properties:
        id: { type: string }
        remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
        name: { type: string }
        colour:
          type: string
          description: >
            Google's own hex value, not one of verdande's ten named colours. Those
            are names so the interface can restyle them per theme and they are
            verdande's palette; a Google calendar is told apart by the colour the
            person already knows it by.
        time_zone: { type: string }
        primary: { type: boolean }
        writable:
          type: boolean
          description: >
            Whether the connected account could write to it. Nothing does — the
            scope is read-only — and it is stored so the day something can, the
            interface knows which calendars could even be offered.
        shown: { type: boolean }

    CalendarEvent:
      type: object
      description: >
        One occurrence, in the shape a grid asks about. A recurrence is expanded by
        Google into its occurrences, so what arrives here is a day, not a rule.
      properties:
        id: { type: string }
        calendar_id: { type: string }
        summary: { type: string }
        starts_at:
          type: string
          description: >
            RFC 3339 as Google wrote it, offset and all. Empty for an all-day
            event, which has a day rather than a moment — inventing midnight for it
            is how an event moves a day when a container moves time zone.
        ends_at: { type: string }
        start_day: { type: string, format: date }
        end_day:
          type: string
          format: date
          description: >
            The last day the event covers, inclusive. Google's all-day end date is
            exclusive and is corrected on the way in, once, rather than by every
            reader.
        all_day: { type: boolean }
        location: { type: string }
        url: { type: string, description: "Opens the event in Google's own interface." }
        calendar_name: { type: string }
        colour: { type: string }

    Note:
      type: object
      description: A page of Markdown. The body is the stored form and the exported form.
      properties:
        id: { type: string }
        project_id: { type: string }
        title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
        body: { type: string, description: Markdown }
        pinned: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        shared_with_me:
          type: boolean
          description: >
            True when the reader reached this note through a direct share rather
            than by owning it or its project. What puts it in the Delt med mig group.
        owner:
          allOf: [{ $ref: "#/components/schemas/Person" }]
          description: Who the note belongs to, present on a note shared with the reader.
        links:
          type: array
          description: What the body points at, read out of the text on the way in.
          items:
            type: object
            properties:
              kind: { type: string, enum: [task, note, project] }
              target_id: { type: string }

    NoteInput:
      type: object
      properties:
        project_id: { type: string, description: "Empty files it under no project." }
        body: { type: string, description: "Markdown. Its first line becomes the title." }
        pinned: { type: boolean }

    Person:
      type: object
      description: A user, as much of one as a name and a colour.
      properties:
        id: { type: string }
        name: { type: string }
        avatar_color: { type: string }

    NoteShare:
      type: object
      description: One person's direct grant on a note.
      properties:
        user: { $ref: "#/components/schemas/Person" }
        role: { type: string, enum: [viewer, editor] }
        linked:
          description: >
            True when the person has this note because another shared note points
            at it, rather than because it was shared with them on its own. Such a
            grant is taken back by removing the link or the share it came from, not
            by removing it here.
          type: boolean
        via:
          description: The title of the shared note that points at this one.
          type: string

    NoteFollow:
      type: object
      description: A note that is shared alongside another because that one links to it.
      properties:
        note_id: { type: string }
        title: { type: string }

    DailyPlanSettings:
      type: object
      description: >
        One person's morning plan, and today's plan itself. One resource, because
        it is one thing: the plan is the resource and the setting is when it gets
        made.
      properties:
        enabled: { type: boolean }
        hour:
          type: integer
          description: Local to the person, because seven o'clock is seven different places.
        last_sent:
          type: string
          description: The date it last went out, in that person's own timezone.
        plan:
          type: string
          description: >
            Today's plan as it was sent — kept rather than recomputed on every
            look. A plan that says something different when you look at it than it
            said when it arrived is worse than either. Absent when the stored plan
            is from another day.
        plan_date: { type: string }
        plan_at:
          type: integer
          description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four.

    AISuggestion:
      type: object
      description: >
        One proposed line, in quick-add syntax, plus one sentence on why it looks
        like that. Nothing has happened yet — a suggestion becomes something when
        somebody says yes to it.
      properties:
        task_id:
          type: string
          description: >
            The task the suggestion is about, when it is about one that already
            exists. Absent when the suggestion is a new task.
        line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
        why: { type: string }

    NoteInvite:
      type: object
      description: >
        A share on a note that is waiting for an account. The address is the whole
        of who the person is until they sign up.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [viewer, editor] }

    Mailbox:
      type: object
      description: One connected mailbox. The password and any token are never returned.
      properties:
        id: { type: string }
        kind: { type: string, enum: [gmail, imap] }
        name: { type: string }
        host: { type: string }
        username: { type: string }
        folder: { type: string }
        last_uid: { type: integer }
        last_sync_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    Error:
      type: object
      description: |
        The one shape every failure takes. `code` is stable and meant to be
        matched on; `error` is English prose for a log, and what a person reads is
        decided by the client, which knows what language they are in.
      required: [code, error]
      properties:
        code:
          type: string
          enum:
            - bad_request
            - validation_failed
            - unauthorized
            - totp_required
            - forbidden
            - not_found
            - conflict
            - rate_limited
            - internal_error
            - payload_too_large
            - gmail_not_configured
            - ai_not_configured
            - totp_not_enabled
            - inbox_protected
        error: { type: string }
        fields:
          type: object
          additionalProperties: { type: string }
          example: { due_date: "must be a date like 2026-03-15" }

    User:
      type: object
      description: Never carries the password hash or the TOTP secret.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        avatar_color: { type: string }
        timezone: { type: string }
        locale: { type: string, enum: [da, en] }
        is_admin: { type: boolean }
        totp_enabled: { type: boolean }
        sidebar_collapsed:
          type: array
          items: { type: string }
          description: |
            The sidebar headings this person has folded away. Always an array,
            never null: a client that has to check for both an absent array and an
            empty one will one day check for only one of them.
          example: ["labels"]
        created_at: { type: string, format: date-time }

    RecoveryCodes:
      type: object
      properties:
        recovery_codes:
          type: array
          items: { type: string }

    AdminUser:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        avatar_color: { type: string }
        is_admin: { type: boolean }
        self:
          type: boolean
          description: The caller, so the interface can grey out what nobody should do to their own account here.
        created_at: { type: string, format: date-time }
        last_seen_at:
          type: string
          format: date-time
          description: |
            Absent when they have never signed in — which is what an invite that
            was accepted and then forgotten looks like. Absent rather than zero,
            because 1970 in an interface reads as a bug.
        project_count:
          type: integer
          description: Projects they own, excluding the Inbox every account has.
        task_count:
          type: integer
          description: |
            What a delete would destroy: the tasks in the projects they own,
            which go with those projects.
        authored_elsewhere:
          type: integer
          description: |
            What a delete would leave behind: tasks they wrote in projects other
            people own. Those survive without an author. A separate number from
            `task_count` because it is a separate sentence — one is what
            disappears, the other is what stays unattributed.

    GmailClient:
      type: object
      properties:
        client_id: { type: string }
        has_secret:
          type: boolean
          description: Whether a secret is stored. The secret itself is never sent back.
        from_env:
          type: boolean
          description: |
            The deployment set this, so the stored pair is ignored and the fields
            are not worth editing.
        redirect_uri:
          type: string
          description: |
            What has to be registered with Google, spelled out. Derived from
            `VERDANDE_BASE_URL` and matched exactly — it is the single most likely
            thing to be wrong, and the error Google returns for it names neither
            value. Google rejects private IP addresses over http, so a LAN address
            will not do: it has to be https, or `http://localhost`.
          example: https://verdande.example.com/oauth/gmail/callback

    BackupRun:
      type: object
      properties:
        id: { type: string }
        started_at: { type: string, format: date-time }
        finished_at:
          type: string
          format: date-time
          description: |
            Absent while a run is still going, and on one the process died in the
            middle of — which is the same shape from outside, and honestly so: a
            run that was interrupted never finished.
        size_bytes: { type: integer }
        error:
          type: string
          description: Present only on a run that failed, in the words it failed with.
        present:
          type: boolean
          description: |
            Whether the file is still on disk. Rotation keeps the most recent
            fourteen, so an older row is a record with nothing behind it and its
            download answers 404.

    PendingInvite:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        project_name:
          type: string
          description: Absent for an invite to the instance itself rather than to a project.
        role: { type: string, enum: [owner, editor, viewer] }
        invited_by: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    Session:
      type: object
      properties:
        id: { type: string }
        current:
          type: boolean
          description: The one making this request.
        device:
          type: string
          description: |
            A summary of the user agent — "Chrome på macOS". A handful of
            substring tests rather than a parsing library: the question it
            answers is "is one of these me?", and the raw string is sent beside
            it so anything the summary gets wrong is still visible.
          example: Chrome på macOS
        user_agent: { type: string }
        ip: { type: string }
        created_at: { type: string, format: date-time }
        last_seen_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    Task:
      type: object
      properties:
        id: { type: string }
        project_id: { type: string }
        section_id: { type: string }
        parent_id: { type: string }
        content: { type: string }
        description: { type: string }
        priority: { type: integer, minimum: 1, maximum: 4, description: 1 is most urgent. }
        due_date: { type: string, format: date }
        due_datetime: { type: string, format: date-time }
        duration_min: { type: integer }
        recurrence_rule: { type: string, description: An RFC 5545 RRULE. }
        recurrence_text: { type: string, description: "The rule in words, for display." }
        assignee_id: { type: string }
        labels:
          type: array
          items: { type: string }
        completed: { type: boolean }
        completed_at: { type: string, format: date-time }
        created_by:
          type: string
          description: |
            Empty when the author's account has been deleted. The task outlives
            the person who wrote it.

        sort_order: { type: number }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TaskInput:
      type: object
      properties:
        project_id:
          type: string
          description: |
            Omitted on create, the task goes to the Inbox. On update it moves the
            task — which needs editor rights on the destination.

        section_id: { type: string }
        parent_id: { type: string }
        content: { type: string }
        description: { type: string }
        priority: { type: integer, minimum: 1, maximum: 4 }
        due_date: { type: string, format: date }
        due_time: { type: string, example: "10:00" }
        duration_min: { type: integer }
        recurrence_rule: { type: string }
        assignee_id: { type: string }
        labels:
          type: array
          items: { type: string }
        sort_order: { type: number }

    QuickAddResult:
      allOf:
        - $ref: "#/components/schemas/Task"
        - type: object
          properties:
            unknown_project:
              type: string
              description: |
                Present when the text named a `#project` that does not exist. The
                task is still created, in the Inbox — a thought is not thrown away
                over a typo — but the caller is told, because the alternative is
                that the name vanishes from the title and nothing says why.

    ParsedTask:
      type: object
      description: What the quick-add parser made of a line.
      properties:
        content: { type: string }
        project: { type: string }
        priority: { type: integer }
        due_date: { type: string, format: date }
        due_datetime: { type: string, format: date-time }
        recurrence: { type: string }
        labels:
          type: array
          items: { type: string }

    Project:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        color: { $ref: "#/components/schemas/Color" }
        icon: { type: string }
        view_mode: { type: string, enum: [list, board, calendar] }
        owner_id: { type: string }
        is_inbox: { type: boolean }
        archived: { type: boolean }
        sort_order: { type: number }
        role: { type: string, enum: [owner, editor, viewer] }
        member_count: { type: integer }
        shared: { type: boolean }
        group_id:
          type: string
          description: |
            The sidebar group *you* have filed it under, absent for none. Always
            absent on a project shared with you: grouping is a column on the
            project row, so it is the owner's filing rather than yours.

    ProjectInput:
      type: object
      properties:
        name: { type: string }
        color: { $ref: "#/components/schemas/Color" }
        icon: { type: string }
        view_mode: { type: string, enum: [list, board, calendar] }
        archived: { type: boolean }
        sort_order: { type: number }
        group_id:
          type: string
          description: |
            Files the project under one of your groups. The empty string takes it
            out of one — `null` cannot mean that, because absent and null are the
            same thing once decoded, and telling "put it nowhere" apart from "I
            did not mention it" is what makes this a PATCH.

    Passkey:
      type: object
      properties:
        id: { type: string }
        name:
          type: string
          description: What the owner called it. "min bærbare" is what makes a list reviewable.
        aaguid:
          type: string
          description: |
            Identifies the authenticator model, which is what lets a list say
            something recognisable beside a key somebody named months ago.
        discoverable:
          type: boolean
          description: |
            Whether the credential lives on the device. Only these can start a login
            with no email typed first.
        user_verified:
          type: boolean
          description: |
            Whether the authenticator verified the person — a PIN, a fingerprint, a
            face. One that did is both factors at once; one that only proved
            possession is a first factor and still meets the TOTP step.
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }

    ProjectGroup:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        color: { $ref: "#/components/schemas/Color" }
        description:
          type: string
          description: |
            What the group is, in the owner's words. A heading carries a name; a
            page carries the sentence that says why these projects are one body of
            work. Empty until somebody writes one.
        collapsed: { type: boolean }
        sort_order: { type: number }

    ProjectGroupInput:
      type: object
      properties:
        name: { type: string }
        color: { $ref: "#/components/schemas/Color" }
        description:
          type: string
          maxLength: 4000
          description: What the group is, in the owner's words.
        collapsed: { type: boolean }

    Color:
      type: string
      default: graphite
      enum: [graphite, tomato, amber, lime, green, teal, blue, indigo, violet, magenta]
      description: |
        A name, not a hex value. The interface resolves it when it paints, so the
        palette can be retuned for a new theme or a new display without rewriting
        every row — and a database that stores a fact rather than a rendering.

        The set is closed: an unknown name is refused rather than stored and
        painted as the default, which would look like a colour that did not save.

        One palette for all five themes. These are small solid marks beside a
        label that carries the meaning, not text or grounds, so what they owe is
        to be told apart and to be visible on both a near-black and a white
        ground — which is why they all sit in the mid-tone band.

    Section:
      type: object
      properties:
        id: { type: string }
        project_id: { type: string }
        name: { type: string }
        sort_order: { type: number }

    Member:
      type: object
      properties:
        user_id: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        avatar_color: { type: string }
        role: { type: string, enum: [owner, editor, viewer] }

    Activity:
      type: object
      properties:
        id: { type: string }
        project_id: { type: string }
        task_id: { type: string }
        actor_id: { type: string }
        actor_name: { type: string }
        kind: { type: string, example: task.completed }
        data: { type: object }
        created_at: { type: string, format: date-time }

    Label:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        sort_order: { type: number }
        task_count: { type: integer }

    Filter:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        query: { type: string, example: "i dag & p1" }
        color: { type: string }
        sort_order: { type: number }

    FilterInput:
      type: object
      properties:
        name: { type: string }
        query: { type: string }
        color: { type: string }
        sort_order: { type: number }

    Comment:
      type: object
      properties:
        id: { type: string }
        task_id: { type: string }
        user_id: { type: string }
        user_name: { type: string }
        user_color: { type: string }
        body: { type: string }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Attachment:
      type: object
      properties:
        id: { type: string }
        filename: { type: string }
        mime_type: { type: string }
        size: { type: integer }
        url: { type: string }

    Reminder:
      type: object
      properties:
        id: { type: string }
        task_id: { type: string }
        remind_at: { type: string, format: date-time }
        offset_min: { type: integer }
        sent: { type: boolean }

    Template:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        color: { type: string }
        task_count: { type: integer }
        created_at: { type: string, format: date-time }

    ApiToken:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string, description: "The first characters, which is all that is ever shown again." }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    Notification:
      type: object
      description: >
        Something somebody else did that you need to know about. `kind` says what
        happened and the client composes the sentence from it, so the bell follows
        the reader's language; `title` is the server's own Danish wording, used for
        Web Push, which has no dictionary to look words up in.
      properties:
        id: { type: string }
        actor_name: { type: string }
        project_id: { type: string }
        task_id: { type: string }
        note_id:
          description: Set on a notification about a note — a shared note somebody else edited.
          type: string
        kind:
          type: string
          enum: [assigned, note.changed, comment]
        title: { type: string }
        body: { type: string }
        read: { type: boolean }
        created_at: { type: string, format: date-time }

    PushSubscription:
      type: object
      required: [endpoint]
      properties:
        endpoint: { type: string }
        keys:
          type: object
          properties:
            p256dh: { type: string }
            auth: { type: string }

    AISettings:
      type: object
      properties:
        provider: { type: string, enum: ["", anthropic, openai, google, compatible] }
        base_url: { type: string }
        model: { type: string }
        has_key: { type: boolean, readOnly: true }

    Version:
      type: object
      properties:
        current: { type: string }
        latest: { type: string }
        update_available: { type: boolean }
        url: { type: string }
        notes: { type: string }
        checked_at: { type: string, format: date-time }
        disabled:
          type: boolean
          description: |
            Distinguishes "no update" from "we never looked", which the interface
            has to show differently or it implies a guarantee it cannot make.
