To ønsker, meldt sammen. --- eksport af det hele --- Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip, og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten downloads for at flytte sine egne opgaver, hvilket er en eksport, der teknisk set findes, og som ingen bruger. `GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så det er den form, der importeres i den anden ende — og den form, man kan hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som noteeksporten, der er en mappe med Markdown og ikke én lang fil. `GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og ikke en zip, fordi en kalender er lavet til at rumme mange ting, og projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato er en linje, de fleste læsere taber på gulvet. Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og det er præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en løkke der kun eksporterer det sidste ikke består. --- en indsat liste bliver til opgaver --- Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk ved indsætning i et `<input>` — så en kopieret liste blev til én opgave med punkterne mast sammen. Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem for at være gættet til to eller fire: en liste fra Apple Noter rykker ind med ét antal mellemrum, en fra et Markdown-dokument med et andet, og begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste, der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den for. Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add` nu et `parent_id`: en indrykket linje skal parses som enhver anden. En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket linje honoreres derfor ikke, og det siges højt som `unknown_project` frem for at ordet bare forsvinder fra titlen. Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks dér. Det er "enten opgaver eller undertasks" afgjort af hvor man indsætter, uden at nogen skal vælge noget i en menu. Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke noget, man skal opdage bagefter — og visningen er også svaret på "forstod den indrykningen", stillet mens der stadig er en Fortryd-knap. --- rettet undervejs --- Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på forælderen og regnes ud ved hentning, og serveren sender ikke forælderen igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun den nye. Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5271 lines
189 KiB
YAML
5271 lines
189 KiB
YAML
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.
|
|
section_id:
|
|
type: string
|
|
description: >
|
|
The section the box was opened in. A "/section" in the text
|
|
wins, because typing it is a choice and standing there is a
|
|
circumstance.
|
|
parent_id:
|
|
type: string
|
|
description: >
|
|
Makes it a sub-task. A sub-task lives in its parent's
|
|
project, so a "#project" on the same line is not honoured —
|
|
it comes back as `unknown_project` rather than being
|
|
silently dropped. Dates, labels and priority are read the
|
|
same way as for any other line.
|
|
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/projects.zip:
|
|
get:
|
|
tags: [projects]
|
|
summary: Every project's tasks as a CSV, in a zip.
|
|
description: >
|
|
One CSV per project, in the column layout Todoist writes — so the files
|
|
import into Todoist and into anything else that reads that shape. A zip
|
|
rather than one wide file because that is the shape on the other side,
|
|
and because a single project can be lifted out of it without opening a
|
|
spreadsheet. The per-project link does the same for one project; this is
|
|
the whole account in one download.
|
|
responses:
|
|
"200":
|
|
description: A zip archive.
|
|
content:
|
|
application/zip:
|
|
schema: { type: string, format: binary }
|
|
|
|
/api/v1/export/tasks.ics:
|
|
get:
|
|
tags: [projects]
|
|
summary: Every dated task as a calendar of VTODOs.
|
|
description: >
|
|
One file, not a zip: a calendar is made to hold many things, and each
|
|
task carries its project with it, so nothing is lost by putting them
|
|
together. Tasks without a due date are left out — a VTODO with no date
|
|
is a line most readers drop on the floor.
|
|
responses:
|
|
"200":
|
|
description: An iCalendar file.
|
|
content:
|
|
text/calendar:
|
|
schema: { type: string }
|
|
|
|
/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.
|