CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Knotebook follows Keep a Changelog conventions: unreleased work accumulates under [Unreleased] and moves into a dated version section once it ships. The first release is v0.1.0.
## [Unreleased]
### Upgrade notes
- **Everyone has to sign in again once after upgrading.** Signing in now records a session on the server, and cookies from before the upgrade don't name one, so they count as signed out. API tokens and connected apps keep working. **Migration 0038** only adds the `sessions` table; rolling back leaves it in place, and the previous version doesn't read it. See [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back).
- **Migration 0039 (site limits) only adds two tables**, `site_limits` and `site_limit_changes`, and changes no existing row. Every limit starts at the value it had before. **`NOTE_MAX_BYTES`, `PARSE_WORKERS`, `PARSE_WORKER_MEMORY_MB`, `PARSE_TIMEOUT_MS` and `PARSE_QUEUE_WAIT_MS` still work and still win**: each one locks its row in Site admin → Site limits, with its range unchanged; to manage one on the page, remove it from `.env` and restart the server. The startup line `parse worker settings in effect`: its `from` entries can also be `admin`, and the number of workers and the longest wait for a worker now report `auto` instead of `default` when they are automatic; `workersSource` can also be `admin`. Rolling back leaves the tables in place and the previous version doesn't read them, so its limits return to the environment variables and built-in values; after upgrading again, the saved values apply again. See [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back).
- **The `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` variables are no longer imported.** A site that goes straight from v0.5 or earlier to this version gets no "SSO" sign-in service from them: upgrade to 0.15.x first so they are imported (all three must be set on that first start and valid: an issuer starting with lowercase `http://` or `https://`, without a username or password, and an issuer and client ID of at most 512 characters each, as the 0.15 documentation describes; otherwise nothing is imported and they are never read again), or set the identity provider up in Site admin → Sign-in after upgrading. The three lines in `.env` can be deleted; if they are left there they are ignored and the server starts as usual. Sign-in services that were already imported are not touched, and keep their old callback URL.
- **Breaking for MCP clients: `create_bulk_transfer_token` is now called `open_notes_as_files`.** It takes the same arguments and answers the same way; the old name is gone, with no alias, so calling it gets the SDK's own "Tool create_bulk_transfer_token not found". Prompts or scripts that name the old tool must use `open_notes_as_files`. Restart your MCP clients so they list the new name. The `401` messages of the `/api/transfer/*` endpoints that pointed at the tool now name `open_notes_as_files` too. See [MCP](docs/mcp.md#open_notes_as_files) (#417).
### Added
- **Site admins can adjust 17 limits in the browser, without restarting the server** (#397). **Site admin → Site limits** has the upload size limit, the note size limit, the four parse worker settings and the nine per-person request rates (API and MCP reads and writes, note content reads and writes, searches, uploads and copies, AI actions, bulk downloads, bulk write-backs); **Site admin → Sign-in → Session and tokens** has how long a web sign-in lasts (1 hour to 30 days, 7 days by default) and the number of active API tokens one person can hold. A limit can be lowered from its default (the number of parse workers, the memory per worker and the sign-in period can also be raised); the parse time limit goes down to 4 seconds and the memory per worker to 512 MiB, and the page says what lowering them can break. A change applies to the next request, upload or write; a new sign-in period only to people who sign in afterwards, and a lower token limit doesn't revoke any token. The page lists the 50 most recent changes with who made them, and shows a setting that an environment variable sets as locked. `GET /api/admin/limits` and `PATCH /api/admin/limits` are the API behind it, `GET /api/auth/config` now includes `maxUploadBytes`, and the editor checks files against the site's upload limit instead of a fixed 10 MiB. See [Self-hosting](docs/self-hosting.md#site-limits).
- **Drag blocks side by side.** Drag a block by its ⋮⋮ to the left or right edge of another block to make a row of two columns, or onto the edge of a block in a row of fewer than four columns to add a column next to it. When every column of the resulting row holds a single picture, the columns are sized so the pictures come out the same height. One Ctrl+Z undoes the drop. See [Side-by-side columns](docs/columns.md) and [Known limitations](docs/known-limitations.md#side-by-side-columns).
- **Resize columns by dragging the divider between them, or with ← / → on the focused divider** (5% of the row per press; quick presses undo together). Widening an image in a column past the column's width now widens the column and squeezes the other columns, down to 200 pixels, or 96 for a column that holds only images. The ⋮⋮ inside a column is 16 pixels wide, and the keyboard shortcut list has an entry for the divider keys. A file dropped on a divider is added next to the nearest block, as anywhere else in the gap.
- **The API, MCP, bulk transfer and the in-app AI accept hex colors.** Text and background colors can be written as `#rgb` or `#rrggbb`, for example `<span style="color:#ff6600">`, besides the ten color names; they are stored and read back as lowercase `#rrggbb` (#378).
- **Custom colors in the editor.** The text and background color menus in the formatting toolbar and the drag-handle menu have a Custom… item: pick a color in the square or type a hex code, then Apply, and the last eight custom colors you applied are kept on this device (#378).
- **Table cells can be colored in the editor.** Hovering over a cell shows a small menu button whose menu has Colors, and the row and column handle menus have Colors too, which apply to the whole row or column; both offer the ten colors and Custom… (#378).
### Changed
- **Dragging a block into an empty column replaces the column's empty paragraph.** Before, the empty paragraph stayed next to the block you dropped.
- **MCP's instructions now say when to use `open_notes_as_files`.** Where the tool is offered — to a token or app credential, on a deployment with the collaboration component — a credential with `notes:write` is told to use it, if it has a shell, to change much of a note or many notes, and a read-only one to read many or whole notes. To make room, the instructions no longer give how many notes `list_notes` and `search_notes` return per call; each tool's `limit` still says. The tool's descriptions now open by saying it reads and writes (or downloads) one or many notes as files, and the read-write one also mentions the command that fetches one note. Its `next` now says that to work on one note you can fetch it without the download, and, with `notes:write`, that a file answered with `413 content_too_large` should be changed with `edit_note` instead — a note fetched on its own carries no `tooLargeToSend` mark (#417).
- **A refused bulk download now says how long to wait.** The two `429`s from `GET /api/transfer/export` keep their `too_many_requests` code. When your hourly downloads are used up, the message gives the wait in minutes, says the limit is per person so a new token doesn't reset it, and says that fetching single notes with the note command doesn't count toward it; when another export is running, it gives the wait in seconds. `open_notes_as_files`'s `next` no longer tells the assistant to read `Retry-After`, which its `curl` commands don't print: it now says what to do for each `429` from its message — fetch the notes it needs with `note.curl` (with where to find their ids) or wait the minutes given, wait the seconds given when another export is running, otherwise wait a minute — and to fetch later changes with `note.curl` instead of downloading again.
- **Removing a member asks for confirmation.** The trash button in a group's member table and in a note's share panel used to remove the person at once; now it first shows a confirmation with their email. In a group, removing yourself says you will lose access and can't add yourself back, and takes you out of the group's panel the way **Leave group** does. The last Admin's button stays disabled (#395).
- **Signing out signs out only that browser, and its cookie stops working on the server.** Before, signing out only cleared the cookie in the browser, so a copy of it kept working for up to 7 days, and the only way to end a session was to change the password, which signed out every browser. Now each sign-in is a session the server keeps, and signing out (or signing in to the same account again in the same browser) deletes it: the account's other browsers stay signed in, and a note the signed-out browser still had open stops accepting its edits within seconds. Changing or adding a password still signs out every other browser, resetting it with a link signs out every other browser too, and disabling an account ends all of its sessions. Expired sessions are deleted as people sign in.
- The `unsupported_color` message now suggests a built-in color name or a hex color, and the built-in AI's message for it changed the same way (#378).
### Removed
- The import of `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` on first start, a stopgap left over from the sign-in providers of v0.6 (#187).
### Fixed
- **Dropping into the gap between two columns works.** A file dragged from the desktop into the gap could fail to be added, a block dragged there by its ⋮⋮ didn't drop, and a file dropped on a column's ⋮⋮ opened in the browser and left the note. Now a file lands next to the nearest block, and a block goes into one of the columns.
- **Releasing a ⋮⋮ drag over the formatting toolbar now drops the block.** The toolbar briefly appears above the dragged block when the drag starts and took the drop, so a quick drag released over it did nothing.
- When a public link is revoked while it is being presented and the page changes to "This link doesn't exist or is no longer active", the address no longer keeps `?present` and the slide part (`#/…`); they are removed without adding a history entry (#234).
- **The move-into-group confirmation now says the note's version history is cleared, and the copy confirmations say it isn't copied.** Both the single-note and the multi-select dialogs, in English and Chinese (#246).
- The remaining failure logs of the SSO callback (code exchange, userinfo, account lookup retry, unexpected errors) and of a failed first-start administrator setup no longer write the whole error, which for a database error could include the user's email, subject or password hash; they log a short summary instead (#211). One of them, the account lookup retry that still fails, now logs at error level instead of warn. The self-hosting guide now says how to leave the quoted values (`DETAIL`) out of PostgreSQL's own error log.
- **Pasting several paragraphs into the middle of a paragraph no longer converts the ordinary links to notes that were already in it.** Only the links you paste become links to notes; an ordinary link to a note's address after the cursor, which ends up in the second half of the split paragraph, now stays an ordinary link. The same goes for pasting copied table cells into a table: ordinary links to notes in cells the paste didn't fill stay ordinary links (#353).
## [0.15.0] - 2026-11-10
### Upgrade notes
- **Migration 0036 (MCP bulk transfer) only adds tables and columns and changes checks**: `transfer_tokens` gets `access` and `range` and its `note_id` may now be empty, two tables are new (`transfer_bases` and `transfer_creates`), and the AI edit history allows the `op` `file_sync`. Existing rows are left as they are. Back up the database first as usual. See [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back).
- **Migration 0037 (MCP bulk transfer) only adds one column**: `transfer_bases` gets `applied_edit_ids`, the AI edit history rows a downloaded copy wrote when it was sent back, so that a file whose rows were reverted is refused with `409 base_unavailable` (`reason: "reverted"`). Existing rows are left empty, which counts as nothing reverted. See [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back).
- **Restart your MCP clients** to see `create_bulk_transfer_token`: a token or app credential gets it on a deployment with the collaboration component. No credential's scope changes.
- ⚠ **Rolling back to v0.14:** **Don't revert the AI edit history rows of files sent back over MCP while v0.14 runs.** In v0.14, a `file_sync` row for a part a file rewrote or added can show a **Revert** button, and reverting it reports success but puts nothing back; the row is then marked as reverted, so it can't be reverted after upgrading again either. To undo such a write, upgrade again first, then revert it. See [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back) and [Known limitations](docs/known-limitations.md#sending-notes-back-as-files-mcp-bulk-transfer).
- **After `APP_SECRET` changes, notes downloaded as files have to be downloaded again**: sending back a copy downloaded before the change answers `409 base_unavailable`.
- **Breaking for MCP clients: `edit_note`'s `rename` now needs `if_match`**, the fingerprint of the title it replaces, which `read_note_outline` now returns as `titleFingerprint` (a successful rename returns the new title's). A rename without `if_match` — until now the only form accepted — is refused with `invalid_body`; one whose `if_match` isn't the current title's fingerprint, because someone changed the title after the client read it, is refused with `fingerprint_mismatch` and leaves that title as it is. Before, an assistant's rename silently overwrote a title someone else had just changed. The content operations' `if_match` is unaffected: a title change doesn't change any content fingerprint. Renaming a note in the web app works as before. See [MCP](docs/mcp.md#rename) (#381).
- **Breaking for MCP clients and the API: Markdown with a `<script>`, `<style>`, `<iframe>`, `<object>`, `<noscript>`, `<title>` or `<template>` element is now refused.** `edit_note` and `create_note`, `POST /api/notes` with `content`, and `POST /api/notes/:id/edits` answer `unsupported_block` and write nothing, wherever in the Markdown such an element is; before, the element was silently dropped along with everything inside it, and the rest was written. The same text inside a code block or an HTML comment, or escaped (`<script>`), isn't an element and is still accepted. Files sent back over MCP bulk transfer are refused the same way. If a client of yours writes HTML like this, have it leave those elements out. See [AI editing](docs/ai-editing.md#markdown-round-trip).
- **Breaking for MCP clients and the API: Markdown with a paragraph inside another paragraph, or nested more than 128 HTML elements deep, is now refused** with `unsupported_block`, by the same endpoints and tools and by files sent back over MCP bulk transfer, and nothing is written. A paragraph can only end up inside another through HTML, such as a `<p>` inside a `<button>`, `<marquee>`, `<svg>` or `<math>` that is itself in a `<p>`; before, its text could be written twice. The depth counts each HTML element, the paragraph inside included, so a quote is one level and a list level two: quotes nested 127 levels deep and lists nested 63 levels deep still fit, 128 and 64 don't. Both kinds of content could keep the server busy for many seconds. Markdown nested several thousand levels deep, which could run the server out of memory, now runs into the time limit for reading Markdown instead (see the next note). See [AI editing](docs/ai-editing.md#markdown-round-trip).
- **Breaking for MCP clients and the API: Markdown that takes the server longer than 10 seconds or more than 1024 MiB of memory to read is now refused** with `unsupported_block` and a message saying the content is too complex, and nothing is written — by `edit_note`, `create_note`, `POST /api/notes` with `content`, `POST /api/notes/:id/edits` and files sent back or created over MCP bulk transfer (the file answers `400`). Ordinary notes are far from it: 256 KB of formatted Markdown takes about 1.4 seconds. What reaches it is dense formatting inside quotes or `<details>` nested a few levels deep (256 KB of bold words inside quotes nested 5 levels needs about 1.5 GiB) and Markdown nested several thousand levels deep; such writes used to freeze the whole server while they were read, or crash it. These writes can also now answer **`server_busy`** (HTTP `503` with `Retry-After`) when they have waited for a parse worker for 45 seconds (by default 4 × `PARSE_TIMEOUT_MS` + 5 seconds, so that waiting behind one job running to its time limit is enough); nothing is written, try again — `POST /api/notes` with `content` and `create_note`, which never answered `server_busy` for a busy queue before, can now. All four numbers can be set: see the next note and [Self-hosting](docs/self-hosting.md#parse-workers) (#398).
- **Breaking for MCP clients and the API: reading a note can now fail, and a write can be refused because someone is typing.** Turning a note into Markdown happens in a parse worker with a time limit of three times `PARSE_TIMEOUT_MS` (30 seconds by default) and the worker's memory limit: a note that needs more — measured, a 10 MB note took about 10 seconds — answers `500 too_complex` from `GET /api/notes/:id/content` and the single-note fetch of bulk transfer, the tool error `too_complex` from `read_note_section` and `read_note_outline`, and is left out of a bulk download (listed as `skipped`, `failed`); putting a write or revert into such a note answers `500 too_complex` (MCP: `too_complex`). Reads also answer **`503 server_busy`** with `Retry-After` when they wait longer than `PARSE_QUEUE_WAIT_MS` for a worker, and a bulk download lists notes it couldn't get a worker for as `skipped`, `busy`. A write (and a revert) is worked out against the note as it is; changes others make meanwhile to other parts of the note are merged with it, while a change to the section its `if_match` names makes it start over (and answer `409` if the section now differs). If that section keeps being touched but ends up the same, after three tries it answers `503 server_busy` with `Retry-After` and writes nothing; a file sent back needs the whole note unchanged, and after three tries answers `503 server_busy` without `Retry-After`. See [Known limitations](docs/known-limitations.md) (#398).
- **Breaking for MCP clients and the API: a note can grow to at most 5 MiB of stored state.** A change that would make a note larger than `NOTE_MAX_BYTES` (5 242 880 bytes by default) and larger than it was, and that adds content (judged on the result: the text, attributes, formatting and other values still in the note, as stored) or grows its stored state by more than 4 KiB at once, is refused with `413 note_too_large` (MCP: the tool error `note_too_large`), carrying the limit as `limitBytes`, and nothing is written — from `edit_note`, `create_note` with `content` (no note is created), `copy_note` and copying in the web app, `POST /api/notes` with `content`, `POST /api/notes/:id/edits`, reverting an edit, applying a note version, and files sent back or created over MCP bulk transfer. In the web app the limit is a soft check made by the editor: before pasting, dropping content in or applying an AI action's result, it estimates how much the note would grow and, if that would take it over the limit, adds nothing and says so. The estimate is close but not exact (measured at about 0.94–1.15 times the real growth), so a note can end up modestly over the limit; typing isn't blocked, and a note over the limit shows a notice that editing may be slow and that deleting content shrinks it. The server doesn't check the browser's live edits, so a custom collaboration client can bypass the limit; see [Known limitations](docs/known-limitations.md). Notes already larger still open, read, download and export; deleting, and replacing content with the same number of bytes or fewer, are always accepted, while formatting text or adding to it is not; nothing is cut or moved automatically. See [Self-hosting](docs/self-hosting.md#note-size) and [Known limitations](docs/known-limitations.md).
- **The server process now runs parse workers, which use memory of their own**: about 120 MB each when idle, and up to about the memory limit (`PARSE_WORKER_MEMORY_MB`, default 1024 MiB) while reading heavy Markdown. Unless `PARSE_WORKERS` is set, the server runs one less than the number of CPUs (at most 4), but no more than half its memory divided by `PARSE_WORKER_MEMORY_MB`, and at least one — so a machine with 2 CPUs, or with 2 GiB, runs one. In a container, the CPUs and memory counted are its limits (cgroup), not the host's; `docker-compose.yml` now has a commented-out `deploy.resources.limits` example. On a 2 GiB machine, set `PARSE_WORKER_MEMORY_MB` to 512. [Self-hosting](docs/self-hosting.md#parse-workers) has a formula and worked values for 2, 4, 8 and 16 GiB.
- **Reading a note gives slightly different Markdown** (#386): `read_note_section`, `GET /api/notes/:id/content` and notes downloaded with MCP bulk transfer now put a backslash before `*`, backticks, brackets, `_` and `~` in the text and before a block marker at the start of a paragraph (`\# x`), give some line breaks as `<br>`, and put spaces at the edges of bold, italic and struck-through text outside the markers (` **b**`). Writing that Markdown back gives the same text, but **scripts that compare the text they read with fixed strings need updating.** See [AI editing](docs/ai-editing.md#markdown-round-trip).
- **A file sent back over MCP bulk transfer can be at most 262 144 bytes (256 KiB)**, the same as the request body limit of `edit_note` and `POST /api/notes/:id/edits`; until now (in this unreleased feature) it was 1 048 576 bytes. A larger file answers `413 content_too_large`, and a note whose downloaded file is larger is marked `tooLargeToSend: true` in the download's manifest: split it into smaller notes or change it with `edit_note`. A large file dense with formatting takes many seconds to read, which is why the limit is lower. See [MCP](docs/mcp.md#limits).
### Added
- **Four settings for reading written Markdown**: `PARSE_WORKERS` (how many parse workers; default the smaller of one less than the CPUs (at most 4) and half the memory divided by `PARSE_WORKER_MEMORY_MB`, at least 1), `PARSE_WORKER_MEMORY_MB` (each worker's memory limit, default 1024), `PARSE_TIMEOUT_MS` (how long one write's Markdown may take to read, default 10000) and `PARSE_QUEUE_WAIT_MS` (how long a read or write may wait for a free worker; unset, 4 × `PARSE_TIMEOUT_MS` + 5000, which is 45000 by default). Out-of-range values stop the server from starting, and the values in effect are logged at startup, with whether the number of workers came from the setting, the CPUs or the memory, and the CPUs and memory found. In a container, the CPUs and memory counted are the container's limits (from its cgroup), not the host's. See [Self-hosting](docs/self-hosting.md#parse-workers) for how to choose them (#398).
- **`NOTE_MAX_BYTES`**: the largest a note may grow to, in bytes of its stored state (default 5 242 880, 5 MiB; 1 to 1 073 741 824; out-of-range values stop the server from starting). When it starts, the server logs how many notes are already over it and their ids (not their titles). The web editor reads it from `GET /api/auth/config` (`noteMaxBytes`) for its own check. See [Self-hosting](docs/self-hosting.md#note-size).
- **Keyboard shortcuts**: press Ctrl+/ (⌘/ on a Mac), or choose **Keyboard shortcuts** in the user menu at the bottom of the sidebar, to see the common keyboard shortcuts and typing triggers. It looks like the settings: the groups (General, Typing triggers, Markdown shortcuts and so on) are listed on the left and the shortcuts of the chosen group are on the right, one group at a time; on a narrow screen the two take turns. It lists only what your site can use: the AI group isn't shown when no AI action is set up, and the line about sending an instruction isn't shown when there is no Instruct action. Press the shortcut again or Esc to close it. It doesn't open while another dialog or menu is open, during a presentation, or on pages without the sidebar (sign-in, public notes). See [Known limitations](docs/known-limitations.md#keyboard-shortcuts).
- **Ctrl+, (⌘, on a Mac) opens your settings**, the same as **Settings** in the user menu, which now shows the shortcut next to it. It works on pages that have the sidebar (not sign-in or public notes) and does nothing while another dialog or menu is open, including the settings themselves.
- **Read and write many notes as files over MCP** (`create_bulk_transfer_token`). An assistant gets a short-lived token — at most 30 minutes — and ready-made `curl` commands: one downloads every note you can see, or only your own notes, notes shared with you, one group's or one folder's, as a `.tar.gz` of markdown files in the same folders as the sidebar; one fetches the latest copy of one note; with `notes:write`, one sends a changed file back; and one revokes the token. A file sent back changes the note only in the blocks where it differs from the copy downloaded. If the note has changed since the download — its text or its title — a changed file is refused with `409 base_unavailable` (`reason: "note_changed"`) and nothing is written: download that note again and redo the change. A file with no `id` creates a note, and the same file sent again within 30 minutes doesn't create a second one. Each changed place is a `file_sync` row in the note's AI edit history and can be reverted. Downloads are limited to 10 per person per hour and one at a time on the site, and files sent back to 30 per person per minute (a file whose text or title differs from its downloaded copy, or that creates a note, counts before its text is parsed, so it counts even when its text is then refused; a file refused before that, for example one that isn't UTF-8 or whose front matter is malformed, doesn't count). The commands call `GET /api/transfer/export`, `GET /api/transfer/notes/:id`, `PUT /api/transfer/notes` and `DELETE /api/transfer/token`. See [MCP](docs/mcp.md#open_notes_as_files), [API](docs/api.md) and [Known limitations](docs/known-limitations.md#sending-notes-back-as-files-mcp-bulk-transfer).
- **The sidebar's search also finds notes by their text.** Notes whose title matches and notes whose body text matches are in the same list: a note that matches only by title takes two lines (title and location), and each section whose text matches takes an entry of three lines (the note's title, its location, and the excerpt), up to three sections per note, 20 notes at a time with **Load more**; the section's heading isn't shown. Clicking an entry opens the note, scrolls to the first block of that section that contains the text, in the middle of the note's scrolling area, and marks it: it flashes twice and then stays highlighted until you press Esc, clear the search box, switch sidebar tab (when the box is already empty), click another result or leave the note (closing the drawer on a narrow screen keeps it) (in the note already open, it scrolls there without reloading the page; an entry from before the note's first heading opens the note at the first matching block, or the top if none); on a narrow screen it also closes the navigation drawer. Body text is searched from the second character typed, and these searches count against the same limit of 60 a minute per person as MCP's `search_notes`. See [Known limitations](docs/known-limitations.md).
- **`GET /api/search`**: search the titles and body text of the notes you can see, with excerpts, `match=body` for body matches only, and pages of up to 50 (`cursor`/`nextCursor`). Takes a session or a token with `notes:read`. See [API](docs/api.md).
- Test-only: end-to-end tests for files sent back to a note someone has open, a file refused because the note changed, reverting `file_sync` rows and downloading what is being typed (`e2e/tests/45-bulk-transfer.spec.ts`), and three benchmarks for downloading notes and sending files back (`apps/server/test/bench/transfer-*.bench.ts`), which don't run in CI.
### Changed
- **Writing and reading notes over MCP, the API and bulk transfer no longer hold up the server.** The Markdown that `edit_note`, `create_note`, `POST /api/notes` with `content`, `POST /api/notes/:id/edits` and files sent back or created over MCP bulk transfer carry is now read in parse workers, threads of their own with their own memory limit; putting the result into the note, reverting such a write, and turning a note back into Markdown (`read_note_section`, `read_note_outline`, `GET /api/notes/:id/content`, the current content sent with a `409`, and notes fetched or downloaded over bulk transfer) happen there too. Other people's requests and live editing keep running meanwhile: measured on Linux, reading back a note of 1 MB, or a paragraph of 49 000 `<br>`-separated words, used to stop everything for 1.2–5.3 seconds and now for at most about 0.15 seconds, and the heaviest 245 KB writes for 0.6–2.1 seconds and now 0.2–0.45 seconds. The answers themselves take about as long as before. Small notes (under 16 KiB stored) are still handled in the server's own thread, where that takes 10–25 milliseconds. One person can use at most two workers at a time, and a second only while nobody else is waiting. Measured on 2 vCPUs, an ordinary note's write takes up to about 0.08 seconds longer than before; 256 KB dense with `<br>` about 0.57 seconds longer and 256 KB of alternating bold and italic about 0.32 seconds longer (#398).
- **AI actions in the editor apply the same checks as writing over MCP or the API** before reading their result: a result with a paragraph inside another paragraph, or nested more than 128 HTML elements deep (quotes 128 levels deep, lists 64), isn't applied, the same as other content the editor can't store, and a quote, heading or collapsible heading with many paragraphs in it is read in one step instead of slowing down with each paragraph.
- **The note header's connection status is shorter, and the avatars of other people on the note are smaller.** If you can edit the note, it now says just **Connected** (it used to add your role, such as “· Owner” or “· Editor”). If you can only read it, it says just **Read-only** instead of “Connected · Viewer · Read-only”, with the same green dot while the connection is healthy. A read-only viewer sees **Read-only** as soon as the connection is up. The warnings are unchanged: Connecting…, Reconnecting…, Access revoked, Note deleted and Reload required, and the three offline warnings — “Not loaded — offline”, “Editing offline — changes not synced yet” and “Offline” — which turn red after 3 seconds. Your own role is no longer shown in the header; for a note shared with you it is still marked Editor or Viewer in the sidebar’s “Shared with me” list, and for a group note you can see it in the group’s members table. The avatars in the header are 24px instead of 28px, and so is the “+N” button.
- **The sidebar's search count** now says “N title matches” (in Chinese, “標題符合 · N 篇”) until the body-text results have arrived, instead of “N results”; once the body-text results have arrived it says “N results” (“N 筆結果”), counting the entries in the list, and “N results loaded, more available” when there is a next page.
- **What you typed in the sidebar search box is kept** when you go between the home page and a note (for example after clicking a result), so clearing the box afterwards still removes the mark in the note. It is cleared when you sign out or another person signs in.
- **The `429` that `POST /api/notes/:id/uploads` and `GET /api/uploads/:id` answer once invalid Bearer attempts from an address are used up now carries `Retry-After`.** See [API tokens](docs/api-tokens.md#errors-and-rate-limits).
- **Error messages from the HTTP API are now in English** ([#372](https://github.com/sw-willie-wu/knotebook/issues/372)). The `message` in every `{ "error": { "code", "message" } }` response, the `message` of the AI stream's `error` event, and the `error_description` of the OAuth endpoints were mostly in Chinese (a few were already in English); they are now all in English, written as full sentences that say what went wrong and, where it helps, what to do. The `code` values are unchanged, and the web app, which shows its own text for each `code`, looks the same. A request over an endpoint's size limit (`413 content_too_large`) now gets a message in the same style instead of the server framework's own. **If you have scripts that match the Chinese `message` text, change them to check `code` instead.**
### Fixed
- **Writing or reading a note with a very long paragraph no longer takes time growing with the square of its length** (#398). A paragraph with tens of thousands of line breaks, formatted words or colored stretches — written over MCP, the API or an AI action, or typed — held the whole server up for tens of seconds while it was written or read back: reading one with 24 000 line breaks took 82 seconds, writing one with 49 000 about 42, and replacing it with another such paragraph about 12 more. These now take time in proportion to the content, and run in parse workers (see the change above).
- **A line break inside a paragraph written as Markdown no longer adds a space at the start of the next line** (#386). Markdown sent to `POST /api/notes`, `POST /api/notes/:id/edits`, MCP `create_note` and `edit_note`, files sent back or created over MCP bulk transfer, and the result of an AI action in the editor stored a paragraph of two lines `first` and `second` as "first", a line break, then " second"; the next line now starts with exactly what follows the break, whichever of the three ways the break is written (a plain newline, a backslash or two spaces at the end of the line). Notes already saved with that space keep it — it isn't removed — and now read back as `first<br> second`, so that writing what you read back no longer drops it. Line breaks in list items, table cells and headings also read as `<br>` now: the backslash form read before couldn't be written back there (a list item split into the item and a new paragraph, and a table broke apart). See [AI editing](docs/ai-editing.md#markdown-round-trip).
- **Writing back what you read no longer turns text into formatting it didn't have** (#386). Reading over MCP or REST, or downloading a note as a file, gave bold, italic or struck-through text that starts with a space as `** b**` (which Markdown doesn't read as bold), left `*`, backticks, brackets, `_` and `~` in the text as they were, and gave a paragraph starting with `# `, `> `, `- `, `+ `, `1. ` or `---` (or a line break followed by such a line) as it was. Writing that Markdown back, or sending the file back, turned them into new bold, code, links, headings, quotes or list items — each round trip changing the text further (in a quote with bold text next to inline code, `**` grew into `***` and the next line became a list item). Now edge spaces of bold, italic and struck-through text, colored or not, read outside the markers (` **b**`); those characters, block markers at the start of a paragraph or quote, and a ` #` at the end of a heading read with a backslash in front (`\*`, `\# x`); and a line break followed by punctuation reads as `<br>`. The text and its formatting then stay the same; only such an edge space is no longer bold, italic, struck through or colored. Text in collapsible list items and headings is covered too, though a collapsible list item written back still becomes a bulleted one. `<` is still not escaped (#379). Notes already changed this way aren't repaired. See [AI editing](docs/ai-editing.md#markdown-round-trip).
- **Markdown with Windows line endings (`\r\n`) no longer leaves a space at the end of lines**: sent to `POST /api/notes`, `POST /api/notes/:id/edits` or MCP `create_note` and `edit_note`, `first\r\nsecond` was stored as "first " and a line break, and every paragraph ended in a space. `\r\n` and a lone `\r` are now read as `\n`, as they already were when pasting Markdown into the editor (files sent back over MCP bulk transfer already had `\r\n` turned into `\n`).
- **Writing back a link to a note over MCP or the API no longer freezes it to the title the note had when you read it.** A link with no text of its own reads as the linked note's title. If that note was renamed before you wrote that text back unchanged — with `edit_note`, `create_note`, `POST /api/notes/:id/edits` or `POST /api/notes` — the old title used to be saved as the link's own text, so the link stopped following the note's title. The server now remembers, for each account, the text reading showed for such links and the text of such links the account wrote, for up to 24 hours (at most the latest 10 000 for each account) and until it restarts, and that text stays no text of its own; text you changed is still kept as the link's own text. Files sent back with bulk transfer already worked this way and are unchanged. See [MCP](docs/mcp.md#links-to-notes) and [Known limitations](docs/known-limitations.md).
- **Presentation overview (O) now shows the content of the slides around the current one**, not just the current one: the others were hidden by the app's base styles, so the overview showed empty frames. The current slide in the overview now has its highlighted outline, and moving between slides in a presentation is an instant switch. See [Known limitations](docs/known-limitations.md) ([#390](https://github.com/sw-willie-wu/knotebook/issues/390)).
## [0.14.1] - 2026-11-02
### Changed
- **Notes with Mermaid diagrams open faster.** A diagram is drawn only when it comes near the visible part of the note, one at a time with a pause between them so typing and scrolling stay responsive, and shows **Rendering diagram…** until it is drawn. A diagram already drawn in this tab (same source, same theme) appears at once when you come back to the note or open the presentation. Switching the theme redraws diagrams far from view only when you scroll to them.
### Fixed
- **Moving many notes into a folder at once no longer stops after 30.** Creating, renaming, moving and deleting folders and filing notes in them — in the web app and with MCP's folder tools — now count against their own limit of 600 a minute per person instead of the 30-writes-a-minute limit that edits, copies and versions use, so doing so no longer uses up the budget for AI edits. Moving a selection in the sidebar now stops with "Too many requests" after about 600 in a minute (sooner if you've made other changes to folders in that minute) rather than after 30; its **Undo** counts against the same limit, so it can still stop partway when the move and the **Undo** together pass it. See [Known limitations](docs/known-limitations.md#folders).
## [0.14.0] - 2026-11-01
### Upgrade notes
- **Notes with side-by-side columns read differently** over MCP and the REST API: a row now reads as `<div data-kb-columns …>` and `<div data-kb-column …>` lines with Markdown inside each column. Before, a row read as its columns' blocks one after another, without the column tags (or, when a block in the row had a color, as one block of HTML). Writing back that flattened text read from an earlier version still turns the row into ordinary blocks. See [AI editing](docs/ai-editing.md#columns).
- **Notes with a resized image or with table column widths read differently** over MCP and the REST API, and the in-app AI gets them in the same form: an image with a set width now reads as `<figure><img … width="W">…</figure>`, and a table with column widths as an HTML table with `colwidth` on its first row's cells, instead of `` and a Markdown table. Two forms that were already HTML are stricter: a colored table with column widths used to have `colwidth` on every row, and a colored image with a set width carried more attributes than the `<figure>` form allows; writing back such text read from an earlier version is refused with `unsupported_size`. See [Sizes](docs/ai-editing.md#sizes).
- **Writes are stricter about sizes and layout.** A `width` on an `<img>` or a `colwidth` not written in the form reading gives, and a `<div>` whose `style` lays it out with `display:flex` or `display:grid` (other than a row or column of side-by-side columns), now reject the whole write with `unsupported_size` or `unsupported_columns`; such a `<div>` used to be accepted and its contents flattened into ordinary blocks. HTML with an event-handler attribute, such as `onerror` or `onclick`, now rejects the whole write with `unsupported_block`; the attribute used to be dropped. This applies to MCP, the REST API and the in-app AI's results. See [AI editing](docs/ai-editing.md#columns).
- **`DELETE /api/folders/:id` is stricter about its body.** It used to ignore any body it could parse; now any body other than none, `null`, `{}`, `{"onlyIfEmpty": false}` or `{"onlyIfEmpty": true}` — another field, an `onlyIfEmpty` that isn't `true` or `false`, a JSON value other than an object or `null`, or a plain-text body — is refused with `400 invalid_body`, and the folder isn't deleted. A script that sends such a body should send none instead (or `{"onlyIfEmpty": true}` to delete the folder only if it is empty). See [API](docs/api.md).
### Added
- **Folders** in the sidebar's **My notes** and **Groups** tabs: create, rename and delete folders, nest them up to five levels, sort the notes in a folder by name, last modified or size, and create notes in a folder. Group folders are shared with the group. Deleting a folder asks first, moves its notes to the trash, and **Undo** brings them back into rebuilt folders. **Shared with me** has folders too (**New folder**), seen only by you: filing a note someone shared with you there changes nothing for anyone else, and its folders are removed rather than deleted (their notes go back to the top level). Existing notes are moved into folders in three ways: **Move to…** in a note's `⋮` menu (see **Changed** for how its lists now work); **Move to** in the sidebar's selection mode, where folders can be ticked, moved within their space and deleted (in **Shared with me**, removed) along with notes; and, with a mouse, dragging a note in **My notes**, **Groups** or **Shared with me**. Moving within the same space doesn't ask, and its message offers **Undo**. A dragged note goes into a folder when dropped on the folder or on a note in it, or to the top level when dropped on a top-level note or an empty part of the list, always within its own space (in **Groups**, its own group), and holding it over a closed folder of that space for half a second opens that folder. Holding it for half a second over another note of the same space that you could drag too, unless that note is in a fifth-level folder or deeper, shows a new folder holding both notes in that note's place; dropping it there creates the folder — **New folder**, or **New folder 2** and so on if that name is taken there — with both notes in it and opens **Rename folder** for it, and once that closes, the message offers **Undo**. **Make a copy** in a note's `⋮` menu puts the copy in the same folder as the original. Items in the sidebar's **Trash** tab show the folder each note was in, after its group's name, and the location shown under notes in **Recent** and in search results includes the folder path.
- **Folders over MCP**: `list_folders` pages through the folders you can see, with each one's path, and every credential has it, read-only ones included; with `notes:write`, `create_folder`, `rename_folder` and `move_folder` create, rename and move folders within their space, and `move_note_to_folder` files a note in a folder of its own space — for a note someone shared with you, in your own folders, which only you see; with `notes:delete`, `delete_folder` deletes a folder and the folders in it, moving the notes in your own or a group's folders to the trash (in your folders for notes shared with you, the notes just go back to the top level), and `restore_note` usually brings each note back into a folder at the path it had, re-creating the missing folders on that path. `create_note`, `copy_note` and `move_note_to_group` take a `folder_id`; a group's folders need a role that can edit notes. Folders are given by id, never by path. Every note in a reply — from `list_notes`, `search_notes`, `create_note`, `copy_note`, `move_note_to_group`, `restore_note` and `move_note_to_folder` — carries `folder`, the folder it is filed in for you (for a note shared with you, where you filed it, never where its owner did), and `read_note_outline` carries that folder's whole path. See [MCP](docs/mcp.md).
- `DELETE /api/folders/:id` takes an optional body `{"onlyIfEmpty": true}`, which deletes the folder only if it is empty: a folder that has a folder in it or, in your notes or a group, notes outside the trash in it, or, in notes shared with you, notes filed in it, answers `409 folder_not_empty` and nothing changes. Without a body, or with `null`, `{}` or `{"onlyIfEmpty": false}`, the endpoint works as before; other bodies are now refused (see **Upgrade notes**). The web app uses it when undoing a folder made by dragging, and when taking one back after making it failed partway. MCP's `delete_folder` doesn't take it. See [API](docs/api.md).
- MCP, the REST API and the in-app AI read and write side-by-side columns as `<div data-kb-columns …>` and `<div data-kb-column …>` lines with Markdown inside each column (see [AI editing](docs/ai-editing.md#columns)); reading used to give the columns' blocks one after another without the column tags, and writing columns was refused. On a selection that reaches across columns, or into or out of a row, the in-app AI now works on the whole row, where it used to show no **AI** button. Dragging to make columns or change their widths is still not part of this. See [Side-by-side columns](docs/columns.md).
- Add several people to a group at once: on the group's **Members** page, type or paste many email addresses into the email box (separated by commas, semicolons, spaces or line breaks — full-width `,` `;` and `、` work too; `Name <address>` copied from an email's recipients works too), or press **Import from another group…** to pick people from another group you're in. Everyone gets the role picked next to the email box. A dialog counts them as it goes, with **Stop** to end it after the one being added, and then lists who was added, who was already a member, which addresses have no account and which aren't email addresses; the addresses that weren't added are put in the email box. There is no limit on how many at a time. Importing is a one-time copy — the groups don't stay in sync. See [Notes in a group](docs/sharing.md#notes-in-a-group).
- The email field for adding someone to a group (**Members**) or sharing a note (**Members only**) now suggests people as you type two or more characters of their email or name — only people you already know: members of your groups and people you've shared your own notes with, never everyone on the site. Pick one with the mouse or with ↑/↓ and Enter; typing a full email still works for anyone else. In the group's email box, which takes several addresses, the suggestions are for the address you're typing, and picking one replaces only that address and adds a comma after it unless a comma, semicolon or line break already follows. Backed by the new session-only `GET /api/users/suggest` (at most 8 results, 60 lookups per minute per user). See [Sharing](docs/sharing.md#the-three-access-levels).
### Changed
- **Move to…** and **Copy to…** in a note's `⋮` menu list places level by level, folders included; before, each was a single list without folders, beside the menu on a wide window with a mouse or under it on a narrow window or a touch screen. On a wide window with a mouse their lists open beside the menu and each folder with subfolders opens its own list beside that one; the first item of a list is **Move here** (**Copy here**) or, for a space, **Top level**, and there is no **Back**. On a narrow window or a touch screen the levels replace one another in place, and **Back**, with a left arrow, goes up a level. Resizing the window across the line while the menu is open closes the menu. The **Move to** and **Copy to** dialog of the sidebar's selection mode also lists places one level at a time. In both, **Top level** has a house icon and **Move here** / **Copy here** a corner arrow, so every item has an icon like the folders, spaces and **Make a copy**.
- **Move to…** in a note's `⋮` menu also appears, when there's somewhere to move the note, on group notes your role can edit, to move them within the group, and on notes shared with you, to file them in your folders in **Shared with me**. Selection mode's **Move to** is now in **Groups** and **Shared with me** too.
- Selection mode on a wide window with a mouse, in the sidebar's tabs and in **Trash**, is one row at the top: **Exit selection** (✕), a **Select all** check box that shows a partly-ticked state, the number ticked (or the progress while a batch runs), and icon buttons for **Move to**, **Copy to** and delete (**Restore** and **Delete permanently** in **Trash**); the buttons at the bottom of the sidebar (and **New note**, or the retention note in **Trash**) are not shown. On a narrow window or a touch screen the buttons stay at the bottom and **Move to** now looks like **Copy to**.
- A **Move to** or **Copy to** that can't be used in selection mode because the ticked notes are in different groups, or because folders are ticked, now shows the reason when you hover it, and a message when you tap it on a narrow window or a touch screen; before, the reason never showed.
- The check boxes in selection mode, in the lists and in **Trash**, are drawn by Knotebook instead of by the browser.
- The sidebar's **Workspace** tab is now called **Groups** (in Traditional Chinese, 「工作坊」 is now 「群組」), including in the trash's section headings and the location shown under notes.
- The sidebar's **Groups** tab lists groups by your role in them: groups where you hold the built-in **Admin** role first, then those where you hold the built-in **Member** role, then those where you have a custom role, each by name. Before, all groups were listed by name, so groups you created could end up below ones others added you to. `GET /api/groups` returns them in the same order, and so do **Settings → Groups**, the group lists in **Move to…** and **Copy to…**, and **Move to** and **Copy to** in the sidebar's selection mode.
- The group menu's items have icons.
- Dragging a link to a note on this site into a note — a note from the sidebar, or a link from another tab — now makes a link to that note, as pasting it does (#352).
- The main buttons and the email, password and other text fields on the sign-in, create-account, reset-password, link-account and app-authorization pages, and on the change-password page you're sent to when a password change is required, are taller (44 pixels instead of 32) and the button text is larger (16 px instead of 14 px), so they are easier to hit; this includes each **Sign in with …** button. Other pages, including the change-password form in Settings, keep the 32-pixel controls.
- The in-app AI now gets the selected blocks as the same Markdown that MCP and the API read, so colors, side-by-side columns, image widths and table column widths go to the model with them. For every AI action, built-in or custom, the server puts a fixed instruction at the start of the action's system prompt asking the model to keep that markup; admins can't change or remove it. The result is checked before it is shown, with the same column, size and color checks as a write over MCP: one that writes columns or sizes in any other form isn't applied, and the AI box shows "The AI edit failed; the note was not changed."; one with a color outside the editor's ten names isn't applied either. See [AI](docs/ai.md).
- MCP's `list_trash` pages through the whole trash with `cursor` and `nextCursor` instead of returning only the 100 notes most recently moved there, and `truncated` now means that more notes follow. Each entry carries `folderPath`, where `restore_note` usually puts the note back. `list_notes` and `list_trash` can return fewer notes than a full page when the notes are large, with `pageCutShort: true`; keep paging until `nextCursor` is `null`.
- `GET /api/trash` and MCP's `list_trash` order notes moved to the trash in the same millisecond by id, descending.
- MCP's `create_note` answers `server_busy`, having created nothing, when it runs into a lock or serialization conflict; it used to answer `internal`.
- The instructions an MCP client gets when it connects say that `list_folders` shows the folders notes sit in, and no longer give the outline's 100-section limit.
### Fixed
- The **Restore** icon in **Trash** is now a full counter-clockwise arrow; at small sizes it used to look like a plain circle.
- Rewriting part of a note over MCP (`edit_note`), the REST API (`POST /api/notes/:id/edits`) or with the in-app AI no longer resets the widths of the images and table columns in it: they now read as HTML that carries the widths, and writing that back unchanged keeps them. See [Sizes](docs/ai-editing.md#sizes) (#305).
- Rewriting a section that holds side-by-side columns over MCP (`edit_note`) or the REST API (`POST /api/notes/:id/edits`), sending back what was read, no longer turns the row into ordinary blocks: rows now read with their column tags, and writing that back unchanged keeps the row. See [AI editing](docs/ai-editing.md#columns).
- Writing over MCP, the REST API or with the in-app AI now saves an image, or another media block, whose address is a full address of one of this site's uploads (`https://<your site>/api/uploads/<id>`) with the relative address `/api/uploads/<id>`. An image inside a toggle heading or a toggle list item, written as HTML, used to be saved with the full address, and copying the note then didn't give the copy its own copy of that image. An image already saved that way keeps its full address until it is written again.
- The in-app AI no longer turns a diagram in the blocks it rewrites into a code block: a ```` ```mermaid ```` fence in its result becomes a [diagram](docs/diagrams.md) again, as it does over MCP.
- Markdown the editor fails to parse, such as `<div data-content-type="paragraph">`, is now refused with `400 unsupported_block` over the REST API (the tool error `unsupported_block` over MCP) instead of a server error; in the in-app AI, such a result shows an error instead of leaving the AI box in its streaming state.
- On narrow windows, a message that pops up while the sidebar drawer is open (for example **Moved to the trash** with its **Undo** button, after you delete another note from the drawer) can now be reached with the keyboard and by screen readers. Before, it could be seen and clicked, and screen readers announced it, but they couldn't move to it or its buttons and the keyboard couldn't reach them.
## [0.13.0] - 2026-10-26
### Upgrade notes
- Migration 0031 sets each existing sign-in service's **Creates accounts** to the value **Allow registration** had before the upgrade, so sign-in behaves as before after the upgrade. Services added afterwards start with it off (a service imported from `OIDC_*` takes over **Allow self-registration with a password** instead).
- **Rolling back:** the old version doesn't read the new setting, and there a first sign-in through a sign-in service creates accounts according to **Allow registration** (「允許註冊」; called **Allow self-registration with a password** in this version) for every enabled service at once, which can be more permissive than after the upgrade. If **Allow self-registration with a password** is on and **Creates accounts** is off for some open identity provider, turn **Allow registration** (「允許註冊」) off on the old version first after rolling back.
- **Upgrading again after a rollback:** check **Creates accounts** on each sign-in service — especially any whose issuer was changed during the rollback, since that change didn't go through the confirmation prompt — remember that services added during the rollback start with it off, and check whether **Allow self-registration with a password** needs to be turned back on.
- Migration 0032 changes the one-time link table for invitations. **Rolling back:** the old version has no invitations, so an unused invitation can't be used while it runs, and its **Site admin → Users** creates accounts with a temporary password again; accounts already made from an invitation keep working. After upgrading again, invitations that haven't expired work again.
- Migration 0033 adds the trash: a column recording who moved a note to the trash, a site setting for how many days notes stay there (7 by default), and their constraints and index; existing notes stay as they are. **Rolling back:** the old version doesn't know about the trash, so notes in it reappear as ordinary notes (with their shares and public links working again) but keep their trash marks. Before upgrading again, either run `UPDATE notes SET deleted_at = NULL, deleted_by = NULL WHERE deleted_at IS NOT NULL` to keep them, or empty the trash before rolling back — otherwise, after upgrading again, they go back to the trash and those past the retention period are permanently deleted, with their attachments and any edits made while rolled back, starting with the first clean-up pass about five minutes after the server starts (up to 2,000 notes per pass, one pass an hour). See [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- Migration 0034 adds folders: a `folders` table, a `note_placements` table for filing notes shared with you, two nullable columns on `notes` (`folder_id`, `trash_folder_path`), their indexes and constraints, and three triggers with the functions they run. Existing notes stay at the top level. **Rolling back:** the old version shows every note without folders; folders and filed notes stay in the database and come back after upgrading again. While rolled back, moving a note into a group puts it at the group's top level; deleting a group, with **Give them to an admin** or **Delete everything**, also deletes the group's folders, and with **Give them to an admin** its notes end up at the top level of that person's notes; and removing a share removes where that person had filed the note. See [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- Migration 0035 widens the scope checks on `api_tokens`, `oauth_requests` and `oauth_codes` so they also allow `notes:read notes:write notes:delete` and `notes:read notes:write notes:move notes:delete`. It changes no stored value, so no existing token or app gets `notes:delete`. **Rolling back:** the wider checks stay and the old version doesn't mind them; it treats a credential with `notes:delete` as if it didn't have it (read-write, or read-write-move), and changing such a credential's access there drops `notes:delete`. An app authorization that asked for `notes:delete` and is still pending when you roll back is granted only read access if it is approved on the old version; authorize the app again.
- **Rolling back** to an earlier version deletes side-by-side columns from notes, together with what is in them, the first time such a note is opened. Back up before rolling back. Tabs left open from before the upgrade ask to be reloaded.
### Added
- Notes can be shown at three content widths — Standard, Wide and Full width. A button in the note header (hidden when the note area is narrow) picks one; the choice is saved per note in this browser rather than in your account, so it doesn't carry over to other devices or change what collaborators see in their own browsers. A shared note's public page has its own width, set in the Share dialog by whoever manages the public link, and every reader sees that width. Includes a database migration (0028) that adds the public page width to notes (#297).
- An admin can reset someone's password with a one-time link. In **Site admin → Users**, press the key button in a person's row (**Actions** column), pick 1 to 24 hours, and send them the link. It works once; creating a new one replaces it, and an unused one can be cancelled. Finishing the reset signs the person in and signs out their other browser sessions, but not their API tokens or connected apps. Links can't be created or used while password sign-in is off, and disabling an account revokes its unused link. Includes a database migration (0030) that adds the `one_time_links` table (#286).
- An admin can invite someone with a one-time link. In **Site admin → Users**, press **Create invitation**, pick 1 to 7 days, optionally add a note only admins see, and send the person the link; it is shown once and cannot be read back. The person opens it, enters their own email and a password, and gets a regular-user account and is signed in. An invitation works once, can be revoked while unused, and isn't affected by **Allow self-registration with a password**. It can't be created or used while password sign-in is off; after it is turned back on, invitations that haven't expired work again. Includes a database migration (0032) that adds invitation fields and checks to the `one_time_links` table (#285).
- Side-by-side columns: type `/grid` (or `/columns`) and pick 2, 3 or 4 columns to put blocks next to each other. Any block can go in a column. In the second or a later column, Backspace at the start of the column's first block, when it is a paragraph, moves it to the end of the column before it; Delete at the end of a column's last block brings the first block of the next column up (except when that last block has blocks nested under it or is a code block). A column this leaves empty is removed (a row of two columns turns back into ordinary blocks); Backspace at the start of an empty column removes it. When the note is too narrow, the columns stack: a column needs at least 200 pixels (96 for a column that holds only images) plus 24 between columns. **Ungroup columns** in a block's ⋮⋮ menu turns them back into ordinary blocks. Public pages, presentations and version comparisons show them too. Rows can't be nested. Not part of this yet: dragging to make columns or change their widths; writing columns over MCP, the REST API or the in-app AI, which is refused for now; and the in-app AI on a selection that reaches across columns or into or out of a row, which shows no **AI** button for now. See [Side-by-side columns](docs/columns.md).
- The note header shows who else has the note open right now — owners, editors and viewers, plus any AI agent working on it — as a row of round avatars (initials, or a sparkle for an AI agent) next to **Connected**, each ringed in the color of that person's cursor. You aren't listed, and someone with the note open in several tabs appears once. Hover or focus one for their name and role; click it to scroll to their cursor (viewers have none). When more people are on the note than fit, a **+N** button opens the full list; on a touchscreen, press and hold anyone to open it (#293).
- Folders API: `/api/folders` to list, create, rename, move and delete folders in your notes, in a group's notes or in notes shared with you, and `PUT /api/notes/:id/folder` to file a note. Creating a note, copying it and moving it into a group can name a folder. Deleting a folder in your notes or a group's moves the notes in it to the trash; deleting one in notes shared with you only takes the notes out of it.
- A **Trash** tab, the fifth in the sidebar. It lists your personal notes and, for groups where your role can both edit and delete notes, the group's notes that were moved to the trash — personal notes first (under **My notes**), each part newest first, each with how many days are left before it's permanently deleted (or, with automatic deletion off, when it was deleted), and, for a group note, who deleted it. Restore or permanently delete a note from its row, select several (Shift-click selects a range) to restore or delete them together, or **Empty** to permanently delete everything listed. Opening the address of a note in the trash shows **Restore** and **Go to trash** instead of an error, for anyone who can restore it. Site admins set how long notes stay in the trash, or turn automatic deletion off, under **Site admin → Storage plans → Trash**.
- **Select** in the sidebar's **My notes**, **Workspace**, **Shared with me** and **Recent** tabs: tick several notes (Shift-click selects a range) and move them into a group (from **My notes**), copy them to My notes or a group, or move them to the trash (except from **Shared with me**) in one go, with a running count while it works. Moving and copying ask once for all of them; moving to the trash doesn't ask. Notes you aren't allowed to act on, or that no longer exist, are skipped, and the message at the end counts them; any other error stops it there instead, saying how many were done, and keeps the note it stopped at and those after it ticked. **Undo** after moving several to the trash brings them all back (if one of them is a group note your role can delete but not edit, there's no **Undo**, and the message says who can restore them).
- MCP: `list_trash` lists the notes in your trash (any credential), `delete_note` moves a note to the trash and `restore_note` brings one back. The last two need the new `notes:delete` scope, which only comes together with `notes:write`. No MCP tool deletes a note permanently or empties the trash (#181).
- The `notes:delete` scope — **Move notes to the trash and restore them** — can be turned on when creating a token, on a token's row in **Settings → Authorization**, and on the OAuth consent page. It is off unless you turn it on; existing tokens and apps don't get it until you turn it on for them or authorize the app again and tick it (#181).
### Changed
- The sidebar's **New note** button, which always creates a note of your own, now shows only on the **My notes** and **Recent** tabs (and while searching); it is gone from **Workspace** and **Shared with me**. To create a note in a group, use the **+** next to the group's name.
- Renaming your username now frees the old name: anyone, including you, can take it — going back to an earlier name counts as a rename against the limit of 5 per rolling 24 hours. Old links are still not forwarded to your new name; once someone else takes the name, old `/n/` and `/p/` links under it lead to their notes where the address matches (for `/n/`, only for people who can read the note). Includes a database migration that clears the names held by past renames (#291).
- Settings → Authorization → API tokens: an app's (OAuth) **Expires** date is now 30 days after the app was last used — or after you authorized it, if it hasn't been used yet — instead of when its current access token runs out, which is never more than a day away and made an app unused for a day show as *Expired*. The date moves forward on its own while the app is in use; an app unused for 30 days is removed (#298).
- Settings → Authorization → **Connect Claude with MCP** now shows two ways to connect: authorizing Claude Code or Claude Desktop (Windows) over OAuth, with no token to create — offered on `https://` sites only — and the personal-token snippets as before (#298).
- Adding a sign-in service no longer requires a client ID. Add the service first to get its callback URL, register that URL on the identity provider (GitLab, for example, gives you a client ID only after that), then enter the client ID and secret with **Edit**. A service without a client ID can't be turned on, and **Test connection** points it out. Includes a database migration (0029) that lets a sign-in service have no client ID yet (#299).
- **Allow registration** is split in two. It is renamed **Allow self-registration with a password** and only governs registering with an email and a password (it has no effect while password sign-in is off, but can still be switched then). Each sign-in service now has its own **Creates accounts** setting, a column in the service table: it decides whether someone with no account gets one on their first sign-in through that service. New services start with it off, changing a service's issuer asks whether to turn it off too, and the login page's **Create an account** button now appears only while registering with a password is available (password sign-in and **Allow self-registration with a password** both on); otherwise `/register` sends visitors to the login page (invitation links still open). The registration page no longer lists **Sign up with …**: someone with no account signs in with the service's **Sign in with …** button, which creates the account if the service has **Creates accounts** on. Includes a database migration (0031) that adds **Creates accounts** to sign-in services and carries over the current **Allow registration**; sign-in behaves as before after the upgrade.
- The sidebar shows one kind of note at a time, picked from a row of icon tabs under the search box: **Recent**, **My notes**, **Workspace** and **Shared with me** (hover a tab for its name). The tab you pick is remembered in this browser. **Recent** now lists the 20 notes most recently edited — by people or AI clients; a note never edited counts from when it was created or renamed — each with where it lives, instead of the two notes most recently created or renamed. While you search, the tabs make way for one flat list of matching notes, each with its location and the matched text highlighted; press Esc or the ✕ in the search box to return to your tab. The collapsible My notes, Shared with me and Workspace sections are gone; each group in the Workspace tab still collapses.
- Deleting a note now moves it to the trash instead of deleting it right away (`DELETE /api/notes/:id` too): **Delete note** in a note's `⋮` menu is now **Delete**, it no longer asks for confirmation, and **Undo** in the message that follows brings the note right back (if your role can delete group notes but not edit them, you can't bring it back yourself, and the message says who can instead). A note in the trash disappears from lists, search, links, its URL and its public link, and from every MCP tool except `list_trash` and `restore_note`; its attachments still count toward storage. It can be restored — with its shares, public link and history — or permanently deleted from the **Trash** tab (or `/api/trash`); notes are permanently deleted automatically after the site's retention period (7 days by default; **Site admin → Storage plans → Trash**, or `PATCH /api/admin/trash/settings`). Only the owner sees a personal note in the trash; for a group note, members whose role can both edit and delete notes — the description of a role's **Delete** permission now says so. Deleting a group with **Delete everything** still deletes its notes permanently, including those in its trash, and its dialog now counts them.
- Items in `GET /api/trash` carry the path of the folder the note is in (`folderPath`; MCP's `list_trash` doesn't show it), and restoring a note — MCP's `restore_note` included — puts it back in that folder, re-creating the path if the folder has been deleted since.
- Deleting a group with **Give them to an admin** keeps its folders: they move, with the group's top-level notes, into a new folder named after the group at the top level of that person's notes.
- The `/api/mcp` challenge and both OAuth metadata documents list `notes:delete`; `POST`/`PATCH /api/auth/tokens` and the OAuth decision accept the two new scope values (#181).
- The personal destination in a note's **Copy to…** menu is now called **My notes** (it was **Personal space**), matching the sidebar tab.
### Removed
- An admin can no longer create an account with a temporary password (**Site admin → Users → Create user**, `POST /api/admin/users`, and its optional `handle`; people can rename themselves afterwards). Use an invitation instead. Accounts already created that way and not yet changed still ask for a new password on their next sign-in (#285).
### Fixed
- Disabling an account now refuses with an error when it is the site's last enabled admin, the same protection removing admin rights already had; two admins disabling each other at the same moment can no longer leave the site with no enabled admin (#250).
- The docs no longer say a plain `http://` deployment can authorize MCP clients over OAuth with `mcp-remote --allow-http`: current clients refuse to send the token exchange to a server that isn't `https://`, so on plain http, connect with a personal token (#159).
## [0.12.0] - 2026-10-15
### Upgrade notes
The full checklist is under **Upgrading to v0.12** in [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- There is no database migration.
- In a clone of the repository, `docker compose up -d` now runs the prebuilt image `ghcr.io/sw-willie-wu/knotebook` (the tag in `KNOTEBOOK_VERSION`, `latest` if unset) instead of building one: after updating the clone, run `docker compose pull`, then `docker compose up -d`; `docker compose up -d --build` keeps building from source. A `docker-compose.override.yml` that doesn't set `image:` runs the prebuilt image too; `image: !reset null` in it keeps the setup of earlier versions, where the image is built in your clone (#284).
### Added
- Prebuilt Docker images on the GitHub Container Registry: each release is published as `ghcr.io/sw-willie-wu/knotebook` for linux/amd64 and linux/arm64, tagged with its version (`0.11.1`), while `0.11` points at the highest patch release of that line and `latest` at the highest release; v0.11.1 is the first. `docker-compose.yml` now runs that image — the tag set by the new `KNOTEBOOK_VERSION` in `.env`, `latest` if unset — so installing and upgrading pull it instead of building it (`docker compose pull`, then `docker compose up -d`); `docker compose up -d --build` still builds from source. Each GitHub Release from this one on also carries a `docker-compose.yml` that runs that release's image and an `env.example`, so installing and upgrading need only those two files, not a clone of the repository; with your own PostgreSQL, the image also runs on its own with `docker run`. See [The app image](docs/self-hosting.md#the-app-image) (#284).
### Changed
- Code blocks and diagrams: the code block's language menu and the diagram's **Edit** (or **Show source**) button now sit inside the block's top-right corner instead of above it, so the blocks no longer leave an empty strip above themselves. They still stay hidden until you point at the block or move to them with the keyboard; the language menu also appears while the cursor is in the code, which makes it reachable on a touch screen — tap into the code first. When they appear, they have a solid background and a border, so they stay readable over code and diagrams in both light and dark mode; on a note you can only read, the code block's language is still shown as plain text, without the border. While you edit a diagram's source, the hint for finishing now sits below the source box.
### Fixed
- Site admin → Users and a group's Members: the name column no longer takes most of the width, leaving the email column too narrow on wide screens. The sign-in services and AI providers tables likewise give their long columns (issuer, base URL) more room.
- Settings and admin tables: the divider after the name column now only shows when the table actually scrolls sideways.
## [0.11.1] - 2026-10-15
### Changed
- Links in the editor now behave like links elsewhere. Clicking a link to a note while editing opens it, the same as when reading (it used to open a small toolbar with an **Open** button); its toolbar — **Edit text**, **Change target**, **Remove link** — now opens when you hover over the link, and the **Open** button is gone. On a touch screen, a tap opens a link; while editing, pressing and holding a link for about half a second opens its toolbar instead of the system's link menu, for ordinary links as well as links to notes (read-only and public pages keep the system menu).
### Fixed
- The version history panel's resize handle now sits in the gap between the note and the panel, like the sidebar's, instead of inside the panel card (#274).
## [0.11.0] - 2026-10-15
### Upgrade notes
The full checklist is under **Upgrading to v0.11** in [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- The first start after upgrading adds the **Instruct** action, enabled, with no model of its own, so it uses the same model as any other action without one: the default model, or, when no default is available (none is marked, or it or its provider is disabled), the earliest-added enabled chat model. On any site where AI actions already work, every editor can send free-form instructions right away, whether or not a model is marked as the default; disable the action in **Site admin → AI agents** if you don't want that.
- On a new install, the built-in **Rewrite** and **Translate** actions show a preview first, like the other built-in actions, instead of applying directly. Upgrading doesn't change an existing site's actions: there, Rewrite and Translate keep the apply mode they have — direct, unless an admin changed it — and an admin can switch them to preview under **Apply mode** in **Site admin → AI agents**.
- If you roll back to 0.10, the Instruct action stays in the database and 0.10 lists it as an ordinary quick action that only fills in `{{text}}`: the literal `{{instruction}}` and `{{focus}}` would reach the model, without the guard. Disable the action before rolling back (#263).
- Migration 0024 adds the time each account was last active and fills it in from the last sign-in time, so after the upgrade **Last active** shows when someone last signed in until they next use the site. Accounts with no recorded sign-in show **Not recorded** until then. Rolling back to 0.10 is harmless for the database: 0.10 doesn't read or write the new column.
- Automatic versions are now saved only after a note has had no new changes for a while — 5 minutes by default, set in **Site admin → Storage plans → Keeping automatic versions** (1–60 minutes); the version appears within about half a minute after that. Leaving a note, and AI or API writes, no longer save a version straight away: an AI write and the edits around it go into the same version once the note goes quiet. Save a version by hand (Ctrl+S) if you want a cut at a particular point. Migration 0025 adds the pending-change columns, the wait setting and the id of each version's base (filled in for bases that still exist). Changes the previous version had not saved as a version when it stopped are saved after the next edit. Rolling back to 0.10 is harmless for the database: 0.10 doesn't read the new columns; in rare cases a note moved while on 0.10 can get one extra automatic version after upgrading again. After upgrading again, a note whose base changed while on 0.10 can show the wrong base under **Current state** (the one it had at the rollback, or **a deleted version**) until a new version is saved or a version is applied, and versions saved on 0.10 on top of an older version keep showing as based on **a deleted version**.
- **Reload every open tab after upgrading.** Tabs opened before the upgrade can't connect to the new server: they keep retrying in the background ("Connecting…", then an offline warning), and edits made in them that hadn't synced are lost when you reload. From now on, when an upgrade changes how the editor stores notes, a tab opened before it shows a notice asking you to reload instead (#269).
- Migration 0026 records every note that exists at that moment, and about 30 seconds after the first start the server turns ordinary links to notes on this site in those notes into links to the notes, in the background — with the owner's permissions, and in a group note only links to notes in the same group. Notes created after the upgrade are not processed. Before changing a note it saves a version named `連結轉換前` (or, if the content is already the note's base version, turns that version into a manual one, keeping its name if it has one — see [Versions](docs/versions.md)); to undo the conversion for one note, apply that version — its links come back as ordinary links and the conversion doesn't convert them again. Progress is in the `app` log; see [Self-hosting](docs/self-hosting.md#upgrading-and-rolling-back) (#269).
- Rolling back to 0.10 after migration 0026 is harmless for the database: 0.10 doesn't read the new table. But 0.10 doesn't know a link's own text or the paragraph it points to: on 0.10, moving or splitting a block that holds such a link (or moving one next to it), or copying and pasting the link, can drop them, and MCP and the API read the link as `[[…]]` with the title saved in the link, so writing that back binds it again by title alone, if at all; 0.10 also doesn't ask tabs opened on this version to reload, so they can still connect and edit (#269).
- After rolling back to 0.10 and upgrading again, 0026 doesn't run a second time and the conversion only picks up notes still waiting for it, so it doesn't convert ordinary links to notes added on 0.10 in any other note, including notes created on 0.10 (#269).
- Moving a paragraph that holds a link to a note made before the upgrade, or a block next to it, can change that section's MCP fingerprint once, even though its text didn't change; an AI client's next `if_match` on that section then gets a fingerprint mismatch and has to read the section again (#269).
- `[[Title]]` written over MCP or the API is now also bound inside tables (#269).
### Added
- Site setting for how long to wait after the last change before saving a version automatically (#272).
- AI on a selection: the **AI** button on the formatting toolbar now opens a box in place of the toolbar, where you can type your own instruction for the selected text or pick a quick action. When the response finishes, the result appears over the selected blocks — covering the text below them until the box closes — as an inline diff of only those blocks. Instructions and preview actions wait for you to apply, discard or retry, while direct actions apply as before and can be reverted (#263).
- A fifth built-in AI action, **Instruct**, carries those free-form instructions. It always shows a preview first, and the server wraps it in fixed tags and a fixed guard that admins can't change (#263).
- Links to notes: a link to a note on this site that you can see — pasted, typed as Markdown, typed as an address, inserted with the link dialog, or written by an AI client — is saved as a link to that note: it follows renames, shows up in backlinks, opens in the same tab, and can have its own text. Clicking one while editing opens a small toolbar to open it, edit its text, change its target or remove the link, leaving its text (#269).
- Links to a paragraph: an address ending in `#b-<block id>` opens the note scrolled to that block and highlights it briefly. **Copy link to block** in the drag-handle menu and a "#" button beside headings copy such a link (#269).
- MCP and the API read links to notes as `[text](/notes/<id>)` (ending in `#b-<block id>` when they point to a place), and save a Markdown link to a note you can see as a link to that note (#269).
- Public pages link to other notes that are public under a custom URL; `GET /api/public/notes/…` returns them in `links` (#269).
- The version history panel on wide screens can be resized by dragging its left edge (240–560 px; double-click the edge to reset to 320 px, or use the arrow keys when it has focus). The width is remembered in this browser (#274).
### Changed
- Versions are no longer shown as v1, v2…: each is identified by its name and the first 7 characters of its id; a version nobody named gets a generated two-word English name. A version based on one that was deleted since says so (#272).
- Non-revertable rows in the AI edits dialog say why, and the dialog notes how many AI edits each note keeps (#272).
- The admin AI actions list and `GET /api/ai/actions` include each action's `kind` (`"instruct"` or `"quick"`); `PATCH /api/admin/ai/actions/:id` refuses to set the Instruct action to `direct` (`instruct_preview_only`) (#263).
- **Breaking (API):** `POST /api/ai` takes `instruction` and `focus` for the Instruct action and now rejects them with 400 for any other action; they used to be silently dropped (#263).
- Version history: each window width now has one way in. The `⋮` menu at the top of a note no longer has **Save current version** (use Ctrl+S, or the button at the bottom of the version list), and shows **Version history** only on a narrow window, where there is no round button at the bottom right.
- Site admin → Users: the **Last sign-in** column is now **Last active** — when someone last used the site while signed in: a request from the browser, opening a note for live editing or typing in one, or signing in — shown as **Online** when that was less than 5 minutes ago, otherwise as the date and time, or **Not recorded**. Requests made with an API token or a connected app don't count. **Online** is not a live connection status; see [known limitations](docs/known-limitations.md) (#267).
- API: `GET /api/admin/users` also returns `lastActiveAt`, when each user last made a signed-in request from the browser, opened or edited a note over the live editing connection, or signed in (`null` if never recorded); `lastLoginAt` is still returned (#267).
- Lists in Settings, a group's panel and Site admin now share one table layout: the name column first, then the details, then the controls, with each row's buttons and its **Enabled** switch at the right end and lined up from row to row, including the models listed under each AI provider, whose buttons and switches line up with the provider's. On a narrow screen a table scrolls sideways inside its own frame while its name column stays in place (the models' names, and the text under a row, scroll with the table), and the panel itself no longer scrolls sideways; when the table doesn't fit, its frame can be reached with Tab to scroll it from the keyboard. The **Providers** list in **Site admin → AI agents** and the **Sign-in services** list in **Site admin → Sign-in** no longer switch to a stacked layout on narrow screens. When a button or switch partly hidden under the name column gets focus, the table scrolls so it is no longer covered; with the mouse, a table with four buttons a row (**Actions** in AI agents, **Sign-in services**) scrolled all the way right on a narrow screen can still have part of a row's first button under the name column — scroll back a little. For screen readers, each list is now a table whose rows are named after their item: AI provider and sign-in service names are no longer headings, so moving by heading no longer jumps between them, and the column headings of those two lists, which used to be hidden from screen readers, are now read as column headers.
- **Site admin → Users:** the columns now read User, Email, Storage, Last active, Role, Plan, Enabled (Role and Plan used to come right after Email). An error from changing someone's role or turning their account on or off now shows on a line of its own under their row instead of under the **Role** menu.
- **Site admin → AI agents:** an action's apply mode and model are now in **Apply mode** and **Model** columns instead of a line under its name, its move up/down buttons are arrow icons with the same names as before, and its switch no longer has an **Enabled** label beside it (the column heading says it). A model's ID is now under its name, and its switch comes after its buttons. The model and action switches are now announced as "Enabled" followed by the model or action name, like the other lists.
- **Site admin → Sign-in:** a sign-in service's row buttons are now in the order Test connection, Icon, Edit, Delete (Icon used to come after Edit), matching the order used in every other list: test and secondary actions first, then edit, then the destructive one. A long issuer URL is now cut to one line; point at it to see all of it.
- **Settings → Sign-in → Sign-in methods:** when each sign-in service was linked and last used are now in **Linked** and **Last used** columns instead of lines under its name. When one can't be unlinked because it is the only way to sign in, its **Unlink** button can now be reached with Tab and shows the reason when you point at it, besides the reason written under the name.
- **Settings → Authorization → API tokens:** a token's creation date now comes before an app's "Name is self-reported…" note, and long names wrap instead of staying on one line. While one token is being edited, the other tokens' pencil buttons can be reached with Tab and show "Finish editing the other token first." when you point at them, instead of being skipped. While editing, **Save** and **Cancel** are check-mark and cross icon buttons at the right end of the row, and the row's **Last used** and **Expires** are hidden until you're done.
- In a group's **Roles** page, **Save** and **Cancel** for renaming a role are check-mark and cross icon buttons. The **Actions** heading over the buttons in **Site admin → Storage plans** and a group's **Members** page is now read only by screen readers, like the other lists. The **By type** rows in **Settings → Storage** and a group's **Storage** page, and its **By member** rows, now have the same spacing and dividers as the tables.
- Searching finds a link to a note by its own text, or by the text it was made with; notes are indexed again in the background after the upgrade (#269).
- Version comparisons and the AI box's preview show links to notes by their text, and mark a link whose target, the place it points to, or its own text changed (#269).
- Public pages no longer request the signed-in note list (#269).
### Removed
- The AI panel and the round AI button at the bottom-right of the note page, together with running an action over the whole note from them. Select everything with Ctrl+A and use the toolbar instead (#263).
### Fixed
- Copying a selection that contains a link to a note (one made with `[[`) no longer leaves the clipboard empty (#269).
- Typing in the title right after creating or opening another note could rename the note you were leaving. While the next note is still loading, the old title box now ignores typing.
## [0.10.0] - 2026-10-11
### Upgrade notes
The full checklist is under **Upgrading to v0.10** in [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- **Back up the database first.** This release's database migrations (0020: drop note self-links; 0021: `notes:move` scope; 0022: last sign-in time; 0023: who takes up group space) run automatically when the new server starts. 0020 only deletes rows of `note_links` that record a note as linking to itself — rows a note id written with uppercase letters could create, and that were never correct — 0021 only lets the `scope` of tokens, app credentials and pending authorizations also be `notes:read notes:write notes:move`, without changing any stored value, 0022 only adds a column to `users`, and 0023 only adds a column to `uploads` and fills it in from who uploaded each file; but take a dump before you start the new version anyway, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`. Rolling back to 0.9 is harmless for the database; what 0.9 then does with `notes:move`, and how files added while it runs are counted in a group's storage, is in the checklist.
- Migration 0022 adds the time each account last signed in. Nothing is filled in for existing accounts: until someone signs in again, the admin API reports `lastLoginAt: null` for them, the same as for an account that has never signed in.
- Migration 0023 records who brought each attachment into its space — whoever uploaded it, or whoever moved or copied its note into a group — and a group's storage **By member**, new in this release, counts by it. Existing attachments are filled in with whoever uploaded them: moves made before the upgrade weren't recorded, so the files of notes moved into a group before then still count for the people who uploaded them. Copies made before the upgrade already count for the person who copied them, because a copy has always recorded the copier as the uploader of the files it copies.
- MCP: every tool now rejects an argument it doesn't know. Until now only `create_note`, `move_note_to_group` and `copy_note` did; the other seven tools dropped it silently. A client or script that sends an extra or misspelled argument — `ifMatch` instead of `if_match`, say — now gets an input-check error (an `isError` result with no `code`) and the tool does nothing; fix the argument's name. The tool list a client sees is unchanged by this.
- **Existing tokens and apps can no longer call `move_note_to_group`, or `copy_note` with `group_id`:** both now need the new `notes:move` scope, which no existing credential has. To allow it, turn on "Move or copy notes into groups" (with "Create and edit notes") for the token or app on its row in Settings → Authorization → API tokens, then restart the MCP client; or authorize the app again and tick both (the second box appears only if the app asks for `notes:move`; an MCP client that follows the `/api/mcp` challenge does); an existing credential's scope can also be changed with `PATCH /api/auth/tokens/:id` from a signed-in browser session (#239).
- **Apps you authorize for the first time are read-only by default.** On the consent page, tick "Create and edit notes" to let an app write: pressing Allow straight away now gives it read-only access, where before it got everything it asked for. Re-authorizing an app starts from the access it already has, never more than it is asking for this time. Personal API tokens were already read-only by default; the Create API token dialog now has a checkbox for each permission, both unticked to start (#239).
- MCP clients cache their tool list; after changing a credential's access, restart the client to see the change (#239).
- A consent page left open from before the upgrade answers Allow with an error; reload it and choose again (#239).
- The first `docker compose up -d` with the new `docker-compose.yml` re-creates the bundled `db` container, because its healthcheck changed; the data in the `db_data` volume is kept.
### Added
- Version history: **All** and **Manual only** buttons above the version list (and above the full-screen list on a narrow window) switch between every version and only those saved by hand or automatic ones whose name was edited (that turns them into manual versions). The two menus used to pick the versions to compare follow the choice; a version you are previewing or comparing isn't affected by it. The choice is the same for every note and is remembered in the browser. `GET /api/notes/:id/versions` accepts `kind=manual` or `kind=auto` to list only that kind (400 `invalid_body` for anything else); it combines with `before` and `limit` (#258).
- `notes:move` scope: with a token or app credential, moving a note into a group (`move_note_to_group`) and copying one into a group (`copy_note` with `group_id`) need it, on top of `notes:write`. It is off unless you ask for it (#239).
- The Create API token dialog has a switch for each permission — "Create and edit notes" and "Move or copy notes into groups" — both off to start, and the token list shows a third kind of access (#239, #247).
- The consent page lets you choose each permission the app asks for; when you authorize the same app again, it starts from the access the app already has and marks it "Currently granted" (#239).
- `PATCH /api/auth/tokens/:id` accepts `scope`, to raise or lower an existing token's or app's access from a signed-in browser session; it applies from the credential's next request. Settings → Authorization does the same from the pencil on each token's row (#239, #247).
- Self-hosting with your own PostgreSQL: set `BUNDLED_DB=0` in `.env` and point `DATABASE_URL` at your server, and `docker compose up -d` no longer starts the bundled `db` container. Leaving `BUNDLED_DB` unset keeps the bundled database, as before. Inside the `app` container, `host.docker.internal` now points at the machine running Docker, for a PostgreSQL running there (see [Using your own PostgreSQL](docs/self-hosting.md#using-your-own-postgresql)).
- API: `GET /api/storage` and `GET /api/groups/:id/storage` also return `byKind`, the bytes of uploads of each kind (image, audio, video, document, other); the group endpoint also returns `byMember`, how much of the group's space each person has taken up: the files they uploaded into its notes, every file of a note they moved into the group, and the files copied with a note they copied into it (#247).
- API: `PATCH /api/auth/profile` accepts `displayName` to change the name others see; it follows the same rule as registering and doesn't count against the rename limit (#247).
- API: `POST /api/admin/users/:id/demote` removes a user's site admin role. It refuses to remove your own (`400 cannot_demote_self`) or when no other enabled admin would be left (`409 last_site_admin`) (#247).
- API: `GET /api/admin/users` returns `lastLoginAt`, when each user last signed in (`null` if they haven't since upgrading). A sign-in made while another change to the same account is in progress can go unrecorded (#247).
- Group storage shows usage by file type and by member, and every member of the group can see it (#247).
- You can change your display name — the name others see — in **Settings → Account info** (#247).
### Changed
- Version history: an automatic version now shows a grey **Auto-saved** tag next to its number; the row no longer spells out "Manual" or "Auto" and no longer has the green dot on the base version. The line under **Current state** now reads **Based on vN · no changes** or **Based on vN · unsaved changes** (or **No base version**), whichever versions the list shows, and a row's "continued from vN" reads "based on vN" (#258).
- Version history: opening another note while the list beside the note is open (wide window) keeps it open and shows that note's versions, instead of closing it. It still closes when the other note can't be edited by you; the full-screen history on a narrow window still closes when you open another note (#258).
- **Breaking (MCP):** `move_note_to_group` and `copy_note` with `group_id` need the `notes:move` scope. A credential without it isn't offered `move_note_to_group`, and its `copy_note` answers `insufficient_scope` when given `group_id`, copying nothing; its `copy_note` description says so (#239).
- **Breaking (API):** `POST /api/oauth/decision` requires `scope` — what was ticked — when allowing, and grants the narrower of it and what the app asked for; an Allow without it is `400`. `GET /api/oauth/request` also returns `existingScope` (#239).
- `POST /api/auth/tokens` accepts `notes:read notes:write` and `notes:read notes:write notes:move` as `scope`; `notes:write` is still accepted for `notes:read notes:write` (#239).
- The `/api/mcp` challenge and both OAuth metadata documents list `notes:move` (#239).
- The read-only MCP `instructions` now tell the assistant to ask the user to turn on "Create and edit notes" for the credential on its row in Settings → Authorization and then reconnect (#239, #247).
- **Breaking (MCP):** `list_notes`, `search_notes`, `read_note_outline`, `read_note_section`, `edit_note`, `create_transfer_token` and `read_note_image` reject arguments they don't know instead of ignoring them. Before, `edit_note`'s `append` with `ifMatch` instead of `if_match` skipped the fingerprint check and wrote anyway (#241).
- API: any member of a group can now read `GET /api/groups/:id/storage`; before, only roles that can manage the group could (others got `403`). Someone who isn't a member still gets `404` (#247).
- After linking a sign-in service from Settings, the browser now comes back to `/settings/login` (`?linked=` or `?link_error=`) instead of `/settings/account` (#247).
- **Settings has six pages:** **Account info** (display name and URL name), **Sign-in** (password and linked sign-in services), **Authorization** (API tokens and the apps you've authorized, and how to connect Claude over MCP), **Groups** (your groups, with your role and its permissions in each), **Storage** (your usage, by type, and the switch for automatic versions of your notes) and **About** (#247).
- **Site admin is a panel again**, opened from **Site admin** in the user menu over the page you were on, with five sections: **Users**, **Groups**, **Sign-in** (was `/admin/auth`, now `/admin/login`), **Storage plans** (now also holds the site's version-history settings; `/admin/versions` redirects there) and **AI agents** (was **AI**). Closing it returns to that page, or to the home page if you opened it from a bookmark or a pasted address; the **Back to notes** link is gone (#247).
- **Each group has its own panel** at `/groups/<id>` — the group's `⋮` menu in the sidebar → **Members & settings** (or **View members**) — with **Members**, **Roles**, **Storage** and **About**; it no longer lives inside Settings, and `/settings/groups/<id>` redirects there. Leaving or deleting the group from it returns you to the page you were on, or to the home page if that was one of the group's notes or you opened the panel from a bookmark or a pasted address (#247).
- Group roles: a permission switch takes effect as soon as you flip it; there is no **Apply** button. Rename a custom role with the pencil (#247).
- Site admin → Users: the role is a menu (**Admin**/**User**) — choosing **User** removes the admin role, which the web app couldn't do before — and **Enabled** is a switch in place of the **Status** column and the **Disable**/**Enable** buttons; turning it off still asks first. A new **Last sign-in** column shows **Never** until the person next signs in after upgrading (#247).
### Fixed
- The bundled database's healthcheck runs `pg_isready` without a shell and over TCP. When a check timed out, the shell was killed but `pg_isready` was left behind, and if it then gave up without a response, PostgreSQL treated it as a crashed server process and restarted, dropping every open connection; and on the first start the check could report the database ready while it was still being initialized.
- MCP: a `note_id` with uppercase letters now reaches the same live note the web app edits: `read_note_outline` and `read_note_section` return what is being typed rather than the last saved copy, the assistant shows up for people who have the note open whenever it would with a lowercase id, and `edit_note` writes into that note — before, with the note open in the web app, an `append` reported success, but the people editing never received the text, and the note as saved afterwards didn't have it either. `read_note_outline` now answers with the note's id in lowercase (#240).
- API: a note id with uppercase letters in the path of `GET /api/notes/:id/content`, `POST /api/notes/:id/edits`, `GET /api/notes/:id/edits` and `POST /api/notes/:id/edits/:editId/revert` is now treated like the lowercase id: reading the content returns what is being typed rather than the last saved copy and shows the caller to people who have the note open whenever the same read with a lowercase id would, and an edit reaches the note as they have it open; listing edits and reverting one get the same fix (#240).
- API: an uppercase user id in `DELETE /api/notes/:id/shares/:userId` or `DELETE /api/groups/:id/members/:userId`, or an uppercase note id when removing a share, now ends that person's open editing connection the same way a lowercase id does, and deleting a note by an uppercase id now closes everyone's open editing connection to it, as a lowercase id does; other share changes and changing a member's role in a group get the same fix, and `PATCH /api/groups/:id/members/:userId` now answers with the member's id in lowercase. A member who isn't a group manager can now leave a group using their own id in uppercase, which used to be refused (#240).
- API: `POST /api/notes/:id/collab-token` with uppercase letters in the id now issues a token for the lowercase id. Connect to `/collab` with the lowercase id; an uppercase one is now refused instead of opening a separate copy of the note (#240).
- API: `POST /api/notes/:id/links` matches the ids in `link_target_ids` regardless of letter case: the note's own id in capitals is no longer recorded as a link from the note to itself, and one id sent in two letter cases counts once toward the 1000-target limit instead of pushing a request over it with `400`. Upgrading removes self-links already stored (#240).
## [0.9.0] - 2026-10-10
### Upgrade notes
- Changes an existing MCP client may notice: a read-write token or app credential now lists three more tools (`move_note_to_group`, `copy_note`, `read_note_image`) and a read-only one lists one more (`read_note_image`); `edit_note` has a sixth operation and a looser output schema. **Scripts that call `create_note` with `groupId` must switch to `group_id`.** Restart your MCP client to see the new tools and key names.
### Added
- MCP: `edit_note` can rename a note with the new `rename` operation (`title`, no `if_match`). A rename is not recorded in the AI edit history; unless the note has a custom URL, its URL follows the new title (#180).
- MCP: new tools `move_note_to_group` (move one of your personal notes into one of your groups) and `copy_note` (copy a note you can read into your personal notes or one of your groups). They work like **Move to…** and **Copy to…** in the web app (#180).
- MCP: new tool `read_note_image` returns an image uploaded to a note as MCP image content, up to 5 MiB (#200).
### Changed
- **Breaking (MCP):** `create_note`'s input `groupId` is renamed `group_id` — MCP tool inputs are now snake_case throughout. A call with the old key is rejected by the input check and creates nothing. `POST /api/notes` still takes `groupId`, and replies keep their camelCase field names (#180).
- MCP: `create_note` now rejects arguments it doesn't know instead of ignoring them, and the new `move_note_to_group` and `copy_note` do the same (#180).
- MCP: `edit_note`'s output fields `editId`, `fingerprint`, `outline` and `unboundWikilinks` are now optional in its output schema; the five content operations still return all four (#180).
- MCP: `create_note`'s `title` description no longer says no tool can rename a note; the read-write `instructions` now describe six `edit_note` operations (#180).
- MCP: `read_note_section`'s description says how uploaded images appear in its markdown (#200).
- Comparing versions side by side now lines the two columns up block by block, like a side-by-side code diff: blocks matched between the two versions that kept their order generally sit in the same row, and where one side has a block and the other has nothing at that spot — for example a paragraph inserted in the middle — the other column leaves a striped gap instead of everything below it drifting out of line. Blocks marked as moved, empty paragraphs matched with one elsewhere, and blocks nested inside another block aren't lined up individually (see Known limitations).
### Fixed
- `PATCH /api/notes/:id` with a `title` containing a NUL character now answers `400 invalid_body` instead of `500` (#180).
- Comparing versions no longer shows unchanged text as deleted in one place and added in another just because the editor gave its block a new identity — which happens when you press Enter at the start of a paragraph, or paste over the whole note. Blocks under the same parent are now also matched by their content: identical blocks of any kind, and text blocks of at least four words whose wording stayed close (a reworded sentence then shows what was removed and added inside it). Typing into the empty paragraph at the end of a note now shows just the new text as added, as long as no other empty paragraph at the same level was removed in the meantime (if one was, the paragraph you typed into may show as changed).
## [0.8.0] - 2026-10-10
### Upgrade notes
- **Back up the database first.** This release's database migration (0019: note versions) runs automatically when the new server starts. It only adds a table, columns, indexes and constraints, but take a dump before you start the new version anyway, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`.
- **Automatic note versions are on after upgrading**, for the whole site and for every user and group. Each version is a full copy of a note's content, kept in the database; to turn automatic saving off for the whole site, use `PATCH /api/admin/versions/settings`.
- **Changes an existing API or MCP client may notice:**
- The user object from `GET /api/auth/me`, `POST /api/auth/login`, `POST /api/auth/register`, `POST /api/auth/oidc/pending/confirm` (its `user`) and `PATCH /api/auth/profile` now includes `autoVersions`; group objects from `GET`/`POST /api/groups` and `PATCH /api/groups/:id` include `autoVersions`; `GET /api/auth/config` includes `autoVersionsEnabled`. `PATCH /api/auth/profile` accepts `{autoVersions}` without `handle`, and `PATCH /api/groups/:id` accepts `{autoVersions}` without `name`.
- Moving a note into a group, and deleting a group by transferring its notes, clear those notes' version history.
### Added
- **Note version history (API).** Every note keeps a history of versions. When something has changed, one is saved automatically once nobody has edited the note for about 5 minutes, when everyone leaves it, and before and after AI or API writes; you can also save one by hand. List, read, apply, rename and delete them through `/api/notes/:id/versions`; applying a version replaces the note's content for everyone. Older automatic versions are thinned out over time (all kept for 7 days, then the newest of each UTC day up to 30 days, then the newest of each UTC week — both numbers are site settings, `/api/admin/versions/settings`); manual versions are never thinned out, and versions don't count towards storage plans. Automatic saving can be turned off for the whole site, for your own notes or for a group. See **Note versions** in [API](docs/api.md).
- **Version history in the web app.** On a wide window, the round **Version history** button at the bottom right of a note (above the AI button, when there is one) opens the note's versions beside it; **Version history** and **Save current version** are also in the `⋮` menu at the top of the note, and **Ctrl+S** (**Cmd+S**) opens the same dialog for saving the current version, with an optional name. The list shows each version's number, name, whether it was saved automatically or by hand, who edited and when, and, at the top, how the current content relates to its versions. Click a version to preview it against the current state — or, from the two menus above the comparison, against any other version — side by side or in one column, optionally showing only the changes; apply it (a dialog asks what to do with changes that aren't in a version yet), rename it or delete it. On a narrow window, the history opens full screen from the `⋮` menu. Automatic versions can be turned off for the whole site in **Site admin → Version history** (which also sets how long automatic versions are kept), for your personal notes in **Settings → Account**, and for a group's notes on the group's settings page. See [Version history](docs/versions.md).
## [0.7.0] - 2026-10-09
### Upgrade notes
- **Back up the database first.** This release's database migrations (0016–0018: the body-text search index, the `transfer_tokens` table, and storage plans) run automatically when the new server starts. The storage-plan migration also gives every existing user and group a plan, so take a dump before you start the new version, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql` (#93, #200).
- **The first start after upgrading indexes your existing notes in the background.** A new database migration adds the body-text search index, which the server then fills in after it is already answering requests; the `app` log shows `全文索引回填開始` when that starts and `全文索引回填完成` when it is done. Until it gets to a note, `search_notes` finds that note by its title only. Rolling back is harmless — an older server ignores the index — and the next upgrade catches up on whatever was edited in between. See [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back) (#93).
- **`search_notes` is now rate-limited** to 60 searches per minute per user; past that, it answers with the tool error `too_many_requests`. Before, only the limits that apply to every MCP request applied to it (#93).
- **Restart MCP clients to pick up `search_notes`'s new reply shape** (see **Changed** below). A client keeps the tool list it fetched until it restarts, and one that checks replies against the cached output schema can reject the new replies until then — see **A client's cached tool list does not shrink on its own** in [Known limitations](docs/known-limitations.md) (#93).
- **Everyone starts on a 2 GiB plan.** The migration creates a plan named Basic (2 GiB) and puts every existing user and group on it. Spaces already over 2 GiB keep everything but can't add attachments until they get a larger plan or drop back under the limit — check **Site admin → Users** and **Site admin → Groups** (or `GET /api/admin/users` and `GET /api/admin/groups`) after upgrading.
- **Changes an existing API or MCP client may notice:**
- Uploads, copies, moves and group transfers can answer `409 storage_quota_exceeded`, and also `409 server_busy` when the storage check can't run in time; `POST /api/admin/users` and `POST /api/groups` can answer `409 server_busy` when the default plan is deleted at that moment; `GET`/`POST /api/admin/users` responses carry a new `storage` object. Creating a user with `POST /api/admin/users` or a group with `POST /api/groups` now fails with 500 if the `site_settings` row has been removed by hand.
- An MCP upload token used on a full space is answered `409 storage_quota_exceeded` and stays usable once space is freed.
- MCP `create_transfer_token` with `purpose: "upload"` answers the tool error `storage_quota_exceeded` instead of issuing a token when the note's storage space is already full.
- A token or app credential's MCP `tools/list` has one more tool, `create_transfer_token` (a read-only credential is offered it for downloads only); a browser session's does not change (#200).
- `POST /api/notes/:id/uploads` and `GET /api/uploads/:id` now judge a request carrying an `Authorization` header by that header alone, ignoring the session cookie, as the other token endpoints do; such a request's `401` carries a `WWW-Authenticate` challenge and counts against the per-IP limit on invalid Bearer attempts. Without the header they behave as before (#200).
### Added
- **MCP `search_notes` searches note body text, not just titles.** A note that matches in its body lists up to 3 of the sections that contain your text, each with its `sectionId` (which `read_note_section` takes), its heading and a short excerpt. See [MCP](docs/mcp.md#search_notes) (#93).
- MCP tool `create_transfer_token`: an assistant can upload an image to a note, or download a note's images, with a short-lived token and a ready-made `curl` command — its MCP credential can't be sent from a shell, and image bytes don't fit in tool arguments. Upload tokens need `notes:write` and edit access, are good for one file and at most 5 can be unused at once per credential; download tokens work any number of times on one note's uploads. Tokens expire within 10 minutes, and revoking the credential that issued them ends them (#200).
- MCP and the API can read and write text and background colors. Reading a note returns colored text as a `<span style="color:red">` inside the Markdown, and paragraphs, headings, list items, quotes and table cells that have a color of their own as HTML with `data-text-color` / `data-background-color`, instead of dropping the colors, so writing back what was read keeps them; content without colors reads back exactly as before. Writing accepts the same HTML, and only the editor's ten built-in color names: any other color value is now rejected whole with the new error code `unsupported_color` (before, it was stored but never shown). See [AI editing](docs/ai-editing.md#colors) (#222).
- **Storage quotas.** Every personal space and every group has a storage plan, which can limit the total size of its attachments (or set no limit); uploads, copies, moves into a group and group transfers that would go over the limit are refused with `409 storage_quota_exceeded` (nothing already stored is ever deleted). Site admins manage plans, the defaults for new users and new groups, and assignments through `/api/admin/storage-plans`, `/api/admin/users/:id/storage-plan` and `/api/admin/groups/:id/storage-plan`; `GET /api/admin/groups` lists every group with its usage. Users see their own usage at `GET /api/storage`, group managers their group's at `GET /api/groups/:id/storage`.
- **Storage quotas in the web app.** **Site admin → Storage plans** lists every plan with its limit, how many users and groups are on it and how many of them are over it, and lets you create, edit and delete plans and choose the defaults for new users and new groups; **Site admin → Users** gains **Storage** and **Plan** columns, and the new **Site admin → Groups** lists every group with its member count, usage and plan — pick a plan in a row to assign it. **Settings → Account** shows your own usage, and a group's settings page shows the group's to people whose role can manage it; both warn once usage reaches the limit. When an upload doesn't fit, the notice says the storage is full, with how much is used and the limit when it's your own space, a group you manage, or you're a site admin.
- **Presentation mode.** Any note can be played as a slide show: choose **Present** from the note's ⋮ menu (in the page header or on its row in the sidebar), or the **Present** button on a public link's page. The highest heading level among the note's top-level blocks becomes the slides that run left to right, the next level the slides below them, and a divider starts a new section; the note's title is the cover. Presenting asks for full screen — Esc leaves full screen, Esc again or ✕ goes back to the note — each slide has its own address (`?present#/…`), and signed-in viewers see collaborators' edits while staying on the same slide; on a public link the slides update when the page fetches the note again. See [Known limitations](docs/known-limitations.md) (#229).
### Changed
- **Move to…** and **Copy to…** are now in a note's `⋮` menu, each with its own icon like the other items, instead of the **Move or copy into a group** row of the Share dialog, which is gone. Groups are offered only where your role can both create and edit notes. On your own personal note, **Copy to…** starts with **Make a copy**; on a group note there is no **Move to…**, and **Copy to…** offers **Make a copy** (in the same group), **Personal space** and your other groups; on a note shared with you (as a viewer or an editor) it offers **Personal space** and your groups, and no **Move to…**. This replaces the `⋮` item **Copy to my notes** and the **Copy to my notes** button in a group note's Share dialog. Hover an item to open the list beside the menu; on a narrow window or a touch screen it expands right below the item instead. Choosing a target opens a confirmation, and a finished move shows a "Moved into …" notice (#216).
- `search_notes` replies have a new shape: the top-level `matchedOn` is gone; each note carries its own `matchedOn` (`title`, `body` or `both`) and `matches`, and the reply carries `matchesTruncated: true` when some matches were left out to keep it small. Notes that match only in their body come after every note whose title matches (#93).
- `POST /api/notes/:id/uploads` and `GET /api/uploads/:id` accept a transfer token as `Authorization: Bearer knbt_…` in addition to the session cookie; on these two endpoints a request carrying an `Authorization` header is judged by that header alone and the cookie is ignored. An ordinary API token is still refused there, now with a `401` that points at `create_transfer_token` (#200).
### Fixed
- Browser tabs and iOS home-screen shortcuts now show the Knotebook icon (the sidebar's italic "K") instead of a blank default icon.
- The last-edited line at the top of a note now updates a few seconds after you edit the note yourself; before, it only followed other people's edits, and your own showed up only after reloading the page.
## [0.6.0] - 2026-10-08
### Upgrade notes
Read these before upgrading from 0.5.x. The full checklist is under **Upgrading to v0.6** in [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- **Back up the database first.** This release's database migrations (0014–0015, which add sign-in services, linked sign-ins and the site's sign-in settings) run automatically when the new server starts. They only add tables and columns, but take a dump before you start the new version anyway, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`.
- **If you use SSO, keep `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` in `.env` for the first start.** They are read only on the first start of this version, where they become a sign-in service named "SSO" that keeps the old callback URL — nothing changes on the identity provider side, and identities already linked to accounts keep working. If they are missing, incomplete or invalid on that start, nothing is imported (an incomplete or invalid set is logged as a warning) and they aren't read again. After that they're ignored and can be removed; sign-in services are managed in **Site admin → Sign-in** (#187).
- **After upgrading, a password registration page is open to anyone who can reach the site.** **Allow registration** is on by default, upgraded instances included: first sign-ins through a sign-in service create accounts as before, and `/register` now lets anyone create an account with an email and a password. Turn it off in **Site admin → Sign-in** if you don't want that (#187).
- **SSO no longer links to an existing account by verified email.** Identities that are already linked keep working, but a first SSO sign-in whose email matches an existing account now goes through a **Link your sign-in** page where the person proves the account is theirs. Signing in with SSO also no longer clears an account's "must change password" mark — see **Changed** below (#187).
- **Going back to 0.5.x:** put the three `OIDC_*` variables back into `.env` first — 0.5 only knows SSO from them — and read the rollback step in **Upgrading to v0.6** for which sign-ins work under 0.5; or restore the dump you took.
- **Changes an existing API or MCP client may notice:**
- `GET /api/auth/config` returns `{providers: [{id, displayName, icon}], registration: {enabled}, passwordLogin: {enabled}}` instead of `{oidc: {enabled}}`, and the `oidc_email_unverified` error code is no longer sent (#187).
- `POST /api/auth/login` answers `403 password_login_disabled` while password sign-in is turned off. The OIDC callback can redirect with the new error codes `oidc_claim_too_long`, `oidc_link_no_proof_method` and `registration_disabled`, and can send the browser to `/link-account` instead of signing in (#187).
- `PATCH /api/notes/:id` with both `slug` and `title` now also needs edit access: a group role that can manage public links but not edit gets `403 forbidden` (sending `slug` alone still works) — see **Fixed** below (#186).
- `POST /api/admin/ai/providers` and `PATCH /api/admin/ai/providers/:id` answer `400 base_url_has_credentials` when `baseUrl` contains a username or password — see **Fixed** below.
- MCP: titles, headings and group names are cut at 200 characters as written in JSON, so text containing `"`, `\` or control characters can come back shorter than before, and a group note's `owner` can carry `nameTruncated: true` — see **Fixed** below and [MCP](docs/mcp.md) (#177).
### Added
- Signing in with more than one identity provider: sign-in services are stored in the database, the login page shows a "Sign in with …" button for each enabled one, and each service has its own callback URL (`<PUBLIC_URL>/api/auth/oidc/callback/<service id>`), except the service imported from `OIDC_*` (see **Changed**), which keeps the old one (#187).
- When someone signs in through an identity provider for the first time and their email already has an account, Knotebook asks whether to link the two. They prove the account is theirs with its password, or by signing in with a sign-in service already linked to it (#187).
- **Site admin → Sign-in** (`/admin/auth`): add, edit, test, turn off and delete sign-in services — GitLab (including self-managed), Google, or another OpenID Connect provider. Each one shows the callback URL to register on the identity provider. Client secrets are stored encrypted and never shown again; changing a service's issuer clears its secret (unless a new one is entered in the same edit) and turns it off. Issuer URLs with a username or password are rejected, and an `OIDC_ISSUER_URL` like that is not imported (#187).
- Registration: `/register` (and `POST /api/auth/register`) creates an account with an email and a password (while password sign-in is on), or with **Sign up with …** through a sign-in service. **Site admin → Sign-in** has an **Allow registration** switch, which also decides whether a first sign-in through a sign-in service may create an account (#187).
- **Settings → Account**: a **Sign-in methods** group to link another sign-in service, or unlink one as long as another way to sign in remains, and **Add a password** for an account that has none (while password sign-in is on) (#187).
- An **Allow password sign-in** switch in **Site admin → Sign-in** (on by default). Turn it off to have everyone, admins included, sign in through sign-in services only; people already signed in stay signed in. If that locks you out, set `PASSWORD_LOGIN_FORCE_ENABLE=true` and restart to accept passwords again (#187).
- Sign-in services can show an icon before their name on the sign-in buttons, the account-linking page, **Settings → Account → Sign-in methods** and their card in Site admin: the GitLab or Google logo, a generic sign-in icon, an uploaded image (PNG, JPEG or WebP; the browser shrinks it to at most 128 px), or none. Pick it with **Icon** on the service's card in **Site admin → Sign-in**; by default it follows the template, so the service imported from `OIDC_*` shows the generic icon until you choose another (#187).
### Changed
- The first admin account, created from `ADMIN_EMAIL`/`ADMIN_PASSWORD` on a new instance, is no longer forced to change its password on first login — you set that password yourself in `.env`. Accounts that are still marked to change their password (such as ones an admin creates, or — on an instance initialized before this change — an env-bootstrapped admin that hasn't changed it yet) are still sent to change it when they sign in (#187).
- **Signing in with SSO no longer links to an existing account just because the identity provider says the email is verified.** Identities that are already linked keep working. A new identity whose email matches an existing account goes through the linking page instead (#187).
- The identity provider's `email_verified` claim is no longer read. Identity providers that don't send it now work, and an email the provider marks as unverified can create an account on first sign-in (#187).
- `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` are read only on the first start of this version. They become a sign-in service named "SSO" that keeps the old callback URL, and are ignored after that. Setting only some of them no longer stops the server from starting (#187).
- An account an admin created for someone must still change its password even if its owner signs in with SSO or links SSO to it — only changing the password clears that. Accounts that already signed in with SSO before upgrading aren't affected (#187).
- `GET /api/auth/config` now returns `{providers: [{id, displayName, icon}], registration: {enabled}, passwordLogin: {enabled}}` instead of `{oidc: {enabled}}` (#187).
- The app's first download is about 5% smaller than in 0.5.0 (652 KB → 618 KB before compression), even though this release adds the registration, account-linking and sign-in settings pages: Site admin, the sections of **Settings**, and the registration, account-linking, change-password and authorization pages now load the first time you open them. The login page and sign-in service icons still load right away. If one of these pages can't be loaded, they behave like note pages already do: when a new version was deployed, Knotebook reloads once on its own and, if that doesn't help, shows an error with **Try again**; while offline it doesn't reload and shows the error right away, with **Try again** available again once you're back online (#201).
### Removed
- The `oidc_email_unverified` error code, which is no longer sent (#187).
### Fixed
- An AI provider's Base URL can no longer contain a username or password (`https://user:pass@host`, or just `user@host`): Site admin → AI now rejects it with a clear message, and so does the API (400 `base_url_has_credentials`). Such a URL never worked — the server's HTTP client refuses it — and the field is stored and returned as plain text, so a password does not belong there; put credentials in the API key field instead. Providers already saved with such a URL are left untouched (still shown as is) until edited; to save an edit to one, first change its Base URL to one without a username or password (the edit form always checks and sends the Base URL). A provider that already has such a URL keeps working as far as it ever did, but changing its Base URL clears the API key stored with it (as any Base URL change does), so enter the key again after saving.
- A group role that can manage public links but cannot edit notes could still change a note's title by sending it together with a `slug` in `PATCH /api/notes/:id`. A body that carries a `title` now always needs edit access, whether or not it also carries `slug` (#186).
- Deleting a note while an image was being uploaded to it could leave the uploaded file on the server's disk with nothing pointing at it. An upload that arrives while the note is being deleted now waits for the deletion to finish and then answers `404 not_found`, and its file isn't kept. The same change also stops deleting a note from deadlocking with deleting its group with **Delete everything** at the same moment, which could make one of the two fail (#188).
- Clicking outside a form dialog no longer closes it and throws away what you typed. This covers creating a user, the AI provider, model and action dialogs, roles, group names, deleting a group, inserting a link, sharing a note, creating an API token, and the settings window. Esc and the close button still close them.
- In a group's member list, a long email address no longer pushes the table past the edge of the settings window and cuts off the remove button. Long names and email addresses now wrap, and the remove button is a trash icon whose accessible name still includes the email (#183).
- In **Site admin → Users**, long emails, usernames and display names now wrap instead of stretching the table far past the edge of the page and pushing the action buttons out of view. The role, status and action columns stay on one line; on phone-width screens the table can still scroll sideways inside its card.
- The "opens in a new tab" icon now also appears on links whose address is written in capitals (`HTTPS://…`), leaves out the scheme (`//example.com`), or uses backslashes in place of slashes. Some rarer spellings still get no icon; see [known limitations](docs/known-limitations.md) (#156).
- In dark mode, the note editor and its menus reset the accent color to the default indigo for anything inside them styled with the accent. Nothing in the editor was styled that way yet (links and `[[` links already worked around it), so this had no visible effect, but accent-colored elements added there later will show the accent you picked (#154).
- After renaming a note, opening and closing the settings window no longer puts the note's old address back in the address bar; this affected group notes too. If the page learns of a new address for the note while the settings window is open (for example after someone else renamed it), closing the window now returns to that new address. Changing the address this way doesn't reload the note or reconnect the editor (#179).
- When your group role changes while you have one of the group's notes open, and the change decides whether you can edit that note, the group's **+** (new note) button in the sidebar now updates right away instead of only after a reload (#182).
- Going from a note you can edit to one you can only view no longer shows "Your access changed to viewer", and going the other way no longer shows "Your edit access has been restored". Your access hadn't changed; only the note had. These messages now appear only when your role changes while the same note stays open.
- MCP: titles, headings and group names are now cut at 200 characters **as written in JSON**, where `"`, `\` and the control characters JSON has a short escape for (`\n`, `\t`, `\r`, `\b`, `\f`) count as two characters and other control characters (such as U+0001) as six. Before, a page of 100 notes whose titles and group name were made of such characters could produce a single response larger than the 262 144 characters these caps are meant to keep it under (one measured `list_notes` page came to 300 656), and a group's name was not cut at all. A cut group name now comes with `nameTruncated: true`. Text with no `"`, `\`, control characters or unpaired surrogates is cut exactly as before; `read_note_section` still pages by 4000 characters as stored (#177).
### Security
- When saving an AI provider, model or action failed with an unexpected database error, the server log recorded the whole failed query along with its values: the provider's encrypted API key, its Base URL (including any `user:password@` in it) and an action's full prompts. The same happened when rewriting a stored key into the newer format at startup failed. These errors are now logged as just the error code, constraint name and what was being saved, the same way errors saving a sign-in service are logged.
- When an AI quick action or **Test** on a provider could not reach the provider, the server log included the error message — and when the Base URL contains `user:password@`, that message is the full URL with the password. Any user who can run an AI action on a note could cause it to be logged. The log now records the error type and the underlying network error code, the provider's Base URL (or, for **Test**, the address it called) without credentials or query, and whether the URL contains credentials. When the provider answered with an error, it records the status and the first 2,048 characters of the response, for both provider types. See [AI quick actions](docs/ai.md) for what is logged, and why `ANTHROPIC_LOG=debug` should stay off.
- Likewise for SSO: when Knotebook could not fetch an identity provider's configuration on the sign-in button, which needs no account, the server log included the underlying error message, which contains the password when the issuer URL has `user:password@`. The log now shows the issuer without credentials or query and only the error type and code — on the sign-in button and also when linking a sign-in method or proving an account while linking. What the browser is shown hasn't changed.
## [0.5.0] - 2026-10-05
### Upgrade notes
Read these before upgrading from 0.4.x.
- **Back up the database first — this upgrade can't be undone.** This release's database migrations (0011–0013, which add groups) run automatically when the new server starts, and there is no way to reverse them. Take a dump before you start the new version, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql` — see [Upgrading and rolling back](docs/self-hosting.md#upgrading-and-rolling-back).
- **Don't go back to 0.4.x on the upgraded database.** A 0.4.x server predates groups: it decides who can open a note only from the note's owner and its per-person shares, and a note that belongs to a group has neither, so no one signing in to it could open a group's notes. To return to 0.4.x, restore the dump you took before upgrading into an empty database (the steps are in the same section) — everything written after the upgrade is lost.
- **Changes an existing API or MCP client may notice:**
- MCP results (`list_notes`, `search_notes`, `create_note`) say whose note it is in an `owner` object instead of an `ownerHandle` string — see **Changed** below.
- REST note objects can now be group notes. On a group note `ownerId` and `ownerHandle` are `null`; new fields — `groupId`, `group: {id, name}` and `permissions` (what you can do with the note) — are on every note object. A client that builds `/n/<ownerHandle>/<slug>` links needs `/g/<groupId>/<slug>` for group notes. `GET /api/notes` now also returns the notes of every group you belong to, and a `GET /api/notes/:id/backlinks` entry has `ownerHandle: null` and a `groupId` when the linking note is a group note. See the [API contract](docs/api.md).
- **Only if your server ran a pre-release build with groups** (a build of the `main` branch from after #103 and before migration 0012 — no released version had groups): the migration hands every group note over to its group — notes that were read-only for their group become editable by every member, and the group's admins can now delete them, manage their public links and change their URL name — removes the per-person shares, custom public URLs and previous-name redirects on those notes, renames a note whose URL name clashes with another in the same group (normally with a numeric suffix), and forwards each one's current `/n/…` address to its new `/g/…` address for a month. Which notes were read-only for their group, and who owned each group note, can't be recovered afterwards. If you go back to that build after upgrading, it still starts on the migrated database but can't serve notes any more — loading a note or the note list fails with a server error — because columns it reads are gone; restore the dump instead. That build's groups API changed too: `DELETE /api/groups/:id` now requires a body (`{mode: "transfer", transferTo}` or `{mode: "delete"}`) and answers `400 invalid_body` without one; the member endpoints take a `roleId` instead of `role: "admin" | "member"`, members are listed with `roleId` and `builtin`, and a group's `myRole` is a role object instead of a string; a note object's `group` no longer carries a `role`; and `PUT`/`DELETE /api/notes/:id/group` are gone — a note joins a group with `POST /api/notes/:id/move`, and no endpoint takes a single note out of a group.
### Added
- Groups (#103, #175): a group owns its notes. A note created in a group belongs to the group, not to a person — it lives at `/g/<group id>/<name>` (the name is unique within the group), and what each member can do with it comes from their role in the group. The sidebar gains a collapsible **Workspace** section with one section per group (each with a `⋮` menu, and a "new note here" button when your role can create and edit notes), and the other three sections (Recent, My notes, Shared with me) can be collapsed too. **Settings → Groups** lists your groups and, per group, its members — people whose role allows it can rename the group, add and remove people, and change their roles; anyone except the group's last admin can leave. See [Sharing](docs/sharing.md).
- Group roles (#175): every group starts with two roles: **Admin** (can do everything — edit and delete any note in the group, turn its public links on and off, give a note a custom URL name through the API, manage members and the group itself) and **Member** (can read, create and edit notes). People whose role allows it can define more roles on the group's **Roles** tab in **Settings → Groups**, choosing any of six permissions for each (every role can read the group's notes); the built-in Admin role can't be changed, and deleting a role moves its members to Member.
- The Share dialog on a group note has no per-person sharing: it explains who has access and links to the group's settings, plus a **Public link** switch when your role can manage public links (a group note has no custom public URL) (#175).
- Moving and copying notes (#175): an existing personal note can be moved into a group (its per-person shares and public link are removed, and its old address forwards to the group address for a month) or copied into one, and any group note you can read can be copied to your own notes — a copy gets its own copies of the note's attachments, which count against your upload limit. No single note can be moved out of a group (deleting a group can give all of its notes to an admin).
- Deleting a group (#175) either gives all its notes to one of its admins — they become that person's personal notes, their public links are turned off, and their old `/g/` addresses forward that admin to them for a month — or deletes the notes with it.
- Groups over the API and MCP (#175): every note response (`NoteDto`) now reports what you can do with it in a `permissions` object, and `GET /api/notes/by-group-path/:groupId/:slug`, `GET /api/groups/:id/roles`, `POST /api/groups/:id/roles`, `PATCH /api/groups/:id/roles/:roleId`, `DELETE /api/groups/:id/roles/:roleId`, `POST /api/notes/:id/move` and `POST /api/notes/:id/copy` are new, alongside the group and member endpoints (`DELETE /api/groups/:id` takes `{mode: "transfer", transferTo}` or `{mode: "delete"}`). MCP clients see group notes in `list_notes`/`search_notes`, can edit them when their role allows, and can create a note in a group by passing `create_note` the new `groupId` argument (when their role can create notes there); they can't move or copy notes, and no MCP tool lists your groups — see the [API contract](docs/api.md), [MCP](docs/mcp.md) and [Known limitations](docs/known-limitations.md).
- The sidebar can be resized: drag the gap between the sidebar and the note — or reach it with Tab and use the left and right arrow keys (16 pixels a step), Home and End — to make the sidebar anywhere from 200 to 480 pixels wide; double-click the gap to go back to the default 256. The gap stays invisible until you drag it or give it keyboard focus, when a thin line appears; hovering it only changes the pointer. The width is remembered in this browser. On narrow windows, where the sidebar opens as a drawer, the drawer keeps its fixed width. The width doesn't adapt to the window, so a sidebar widened on a large screen can leave too little room on a small one — see [Known limitations](docs/known-limitations.md).
- Every note in the sidebar has its own `⋮` menu with the same items as the `⋮` at the top of an open note: **Copy link**, **AI edit history**, on group notes **Copy to my notes**, and, on notes you can delete (your own, or a group note when your role allows it), **Delete note** — so the sidebar can delete notes again. Used on the note you have open, it does what the top-of-note menu does (deleting takes you back to the home page). Used on another note, **AI edit history** opens that note with its history already showing, and deleting it leaves you where you are. Deleting a note from the sidebar while that note is still loading, and screen-reader names for notes that share a title, are covered in [Known limitations](docs/known-limitations.md).
- A request that has waited 10 seconds for a free database connection now fails with a server error instead of waiting indefinitely, and two optional settings, `DATABASE_POOL_MAX` (default 10 connections) and `DATABASE_POOL_CONNECTION_TIMEOUT_MS` (default 10000), set how many database connections the server opens and how long a request waits for one — see [Self-hosting](docs/self-hosting.md) (#175).
### Changed
- MCP results say whose note it is in an `owner` object — `{kind: "user", handle}` for a personal note, `{kind: "group", id, name}` for a group note — instead of an `ownerHandle` string (#175).
- The sidebar's `⋮` menus — on groups and on notes — now appear only when you hover over their row or reach them with the keyboard, the same way the small `+` buttons on the Workspace and group headings already did, instead of always being visible; a `⋮` stays visible while its menu is open, and on touch screens they are always shown.
- The last-edited line at the top of a note now reads "Last edited by *name* · *date*" and is set apart from the connection status by a thin divider — it could be misread as naming the note's owner; hovering it shows who and the full date and time. When the top of a note runs short of room (a smaller window, a wide sidebar, the AI panel open), the title gives way first and then this line is shortened with an ellipsis, instead of the **Share** and `⋮` buttons being squeezed; when there still isn't enough room they can be cut off — see [Known limitations](docs/known-limitations.md).
- Site administration (user management and AI provider setup) moved out of the Settings dialog onto its own page at `/admin/users` and `/admin/ai`, reached from a new **Site admin** item in the user menu that only admins see; it uses the normal app layout, with the sidebar showing the admin sections and a way back to your notes. Settings now holds only Account and Groups, the same for everyone. The old `/settings/users` and `/settings/ai` addresses redirect to the new pages.
- The Settings dialog (Account, Groups) and the site admin pages (Users, AI) now share one consistent layout — a page title and description at the top, groups separated by hairline dividers instead of ad-hoc spacing, and matching type sizes for group titles and body text throughout, instead of each section inventing its own headings and spacing.
- The Share dialog of a personal note is now organized by access level: picking **Private**, **Members only**, or **Public link** shows only the controls that level needs, instead of the member list and public-link controls both being visible regardless of which one is selected. The dialog's icon-only trigger button also now reflects the note's current access level (a lock, a share icon, or a globe; on a group note, a people icon or a globe).
- A personal note's public link now defaults to an anonymous, unguessable URL (`/p/<token>`), controlled by an **Anonymous link** toggle; turning it off switches the same URL to a human-readable, custom form (`/p/<username>/<name>`) instead of adding a second URL alongside the token one. **Regenerate link** now rotates both the token and, when Anonymous link is off, the custom name together, so a leaked custom URL can always be fully retired — not just the token behind it, leaving the same guessable name reachable with a fresh token.
- Buttons and form controls (inputs, native `<select>`s) are now a uniform 32px tall across the app, and buttons follow a single set of variants chosen by how important an action is on the screen it appears on, not by how destructive it is — see the usage notes at the top of `components/ui/button.tsx`.
- Paragraphs, list items, and quotes in the editor are now justified (both edges align to the column), paired with a rule that allows a long, unbroken run of text (a URL, an identifier) to break mid-word so it can't stretch the rest of that line's whitespace to fill the gap.
### Removed
- The Share dialog's internal note-to-note link section (a copyable link plus a custom-slug field, for linking notes within the app) has been removed — link to another note from inside a note with a `[[title]]` wikilink instead; anyone the note is already shared with sees it in their own sidebar without needing a link.
### Fixed
- Hovering a table whose bottom or right edge was outside the window no longer adds a scrollbar or makes the layout jump — neither a page-wide one, nor, for a table too wide for the window, a horizontal one on the note content area. The row/column "add" buttons that appear on hover are positioned with plain CSS absolute positioning, and the note content had no positioned ancestor for them to anchor to, so they escaped every container meant to hold them and anchored to the page itself, which then had to grow to fit them; giving them one contains them, and the note's own scroll container now also clips horizontal overflow directly, so a button wider than the visible area is simply cut off at the edge instead of making the container scroll sideways (#160, #162).
- Uploading an image to a note that is deleted while the upload is being processed now answers `404 not_found` instead of a server error; the uploaded file isn't kept (#175).
## [0.4.1] - 2026-09-16
### Added
- Typing a markdown link — `[text](url)` — now turns into a real link the moment you finish it, matching what pasting the same text already did. The `/` menu also gains a **Link** item that opens a small dialog (display text, URL) and inserts the link without you having to remember the syntax. Both share the same rule for what counts as a valid link, so they behave consistently; a handful of edge cases (syntax that doesn't survive a round-trip unchanged, formatting-in-progress, an open suggestion menu, and a couple of others) are recorded in [Known limitations](docs/known-limitations.md) (#99).
### Fixed
- Links in a note are now visible as links. A link inside the editor was drawn in exactly the same colour as the surrounding text, with no underline, so nothing revealed one until you moved the pointer over it or clicked it; every link now uses your accent colour together with a solid underline, and a link to an `http://` or `https://` address also carries a small "opens in a new tab" icon, which is what those links do. Wikilinks (`[[…]]`) get the same colour and underline, and deliberately no icon, since they navigate in the same tab; a wikilink whose target has been deleted keeps its distinct greyed-out, dashed styling. The icon can wrap onto a line of its own — see [Known limitations](docs/known-limitations.md) (#153).
- `PATCH /api/notes/:id` now stamps `updated_at` using the database's own clock instead of the application server's, matching how it's already set on creation. The two clocks could drift out of order — different machines, or, even on the same machine, the application's millisecond-precision timestamp landing below the database's microsecond-precision one — which could flip a note's position in "most recently updated first"; that's also why two tests that depend on this ordering could occasionally fail locally without any code change (#142).
- A note created with a title in the same request — over the API's `POST /api/notes`, MCP's `create_note`, or the editor's `[[…]]` **create and link** menu — now gets a URL derived from that title, instead of keeping the database's `untitled-<8 hex characters>` default. Previously only *changing* a title recomputed the slug, which the **New note** button hides, since it creates an empty note and then saves the title you type into it — but nothing else did: a note created and linked straight from `[[…]]` kept its `untitled-…` URL, and an assistant could not fix one at all, since no MCP tool renames a note. A note created without a title still keeps the `untitled-…` default, and a title with no usable URL form still falls back to `untitled` — punctuation on its own, a reserved word, or a uuid, or a title ending in one — the same fallback a title change already used (#145).
- Test-only: `migrate.test.ts`'s query-planner assertion seeded 5,000 users without an explicit `handle`, so roughly one run in 344 hit the `users_handle_unique` constraint by chance (each default handle is only 8 hex characters). The seed now assigns each row a deterministic, guaranteed-unique handle (#150).
## [0.4.0] - 2026-09-14
### Added
- You can now create **API tokens** to let a script or an AI assistant work with your notes on your behalf. Settings → Account → API tokens issues a token that acts as you on a small set of note endpoints — listing your notes, reading a note's details, and (optionally) creating one; the token is shown once, stored only as a hash, and can be revoked at any time. Tokens are deliberately limited: they work on that short allow-list of endpoints and nothing else, so a leaked token cannot take over the account, change your password, or manage sharing. They are also independent of your password — changing it does **not** revoke them, the same way personal access tokens work elsewhere; revoke them here instead. Token requests are rate-limited separately from browser sessions, so a runaway script cannot lock you out of the web UI. See [API tokens](docs/api-tokens.md) (#107).
- API tokens (and Apps authorized over OAuth) can now **read a note's content** as markdown — the whole note or a single section, together with an outline and per-section fingerprints that a later write can use to avoid overwriting concurrent edits. Reading does not change any of the note's content. See [API tokens](docs/api-tokens.md) (#106, #136).
- Signing in now takes you back to the page you were trying to open. Previously, following a link to a note (or any other page) while signed out sent you to the login page and then dropped you on the home page, and you had to find your way back by hand. This works for both password and SSO sign-in; the destination is validated as a page path on this instance, so a link can never use it to bounce you to another site, and on the SSO route it is validated a second time when you come back from the identity provider. Three cases still land you on the home page: an account that has to change its password on first sign-in, a destination that fails validation, and signing out (where leaving is the point) (#107).
- **MCP clients can now authorize themselves** instead of you pasting a token. Knotebook includes an OAuth 2.1 authorization server, designed for an MCP client running on your own machine — such as Claude Code connecting directly, or Claude Desktop through a local stdio bridge — to discover the server, register itself, open a consent page in your browser, and, once you press Allow, receive its own credential; verified end to end with `mcp-remote --allow-http` against a plain-`http://` deployment. Claude Code's built-in HTTP transport needs an `https://` deployment — on plain http it walks through consent and then refuses to send the token request — see [How to connect](docs/mcp.md#how-to-connect) for both commands. That credential appears under Settings → Account → API tokens as an *App* row beside your personal tokens and can be revoked there; re-authorizing the same app replaces its previous credential. The consent page shows the app's self-reported name (marked as unverified) and the loopback address it will return to — only apps running on your own machine can be authorized, which is what keeps unauthenticated registration from becoming a phishing surface. Apps unused for 30 days are cleaned up automatically. See [API tokens](docs/api-tokens.md) and the OAuth entries in [Known limitations](docs/known-limitations.md) (#107, #132).
- API tokens and authorized Apps can now **write note content**: replace the whole note, replace/insert after/delete one section, or append — each guarded by the fingerprint returned by a read, so concurrent human edits are never silently overwritten, and a note can now be created with its text in the same request. Changes appear live in every open browser tab. Every successful write is recorded, and can be reverted — through the API, and from the app (see the entry below) — while the note has not moved on and the row is still among the at most 100 rows a note keeps; the note now remembers who edited it last, and whether that was a person or a program. See [AI editing](docs/ai-editing.md) (#106, #137).
- **AI edits are now visible in the app.** While an API token or an authorized App works on a note, everyone who has that note open watches its cursor move, briefly labelled `username (agent)` after each change (hover the cursor to bring the name back); the title bar shows who edited the note last and whether that was a person or a program; the ⋮ menu's new **AI edit history** lists the writes the API made — a note keeps at most 100 — and lets you revert one; and Settings → Account renames the agent shown for each credential (the default is the first word of the token's or app's name). Presence exists only while somebody has the note open and fades after two minutes of quiet — the edit history is the lasting record. See [AI editing](docs/ai-editing.md) and the presence entries in [Known limitations](docs/known-limitations.md) (#106, #138).
- **An MCP client can now read and write your notes.** Point Claude Code, Claude Desktop, or any other client that speaks MCP at `/api/mcp` — by letting it authorize itself over OAuth, or, if the client lets you set an `Authorization` header, with a personal API token — and it gets six tools: `list_notes` and `search_notes` to find a note, `read_note_outline` and `read_note_section` to read one a section at a time, and, with a `notes:write` credential, `edit_note` and `create_note` to change one or make a new one. A read-only credential simply never sees the last two. Reading is always sectioned — no tool hands back a whole note, and one call returns at most 4000 characters of text or 100 section headings — which caps any single answer rather than how much an assistant can read in total. `edit_note` goes through the same path the API already used, so a change lands live in every open tab and is recorded in that note's **AI edit history**, where you can undo it while the note has not moved on and it is still among the at most 100 rows a note keeps; every operation but `append` also has to hand in a fingerprint, which is what stops it overwriting what somebody just typed. A `create_note` carrying `content` is recorded the same way; one without it simply makes an empty note and records nothing. The rate limits are the ones API tokens already had, counted per tool call — except the 300-reads-a-minute one, which one `POST /api/mcp` spends once no matter how many tools it calls, because the endpoint always declares read access. See [MCP](docs/mcp.md) for the tools, the commands for each client, and the exact limits — and the MCP entries in [Known limitations](docs/known-limitations.md) (#108).
### Changed
- `POST /api/notes` now rejects unknown body fields with `400 invalid_body` instead of ignoring them, so a misspelled `content` field is reported rather than silently creating an empty note (#106, #137).
## [0.3.3] - 2026-09-02
### Added
- Every account now has a **username** — a short, URL-safe name (lowercase letters, numbers, hyphens) shown and changeable under Settings → Account, for both password and SSO accounts. Existing accounts get one derived from their email's local part on upgrade; SSO sign-ups prefer the identity provider's `preferred_username` claim when the ID token carries it, and admins can set one when creating an account via the API. Renaming is allowed 5 times a day, and a released name is **permanently retired — nobody can ever take it, including you changing your mind** — so links shared under an old name can never start serving someone else's content. The username appears in Settings and the admin user list, and every new-form note URL carries it (`/n/<username>/<slug>` — see the note-URL entry below; old-form `/notes/…` links don't and are rename-proof); it can also appear in a public share's URL when you opt in (see the custom-public-URL entry below).
- Note URLs are now human-readable: `/n/<username>/<slug>` — no uuid suffix. The slug follows the note's title automatically (renaming the note moves its URL, collisions among your own notes get a numeric suffix), or you can pin a custom one in the Share dialog, which now shows the three states honestly: the input is empty unless you've set a custom slug, its placeholder always shows the automatic URL you'd get, and a "Use automatic URL" button appears only when there's a custom slug to give up. Slugs are unique **per user** rather than instance-wide, so `slug_taken` now only ever means another of your own notes. Every previously shared `/notes/…` link keeps working forever (the pre-revamp slug is frozen as a permanent alias, and uuid-suffixed links resolve as before); the sidebar's "current note" highlight now tracks the note itself rather than the URL, so it survives renames. One API-visible behaviour change: `PATCH /api/notes/:id` with `slug: null` now means "back to an automatic slug recomputed from the title" rather than clearing it, and counts against the slug-change rate limit. One upgrade-time note: the migration derives slugs from titles in SQL, which handles ASCII only — a note with a non-ASCII title comes out as `untitled(-n)` and its URL shifts once more, silently, on its first title save afterwards. The other costs that come with human-readable URLs — automatic URLs moving with the title, one level of grace for custom slugs, slug re-use within an account, and username renames breaking `/n/` links — are recorded in [known limitations](docs/known-limitations.md) (#122).
- A note can now be shared with a public link — anyone who has it can read the note at `/p/<token>`, no account needed. The Share dialog gains an access selector with three levels: **Private** (only you; picking it while members exist asks for confirmation first — covering the link too when one is on — and removes the link before the members so an interrupted switch never leaves the link live; with no members it just turns the link off), **Members only** (the existing email invitations, unchanged), and **Public link**, which shows the URL with copy and regenerate buttons. The public page is strictly read-only, shows the note's title, content and its uploaded images, requires no login, carries `X-Robots-Tag: noindex`, and renders none of the editing machinery; regenerating or turning the link off makes the old URL answer an indistinguishable 404 immediately. The link token is unguessable (256-bit), never appears in server logs, and the public endpoints are rate-limited in a way that doesn't reveal whether a token exists. What anonymous readers see is a snapshot that can lag active editing by a few seconds — the full behaviour, and its recorded trade-offs (Yjs state exposure, cross-note images, browser-cached images after revoke, `TRUST_PROXY` behind a reverse proxy), are documented in [Sharing](docs/sharing.md) and [known limitations](docs/known-limitations.md) (#72).
- A public share can now also have a **custom public URL**: `/p/<username>/<name-you-choose>`, set from a new row in the Share dialog while the public link is on. It serves the same read-only page as the token link (images included) and is an addition, not a replacement — the token link keeps working, **Regenerate link** replaces the token without touching the custom URL, removing the custom URL leaves the token alive, and switching to Members only/Private deletes both. The trade-off is spelled out in the dialog itself: a readable URL is a guessable URL, and it reveals your username — for anonymous sharing, use the token link. Names are unique per account (yours can't collide with another user's), validated like note slugs, and share the slug-change rate limit. Renaming your account kills the old custom URL immediately (old usernames are never forwarded) — the name itself survives and keeps serving under your new username. The custom-URL page carries the same `noindex` and the same indistinguishable-404 discipline as the token page; the finer print (username disclosure, doubled read quota for a note reachable both ways) is in [Sharing](docs/sharing.md) and [known limitations](docs/known-limitations.md) (#122).
- Knotebook now sends a Content-Security-Policy with the page. A script injected into a note's content does not run, plugin content (`<object>`/`<embed>`) is refused, `fetch`/XHR/WebSocket can only reach Knotebook itself, and the app can no longer be framed by another site. Images, audio and video embedded by URL keep working, over both http and https — that is a deliberate policy choice, since a note can already reference an external image on purpose, and the same holds for diagrams; a note's content can therefore still cause requests to an outside host by referencing one. Two consequences worth knowing if you self-host: stylesheets, fonts and scripts have to come from Knotebook itself, so a reverse proxy that injects an analytics script into the page, or assets moved to a separate CDN, will be blocked, and Knotebook can no longer be embedded in an iframe — see [Self-hosting](docs/self-hosting.md#content-security-policy). Pages also carry `Referrer-Policy: no-referrer`, so a site hosting an image you embedded no longer learns which note is being read, and `X-Content-Type-Options: nosniff` (#101).
### Changed
- A code block's language dropdown now appears above the block, at its right edge, when you hover the block — the same place and the same behaviour as a diagram's Edit button, in the same small bordered style. It used to sit inside the block's top-left corner, overlapping the start of the first line, which is where you usually click to put the cursor. Keyboard users still reach it by tabbing (focus reveals it). On a note you can only read, it drops its border and background so that hovering the block reveals a plain language label rather than a control you cannot use, since the dropdown is disabled there anyway. Opening it now shows a menu styled like the app's other menus — same surface, border, radius and hover highlight — with a caret on the control that flips when the list is open, in browsers that support customizable selects (Chromium 135+, Safari 27). Where they do not (Firefox today), the browser keeps its own native menu, whose options now at least follow the light/dark theme instead of BlockNote's hard-coded black, which was unreadable against a dark menu (#111).
- A note's title row and the backlinks row span the full card width — title on the left, controls on the right — while the text keeps its centred column. Earlier in this unreleased cycle the two rows had been moved into the text's centred column to line their left edges up (#88); that alignment never shipped and is superseded by this layout revamp, which returns the ~100px of title room the alignment cost on a narrow window and removes the few-pixel scrollbar offset it carried. On a window narrower than 768px the note text's side inset also shrinks from 70px to 20px, so the text and the title share one left edge exactly where the horizontal space is scarce; one cost of that, hover handles clipping on a narrow window, is recorded in [known limitations](docs/known-limitations.md) (#115).
- Knotebook now works on a narrow window (below 768px). The sidebar hides and becomes a slide-in drawer — opened from a hamburger button in the note's title row, or from a small top bar on pages without one — closing on Escape, on tapping the backdrop, or when you pick a note; Ctrl/Cmd+K opens it with the search box focused. The note text's side inset shrinks so the width goes to your writing, the connection badge collapses to its status dot (screen readers still hear the full text), and the sidebar's note rows grow to a comfortable touch size. One window width is the single breakpoint for all of it (#115).
- The AI entry point is now a round floating button at the bottom-right of the page instead of a collapsed rail beside the note. Clicking it opens the same action list as before — beside the note on a wide window, or as a full-width sheet rising from the bottom on a narrow one, replacing the old right-hand drawer there. The button fades out while you scroll the note and returns when you stop, and keyboard focus always makes it visible; the backlinks row keeps a little end padding so its last chip can scroll clear of the button (#115).
### Fixed
- The public share page now fills the browser window like the rest of the app, instead of floating as a width-capped rounded card that left background margins on wide screens — and the whole page is one flat surface, with no visible content-column "card" either (the page ground now matches the editor's own background color, in both themes). Text line length is unchanged — the reading column is still centered and capped.
## [0.3.2] - 2026-08-31
### Added
- Code blocks now have syntax highlighting. Pick a language from the dropdown in the block's top-left corner — it used to be empty — or type it in a ``` fence (aliases like ```py and ```ts work); 23 languages are included, from TypeScript and Python to SQL, YAML and Dockerfile. Colors are semantic — keywords, strings, comments — with one palette for the light theme and one for dark, switching instantly with the theme; the accent color you chose does not affect them. In light mode a code block is no longer a hard-coded dark box: it now sits on a light grey background with a border, like other surfaces. Copying or exporting a code block still produces the plain source — the coloring never becomes part of the note. The highlighter loads only when a note actually shows a code block, and each language's grammar loads on first use, so notes without code download nothing extra (#96).
- Notes can contain Mermaid diagrams. Insert one with `/diagram` (`/圖表` in Traditional Chinese) from the slash menu, click a diagram to edit its source, then press Escape or click away to draw it again. Pasting a ```mermaid fenced code block turns it into a diagram automatically — including diagrams produced by the AI actions — and the button in a diagram's top-right corner opens the source as well — labelled Edit, or Show source for readers, who get the same text read-only. Diagrams follow the light/dark theme. When the source has a syntax error the block shows the message next to the source, so it stays editable rather than becoming an opaque failure. Copying a diagram out of Knotebook produces the ```mermaid source rather than an SVG, so it renders on GitHub or Obsidian and can still be edited there. A diagram can reference a remote image, the same way a note can embed an image by URL — that is the existing policy for note content rather than something new. What a diagram cannot do is make anything execute: Mermaid runs with `securityLevel: "strict"`, `bindFunctions` is never called, and `themeCSS`/`htmlLabels`/`fontFamily` are locked against a diagram's own init directive so labels stay SVG text. Links a reader can click are kept, marked `rel="noopener noreferrer"`. Mermaid itself is loaded only the first time a note actually draws a diagram, so notes without diagrams download nothing extra (#94).
### Changed
- A selected block now gets a 2px outline in the theme's accent color instead of BlockNote's built-in 4px bright blue. The blue was hard-coded, so it ignored both the light/dark theme and the chosen accent color, and on a large block — an image, a file, a diagram — it read as a thick blue frame around the whole thing rather than a selection cue.
- The backlinks row's horizontal scrollbar is now thinner than the app's other scrollbars (6px instead of 10px) and sits at the very bottom of the row, with a clear gap between it and the chips above. It used to be full-width thickness crowded against the chips in a 36px-tall row, and further from the card's bottom edge than it needed to be.
- In the backlinks row, the horizontal scrollbar now always reserves its space, and the label is aligned to the same optical centre line as the chips. Previously the scrollbar only appeared once there were more chips than fit, and it took its 10px out of the chips' half of the row — so the chips sat about 5px higher than the label exactly when a note had many backlinks (#90).
- The backlinks strip at the bottom of a note is now a single row — the "Mentioned in N notes" label sits inline with the source chips, which scroll sideways when there are more than fit — and it is always present, showing "No mentions yet" when nothing links here. Previously the label sat on its own line above a wrapping list of chips, and the whole strip disappeared when a note had no backlinks, which made the feature invisible on any note nobody had linked to yet and let the note card's height jump around. The row is the same height as the account row at the bottom of the sidebar, so the two line up.
- The note's text column now scales with the window instead of stopping at a fixed 680px: it takes 85% of the scrolling area's width, between 680px and 1056px. Windows up to about 1100px wide keep the column they had — 680px is the floor, and a window narrower than that still gets a full-width column — while on wider screens the article grows with the card instead of sitting as a narrow strip between two large empty margins (#87).
### Fixed
- Redo now works after undoing a note all the way back to empty. Previously, undoing until the note was blank silently discarded the redo history a fraction of a second later — the editor puts the required empty paragraph back into a blank note, and that correction was being recorded as if it were a fresh edit of yours, which is exactly the situation where an editor is supposed to throw redo away. That correction is no longer treated as an edit, and redo brings your text back reliably, including undoing and redoing across the same boundary repeatedly. Deliberately deleting everything in a note is still a normal, undoable edit. This closes the one known gap in the undo fix shipped alongside it (#100).
- Typing `[[` to link another note now works when the two brackets arrive together rather than one at a time. Previously the menu only opened if the editor saw each `[` as its own separate keystroke, so it failed when you typed quickly and the browser batched them, and it failed every time under a Chinese input method — where committing the pending characters sends the whole run, brackets included, as a single edit. The workaround people found (pressing `[` a few more times until one landed on its own) is why it looked intermittent rather than broken. Detection now looks at what sits before the cursor once the input lands, so anything the input method commits alongside the brackets is kept instead of being swallowed. Typing `[` over a selection still replaces the selection and opens the menu, as before (#98).
- Undo works again in the editor. Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y did nothing at all — silently, with no error — on every note, in every browser. Notes are collaborative documents, so undo is handled by Yjs rather than by the editor's own history, and the piece of Yjs that records what you changed was being torn down and never reconnected the moment a note finished loading: it stopped listening before you could type your first character. Undo history is per note and only ever takes back your own edits, never a collaborator's. Redo works too, with one exception that is still open: if you undo all the way back to an empty note, redo cannot bring the text back (undo itself is fine, including undoing to empty) — see [Known limitations](docs/known-limitations.md) and #100 (#97).
## [0.3.1] - 2026-08-26
### Changed
- Scrollbars throughout the app — the note content, the sidebar's note list, the AI panel, the settings dialog and any other scrolling area — are now a thin, rounded, semi-transparent grey that follows the light/dark theme, instead of the operating system's default scrollbar. The track is transparent and the thumb gains contrast on hover (darker in light mode, brighter in dark mode). In Firefox the browser's native thin scrollbar is used, in the same theme-following grey but without the hover effect. On macOS this also means scrollbars are always visible in their own 10px gutter rather than overlaying content and auto-hiding: styling a scrollbar opts that scroll area out of the system's overlay scrollbars (#84).
- The six accent-color dots in the user menu now show which one is selected with a checkmark inside the dot instead of a ring around it — the ring's contrast against the light theme's popover fell short of WCAG's 3:1 minimum, and it shared the same ring slot as the keyboard focus ring, which overrode it, so a focused dot lost its selection cue. The keyboard focus ring itself is unchanged and no longer competes with anything: focus and selection are independent now, so a focused, selected dot shows both at once. The accent-color group of dots also gained an accessible group name (`aria-labelledby`) tied to its "Accent color" heading (#78, #79).
- Traditional Chinese accent color names changed: 青 → 青瓷, 鼠尾草綠 → 鼠尾草, 粉 → 玫瑰. The English names, and the indigo/blue/gold names in both languages, are unchanged (#80).
## [0.3.0] - 2026-08-26
### Changed
- Dark mode's palette is now a neutral dark grey with a slight cool tint, replacing the near-black default, and layered in three visible steps — page background darkest, cards a step lighter, floating surfaces (dialogs, toasts, the BlockNote slash menu/formatting toolbar/tooltips) lighter still — instead of cards and floating surfaces sharing one shade. BlockNote's editor surface and its floating menus now match this scale instead of showing their own built-in dark colors (#75).
- The main layout is now built from cards floating on a deeper background instead of flat, edge-to-edge panels: the sidebar, the note's content, and the AI panel each sit in their own bordered card, while inputs and outline buttons keep the page's background color rather than the card's, so they read as inset into whatever surface they sit on. The sidebar note list now groups into Recent / My notes / Shared with me, with a title search box above it (Ctrl/Cmd+K focuses it, Escape clears it and moves focus away). Deleting a note has moved off the sidebar row and into the note's own ⋮ menu in its header, alongside a new "Copy link" entry; the sidebar itself no longer has a delete button. Backlinks are now a row of chips at the bottom of the note instead of a collapsed section that had to be expanded to see anything. The light theme's page background is now a light grey instead of pure white, to give the new cards something to float on top of — a visible change for anyone using it (#76).
- Knotebook now offers six accent colors — Indigo (the default), Blue, Teal, Sage, Rose and Gold — picked from a row of color dots in the user menu, below the theme setting. The chosen accent tints four places: the "K" wordmark, the sidebar's selected note row, the "New note" button, and a note's share icon. This changes the default look for everyone, not just people who open the picker: those four places used to be neutral and are now tinted Indigo out of the box, and the "New note" button in particular went from a solid filled button to a soft translucent tint of the accent color. As with the theme setting, the choice lives only in this browser's local storage; it is not synced across devices or saved on the server (#77).
## [0.2.0] - 2026-08-25
### Security
- An embedded image, video, audio or file URL is now checked again when it is rendered, not only when it is typed into the embed field. Note content is a collaborative Yjs document, so any collaborator with edit access can write block properties directly and bypass the input check entirely — a `javascript:` or `data:` URL written that way went into every other reader's document unfiltered (inert in an image or media `src`, but it had no business being there). Uploaded files keep working: the render-side check deliberately accepts the relative URLs uploads produce. A URL that fails the check renders as a broken image or player rather than being fetched (#12).
- An AI provider's stored API key is now cryptographically bound to the provider it belongs to, so a key that ends up under the wrong provider is reported as undecryptable instead of being used. The ciphertext was AES-256-GCM but carried no associated data, so a key copied into another provider's row decrypted just fine and that provider would happily send it upstream — which is how a row restored from an old backup, or a hand-written `UPDATE`, could silently start billing someone else's account. This is an integrity check rather than a defence against an attacker who can already write to the database: anyone who can move ciphertext between rows can also point a provider's base URL at a host they control and read the key in plaintext. Old ciphertexts still decrypt and are rewritten in the new format on the next start (including disabled providers; a failed rewrite is logged and retried next time, never blocking startup). **The rewrite is one-way** — see [Known limitations](docs/known-limitations.md) before rolling back to an older version (#14).
- Changing an AI provider's Base URL now clears its stored API key. The key is sent in plaintext to whatever host that field names — by design, that is what a provider is — so until now any administrator could point an existing provider at a host they controlled, press Test, and receive a key they were never able to read back through the UI. Supplying a new key in the same edit keeps the provider working in one step, the Settings UI warns before saving, and a provider with no key stored now says so on its card. Editing a provider without touching its URL does not clear anything. This does not make an untrusted administrator safe — they can still point a provider somewhere and wait for a key to be entered again, which is now visible rather than silent, and logged with the account and the old and new host. See [Known limitations](docs/known-limitations.md) (#46).
- Copying a note block to the clipboard, and the markdown handed to an AI quick action, now pass embedded media URLs through the same check that rendering does. The #12 fix guarded every place a URL enters this app's own pages, but export took the raw stored URL — so copying a block a collaborator had poisoned pasted `<a href="javascript:…">` or `<img src="javascript:…">` into whatever application received it, and an AI action sent the raw URL upstream. Not an XSS in Knotebook itself (that path was already closed), but poisoned content should not leave the app either. A URL that fails the check is exported as `about:blank`; uploaded files and ordinary web links export unchanged (#43).
- The server now notices when a note's document contains a media URL with a scheme like `javascript:` or `data:`, and says so in the log. Note content is writable by any collaborator with edit access, and the client-side guards (#12, #43) replace such URLs everywhere they could do harm — but the string itself stays in the document indefinitely, invisible to whoever runs the instance. On save, the server scans media-URL fields and logs one warning per note per load cycle naming the note, the schemes and the block types (never the URL itself, and never more than one line however often the note saves). It deliberately does not rewrite or reject anything: teaching the server to validate document content is a design decision this change does not make. See [Troubleshooting](docs/self-hosting.md#troubleshooting) (#44).
- A collaboration connection that keeps sending messages the server cannot handle is now dropped. The collaboration library's reaction to such messages — an unknown message type, a type its enum names but its handler has no case for (`SyncStatus`/`Ping`/`Pong`), or an authentication message of a kind only servers send — is to write an unstructured error line and carry on, without closing the connection or counting against any of this app's log throttles; a signed-in user connected to their own note could therefore write to the server's log at close to line speed for as long as they liked. Those messages are now classified against the set the library actually handles and dropped before they reach it; a connection that accumulates ten of them is forcibly disconnected, leaving one log line naming the note, address and message type — that line itself capped process-wide at ten per minute, since it can be triggered before anyone is signed in. The tolerance exists because an unknown type can also be a newer client talking to an older server during an upgrade; the flip side, recorded in [Known limitations](docs/known-limitations.md), is that a future client version introducing a frequent new message type would see its connections dropped until the server is upgraded too. As a second layer, the compose file now caps the `app` container's log size (50 MB × 5 files) so any future log-writing path degrades into rotation rather than a full disk (#50).
### Fixed
- Test-only: the note page's error screen now has an integration test pinning that opening and closing the settings dialog on top of it does not trigger a page reload. The error boundary added in #66 deliberately keys its "user navigated away, reload to recover" behaviour on the note reference from the URL rather than the router's location key, because closing the settings dialog navigates back to a saved location and that produces a fresh location key — keying on it would turn "close the dialog" into a surprise full-page reload. That choice was only recorded in code comments; switching it back would have kept every existing test green. The new test walks the real route tree through open-settings/close-settings on a failed note page and asserts zero reloads, alongside positive controls proving the reload spy actually intercepts the boundary's default reload path on both lifecycle routes. The same change also hardens two pre-existing settings-dialog tests that waited on the note page's real lazy import with the default one-second timeout — the only such waits in the suite, and intermittently red under a cold, loaded worker even without this branch (#68).
- A note page whose code chunk fails to load — the tab is offline, or a deploy rotated the asset hashes out from under an open tab — no longer strands the user on a blank white page. The note editor's code is loaded on demand (#19), and a failed load used to escape as an uncaught render error that unmounted the entire app. Now, when the failure looks like a deploy rotation (the network is up and this tab's one automatic retry hasn't already been spent), the page reloads itself once — picking up the new asset hashes, usually without the user noticing; the retry budget is re-armed each time a chunk load succeeds, and a genuinely broken deploy degrades to an error screen instead of a reload loop. In every other case — offline, retry exhausted, storage unavailable, or a crash inside the note page itself rather than a chunk failure — the app shell stays up with a message and a retry button, and navigating to another note from the sidebar — when the network is up — recovers via a reload too. As a backstop, the whole app is now wrapped in a top-level error boundary, so *any* unhandled render error shows a "Something went wrong — Reload" screen instead of a white page. The chunk-failure detection matches known browser, bundler and reverse-proxy error messages (a failed load of this chunk usually surfaces as Vite's CSS-preload error, not a bare failed import) and the one-reload budget lives in per-tab session storage — both edges are recorded in [Known limitations](docs/known-limitations.md) (#66). While the tab is offline, the retry and reload buttons are disabled — without a service worker, reloading offline would land on the browser's network-error page instead of back in the app — and they re-enable the moment the browser reports the connection is back.
- An AI quick action that fails because its OpenAI-compatible provider has no API key stored now says so — "AI provider is unavailable — ask an admin to re-enter the API key" — instead of the unrelated "The AI provider returned an error". A provider with no key is a legitimate configuration for a local vLLM or Ollama, so the request is still sent; but since changing a provider's Base URL clears its key (#46), "models attached, key gone" became an easy state to reach, and the resulting upstream 401 deserved a message pointing at the actual cause. The mapping is deliberately narrow: only an upstream 401/403 on a provider with no stored key — a provider whose key was revoked upstream still reports a provider error, not a missing one (#53).
- Test-only: the backlinks endpoint's two safety properties — readers only see source notes they have access to, and the list is capped — now have a combined regression test pinning their order. Each property was tested separately, but nothing failed if the implementation ever capped first and filtered after, which would show readers an empty or incomplete list while burying the titles they may see under a stranger's flood of newer notes. The new case floods past the cap with inaccessible sources and asserts the reader still gets exactly their few visible ones; rewriting the query cap-first was verified to turn it red (#25).
- Test-only: the end-to-end tests' shared note-creation helper now waits for the "Connected" badge before handing the note over for typing, instead of borrowing that precondition from #48's read-only-until-synced behaviour. The flake this guards against — text typed before the collaboration handshake completed was silently lost on reload — was closed as a side effect of #48; the explicit wait keeps the suite stable if editing before first sync ever comes back (#33).
- The integration test harness now drops each throwaway database it creates instead of leaving it behind. Every test provisions its own `test_<random>` database and only closed its connection pool afterwards, so a single full run accumulated several hundred databases in the one Postgres container — until `CREATE DATABASE` started failing partway through and the run went red in a way that looked like a flake but cleared on a rerun (a fresh container). Each database is now dropped when its test finishes, with `WITH (FORCE)` to evict any straggler connection and a drop failure logged rather than allowed to fail an otherwise-green test (#51).
- The editor no longer looks connected while it is offline, and no longer lets you type into a note that never loaded. Previously the badge read "Connecting" identically at second 1 and minute 30, and the editor accepted typing the whole time — worst of all when a note failed to load at all, where you could type into a blank document and lose it on reload. Now a note that has never synced is read-only until it loads, and once a connection has dropped for more than a few seconds the badge escalates to a warning: "Editing offline — changes not synced yet" when there is synced content to merge back, or "Not loaded — offline" when there is not. Brief reconnects (a few hundred milliseconds) do not flash anything (#48).
- A rejected collaboration handshake no longer tells people they lost access to a note when they did not. The server had one rejection reason for three different situations, and the client turned all of them into "You no longer have access to this note", closed the note and returned to the list. So an account that was disabled, or whose sessions were invalidated by a password change elsewhere, produced that notice instead of a sign-out; an unexpected server-side error produced it too. The server now separates a genuine access decision from "this token cannot be used right now", and the client only acts on the former — anything else retries the handshake on the same widening delay used for token failures, which for a revoked session converges on a sign-out within seconds. A revoked share still converges on the same notice it always did. Relatedly, a note that finished deleting while a tab was in its reconnect backoff now says the note was deleted rather than blaming the person's access, for a two-minute window after the delete; past that the server deliberately cannot tell the two apart, because distinguishing them for everyone would turn the connection into a way to ask whether any given note id exists (#35).
- A collaboration token response that arrives after switching notes is now ignored instead of being applied to the note now on screen. The request for the previous note stayed in flight across the switch, and its role landed in the current note's state — worst case, the previous note's `none` arrived while the current one was in its post-revocation retry window and ejected someone from a note they had every right to be in (#36).
- A note that cannot fetch its collaboration token now keeps trying instead of sitting on "Connecting" forever. When the token request kept failing past its retry budget, the connection was left open but unauthenticated with nothing scheduled to try again — so a server hiccup during a page load meant that tab stayed dead until it was reloaded, even after the server recovered. It now restarts the whole handshake on a widening delay, and resets that delay once a connection succeeds (#39).
- A reconnect that was slow to close no longer wedges the connection. The one-second safety net that covers "the socket was already gone" used to give up waiting for the real close event; if that event then arrived, it was read as an ordinary disconnect and nothing reconnected, leaving the editor on "Connecting" until reload (#34).
- Losing access to a note before its collaboration connection is established no longer parks the editor on "Connecting" forever — whether the note was still opening or the connection happened to be reconnecting at the time. When the share is revoked before that handshake completes, the server rejects the handshake itself rather than sending the close message the client was waiting for, and that rejection carried no state change at all. The client now reads it, retries once with a fresh token, and then shows the same "You no longer have access to this note" notice and returns to the note list as any other revocation (#6).
- A single SSO sign-in no longer spends two units of the same rate-limit quota. `GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback` shared one 30-per-minute-per-IP bucket, and a complete sign-in always goes through both — so the usable number of sign-ins was half the advertised one, which offices behind a shared outbound IP hit first. Each endpoint now counts against its own bucket (#16).
- The failed-login throttle no longer grows without bound. Its account and IP records were only dropped when the same key happened to be touched again after 15 idle minutes, so a spray of distinct accounts or addresses left entries behind for the life of the process. Both tracks now share the same bounded map the other limiters already used, and the record evicted first is always the one whose last failure is oldest — the one closest to expiring anyway (#15).
- Deleting an AI provider now also drops it from the degraded set. Nothing else clears that entry — re-entering an API key is the only other path — so the id lingered for the life of the process (#17).
- The password-length hint is generated from the shared `MIN_PASSWORD_LENGTH` constant instead of being written out in each translation, so raising the minimum can no longer leave the UI advertising the old one (#22).
### Changed
- The first page load no longer downloads the note editor. The editor and its collaboration machinery — two thirds of the previous 1.6 MB bundle — only matter once a note is actually opened, but they shipped in the initial bundle that the sign-in page, the note list and the settings dialog all had to wait for. The note page is now loaded on demand: the initial bundle drops to roughly a third of its former size (about 495 KB to 167 KB compressed), opening the first note fetches the editor chunk with the same loading indicator the page already shows while fetching the note, and a CI check now pins both the entry-bundle ceiling and the presence of the split-out chunk so neither regresses silently (#19).
- Email lookups — sign-in, adding a share by email, OIDC account linking — now use an index. Every one of them compares `lower(email)`, but the schema only indexed the raw column, so each lookup walked the whole users table; unnoticeable on a small instance, a real cost as accounts accumulate. The new index is deliberately non-unique: case-variant duplicate emails are allowed to exist, and OIDC sign-in depends on being able to detect that situation rather than having the database reject it — a test pins both the index and that property (#18).
- The re-verification deadline that closes a collaboration connection when its holder loses access is now documented as what it actually is: deliberately shorter than the client's worst-case token-retry backoff (5s vs 7.5s), so a revocation never waits out a retrying client. A false positive costs the affected client a reconnect — and if the backend is still failing, its usual restart backoff and an offline badge until things recover — but never mislabels them as having lost access. The comment next to the constant used to claim the margin covered that backoff, which was false and would mislead anyone tuning either number. Both constants now live in the shared package, and a test pins the deadline-shorter-than-retries relationship so changing either side forces a conscious revisit (#40).
- A refused collaboration connection now leaves a line in the server log — which note, which account, which of six causes, and whether it was refused while connecting, closed after a permission re-check, or closed because the client did not answer that re-check in time — and an unexpected error inside the handshake is logged with its stack trace instead of being flattened into a generic rejection. Since a refusal now navigates the person away rather than leaving them stuck on "Connecting", the report that reaches the maintainer is "I got kicked out", with nothing on either side to say why. The refusal lines are capped per account, with errors on their own budget and a warning when the cap bites, and refusals decided before the token identifies anyone are logged at `debug` so an anonymous socket cannot drive them at all. (The line written when a client misses a re-check is not capped — it is bounded by how many connections that re-check covered.) See [Troubleshooting](docs/self-hosting.md#troubleshooting) (#37).
- A few stray simplified-Chinese characters and one garbled sentence in source comments (#34).
- **Breaking for reverse-proxy deployments:** `X-Forwarded-For` is no longer believed by default. It used to be trusted from any source, which let anyone forge their apparent address and walk around the per-IP login lockout and the OIDC endpoint limits. The new `TRUST_PROXY` setting turns it back on — a list of trusted proxy addresses (IPs, CIDRs, or `loopback`/`linklocal`/`uniquelocal`), a hop count, or `true` for the old behavior. **If you run behind a reverse proxy and do not set it, every visitor arrives as the proxy address and shares one rate-limit bucket**, so five bad passwords from anyone lock out everybody. The server logs a warning at startup when `PUBLIC_URL` is https while the setting is off, and once at runtime when it sees a forwarding header it was told not to trust. Note that your proxy has to send `X-Forwarded-For` for the setting to do anything (nginx does not by default), and a proxy that rewrites `Host` and relies on `X-Forwarded-Host` will now fail image uploads with 403 until `TRUST_PROXY` is set (#13).
- The collab-token rate limit stays keyed on the user, not the note, and the reasoning is now recorded next to it: the limit exists to bound what one signed-in user can cost the database, keying it per note would multiply an attacker's allowance by their note count, and keying it on the address would penalise offices behind one outbound IP (#24).
- The AI session's context value is memoised, so a streaming response no longer rebuilds every consumer — including the portal-mounted toolbar — on each delta (#20).
- `POST /api/auth/login` and `GET /api/auth/me` are typed against the shared `UserDto`, and `GET /api/auth/config` against `AuthConfigDto`. A change to those shapes now fails the build in `apps/server`, where the mistake is, rather than in the web app that consumes them (#21).
- A stray simplified-Chinese character in a source comment (#23).
## [0.1.1] - 2026-08-20
### Added
- Release automation: pushing a `vX.Y.Z` tag publishes the matching GitHub Release, with the notes taken from this file's section for that version. The workflow fails rather than publishing an empty release when a version has no section here, and can be re-run by hand for a tag pushed earlier.
### Fixed
- Markdown pasted from a Windows clipboard renders as blocks again. Line endings were the whole story: the Markdown parser does not normalise CRLF, so `- ` lists and ``` fences stayed literal while headings came through fine — which is why the failure looked arbitrary. The paste handler now normalises before parsing (#28).
- Copying a Markdown file out of VS Code and pasting it produces blocks rather than one code block. VS Code tags the clipboard with the file's language, which previously wrapped the entire paste in `language-markdown`; that tag is now honoured for code and ignored for Markdown files (#27).
- A code snippet copied from a documentation page pastes as a code block again, instead of having a leading `# comment` promoted to a heading (#27).
- A failed session lookup no longer leaves the whole app spinning. When `GET /api/auth/me` fails with anything other than 401, the route guards show an error with a retry button instead of treating the unresolved session as "still loading" (#7).
- Sidebar highlighting and delete-navigation recognise a custom slug regardless of case or Unicode normalisation form, matching how slugs are normalised on save (#8).
- Editing a note title while a save is in flight no longer loses those keystrokes — neither the server's echo nor the error-path reset overwrites what was typed after submitting (#10).
- "Copy link" works on plain-http deployments. `navigator.clipboard` only exists in a secure context, so the button was inert in the LAN mode this project documents; it now falls back to a copy inside the share dialog, and shows the URL in a read-only field when neither route can copy (#9).
- Dark mode no longer flashes a white screen on first paint (#11).
## [0.1.0] - 2026-08-14
### Added
**Server foundation & auth**
- Password-based authentication: environment-variable admin bootstrap (`ADMIN_EMAIL`/`ADMIN_PASSWORD`) that atomically creates the first admin account at startup and forces a password change on first login, plus session cookies (JWT-backed), login/logout, self-service password change, and login rate limiting with exponential backoff/lockout.
- OIDC/SSO login: `GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback` implement the authorization-code + PKCE flow against any OpenID Connect Discovery-compatible identity provider, auto-provisioning new accounts and linking to existing password accounts on a verified email, gated by a per-IP rate limiter; `GET /api/auth/config` lets the client discover whether it's enabled.
- Notes CRUD API with owner/editor/viewer sharing and role-based permissions, and admin user management (create, disable, enable, promote).
- Docker Compose deployment (`app` + Postgres `db`) with a production build, a persistent `uploads` volume, and a trusted-LAN plain-http deployment mode with a loud startup warning.
**Collaborative editing & sharing revocation**
- Real-time collaborative editing (Yjs/Hocuspocus) mounted at `/collab`, with a short-lived collab-token endpoint and role-based auth/re-verification on connect and on token refresh.
- Immediate revocation semantics: admin-disable and share-removal force-close the affected user's live collab connections; note deletion gates out reconnects during teardown.
- Y.Doc persistence with an optimistic lock and bucketed, auto-pruned snapshot backups (`note_state_backups`), plus a documented manual restore runbook (see `docs/backup-restore.md`).
- Browser UI: note list sidebar, app shell, block editor (BlockNote) with collaborative cursors and title editing, canonical note URLs, custom slugs, share dialog with role management, and an admin users page (later migrated into the Settings modal — see below).
**Wikilinks & backlinks**
- `[[wikilink]]` inline content: `[[` trigger, suggestion menu, create-and-link, and three-state rendering (resolved/unresolved/loading) in the block editor.
- Wikilink index sync (`POST /api/notes/:id/links`) with a transactional `links_clock` gate and batched target authorization, submitted by a debounced client-side link-sync watcher.
- `GET /api/notes/:id/backlinks` with inline reader authorization, and a collapsible backlinks section on the note page.
**Image uploads**
- `POST /api/notes/:id/uploads` and `GET /api/uploads/:id`, with multipart size/type limits, magic-byte content-type sniffing, and a per-user rate limit.
- Uploaded blobs are deleted after note deletion commits (best-effort).
- Restored image block with a custom FilePanel: an Upload tab for images, Embed-by-URL only for audio/video/file blocks.
**AI quick actions**
- Bring-your-own-endpoint AI: shared contracts for error codes, SSE events, and action/admin DTOs across `anthropic` and `openai_compatible` provider types.
- `POST /api/ai` SSE proxy (`text/event-stream`) with upstream reasoning/thinking-output filtering, an abort chain, and a per-user rate limit.
- Guarded apply chain in the editor: block-anchor snapshots, hash-gated apply, semantic revert, and wikilink rebinding after an AI edit.
- AI panel with a session state machine, a floating selection toolbar, streaming responses, and a split-pane note layout (editor + AI panel + backlinks, independently scrolling).
**Admin AI configuration**
- Provider/model/action CRUD (`/api/admin/ai/*`) with a connection-test endpoint, shared model resolution, and `GET /api/ai/actions` (only actions that currently resolve to a usable model).
- AI provider API keys encrypted at rest (AES-256-GCM, key derived from `APP_SECRET`); four built-in actions (rewrite/translate/summarize/continue) seeded idempotently at boot, renameable/disableable but not deletable.
**Settings modal**
- Notion-style Settings modal (Account, Users, AI) opened over the background route from the user menu, replacing the standalone `/admin/users` page; shares its password-change form with the standalone `/change-password` page used for forced resets.
**End-to-end testing**
- Playwright end-to-end test suite (`e2e/`) exercising bootstrap, notes, live collaboration, sharing/revocation, AI quick actions, and OIDC login against a stub identity provider, runnable locally via `scripts/test-e2e.sh` and wired into CI as a dedicated `e2e` job on pushes to `main` (and manual dispatch).
### Changed
- Email matching (login, sharing, admin user creation, OIDC account linking) is now case-insensitive: every write path normalizes the address to lowercase, and every lookup compares via `lower(email)`. `alice@example.com` and `Alice@example.com` now resolve to the same account.
### Fixed
- Hocuspocus setup hash-busy handling and collab hook contract corrections found in final review.
- Router `maxParamLength` raised to 512 so astral (surrogate-pair) characters in note references don't 404.
- Toast id generation no longer depends on `crypto.randomUUID`, which is unavailable in non-secure contexts such as LAN plain-http.
- Viewport-locked app shell so the editor, AI panel, and backlinks regions scroll independently instead of the whole page.
- `/admin/users` now redirects (`<Navigate replace>`) to `/settings/users` instead of 404ing, so old bookmarks/links keep working.
- AI action reorder buttons are disabled while a move is in flight, preventing rapid double-clicks from writing duplicate `sortOrder` values that made ordering unrecoverable from the UI.
### Security
- CSRF protection on uploads scoped to `Origin` header validation, with a documented exemption for requests that carry no `Origin` at all (matching legitimate non-browser clients).
- Byte-capped request body draining and an embed-URL scheme whitelist to bound abuse from unauthenticated or rejected requests.
- Outbound AI provider requests (connection test and streaming) use `redirect: "manual"` so a 3xx from a misconfigured/malicious upstream can't cause the custom `Authorization` header to be forwarded to a third-party host — undici only strips `Authorization`/`Cookie` automatically on redirect, not custom-named auth headers.
- AI provider API keys are never serialized into any API response (admin endpoints return a derived `hasKey` boolean instead of the ciphertext).
## Documentation
- [README](README.md) — quickstart and project overview.
- [docs/self-hosting.md](docs/self-hosting.md), [docs/api.md](docs/api.md), [docs/ai.md](docs/ai.md), [docs/known-limitations.md](docs/known-limitations.md), [docs/backup-restore.md](docs/backup-restore.md).