From 0b467beb3e01468fd2ac767c1ec6d4ba6e6cad35 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 09:05:36 +0200 Subject: [PATCH] docs: the API and the handover, brought up to what is here The OpenAPI document gains the audit log, the backups, the Gmail client and the sidebar sections, and loses the paragraph that said deleting an account takes every task the person ever wrote. The handover gains the day: why an account can be deleted without taking other people's work, why the audit log pages with a keyset, why a week view exists at all, why form fields are 16px on touch, and why `autofocus` is gone. And the trap that cost the most, which was not in the code: the build hung on 0% CPU because iCloud had evicted node_modules and a stalled download blocks a read forever. The fix is a repo path outside a synced folder. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 375 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 366 insertions(+), 9 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 701c28a..8830789 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -226,6 +226,43 @@ paths: "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/me: get: tags: [auth] @@ -856,6 +893,261 @@ paths: "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 `:`. 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.readonly` 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/errors: get: tags: [auth] @@ -1012,11 +1304,12 @@ paths: The only hard delete in this API. Everything else is soft-deleted with a trash behind it; this is not recoverable. - What goes is wider than it looks: their projects, every task in those - projects, **and every task they wrote anywhere** — `tasks.created_by` - cascades, so retiring a colleague's account removes their contributions - from shared projects too. Tasks merely assigned to them are unassigned - and survive. + 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: @@ -2516,6 +2809,14 @@ components: 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: @@ -2550,9 +2851,60 @@ components: task_count: type: integer description: | - Everything a delete would destroy: tasks in projects they own, plus - every task they wrote anywhere. Not the same as "their tasks", and - the difference is the whole point of showing it. + 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 @@ -2609,7 +2961,12 @@ components: items: { type: string } completed: { type: boolean } completed_at: { type: string, format: date-time } - created_by: { type: string } + 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 }