# Known limitations Per-person rate figures in this document for API and MCP reads and writes, note content reads and writes, searches, uploads and copies, AI actions, bulk downloads and bulk write-backs are the defaults; a site admin can lower each of them in Site admin → Site limits (see [Site limits](./self-hosting.md#site-limits)). The other rate limits are fixed. - **A flood of distinct accounts or addresses can push an active login lockout out of memory.** The failed-login throttle keeps at most 10,000 records per track (account and IP), evicting whichever record's last failure is oldest — so an attacker who can produce more recent failures across that many distinct keys, faster than the locked-out record's backoff window (at most 15 minutes), can drop someone else's lockout. Doing so costs thousands of requests from many distinct source addresses, since the attacker's own address gets locked out after 5 failures and every attempt still pays for a password hash. The bound itself is not optional: without it the tables grow for the life of the process. - **A media URL that fails the safety check shows up as a broken image, with no explanation.** Image, video, audio and file blocks only render URLs that resolve to `http(s)` — anything else (a `javascript:` or `data:` URL written straight into the collaborative document, bypassing the embed field) is replaced with an inert placeholder at render time, so it looks exactly like a broken link. The check runs in each reader's browser: the offending string stays in the document and is still delivered to everyone, there is no UI that shows or clears it, and the only fix is to delete the block. - **The "opens in a new tab" icon on a link can wrap onto a line of its own.** A link to a web address is followed by a small icon, and a line is allowed to break between the link's text and that icon — so the text can end one line with the icon alone at the start of the next. This is appearance only: the link itself, where it goes and what it is labelled, is unaffected (#153). - **A few unusual ways of writing a web address get no "opens in a new tab" icon.** The icon is chosen by how the link's address is written: it appears when the address starts with `http:` or `https:` in any letter case (`HTTPS://example.com`), or with `//` (an address with the scheme left out, such as `//example.com`) or its backslash spellings (`\\example.com`, `/\example.com`, `\/example.com`). Some spellings the browser still reads as a web address are not recognized — for example an address that starts with a space or another invisible control character (` https://example.com`), or one with a tab or line break anywhere in it (the browser drops those characters, so `/`, a tab, then `/example.com` is read as `//example.com`). Such a link still opens the site in a new tab when clicked, but shows no icon. A link that has become a link to a note on this site opens in the same tab (see the entries on links to notes below); any other link to a path on this site — for example a public page (`/p/…`), a note address with `?present`, or a link that stayed an ordinary link — still opens in a new tab with no icon. The gap only ever leaves an icon out: it is never shown on a link that does not open a page in a tab, such as `mailto:` and `tel:` (#156). - **Rolling back to a pre-0.2 version after starting 0.2 makes stored AI provider keys unreadable.** On first start, 0.2 rewrites each stored AI provider key into a format that binds it to its provider; older versions do not understand that format and treat those keys as corrupt. The providers show as degraded and their keys have to be entered again. Nothing else in the database is affected. - **A tab that cannot reach the collaboration server keeps retrying in the background; edits made while offline are still lost if you reload before it recovers.** When the collab token cannot be fetched — or when the server rejects the handshake for a reason that is not an access decision, such as a token that failed to verify or an unexpected server-side error — the tab restarts the whole handshake every 5, 15, then 60 seconds (jittered) for as long as it stays open, which recovers on its own once the underlying problem is fixed. The connection badge escalates to a warning after a few seconds ([issue #48](https://github.com/sw-willie-wu/knotebook/issues/48)): if the note had already synced once, it reads "Editing offline — changes not synced yet" and the editor stays writable, and those edits merge back **when the connection recovers**; if it never synced (the note failed to load at all), the editor is read-only and the badge reads "Not loaded — offline", so you cannot type into a blank document at all; a read-only viewer sees a plain "Offline" rather than an editing message. The remaining data-loss window is the first case: there is no `y-indexeddb`, so anything typed while "Editing offline" lives only in that browser tab and is lost if you reload or close it before the connection comes back. Those retries count against the per-user collab-token rate limit, which is shared across every open tab, so a user with many stuck tabs can exhaust their own quota. This is also what a server-side error during the handshake looks like from the browser, which is why it is logged on the server — see [Troubleshooting](./self-hosting.md#troubleshooting). - **A note move to the trash that fails partway still ejects everyone who had it open.** Deleting a note — which moves it to the trash — closes every live connection to it *before* the transaction that moves it runs, and each of those tabs converges on "this note was deleted" and returns to the note list. If the transaction then fails and rolls back, the note is still where it was, not in the trash — but nothing calls those people back, and they were told something untrue. Reopening the note works (the server releases its delete gate as soon as it sees the transaction failed), so the cost is the wrong message and the interruption, not lost work. - **An administrator can still obtain AI provider API keys, just not silently.** A provider's key is sent, in plaintext, to whatever host its Base URL names — that is what configuring a provider means. Changing the Base URL therefore clears the stored key, so a key entered for one host cannot be redirected to another. What remains is the slower path: an admin points a provider at a host they control, and waits for someone to enter a key again. That leaves traces — the provider is marked as having no key in Settings, and the server logs `AI provider 的 base URL 被寫入` (on every edit that includes a Base URL, whether or not it changed; creating a provider does not log it) with the account that did it and the old and new target — but nothing prevents it. Do not rely on noticing the URL itself: it is shown truncated, and `https://api.openai.com@evil.com/v1` reads as the real thing at a glance. **The log line is the trustworthy trace** — specifically its `to` and `hasKeyAfter` fields, which come from the write itself; `from` is read separately and can lag under concurrent edits. Separately, because Base URL is deliberately unrestricted (so that you can point a provider at a host on your own network), an admin can also make the server issue requests to arbitrary addresses, including ones only it can reach; the response body is never returned to them, but whether the request succeeded is observable. **Administrator is the highest privilege level in Knotebook; treat it as equivalent to holding every configured provider key.** - **An error response from the AI provider is written to the server log, and a gateway that echoes credentials in it puts them there.** When a provider answers with a non-2xx status — during an AI action, or when you test a provider's connection in Site admin — the server logs the status and the first 2,048 characters of the response body at `warn` level (the body is never sent to the browser). Most providers' error bodies hold only a message, but a few gateways and proxies repeat the `Authorization` header or part of the key in them, and then that text lands in the log. Only someone who can read the server's logs sees it; the Knotebook log line does not carry the key itself. If you ship logs to another system, treat them as sensitive, or filter the provider-error lines (`upstreamBody` on AI actions, `body` on the connection test) before they leave the host. Tracked in [issue #211](https://github.com/sw-willie-wu/knotebook/issues/211). - **In-memory rate limiting and login lockout reset on restart.** Failed-login backoff counters, the password-hashing concurrency semaphore, the password reset link and invitation limiters (invalid links per IP, attempts per link), the collab-token/slug-change rate limiters, and the short-lived gate that lets a just-deleted note report itself as deleted rather than inaccessible all live in process memory; restarting the `app` container clears them. This is also why they cannot be shared across instances — see the [single-instance warning](./self-hosting.md#single-instance-warning). - **Email suggestions show the emails of everyone you share a group with.** The add-member and share forms suggest people as you type (see [Sharing](./sharing.md#the-three-access-levels)), drawn from your groups' members and the people you've shared notes with. Those emails were already visible to you — any member can open a group's member list — so the suggestions don't reveal anyone new, but they make it quicker to look through: in a large group, every member's email is a few keystrokes away. Disabled accounts aren't suggested, but can still be added by typing their full email. The lookup is limited to 60 per minute per user — typing many addresses into a group's **Members** box can reach it, and suggestions then stop until the minute is up, without a message — and like the other in-memory limits, that resets on restart. - **Email matching is case-insensitive; only new writes are stored lowercased.** Every account-writing path (self-registration, accepting an invitation, environment-variable admin bootstrap, OIDC auto-provisioning/linking) normalizes the address to lowercase before writing it, and every lookup (login, sharing, OIDC account linking) compares via `lower(email)` — so `alice@example.com` and `Alice@example.com` now resolve to the same account. The one caveat: this doesn't retroactively merge any pre-v0.1 rows that already exist as separate accounts differing only by case (from before this normalization was in place). Those remain two distinct users; `lower()` matches both at once, and login/sharing deterministically fall back to the older row (see the duplicate-row runbook below), while OIDC linking refuses to guess and rejects with `oidc_conflict` instead. If you're setting up a fresh instance you'll never hit this — it only matters when carrying over data from a database that predates case-insensitive matching. - **Resolving pre-v0.1 duplicate email rows (e.g. `alice@example.com` vs. `Alice@example.com`).** If two `users` rows exist whose emails differ only by case, `lower(email)` lookups hit both: `POST /api/auth/login` and note sharing pick the older row deterministically (`ORDER BY created_at, id LIMIT 1`) and silently ignore the newer one, while OIDC login/linking rejects with `oidc_conflict` (`multi_email_match`) rather than guessing which one you meant. Detect duplicates with: ```sql SELECT lower(email) AS normalized, array_agg(id) AS user_ids, array_agg(email) AS raw_emails FROM users GROUP BY lower(email) HAVING count(*) > 1; ``` Fix by picking one canonical row per group — the older one, to match the login/sharing behavior above — migrating anything owned by the other row(s) (owned notes' `owner_id`, `note_shares` rows, etc.) onto it, then either deleting the now-empty duplicate row(s) or renaming their `email` to something outside the group so it stops colliding. Finally normalize the canonical row's own `email` to lowercase (`UPDATE users SET email = lower(email) WHERE id = '';`) so it matches what every write path stores today. There's no automated migration for this in v0.1 — it's a manual, one-time cleanup for instances carrying data forward from before case-insensitive matching existed. - **Redoing across an "undone to empty" boundary while a collaborator is typing can leave a stray empty block.** Redo after undoing a note back to empty works reliably when you're editing alone (issue #100). In the narrow window where a collaborator writes into the note between your undo-to-empty and your redo, the cleanup step deliberately stands down (removing anything a collaborator may now depend on risks losing their work), so the redo can restore your content alongside the placeholder paragraph as a separate empty block. In the paths we have been able to construct, nothing is lost — your restored text and the collaborator's edits are both kept — and deleting the stray empty block by hand resolves it. - **A collaborator's edits to the blocks you are dragging can be lost.** A block dragged by its ⋮⋮ and dropped above or below another block lands with the content it had when you started dragging, so what a collaborator types into it while you hold it disappears. Dropping it beside another block or into an empty column keeps what they typed. When a collaborator deletes one of the dragged blocks before you let go, the drop brings the deleted block back and an empty paragraph can remain where the blocks were. - **The editor's collaboration library, y-prosemirror, is patched.** `patches/y-prosemirror@1.3.7.patch`, applied by pnpm at install, puts the cursor at the nearest text when a block selected as a whole is gone by the time y-prosemirror restores the selection, where the unpatched library raises an error and leaves the note out of step with your collaborators' (#428); upgrading y-prosemirror means carrying the patch over or dropping it once the library handles this itself, and `apps/web/src/collab/node-selection-restore.test.ts` fails when it no longer applies. - **A note's title is not part of the real-time collaborative document.** The block editor content syncs live over Yjs/Hocuspocus, but the title is a separate field updated via `PATCH /api/notes/:id` with last-write-wins semantics — two people renaming a note at the same moment can silently overwrite one another, unlike the CRDT-merged body. A rename made over MCP is the exception on its own side: it carries the fingerprint of the title it replaces and is refused if the title is no longer the text it had when that fingerprint was taken — a title changed and then changed back to the same text counts as unchanged (#381) — but a rename in the web app still overwrites whatever title is there, including one an MCP client just set. - **Resetting someone else's password takes an admin and a link they send themselves, and it doesn't sign out the person's API tokens.** There is no email and no self-service "forgot password": an admin creates a one-time link in **Site admin → Users** (valid 1 to 24 hours) and passes it on. Completing a reset signs out the person's other browser sessions but doesn't revoke their API tokens or connected apps — revoke those separately in Settings → Authorization if the account may be compromised. An admin can open the link they made, in a browser where they are signed out, and set the password themselves; nothing prevents that, but it isn't silent: the person's other browsers are signed out, their old password stops working, and the server logs both creating and using the link. Self-service password change has a UI now (**Settings → Sign-in**, reachable once signed in via the user menu; any account still flagged to change its password — such as an account an admin created with a temporary password in **Site admin → Users** before invitations replaced that, or, on instances set up before this change, an env-bootstrapped admin that hasn't changed its password yet — is instead routed to the standalone `/change-password` page automatically when they sign in, with a password or through SSO); changing it yourself needs your current password. - **A password reset link or an invitation link can end up in browser history or in a chat.** The link keeps its token after a `#`, which the browser doesn't send to the server, and the reset page and the invitation page (`/register` with a link) remove it from the address bar as soon as they load. But before that, the address may already have entered the browser's global history, and it stays in whatever chat tool or email carried it. It works once and for at most 24 hours (an invitation, at most 7 days); cancel an unused reset link, or revoke an unused invitation, in **Site admin → Users** if it went to the wrong place. - **Password reset links and invitations share two in-memory limits, and neither one is what stops guessing.** Invalid links of either kind are limited to 30 per minute per IP together (`ONE_TIME_LINK_MISS_LIMIT`; then `429 too_many_requests`), and completing a reset or accepting an invitation is limited to 10 attempts per hour per link (`ONE_TIME_LINK_ATTEMPT_LIMIT`). Both reset on restart. The first doesn't reduce database work and doesn't hide whether a guess was right — a valid link is never blocked by it. What protects a link is that its token is random, 256 bits long, and can't be guessed; the first only changes the answer (`429` instead of `410`) for a client that keeps sending invalid links. The second only matters to someone who already holds a valid link and keeps sending requests that fail. - **The old `/settings/users`, `/settings/ai` and `/settings/groups/` paths redirect.** `/settings/users` and `/settings/ai` go to `/admin/users` and `/admin/ai` — site administration is its own panel, opened from **Site admin** in the user menu (admins only); `/settings/groups/` goes to the group's panel at `/groups/`, and `/settings/groups//roles` to its **Roles** page. `/admin/auth` and `/admin/versions` go to `/admin/login` and `/admin/storage`. Bookmarks to the old paths still work; a non-admin following an `/admin/*` link, or one of the old `/settings/users` and `/settings/ai` paths, ends up on the home page. - **Closing a panel navigates forward, not back.** Opening Settings, a group's panel or Site admin pushes a new browser history entry rather than replacing the current one; closing it (Esc or the ✕; clicking outside doesn't close it) navigates back to wherever you were. If you then press your browser's own Back button, it reopens the panel instead of leaving the page you were on before. Switching to another page inside the panel (from its list of pages) also adds a history entry, so it takes one extra Back press for the panel plus one for each switch you made, including going back to a page you had already visited. - **On a narrow screen, the pinned name column can cover part of a row's first button.** A table too wide for the panel scrolls sideways inside its own frame while its name column stays pinned on the left. In the tables with four buttons a row — **Actions** in **Site admin → AI agents** and **Sign-in services** in **Site admin → Sign-in** — the name column can still cover part of the leftmost button once the table is scrolled all the way to the right, and a click on the covered part lands on the name instead. Scroll back a little, or move to the button with Tab: when a button or switch that the name column partly covers gets keyboard focus, the table scrolls so it is no longer covered. Only the top-level name column is pinned: the models listed under each AI provider scroll with the table, names included, and so does what is shown under a row — such as a sign-in service's callback URL, a warning, or **Add model** at the top of a provider's models — so on a narrow screen you may need to scroll right to see all of it. - **A deleted built-in AI action reappears on the next server restart.** The five built-in AI actions (rewrite/translate/summarize/continue/instruct) are re-created idempotently on every startup if missing. Renaming or disabling a built-in action persists normally — only deletion is undone. If you don't want a built-in action available, disable it (or rename it to repurpose it) rather than deleting it. - **The Instruct action's guard is a prompt, not a filter.** The instruction, the highlighted characters and the selected text are put inside fixed tags without any escaping, so text that itself contains something like `` can blur where one part ends, and whether an instruction is off-topic is left to the model. The guard tells the model to return the text unchanged for an off-topic instruction, but that isn't guaranteed; when it does, the AI box says there are no changes, which is not an error. - **The AI's instruction to keep columns and sizes is a prompt, not a filter.** Every AI action asks the model to keep the [side-by-side column](./columns.md) tags and the `width` and `colwidth` values in the text it is given, and a result that writes them in another form is refused. A result that leaves that markup out altogether passes the checks, though: applying it replaces the blocks it was made from like any result, so a row it was given turns into ordinary blocks and the image widths and table column widths it left out are gone. **Revert** (after a "direct" action) or Ctrl+Z undoes it. See [AI](./ai.md). - **A selection that includes or is inside a code block shows no formatting toolbar, so the AI box can't be opened on it.** This comes from the editor's toolbar rule, which hides the toolbar whenever a selection includes or is inside a code block — including Ctrl+A on a note that contains one. To run AI on such a note, select the parts around the code block one at a time. - **If the code for the AI box's preview fails to download, a result you haven't applied yet is lost.** The preview is loaded on demand together with version history; when that download fails the page reloads to fetch it (automatically at most once after each successful load; after that, its **Try again** button reloads the page), and the reload discards the AI result that was waiting in the box. - **On touch screens, custom AI actions in the AI box have no visible name.** The built-in actions have their own icons; custom actions share one generic icon whose name shows only as a mouse tooltip or to screen readers. - **The AI box's result covers the text below the selected blocks until you close it.** When a response finishes, the box moves on top of the selected blocks and extends downward over the paragraphs that follow; to read them, apply, discard or close the result first. - **The AI box's result stays at the top of the note's scrolling area once the selected blocks scroll up out of view.** It keeps covering whatever note text is there, even after the selected blocks have scrolled away entirely, until you apply, discard or close it. - **The AI box takes the width of the first block it works on.** That is the first selected block — or, when a selection reaches across [side-by-side columns](./columns.md) or into or out of a row and the action works on whole top-level blocks, the first of those, so when such a selection starts inside a row, the box is as wide as the whole row. Its preview wraps lines like the note does for that block, but when a selection spans blocks at different indent levels, the other blocks may wrap differently in the preview than in the note. - **When the AI box's preview is taller than the box, a scrollbar can make its lines wrap differently than in the note.** The box is at most 40% of the window's height and scrolls beyond that; on systems whose scrollbars take up space instead of overlaying the content, the scrollbar narrows the preview, so a long result may wrap at different points than the same text in the note. - **In the AI box's preview, code blocks show no coloured bar for added, removed or changed blocks.** The bar is drawn as an inset shadow on the block, and the code block's full-width background paints over it; the strike-through and fading of removed blocks still show. The version history panel marks blocks with a left border instead and is not affected. - **The AI box's instruction field grows with its text, up to four lines, in browsers that support CSS `field-sizing: content`.** Firefox doesn't as of October 2026, so there the field stays one line tall and scrolls inside itself. - **A note's automatic URL changes the moment its title does — with no grace period.** Note URLs are `/n//` for personal notes and `/g//` for group notes (#122, #175). Unless you set a custom slug, the slug follows the title: renaming the note usually retires the old automatic URL immediately (no redirect). Custom slugs get exactly **one** level of grace: changing a custom slug leaves a single redirect from the immediately-previous custom name; changing it again drops that. In-app **link data** is keyed on the note's id — a link to a note inside a note (made with `[[`, or by pasting, dragging in, typing or writing that note's address, including over MCP) or a sidebar entry never points at the wrong note, and live collaboration is unaffected — but the URL such a link navigates to is computed from your cached copy of the note, which can be momentarily stale (see the stale-sidebar entry below); only shared URL *strings* break permanently. - **A released slug can be re-used by the same owner's other notes (or the same group's) — an old link can silently start showing a different note.** Slugs are unique per user for personal notes and per group for group notes, not globally (`409 slug_taken` now only means *another of your own notes* (or, on a group note, another note in the same group) has that name). If you free a slug (by renaming a custom one, changing a title, or permanently deleting the note — moving it to the trash doesn't free it) and later another of your notes (or, in a group, another note of that group) takes the same name, old copies of that URL resolve to the new note with no warning. - **Editors can change a note's automatic URL out from under the note's owner (or, on a group note, the rest of the group), silently and without limit.** Anyone with edit access can rename the note; if its slug is automatic, that rename recomputes the URL — with no rate limit on title changes and no redirect left behind. This is deliberate: an editor is a trusted collaborator who can already rewrite the note's entire content, and the abuse surface is the same as "an editor renaming your note maliciously". Custom slugs are owner-only (on a group note: a role that can manage public links). Over MCP, `edit_note`'s `rename` does the same; with a token it counts against the 60-per-10-minutes token write budget, and over a browser session there is no limit. - **Old-form `/notes/…` links keep working forever, and are actually more durable than the new form.** Links that existed before the URL change (the frozen pre-#122 slug, and any `…-` form) resolve permanently, because they don't depend on the owner's username. The new `/n//…` form breaks if the owner renames their username — that fragility is the inherent cost of removing the uuid from URLs. - **Clicking a note in your own sidebar can hit a dead link for a moment after a collaborator renames it.** In-app links are built from your cached copy of the note list; if a collaborator just changed the title (and with it the automatic slug), a click in the stale window lands on "This link is invalid" and returns you home. Refreshing the sidebar (it refetches on window focus) resolves it. This was accepted over an id-fallback carried in navigation state, for two reasons: such state only survives in-app clicks (a refresh, a new tab, or a pasted link never has it, so it's half a fix at the cost of a third resolution path), and silently rerouting a failed URL to an id would hide the "this link is dead" signal from the person who most needs to re-share it. - **The URLs assigned during the URL upgrade migration (#122) can shift once, silently, on the first title save.** The migration derives a slug from the title in SQL for each note **without a custom slug** (custom slugs carry over untouched and never recompute), which handles ASCII only — a note with a non-ASCII title (Chinese, accents, …) comes out of the migration as `untitled(-n)`, and the first time anyone saves its title afterwards, the in-app derivation (which keeps CJK and accented characters) recomputes it, changing the URL once with no redirect. - **The sidebar's width doesn't adapt to the window.** The width you drag it to (200–480 pixels) is remembered per browser and kept as is when the window gets smaller, so a sidebar widened on a large screen can leave the note too narrow on a laptop. Double-click the gap between the sidebar and the note to go back to the default width. - **The Recent tab doesn't reorder for edits made elsewhere until the note list refreshes.** It follows the note you have open as soon as its header shows the new edit time, but an edit someone else (or an AI client) makes to another note moves that note up only after the list is fetched again — when the window regains focus, the page reloads, or you create, move or delete a note. - **The result count may not be announced by a screen reader when you type the first character of a search.** The sidebar's search results and their count appear together, and some screen readers skip a live region that is inserted with its text already in place; the count is announced when it changes on later keystrokes. The count now covers title and body-text matches together and changes once the body-text search has finished (while a newer search is still running it shows only the title matches); it is the one live count line. The line saying the body-text search is running or has failed is also a live region, with the same gap when it appears with its text already in place. The "load more" error line and the "no notes match" line are not live regions, and neither is the "Searching note text…" line shown while the search code is still downloading. - **The version history panel's width doesn't adapt to the window either.** On a wide screen the panel keeps the width you drag it to (240–560 pixels, remembered per browser), so with both the sidebar and the panel widened, a laptop-sized window leaves the note only a narrow strip. Double-click the panel's left edge to go back to 320 pixels (#274). - **Notes with the same title have sidebar `⋮` buttons that sound the same to a screen reader.** Each is named after its note ("Note actions for *title*"), so several notes called, say, "Untitled" give several identical "Note actions for Untitled" buttons; tell them apart by the row they sit in. - **Moving a note to the trash from the sidebar while that same note is still opening doesn't always end the same way.** If you click a note and use its sidebar `⋮` to move it to the trash before the page has found the note, the page treats it like moving a different note and doesn't leave on its own. What follows depends on timing: if the page finds the note already in the trash, it shows "This note is in the trash" with **Restore** — or, when your role in the group can delete notes but not edit them, says "This link is invalid or the note doesn't exist." and takes you to the home page; if the page found the note just before the move, it says "This note has been deleted." and takes you to the home page. Either way the note is in the trash. - **Admins cannot delete user accounts, only disable them.** Account deletion (including note ownership transfer) is not implemented yet; tracked in [issue #73](https://github.com/sw-willie-wu/knotebook/issues/73). - **"Online" in Site admin → Users means recently active, not connected right now.** The **Last active** column holds the last time someone used the site while signed in — a browser request to an endpoint that requires being signed in, opening a note for live editing (as a read-only viewer too), a change they make to a note over that live connection, or signing in — and shows **Online** while that time is less than 5 minutes old by the clock of the browser showing the list. It is worked out when the list is drawn — nothing re-checks it on a timer, so a list left open can keep showing **Online** after the 5 minutes are up. Requests made with an API token or a connected app (MCP) don't count, nor does coming back from linking a sign-in service in Settings, nor just having a page open: someone who leaves a note open without typing or otherwise using the page drops out of **Online** 5 minutes after their last activity. To keep writes down, each server process records an account at most once a minute, and the database skips the write while the stored time is less than a minute old, so with one server process the time shown usually trails someone's latest activity by less than a minute and someone who keeps working stays **Online**; with several processes it can trail by more, because each process counts its minute on its own (the database check still allows only one write per account a minute, and each process tries at most once in that time). Activity while another change to the same account is in progress, or while the database can't be reached, can go unrecorded, and that process doesn't try again for that account for a minute. - **Notes in the trash are deleted for good after the retention period, usually within an hour of it.** Deleting a note (`DELETE /api/notes/:id`) moves it to the trash; it stays there for the site's retention period (7 days by default — see [Trash](./self-hosting.md#trash)), counted from the moment it was moved. The server clears out expired notes in a background pass once an hour (the first one 5 minutes after it starts), at most 2,000 notes per pass, oldest first. So a note usually outlasts its expiry time by up to an hour; when more than 2,000 are due at once — after the retention period is shortened, for example — the rest wait for later passes and it takes several hours to clear them all, and nothing is cleared while the server is down. Clearing out deletes the note with its shares, versions, history and attachments; files are removed from disk best-effort after the transaction commits, and one that can't be removed is orphaned — see "Uploaded file cleanup is not automatic" below. With the retention period set to keep notes until someone deletes them, notes stay in the trash indefinitely, and their attachments keep counting toward storage. - **A note in the trash still holds its URL and its custom public URL.** While it's in the trash, another note in the same space (yours, or the same group's) given the same title gets a numbered URL (such as `…-2`), setting another note's custom URL to it answers `409 slug_taken`, and its custom public URL name can't be given to another note (`409 public_slug_taken`) until the note is permanently deleted. Restoring it brings both back unchanged. This also means a group member who can't see the group's trash (their role can edit but not delete, for example) and changes a note's title or URL to one a note in the trash holds learns that a note they can't see has that URL — only that the URL is taken, never that note's title or content. This is accepted: freeing the URL instead would make restoring a note fail whenever its URL had been reused. - **Deleting a group with Delete everything also permanently deletes the notes in its trash.** They don't go to anyone's trash first; nothing in the group can be restored afterwards. - **Someone whose role can delete notes but not edit them can't get back a note they deleted.** A group's trash is visible only to members whose role can both edit and delete notes, so a note such a person deletes disappears for them; a member whose role has both, such as an Admin, can restore it. Right after the deletion, the web app's message says who can restore it instead of offering **Undo**. Over MCP it is the same: after such a person's `delete_note`, `list_trash` doesn't list the note and `restore_note` answers `not_found`. - **An image copied into another note breaks while the note it was uploaded to is in the trash.** Access to an image is decided by the note it was uploaded to (see "An image block copied and pasted into another note…" below), so where it was copied into another note it fails to load (`403`) until that note is restored, and for good once that note is permanently deleted. - **A direct write can land in a note that is being moved to the trash.** Moving a note to the trash waits up to 5 seconds for the server to close the note's live document. If that doesn't happen in time while a write that doesn't go through the editor is in progress — `POST /api/notes/:id/edits`, a revert, applying a version, or MCP `edit_note` — the note is moved anyway and that write can end up in the note in the trash. Nothing is lost: it shows once the note is restored. - **Markdown that takes the server too long or too much memory to read is refused.** The server reads written Markdown into note content in parse workers — threads of their own with their own memory limit — so other people's requests and live editing keep running meanwhile (#398). A write whose Markdown takes longer than `PARSE_TIMEOUT_MS` (default 10 seconds) or more memory than `PARSE_WORKER_MEMORY_MB` (default 1024 MiB) to read is refused with `unsupported_block`, its message saying the content is too complex, and nothing is written. How long and how much depends on what the Markdown holds more than on its size. Measured on a machine of 2 vCPUs and 4 GiB: about 256 KB (the largest write accepted) of ordinary formatted Markdown takes about 1.4 seconds and fits in 256 MiB; the slowest content measured that still fits the defaults is 256 KB of words separated by `
` in one paragraph, about 7 seconds and about 640 MiB. Dense formatting inside quotes or `
` needs more memory the deeper it sits — 256 KB of bold words inside quotes nested 5 levels needed about 1.5 GiB — and is refused with the defaults; Markdown nested several thousand levels deep runs into the time limit. (Quotes nested 128 levels deep and lists nested 64 levels deep are refused for their depth.) A site that needs such content can raise the memory limit (Site admin → Site limits, up to 3072 MiB, or `PARSE_WORKER_MEMORY_MB`) or, with `PARSE_TIMEOUT_MS`, the time limit; see [Self-hosting](./self-hosting.md#parse-workers). This applies to `edit_note`, `create_note`, `POST /api/notes` with `content`, `POST /api/notes/:id/edits` and files sent back or created over MCP bulk transfer alike. - **One person can keep parse workers busy, and when every worker is busy, others wait.** Reading the Markdown of a write, putting a write into a note, and turning a note of 16 KiB or more back into Markdown (reading it over MCP or the API, downloading it) all queue for the same parse workers. One person uses at most two workers at a time, and a second only while nobody else is waiting; but on a machine with 2 CPUs the server runs one worker by default, so someone reading very large notes, or sending heavy writes, one after another can make everyone else wait up to a job's time limit each time — three times `PARSE_TIMEOUT_MS` for reading or writing a note (30 seconds by default), four times for a file sent back over bulk transfer. A job that waits longer than `PARSE_QUEUE_WAIT_MS` (by default 4 × `PARSE_TIMEOUT_MS` + 5 seconds, 45 seconds, so that waiting behind one job running to its limit is enough) answers `server_busy` and writes nothing; behind two such jobs it can still run out. Each job counts against the sender's read or write budgets, but those budgets allow enough of them to keep one worker busy. At most 100 jobs wait at once; more answer `server_busy` straight away. Notes under 16 KiB don't wait for parse workers. Live editing in the browser doesn't use them. - **Putting a large write into a note, and reading a large note back, still hold up the server briefly.** Reading the written Markdown, putting it into the note and turning a note back into Markdown happen in parse workers, but some linear work on the note's stored structure stays in the server's own thread: encoding a note someone has open to hand it to a worker (about 5.4 MB stored: about 14 ms typed straight through, about 173 ms heavily edited), putting the result into the open note, and saving the note. Measured on Linux: with about 245 KB in one paragraph of `
`-separated words or of alternating bold and italic, a write held the server up for about 0.2–0.45 seconds (before parse workers did this, 0.6–2.1 seconds); reading a note of 1 MB, or one paragraph of 49 000 such words, back about 0.01–0.15 seconds (before, 1.2–5.3 seconds). Applying a note version, and the one-time link conversion after upgrading, still turn the note around in the server's own thread. Text with a very long run of spaces (which only typing in the editor can make) takes time growing with the square of that run to read back: 100 000 spaces in one text took about 2.2 seconds — now in a worker, so it can run into the time limit below (#398). - **A write can be refused with `server_busy` when someone keeps changing the same section back and forth.** A write from MCP, the API or bulk transfer, and a revert, is worked out in a parse worker against the note as it is at that moment. Changes others make meanwhile to other parts of the note don't matter — the write is merged with them — but if they touch the section the write's `if_match` names (for a whole-note `if_match`, or a file sent back, any part of the note), the write is worked out again, and a changed section then answers `409`. Only when the section was touched but ends up the same, three times in a row, is the write refused with `503 server_busy` (`Retry-After: 5`) and nothing written. A file sent back over bulk transfer needs the whole note unchanged, so steady typing anywhere in a large note can overtake it three times; try again when the typing stops (#398). - **In the browser the note size limit is only a soft check, and a note can end up somewhat over it.** The server doesn't check live edits from the browser at all. The web editor checks before pasting, dropping content in or applying an AI action's result, using an estimate of how much the note will grow — the text of what is being added plus a fixed amount for each paragraph, list item, table cell and other element, for each piece of formatting (with a link's address) and for long attributes such as a diagram's source, minus what it replaces. Measured against the real growth, the estimate came out between about 0.94 and 1.15 times it for every shape tried (paragraphs, flat and nested lists, checklists, tables, long text, Chinese text, text with formatting or links on most words, pasted code, diagrams), so near the limit a paste can be let through that takes the note a little over it, or refused when it would just have fit. Replacing content with the same content is estimated as no growth, but still grows the stored state a little (the collaborative document's bookkeeping, a few percent of what was replaced), so that is bounded too. Typing isn't checked, so a note can also grow past the limit a keystroke at a time, and undoing a deletion (Ctrl+Z) can put content back. Someone with edit rights who writes their own collaboration client instead of using the editor bypasses the check entirely. Writes over MCP, the API and bulk transfer, reverts, applying a version and copying are checked exactly by the server (`413 note_too_large`). - **A note over its size limit can still grow a little through MCP, the API and bulk transfer.** Over the limit, a write is judged by what it leaves behind: it is refused if the content still in the note grows, or if the note's stored state grows by more than 4 KiB in that one write. Replacing content with the same amount or less is accepted, but it still leaves bookkeeping behind in the collaborative document — about 40 bytes per replacement, measured — so a note over the limit that keeps being reworded grows by that much each time; deleting content shrinks it again. Formatting text, or changing an attribute to a longer value, adds content and is refused on a note over the limit. - **The note size limit counts the note's stored state, not its text.** The stored state also holds formatting and the structure collaborative editing needs, so a note is larger than its text, and one whose text was inserted here and there (or by several people at once) is larger than the same text typed straight through — measured, a character inserted in the middle of existing text takes about 23 bytes, one typed at the end about 1. Deleting text frees almost all of its space. There is no way yet to see a note's size in the app; the server logs, when it starts, the ids of notes already over the limit. - **A tab that is already open can check uploads and note size against an old limit.** The editor reads the upload size limit and the note size limit when the page loads its site settings, and checks a file before sending it, and a paste or a drop before adding it, against that value. After a site admin changes either limit, a tab that is already open keeps the old one until it reads the settings again, which usually happens when you switch back to it; reload the tab to be sure. Meanwhile it can refuse a file the new limit would take, or send one the new limit refuses (the server checks uploads against the limit in force when the request arrives and answers `413 file_too_large`); and it can refuse a paste or drop the new note size limit would take, or let through one it would refuse, since the server doesn't check the browser's edits (see "In the browser the note size limit is only a soft check" above). - **Shortening the web sign-in period doesn't sign anyone out.** The period set in Site admin → Sign-in → Session and tokens is read when someone signs in (and when a changed, newly added or reset password starts a new sign-in), and that sign-in keeps the end time it was given; only sign-ins from then on use the new period. - **Changes to the site limits are not passed on between server instances.** The values are kept in memory and read from the database when the server starts, so with more than one `app` process (not supported, see the [single-instance warning](./self-hosting.md#single-instance-warning)) another process uses the old values until it is restarted, and when two processes change limits at the same time, the old value recorded in the list of changes can be out of date. - **Lowering a rate limit, or raising it again, doesn't reset anyone's count.** The windows already open keep their counts: someone who has already used as many requests as the new, lower number, or more is refused until their window ends. Requests that are refused are counted too, so after a limit is raised, someone who kept retrying while they were blocked may already be over the new number and has to wait for the window to end as well. - **Changing the memory per parse worker replaces every worker.** The replacement workers take about 0.5–0.7 seconds to become ready, and jobs wait in line meanwhile. A busy worker finishes its job before it stops, so for a short while the old workers and the new ones exist together and use more memory than the formula in [Self-hosting](./self-hosting.md#choosing-the-values) allows for. If a worker had failed to start and the server was waiting before it tried again when the limit is changed, a healthy worker is replaced in that state too: there can then be no parse worker for up to about 30 seconds, and a job that waits longer than the longest wait for a worker (45 seconds by default) answers `503 server_busy`. - **A very large note can't be read over MCP or the API, or downloaded.** Turning a note into Markdown may take three times `PARSE_TIMEOUT_MS` (30 seconds by default) and as much memory as a parse worker has (`PARSE_WORKER_MEMORY_MB`, 1024 MiB by default). A note that needs more answers `500 too_complex` (MCP: the tool error `too_complex`), and a bulk download leaves it out, listed as `skipped` with `reason: "failed"`. Measured on Linux, a note of 10 MB of ordinary text took about 10 seconds; one dense with formatting gets there sooner. It still opens in the web app. Raise `PARSE_TIMEOUT_MS` (the Site limits page only goes down from 10 seconds) or the memory per worker (`PARSE_WORKER_MEMORY_MB`, or Site admin → Site limits) to read such notes (see [Self-hosting](./self-hosting.md#parse-workers)) (#398). - **Most `429` responses have no `Retry-After` header or wait time at all.** The exceptions: the `429 too_many_attempts` of `POST /api/auth/login` and `POST /api/auth/oidc/pending/confirm` includes a wait time, but in the JSON body (`retryAfterMs`), not a standard `Retry-After` header; every `429` of the four bulk transfer endpoints under `/api/transfer/` carries `Retry-After`; and so does the `429` that `POST /api/notes/:id/uploads` and `GET /api/uploads/:id` answer once invalid Bearer attempts from an IP are used up. The other rate-limited endpoints — `POST /api/notes/:id/collab-token`, slug changes via `PATCH /api/notes/:id`, `POST /api/notes/:id/uploads`, `POST /api/notes/:id/copy`, `GET /api/notes/:id/content`, `GET` and `POST /api/notes/:id/edits` and `POST /api/notes/:id/edits/:editId/revert`, `POST /api/notes`, `GET /api/search`, the public-link management and public share endpoints (#72), username renames via `PATCH /api/auth/profile`, the API-token limits (per-user token reads and writes, invalid Bearer attempts per IP on the endpoints that take an API token, and `POST /api/auth/tokens`), `POST /api/auth/oidc/pending/prove/:providerId`, `POST /api/auth/oidc/link/:providerId`, `POST /api/auth/register`, and the OAuth endpoints (`POST /oauth/register` 30/hour, `GET /oauth/authorize` 30/min, `POST /oauth/token` 60/min, all per IP) — return a bare `429` with no timing information at all (the OAuth ones as `{error:"invalid_request"}` or plain text); callers must back off and retry at their own discretion. - **Setting `POSTGRES_PASSWORD` after the first `docker compose up` has no effect.** Postgres only applies that environment variable while initializing a brand-new (empty) data directory. If the `db` named volume already exists, changing `POSTGRES_PASSWORD` in `.env` and restarting does nothing — either run `docker compose exec db psql -U knotebook -c "ALTER USER knotebook WITH PASSWORD '...'"` and update `DATABASE_URL` in `.env` to match, or `docker compose down -v` to discard the volume and reinitialize from scratch (this deletes *all* named volumes, not just `db` — it takes the `uploads` volume with it too, so every note and every uploaded image is gone). - **`ADMIN_EMAIL`/`ADMIN_PASSWORD` only take effect on first initialization** — the same "only applies while there's nothing there yet" shape as `POSTGRES_PASSWORD` above. Once the instance has been initialized (which only ever happens via these variables — see [Self-hosting](./self-hosting.md)), leaving them set in `.env` and restarting the `app` container does nothing: no new account is created, no error, no warning. There's no supported way to use them to change an existing account after the fact — see the [Quickstart](../README.md#quickstart-10-minutes) note above for how they behave on a genuinely empty database. - **Switching identity providers means linking each person again.** Sign-in identities are keyed on the identity provider's issuer and subject (`sub`), not on email. When people start signing in through a different identity provider, an account whose email matches is not taken over automatically: each person is asked to prove the account is theirs — with its password, or by signing in with a sign-in service already linked to it — before the new identity is linked. Someone whose email doesn't match any account gets a new, separate account while that sign-in service has **Creates accounts** on (otherwise the sign-in is refused). Someone whose account has no password and whose only linked sign-in service has been turned off can't do that alone; the site administrator has to help. - **No RP-initiated logout.** Signing out of Knotebook only ends Knotebook's own session in that browser — it doesn't log you out of the identity provider. If your IdP keeps its own persistent session (or you have SSO across other apps), clicking "Sign in with …" again can sign you straight back in without re-entering credentials there. That's expected OIDC behavior, not a bug in Knotebook. - **OIDC discovery is cached until the sign-in service's settings change or the server restarts.** The IdP's configuration (endpoints and supported algorithms; its signing keys are re-fetched on their own) is fetched once via OpenID Connect Discovery and reused until one of that sign-in service's settings is changed in Site admin (turning it off or on counts) or the `app` container restarts — so after changing the IdP itself, turn the service off and on again or restart; see [Sign-in providers](./self-hosting.md#sign-in-providers). - **The OIDC login/callback endpoints are rate-limited per-IP and in-memory.** `GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback` (and their per-provider forms, `…/login/` and `…/callback/`, which share the same two limiters) each get their own 30-requests-per-minute-per-IP limiter — like the other in-memory limiters above, they reset on restart and can't be shared across instances. Because the two quotas are separate, an exhausted *callback* quota only shows up at the end of the flow: the sign-in starts normally, the user authenticates at the identity provider, and the failure appears when they come back. How trustworthy the per-IP dimension is depends on your `TRUST_PROXY` setting — see [Deployment prerequisites](./self-hosting.md#deployment-prerequisites). Starting "Confirm by signing in with …" on the account-linking page counts against the same per-IP login limit, and a wrong password typed on the linking page counts against the same lockout as the login form for that email. Starting **Link** in **Settings → Sign-in** counts against that same per-IP login limit too. - **HS256-only identity providers aren't supported — and how that surfaces depends on what they advertise.** Signing-algorithm support is checked in two stages: if the IdP's own discovery metadata (`id_token_signing_alg_values_supported`) lists algorithms but none of them is RS/ES/PS-prefixed (an empty list counts as none, and so does a list of only EdDSA), the sign-in service is treated as unavailable before login even starts (`oidc_unavailable`); if the metadata claims asymmetric support but the ID token Knotebook actually receives back is signed some other way (e.g. HS256) — a mismatch between what the IdP declares and what it does — signature verification fails during the code exchange instead (`oidc_exchange_failed`). See [Sign-in providers](./self-hosting.md#sign-in-providers) for what to configure on the IdP side. - **Linking a password account to SSO doesn't invalidate the original password.** After linking, both password login (while the site allows password sign-in) and SSO login work for that account going forward. There's currently no way to disable password login for a single SSO-linked account. A password the account holder no longer remembers can be replaced only through an admin's reset link (see "Resetting someone else's password" above); there is no self-service way. - **Signing in returns you to the page you were trying to open — unless you have to change your password first.** When you land on the login page because a page needed a session, Knotebook remembers where you were headed and sends you back there after you sign in, over both password and SSO login. The one gap is a forced password change: an account flagged to change its password (such as one an admin created with a temporary password in an older version) is routed to the change-password page, and after changing it they land on the home page rather than the page they originally asked for. Signing out, and a session that expires while you have a note open, also drop the destination on purpose — the first because leaving is the point, the second because the note page navigates away before the guard runs. - **Only same-site page paths are honoured as a return target.** The remembered destination has to be a path on this instance that maps to an actual page, at most 2048 characters, made of plain printable ASCII — non-ASCII characters have to arrive percent-encoded, which is what a browser sends anyway. A full URL to another site, an `/api`, `/oauth` or `/.well-known` path, the login page itself, the registration page or the account-linking page, or anything carrying a control character is silently ignored and you land on the home page instead. The same rule is applied twice on the SSO route: once when the destination is stored and again when you come back from the identity provider, so tightening the rule never resurrects a destination that was remembered under an older one. - **Starting SSO sign-in in two browser tabs at the same time can fail both of them.** Both tabs share the same OIDC state cookie (scoped to the browser, not the tab); starting a second "Sign in with …" before the first completes overwrites that cookie, and the callback route clears whatever cookie it finds on the way in — so either tab's callback can land after the other has already consumed/cleared it and fail with `oidc_state_mismatch`, including the one you started first. Sign in via SSO from one tab at a time. - **The account-linking page tells someone that an email has an account.** When a person signs in through an identity provider whose email matches an existing account, the linking page — or the error shown when that account can't be linked — confirms that this email has an account here, the same thing a "this email is already in use" message would reveal. The registration form does exactly that: `POST /api/auth/register` answers `409 email_taken` when the email already has an account, so anyone can use it to check whether an email is registered here (each IP gets 10 attempts per hour, in memory like the other limiters above). - **Adding someone to a group, or sharing a note with them, tells you whether their email has an account.** `PUT /api/groups/:id/members` and `PUT /api/notes/:id/shares` answer `404 user_not_found` when no account has that email, and neither has a per-person or per-IP limit. Anyone signed in can create a group and add people to it, so anyone signed in can check as many emails as they like this way; adding several people at once on a group's **Members** page shows **No account with this email** for each such address, with no limit on how many at a time. It isn't silent: an email that does have an account is really added to the group (or the note is really shared with it), and that person — unless their account is disabled, since disabled accounts can be added too — sees the group in their sidebar (or the note under **Shared with me**). - **The account-linking page shows which ways the account can be proved.** Anyone holding an identity-provider account with the same email sees whether the Knotebook account has a password and the names of the enabled sign-in services linked to it (leaving out one for the identity provider they just used). It does not show whether the account is disabled, or whether it is already linked to another identity at the same identity provider — those only come out after the person has proved the account is theirs. - **An account with no password can't be linked from the outside when none of its sign-in services can be used to prove it.** That is the case when every sign-in service linked to it is turned off, or when the only one left is for the same identity provider the person just signed in with (for example, an account at that identity provider that was recreated and now has a different subject). The person then has no way to prove the account is theirs, and they get an error asking them to contact the site administrator. - **"Must change password" is cleared only by changing the password.** An account an admin created with a temporary password in **Site admin → Users** (no longer possible; invitations replaced it) keeps asking for a new password even if its owner signs in with SSO or links SSO to it. This changed in v0.6: before, signing in with SSO cleared it. - **The identity provider's `email_verified` claim isn't used.** An email the identity provider marks as unverified can still create an account on first sign-in, and identity providers that don't send the claim at all work normally. Linking a new identity to an existing account always needs that account's password or a sign-in service already linked to it. - **Someone can take a colleague's email and receive what is shared with it.** Registering with a password, accepting an invitation and a first sign-in through an identity provider all create an account with the email given — typed on the registration page or the invitation page, or returned by the identity provider — without checking that the person owns it. If no account has that email yet, whoever registers it first (or uses an invitation link with it) holds the Knotebook account for it, and notes shared with that email, or that email added to a group, then reach them. The site administrator can only disable such an account after the fact; accounts can't be deleted (see "Admins cannot delete user accounts" above). - **Turning a sign-in service off doesn't sign anyone out.** People who signed in through it keep their session until it expires, and their API tokens and connected apps keep working (a token created without an expiry keeps working until it's revoked). Deleting the service doesn't change that either. To cut someone off right away, disable their account in **Site admin → Users** — that stops their sessions and tokens too. - **A sign-in service that creates accounts lets in anyone its identity provider lets in.** While **Creates accounts** is on for a sign-in service that anyone can get an account at (Google, gitlab.com), anyone with an account there can create a Knotebook account through it, as long as their email has no account here yet. Keep such a service's **Creates accounts** off and invite people from **Site admin → Users** instead (this needs password sign-in on), or only turn it on for an identity provider that only your people can sign in to. - **Registering with a password is on by default, upgraded sites included.** With the defaults (**Allow self-registration with a password** and password sign-in both on), anyone who can reach the site can create an account at `/register`. Accounts created this way can use the AI quick actions the site has set up — the site pays for that usage and its quota — and take up upload space; only site administrators can manage the AI providers' API keys, and Site admin never shows a key (only whether one is set). A sign-in service's **Creates accounts** starts off when the service is added (a service imported from `OIDC_*` by v0.6 to v0.15 took over the value of **Allow self-registration with a password** at the moment of the import), and when upgrading, each existing service took over the registration setting the site had before. Together with the email point above, turn **Allow self-registration with a password** off in **Site admin → Sign-in** if you don't need it. - **Linking a sign-in service from Settings can end on the login page.** If, while you're on the identity provider, that sign-in service is turned off or deleted or the sign-in cookie expires, or if the per-IP limit on coming back from identity providers is hit, you come back to the login page with an error rather than to Settings — you're still signed in. - **Adding a password doesn't ask you to sign in again, and suspending someone at the identity provider doesn't revoke their password or API tokens.** While password sign-in is on, anyone holding a signed-in session can add a password to an account that has none, the same as creating an API token. When someone leaves, disable their account in **Site admin → Users**: suspending them only at the identity provider leaves a password they added (usable whenever password sign-in is on) and their API tokens working. - **With password sign-in turned off, the identity provider is the only way in.** If it's down, its client secret expires, or someone is suspended there, they can't sign in to Knotebook. Recovering the whole site needs access to the deployment's settings and a restart (`PASSWORD_LOGIN_FORCE_ENABLE`, see [SSO-only sign-in](./self-hosting.md#sso-only-sign-in)); while that variable is `true`, anyone can sign in with a password, not only admins, and while **Allow self-registration with a password** is on, anyone can also register with a password again — remove it and restart as soon as you're done. - **Turning password sign-in off doesn't sign anyone out.** Existing sessions, API tokens, connected apps and MCP clients keep working. To cut someone off right away, disable their account in **Site admin → Users**. - **While password sign-in is off, the account-linking page still accepts passwords.** That is how password users move over to SSO on their own, so guessing a password still has one way in. Wrong passwords there still count toward the lockout for that email and address — the same counters the login form uses while password sign-in is on — and the guesser first needs an identity-provider account with the same email. - **Turning password sign-in off doesn't remove anyone's password.** Turning it back on makes every password work again immediately — and so does setting `PASSWORD_LOGIN_FORCE_ENABLE=true`, once the server has restarted — including old ones nobody changed. - **A password account whose email differs from its identity-provider email can't move over on its own once password sign-in is off — unless its owner is still signed in.** The account-linking page only appears on a first sign-in with the same email, and admins can't link a sign-in for someone else. Someone still signed in from before can link one from **Settings → Sign-in** (turning password sign-in off doesn't sign anyone out); otherwise turn password sign-in back on for a while so they can sign in and do that. - **The check when turning password sign-in off only looks at you.** It requires at least one sign-in service that is turned on and a linked one for the admin making the change. Other admins aren't checked and may be unable to sign in afterwards. - **Test connection can't check the client ID or secret.** Apart from noting a missing client ID and checking that the saved secret can still be decrypted, it only reads the identity provider's published configuration; it never exchanges a code, so a wrong client ID or secret first shows up when someone tries to sign in — as an error on the identity provider's page or as `oidc_exchange_failed` when they come back. - **An administrator can still obtain a sign-in service's client secret, just not silently.** Knotebook sends the client secret to the token endpoint that the issuer's configuration names, so changing a service's issuer clears its saved secret and turns it off — a secret entered for one identity provider can't be redirected to another by editing the service. What remains is the slower path: an admin points a service at an issuer they control and waits for someone to enter the secret again. That leaves traces — the text under the service's row says it has no client secret, and the server logs `登入服務的 issuer 被寫入` with the account that did it and the old and new issuer — but nothing prevents it. **The log line's `to` and `hasSecretAfter` fields come from the write itself and are the trustworthy part; `from` is read separately and can lag under concurrent edits.** Separately, **Test connection** and **Check issuer** let an admin make the server send a request to any address it can reach; apart from the issuer a valid discovery document reports and whether it lists `client_secret_post` (shown as a warning), the response isn't shown, but whether it succeeded is. **Administrator is the highest privilege level in Knotebook.** - **The affected-account counts can be off when a sign-in service has never connected.** Linked sign-ins are matched by the issuer the identity provider reports, which can differ from the one typed in (for example by a trailing `/`). Until Knotebook has read a service's discovery document successfully — at the start of a sign-in through it, or with **Test connection** — it only has the typed value (and changing the issuer drops what it had recorded). The service imported from `OIDC_*` may already have one recorded at import, inferred from existing sign-ins. So the counts shown before turning a service off may be too low when that service has never connected, and the "no other way to sign in" count may be too high when another service that would still let those people in has never connected. Press **Test connection** first. - **Pasting a right-click-copied image is blocked.** Copying an image from a web page or another app with your browser/OS's "Copy Image" and pasting it into the editor gets intercepted with a "please save the file first, then drag it in" toast, even though it looks like a plain image paste. The clipboard in that case carries an HTML representation alongside the image file, and the guard treats "file plus a usable text/HTML representation" as ambiguous and blocks it outright rather than guessing which one you meant. Work around it by saving the image to disk first and dragging the file in, or by using your OS's "Copy image as file" if it has one. Dragging an image straight off a web page (no save step) embeds it by its external URL instead of uploading it — see "External images embedded by URL" below. The same guard also blocks pasting a file copied from a file manager (e.g. Windows Explorer) whenever the clipboard additionally carries a plain-text filename representation, for the same first-match-wins reason. - **Uploaded file cleanup is not automatic.** Orphaned blobs on disk (a DB row was never created, or was deleted, but the file wasn't removed) can accumulate from: an `INSERT` failing after the file was already written (best-effort unlink, which can itself fail); the best-effort post-commit unlink failing when a note is permanently deleted from the trash or cleared out after the retention period, and the same for deleting a group with **Delete everything**; a note copy that fails after it has already written some attachment files, when the best-effort unlink of those fails too (#175); and a known upstream BlockNote behavior where, during a multi-file drag-and-drop under live collaboration, a block can be removed (by undo, a manual delete, or a remote collaborator) while its upload is still in flight — the editor's own `updateBlock` then throws, aborting the rest of that batch and leaving any already-uploaded files in that batch orphaned. None of this corrupts data; it just wastes disk space. Automatic orphan cleanup is planned for a future release. Such files don't count toward storage quotas. - **An image block copied and pasted into another note still points at the note it was originally uploaded to, not the note displaying it.** (Copying a whole note — **Copy to… → My notes**, or copying it into a group — is different: the copy gets its own copies of the note's own attachments.) BlockNote's image blocks store a plain `/api/uploads/:id` URL, and copying that block into a different note copies the URL, not the file. Two consequences: permanently deleting the *source* note deletes the blob too, permanently breaking the image everywhere else it's been copied, with no way to recover it (while the source note is only in the trash, the image is broken until it's restored); and a reader who can see the note the image is copied into, but not the note it was originally uploaded to, gets `403` trying to load it — access is checked against the upload's owning note, not wherever it's currently displayed. - **Storage quotas count attachments only, per space.** Every personal space and group has a storage plan (with a limit, or none); a limit counts attachment bytes only (not note text, and not files left on disk without a database row — see "Uploaded file cleanup is not automatic"). Someone a personal note is shared with as an editor uploads into the owner's space: they can fill it, after which the owner can't add attachments either, until a site admin gives a larger plan or space is freed — by permanently deleting notes that have attachments (moving them to the trash doesn't free anything), or moving them into a group. The per-user upload rate limit still applies on top — 120 files per 10 minutes, where a copy counts one per attachment of the note it copies (a note with more than 120 is capped at one window's worth; an attachment whose file is missing still counts). - **Removing an image from a note doesn't free its space.** Every file uploaded to a note (or copied into it with the note) stays recorded against that note, and keeps counting toward its space, until the note itself is permanently deleted (moving it to the trash doesn't free it) — whether or not the note's content still shows it. So a note whose images have all been removed still carries their size: moving it into a group adds that size to the group (and can be refused when the group is full), and only permanently deleting the note frees that size for good (moving a personal note into a group frees it from the personal space by handing it to the group). - **Storage usage is the recorded size, not disk usage.** A space's usage adds up the sizes recorded for its attachments; it doesn't look at the disk. - **A group's By member can credit the wrong person for files from before v0.10.** It counts each file for whoever brought it into the group — uploaded it into one of the group's notes, or moved or copied its note in — but nothing recorded who moved a note before v0.10, so the files of notes moved into a group before the upgrade still count for the people who uploaded them. (Copies made before the upgrade aren't affected: a copy already recorded the person who copied it.) If the server was rolled back to v0.9 for a while, files uploaded or copied while v0.9 ran count under **Deleted user** after upgrading again (until their note is next moved into a group, which counts every file of the note for the person who moves it), and notes moved into a group while v0.9 ran keep counting for whoever they counted for before (usually the people who uploaded them). - **An image block copied from another note into yours doesn't count against your space.** It still points at the other note's attachment, which stays in that note's space (see "An image block copied and pasted into another note…" above). - **Adding attachments to the same space happens one at a time.** Uploads, copies, moves and transfers that add attachments to one space wait for each other; while a large group is being transferred to someone, their own uploads can wait, and after 5 seconds they are answered `409 server_busy` (retry). - **A plan change can race an upload already in progress.** An upload that read the old limit a moment before an admin changed it is judged by the old limit. - **Copying into a full space can be refused even when nothing would be copied.** Before copying anything, a copy that has attachments to copy (the note's own attachments that its content links to as `/api/uploads/`) checks whether the place it goes into is already at or over its limit, and if so answers `409 storage_quota_exceeded`. That check counts the note's attachments as recorded in the database, so a note whose attachment files are all missing on the server — which the copy would skip — is still refused when the space is full. - **Anyone who can read a note can re-store its attachments without having uploaded them.** Copying a note counts against the copier's upload limit, so copying attachments you uploaded yourself costs about what uploading them did. But anyone who can read a note — a viewer it's shared with, or a group member whose role can only read — can copy it, storing new copies of files other people uploaded. Per user that is capped at 120 copied files every 10 minutes, with one exception: a note with more than 120 attachments is copied whole, as long as none of the current 10-minute window's upload limit has been used, and the copy then uses up that window. Each copy also counts against the limit of 30 writes per minute. Repeatedly copying a note with many attachments can therefore use up storage faster than uploading could (#175). Copied attachments count against the copier's personal space (or the group copied into), so re-storing someone else's attachments uses up your own storage quota. - **Copying many notes at once from the sidebar stops after about 30 in a minute — sooner if you've made other writes in that minute, or, when the notes have attachments, copied or uploaded many attachments in the last 10 minutes.** Each copy counts against the per-person limit of 30 writes a minute, which is shared with writes through the API and MCP (edits, creating a note with content, and MCP's `delete_note` and `restore_note`), with reverting an AI edit (in the browser too), and with saving, applying, renaming and deleting versions; copying a note's attachments also counts, one per attachment, against the upload limit of 120 every 10 minutes, which uploading files shares. A larger selection stops partway with "Too many requests. Please slow down."; the notes already copied stay copied, and the one it stopped at and those after it stay ticked, so you can try again once the limit has passed — within a minute for writes, up to 10 minutes for attachments. Moving notes into a group or to the trash isn't limited this way, and neither is anything done with folders, which has its own, much larger limit (see [Folders](#folders)). - **While several notes are being moved into a group or copied from the sidebar, or folders deleted or removed, the confirmation can't be closed — if the server stops answering, reload the page.** It stays open until the last request comes back, and the web app gives a request no time limit; the **Move to…** and **Copy to…** dialogs for a single note behave the same way. A request already sent may still have gone through, so check the notes after reloading. - **A note's backlinks reflect whoever most recently wrote its outgoing link set, filtered to what *they* could see at the time.** The link index is replaced as a whole (not a diff) — by the editing client when a browser saves the note, and by the server from the content it wrote when an API or MCP write, an applied version, a reverted AI edit or a copy lands — and each target is filtered through the read access of whoever made that write. If an editor with narrower access than a previous editor saves the note, links to notes they can't see drop out of the index — and reappear if someone with broader access edits it again. This is by design (the index follows whoever wrote last, not a ground truth independent of who's editing), not a bug, but it means backlinks can be surprising in heavily-shared notebooks. The one-time link conversion after upgrading to 0.11 only adds links, filtered with the note owner's access (in a group note: notes in the same group) (#106, #269). - **`GET /api/notes/:id/backlinks` is capped at 200 results (`MAX_BACKLINKS`), most-recently-updated first, with no pagination.** A note linked from more than 200 others silently shows only the newest 200 sources. - **`POST /api/notes/:id/links` has no rate limit.** Unlike most other write endpoints, this one is deliberately not throttled — its per-request cost is already bounded by the single batched authorization query and the 1000-target cap (`MAX_LINK_TARGETS`), and it's normally called by a debounced background sync, not directly by user action. - **The note list has no pagination**, and it's the only source used for wikilink autocomplete candidates, for resolving a link to a note's live title, and for converting a pasted, dragged-in or typed link to a note at once (other addresses are looked up on the server) — both effectively cap out at however many notes the list endpoint returns before something has to change. Knotebook's full-text search (the sidebar's search, `GET /api/search` and `search_notes` over MCP, #93) doesn't feed either of them yet; a server-side lookup for wikilink candidates and titles is a future release. - **External images embedded by URL are hotlinked, not proxied.** BlockNote's image block also accepts a plain external URL (not just an uploaded file) — Knotebook renders that URL directly in the reader's browser rather than fetching and re-serving it, so the external host sees the reader's IP address on every view. It does not learn which note that is: pages are sent with `Referrer-Policy: no-referrer` (#101). Treat this the same as embedding any external image in any other tool. - **An assistant tricked by a prompt injection can send your notes' images off-site.** `create_transfer_token` puts a short-lived token in the assistant's context, its shell commands and the client's transcript. An assistant steered by instructions hidden in content it reads (for example a note someone shared with you) can ask for a download token for every note it can read and send the images' bytes anywhere — a path that did not exist before, when an API token or app credential could not fetch a note's images at all. It can also fill notes it can edit with uploads: at most 5 unused upload tokens per credential at once, and up to the 60-per-10-minutes token write budget — with the default limits, about 600 MiB of 10 MiB files every 10 minutes (a site admin can lower the budget and the upload size limit). Uploads work in the other direction too: it can put image files from your machine, such as screenshots, into a note other people can read (for example a group note, or one someone shared with you as an editor) — through your Knotebook address, which you have probably let it reach with `curl`. Short lifetimes, one note per token, keeping the token out of URLs and Claude Code's prompt before it runs a shell command limit this; none of them prevents it (#200). - **An assistant tricked by a prompt injection can move your personal notes into a group, or copy notes into one.** This needs the `notes:move` scope: an MCP client whose token or app credential has it gets `move_note_to_group`, and can pass `group_id` to `copy_note`. A credential only gets `notes:move` when you ask for it — by turning on **Move or copy notes into groups** when you create a token or later on its row in **Settings → Authorization**, by ticking it on the consent page, or through `POST`/`PATCH /api/auth/tokens` — and no credential gets it automatically, including those issued before it existed. Moving hands a private note to everyone in the group and removes its per-person shares, public link and version history, which don't come back, and a single note can't be moved back out. Copying can put the content of a note someone shared with you into one of your groups, where the members who can read the group's notes see it. This is what **Move to…** and **Copy to…** do in the web app — the tools can also move or copy into a group where your role can create notes but not edit them — except that no one has to click. Mitigations: leave `notes:move` off unless a program really needs it — a credential without it doesn't have `move_note_to_group`, and its `copy_note` refuses `group_id` with `insufficient_scope`; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense. `notes:move` covers handing over an existing note and copying a whole note into a group, not writing into a group's notes: a `notes:write` credential can still put what it has read into a group's notes — for example with MCP's `create_note` (with `group_id`) and `edit_note`, or the REST `POST /api/notes` (with `groupId`) and `POST /api/notes/:id/edits` (#180, #239). - **An assistant tricked by a prompt injection can move your notes to the trash.** This needs the `notes:delete` scope: an MCP client whose token or app credential has it gets `delete_note`, `restore_note` and `delete_folder`. A credential only gets `notes:delete` when you ask for it — by turning on **Move notes to the trash and restore them** when you create a token or later on its row in **Settings → Authorization**, by ticking it on the consent page, or through `POST`/`PATCH /api/auth/tokens` — and no credential gets it automatically, including those issued before it existed. It can move any note your account could move to the trash in the web app: your own personal notes, and the notes of groups where your role can delete notes. While a note is in the trash, someone who can restore it can bring it back, in the web app or with `restore_note`; but it is deleted permanently once it is due under the site's trash setting — the shorter the retention period, the less time there is to notice — unless the site is set not to empty the trash on its own, and someone can delete it permanently sooner in the web app. A group note can only be brought back by a member whose role can both edit and delete notes, so if your role can delete but not edit, neither you nor the assistant can see or restore the group notes it moved there. No MCP tool deletes a note permanently or empties the trash. Mitigations: leave `notes:delete` off unless a program really needs it — a credential without it doesn't have `delete_note`, `restore_note` or `delete_folder`; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense (#181). - **An assistant tricked by a prompt injection can delete your folders, or rearrange them.** With the `notes:delete` scope, one `delete_folder` call deletes a folder and every folder inside it and moves every note in them that isn't in the trash yet to the trash, all at once — in your personal folders, and in a group's folders when your role there can edit and delete notes (in your folders for notes shared with you, it only removes the folders). Those notes can be brought back while they are in the trash, re-creating the folders on the way to each one, as in the entry above; folders that held no notes don't come back. Without `notes:delete`, a credential with `notes:write` can't delete folders, but it can create, rename and move them and file notes in them (`create_folder`, `rename_folder`, `move_folder`, `move_note_to_folder`) — and in a group where your role can edit notes, every member who can read the group's notes sees those folders, names included. Mitigations: leave `notes:delete` off unless a program really needs it, and `notes:write` too when it only has to read; Claude Code asks before a tool call unless you've allowed the tool or turned prompts off — that is not a defense. - **`read_note_image` shows an image only if you can read the note it was uploaded to.** It needs read access to the `note_id` you pass and to the note the image belongs to — the same rule as fetching `/api/uploads/` in the browser — so an image block copied from another note shows only if you can read that note. An image written with an external URL can't be read through it (#200). - **An image uploaded but never written into a note is still an attachment.** An upload made with a transfer token that `edit_note` never references — or whose reference is later removed — keeps its file and its `uploads` row until the note it belongs to is permanently deleted, and its size counts toward that note's storage space (see "Removing an image from a note doesn't free its space"). A transfer-token upload that fails after the server has started reading the file (`413`, `415`, `400`, or a `409` — `storage_quota_exceeded` because the file doesn't fit, or `server_busy`) still uses the token up (#200). - **The `curl` commands `create_transfer_token` and `open_notes_as_files` return are built from `PUBLIC_URL`.** If `PUBLIC_URL` doesn't match the address the assistant can reach, its uploads and downloads, and the files it sends back, go to the wrong place — the same family as "A wrong `PUBLIC_URL` makes MCP requests that carry an `Origin` header answer `403`" (#200). - **On a plain-http `PUBLIC_URL` (for example a LAN deployment), transfer tokens cross the network unencrypted,** exactly as API tokens do there (#200). - **An MCP connection made with the browser's sign-in session has no `create_transfer_token` or `open_notes_as_files`.** The tools need a token or app credential to tie their transfer tokens to; the web app uploads and shows images with its own session instead (#200). - **Uploads have no CSRF check when the request carries no `Origin` header.** `POST /api/notes/:id/uploads` and the sign-in service icon upload (`PUT /api/admin/auth/providers/:id/icon`, which only admins get through to) reject a mismatched `Origin`, but a request with *no* `Origin` header at all is allowed through, matching how legitimate non-browser API clients behave. This is a deliberate trade-off documented for completeness, not an oversight. - **Revoked upload access can still be served from a browser's cache.** `GET /api/uploads/:id` sets `Cache-Control: private, max-age=31536000, immutable` so a browser never re-checks it — appropriate for a URL that never changes content, but it also means a viewer whose share was just revoked can keep seeing an image they already loaded until that cache entry is evicted on its own, well past any revocation SLA the rest of the system aims for. - **The uploads directory is a Docker named volume; if it isn't mounted, uploaded images vanish the next time the `app` container is rebuilt.** The default `docker-compose.yml` already mounts it (`uploads:/app/uploads`) — this only bites if you've customized the compose file and dropped that volume. - **Only image blocks can upload; audio, video, and file blocks are embed-by-URL only.** The block editor's file panel gives image blocks both an Upload tab and an Embed tab, but audio/video/file blocks only ever get the Embed tab — there's no server-side upload path for them, only pasting in a URL to an already-hosted file. - **AI quick-action requests are rate-limited to 30 per user per minute by default (a site admin can lower it), in-memory.** Like the other in-memory limiters above, this counter lives in process memory and resets whenever the `app` container restarts (and can't be shared across instances — see the [single-instance warning](./self-hosting.md#single-instance-warning)). - **AI quick actions round-trip your note content through Markdown, which can lose fidelity.** The selected block(s) are serialized to Markdown before being sent to the AI provider, and the response is parsed back from Markdown into blocks; formatting that doesn't survive that round-trip cleanly (e.g. some nested structures or block-specific attributes) can be altered by running a quick action over it, independent of anything the AI itself changed. - **Anthropic responses are capped at 4096 output tokens (`max_tokens`), with no error on truncation.** If a quick action's response hits that limit, Anthropic's API stops generating and Knotebook streams whatever was produced without surfacing an error — long outputs (e.g. "continue" on a lengthy selection) can be silently cut off mid-sentence. This is a fixed server-side constant today, not yet a configurable setting. - **Pasting a plain-text file view (a `/raw/` URL) can be parsed as Markdown even when the file is source code.** A browser rendering a `text/plain` document wraps it in a bare `
`, which carries no formatting of its own, so the clipboard cannot say whether that file was a Markdown document or a Dockerfile. BlockNote's Markdown detector then decides, and it fires on incidental syntax: an underscore pair such as `$SERVICE_NAME` or a backticked word inside a comment is enough. Once it treats the payload as Markdown, a leading `# comment` becomes a heading. A code snippet copied from a *documentation page* is not affected — that arrives as a `
` block, which Knotebook honours as a code block — and neither is a copy out of VS Code, which tags the clipboard with the file's language. To paste a source file from a raw URL verbatim, insert a code block first and paste inside it: pasting inside a code block always inserts plain text.
- **Pasting a large amount of plain text into an ordinary paragraph is slow in the browser, and very large pastes never arrive.** Plain text pasted into the editor is read as Markdown, so lines that follow one another with no blank line between them (logs, chat transcripts, hard-wrapped text) become one paragraph with a line break at the end of each line, and the browser editor slows down more than proportionally with that shape. Measured on 2026-11-08 in headless Chromium: pasting 1 MB froze the tab for about 6 seconds and each keystroke afterwards took about 0.35 seconds; 2 MB froze it for about 26 seconds, then about 2.8 seconds per keystroke; at 3 MB the tab stopped responding and the text never reached the server. The same text pasted inside a code block takes about a second even at 10 MB. To paste a large block of text, create a code block first and paste into it, or separate the paragraphs with blank lines before pasting. Not planned for this release.
- **A collaboration client that sends ten messages the server doesn't recognize gets disconnected — including a hypothetical newer client version.** The server classifies incoming collaboration messages against the set its bundled library version actually handles and force-drops a connection after ten unrecognized ones (see [Troubleshooting](./self-hosting.md#troubleshooting)); this is the guard against a signed-in account flooding the log (#50). The ten-message grace absorbs incidental noise, but if a future client version ever introduces a *frequent* new message type, browsers running it against an older server would be disconnected and reconnect in a loop until the server is upgraded too. In practice the web client and server ship in one image, so the realistic skew — a stale browser tab across a deploy — runs an *older* client against a newer server and never sends unknown types.
- **"This looks like a failed chunk load" is decided by matching error messages.** When the note page's on-demand code chunk fails to load (#66), the automatic recover-by-reload path only fires if the error message matches a known wording: the phrasing Chromium, Firefox and Safari use for a failed dynamic import, Vite's own "Unable to preload CSS" message (what a failed load of this chunk actually produces most of the time, since the chunk ships with a CSS file that is fetched first), and the MIME-type error a `try_files`-style reverse proxy produces when it answers a missing asset with `index.html`. If a future browser or bundler version rewords any of these, nothing breaks — the failure just lands on the generic error screen with a retry button instead of reloading automatically. The trade-off fails toward the safe side (never toward a blank page or a reload loop), which is why message matching was accepted at all. Separately, the *nested* chunks the note page loads on demand after mounting (currently syntax highlighting) are outside this mechanism entirely: they fail as unhandled promise rejections, not render errors, so a deploy rotation can leave code blocks silently unhighlighted until the next full reload.
- **The note page auto-reloads at most once per successful load, per tab.** The reload that recovers from a deploy rotation (#66) spends a per-tab budget tracked in `sessionStorage`, re-armed only when the note chunk actually loads successfully — so a persistently broken deploy shows the error screen rather than reloading forever, and a tab whose storage is unavailable (some private-browsing modes) skips the automatic reload entirely and goes straight to the error screen with its retry button. Duplicating a tab copies the spent budget along with it; each tab re-arms on its own next successful load.
- **Scrollbars are deliberately low-contrast.** The scrollbar thumb's contrast against its track falls short of WCAG 1.4.11's 3:1 in every state but one (light: resting ≈2.1:1, hover ≈2.9:1; dark: resting ≈2.9–3.0:1) — a conscious visual-subtlety trade-off, not an oversight. The one state that clears the bar is dark-mode hover, at ≈3.9:1. Scroll affordance is still available through other cues: content gets visibly clipped at the viewport edge, and the thumb still occupies space in its track even where its contrast is hard to make out.
- **On a window narrower than 768px, hovering a block shows its handles partially clipped.** Below 768px the note text's side inset shrinks to 20px (so the text lines up with the title where horizontal space is scarce), and BlockNote's hover handles — the `+` and `⠿` that appear to the left of a block — live in that inset and are wider than it, so a mouse user on a narrow window sees them cut off by the card edge. Touch devices never show hover handles, and windows 768px and wider keep the full 70px inset, so the common cases are unaffected ([issue #115](https://github.com/sw-willie-wu/knotebook/issues/115)). Fixing the table-hover page scrollbar (#160) made the note's centered content wrapper the nearest positioned ancestor for these handles too, which widened the clipping: measured at a 390px window, the visible sliver went from about 33px (clipped by the window edge) down to about 20px (now clipped by the card edge) — the 13px difference is the card's 1px border plus its 12px page padding.
- **A public share page shows a snapshot that can lag the editors by a few seconds.** Anonymous readers get whatever the collaboration server last persisted — during active editing that's roughly the 2-second persistence debounce, up to 10 seconds under continuous typing, flushed when the last editor disconnects. Reloading fetches a fresh snapshot; live sync for anonymous readers is out of scope (see [Sharing](./sharing.md)).
- **A public share exposes the note's full CRDT state, not just its rendered text.** The public content endpoint returns the note's Yjs document as-is, which includes collaboration-internal structure (client ids, and — where the garbage collector hasn't reclaimed them yet — traces of edit history). GC is on, so deleted content is usually already gone, but "what the page displays" and "what the response contains" are not byte-for-byte the same thing. Treat making a note public as publishing the document, not a rendering of it.
- **Images an anonymous reader already loaded survive revoking the public link, in that reader's browser cache.** The public image endpoint uses the same `Cache-Control: private, max-age=31536000, immutable` as the logged-in one — the note *content* stops being served immediately on revoke (`no-store`), but an image blob that a browser has already cached can be re-displayed from that cache until it's evicted. Same trade-off as the revoked-member case above.
- **An image block copied and pasted from another note breaks on the public page.** A public token only authorizes blobs uploaded to the note it shares; an image block whose file was uploaded to a *different* note (copied over as a block) gets a 404 from the public image endpoint. That's the boundary working as intended — the alternative would let one public note leak any note's uploads — but the visible symptom is a broken image for anonymous readers of content that looks fine to members. Copying a whole note avoids this for the note's own attachments; images in it that point at another note's attachment still break (see "Copying a note doesn't rewrite every link to its attachments" below).
- **An image embedded by absolute same-origin URL breaks on the public page.** The public page rewrites uploaded images' relative `/api/uploads/:id` URLs to the public image endpoint that matches how the page was reached — the token form or, for a custom public URL, the `//` form (#122) — but only that exact relative form: a block whose URL spells out the full origin (`https://your-host/api/uploads/...` — the Embed tab requires absolute URLs, so this is what pasting your own upload's address produces) is left alone, and anonymous readers get a 401 broken image. Upload images through the Upload tab and this never comes up.
- **A custom public URL reveals your username, and is meant to be guessable.** The point of `https://your-host/p//` is that people can read and remember it — which also means anyone can *probe* it, and the URL itself discloses which account owns the note. Usernames default to the identity provider's `preferred_username` claim (for SSO sign-ups that carry one) or the local part of the account's email — so under corporate SSO they are often the internal account name. If either of those is a problem for a given note, share its token link and leave the custom URL unset — the token form discloses nothing (see [Sharing](./sharing.md)).
- **A note reachable both ways gets double the public read quota, and the throttle table grows accordingly.** The token form and the custom-URL form of the same note count reads in separate rate-limit entries (the keys deliberately differ so readers of one form never throttle the other), so the effective per-IP read quota for that note is twice what a single form gets. The two forms also double the number of live entries the fixed-size throttle table holds, which under heavy mixed traffic shortens how long any one entry effectively lasts (the table evicts oldest-first). Both are accepted: the quota exists to bound work per request source, not to meter a note's total readership.
- **Copying a note doesn't rewrite every link to its attachments.** A copy gets its own copy of an attachment only when the attachment belongs to the note being copied and a block's URL is exactly `/api/uploads/`. In the copy, these still point at the original attachment, so access to them is still decided by the note that attachment belongs to (once that note is in the trash or permanently deleted the image breaks, and someone who can't read that note gets `403`): an image block pointing at **another** note's attachment (for example one copied and pasted from another note — see "An image block copied and pasted into another note…" above), an upload URL inside a text link, and an image whose URL is written out in full as `https:///api/uploads/…`. An attachment whose file is already missing on the server is skipped, and the copy keeps its original URL (#175).
- **Copying a note with many attachments holds it briefly and doubles their disk usage.** The copy duplicates the attachment files it copies inside the same database transaction that creates it. While that runs, people can keep editing the original's content, and its title as long as that doesn't change its URL name — a title-only change goes through straight away. Renaming a note that has no custom URL name usually does change it (the automatic URL follows the title), so that save waits, as do deleting the note, moving it into a group, changing its URL name or deleting the group it's in, until the copy has finished. Apart from the upload limit (see "Anyone who can read a note can re-store its attachments…" above) and the storage quota of the place the copy goes into, nothing caps how many attachments one copy duplicates (#175).
- **Moving a note into a group, or deleting a group and giving its notes to an admin, drops the note's previous-name redirect.** Of its `/n/…` addresses, only the one the note had at the moment of the move forwards to its new group address, for a month; if you had changed its URL name before that, the older name stops working immediately (old-form `/notes/…` links keep working). Its per-person shares and its public link (including any custom public URL) are removed; someone whose role in the group can manage public links can turn on a new public link afterwards — an anonymous one, since a group note has no custom public URL. Deleting a group with **Give them to an admin** works the same way: each note's `/g/…` address at that moment forwards to its new `/n/…` address, for a month, while a URL name it had in the group before that stops working, and its public link is removed — the admin who now owns it can turn on a new one. In both cases a forwarding entry the note already had — say, from being moved into a group less than a month earlier — keeps working until its month is up (unless another note gets that address in the meantime), and leads to the note where it is now, for the people who can read it (#175).
- **Group notes can't be shared with individual people.** Access to a group note comes through the group: the Share dialog of a group note has no per-person sharing, and the per-person sharing endpoints answer `403` on a group note you can read. To let someone in, add them to the group — which also gives them every other note in it (#103, #175).
- **MCP clients don't list your groups.** `list_notes` and `search_notes` include the notes of every group you belong to, and each result's `owner` names the group, with its id; `edit_note` works on a group note when your role in that group can edit, and `create_note` with `group_id` creates a note in a group when your role there can create notes. No MCP tool lists your groups as such. `list_folders` lists the folders of every group whose notes your role can read, with the group's id and name in each folder's `space`, so a group that has folders shows up there even before it has notes; for a group with neither notes nor folders yet, you need its id from a signed-in browser session: it is in the address of the group's panel in the web app (the group's `⋮` menu in the sidebar → **Members & settings**, or **View members** if your role can't manage members), `/groups/`, and in what `GET /api/groups` returns. The REST move and copy endpoints still accept only a signed-in browser session; a token reaches the same operations through MCP's `move_note_to_group` and `copy_note` (#103, #175).
- **A group's last admin can't leave it or step down.** A group always keeps at least one admin: leaving, being removed, or being demoted as the only admin answers `409 last_admin`. Make someone else an admin first, or delete the group (its notes go to an admin you pick — you, if you're the only one — or are deleted with it) (#103, #175).
- **Disabling a group's only admin leaves the group without a working admin.** Disabling an account doesn't touch its group memberships, so unless another member's role can manage members (which lets them make anyone, themselves included, an Admin) or manage roles and the group (which lets them give their own role Manage members), nobody left in the group can rename it, manage its members or delete it. A site admin can fix it through the API — from a signed-in browser session, since the group endpoints don't accept API tokens, and with the group's id, which has to come from elsewhere because `GET /api/groups` lists only a site admin's own groups (a remaining member sees it in the address of the group's panel, `/groups/`, or of any of its notes, `/g//…`; failing that, the database): promote another member to the group's Admin role (`PATCH /api/groups/:id/members/:userId` with `{roleId}` set to that role's id, from `GET /api/groups/:id/roles`), or, if nobody else is left, add someone with the group's Admin role (`PUT /api/groups/:id/members` with the Admin role's `roleId`) (#103, #175).
- **Site admins can manage a group they don't belong to only through the API.** On the group endpoints a site admin can manage every group — whether or not they're a member, and whatever their role in it is — but they still can't open its notes without being a member, the sidebar and **Settings → Groups** only show groups you're a member of, and opening another group's panel (`/groups/`) shows a not-found message. Using the API takes a signed-in browser session (the group endpoints don't accept API tokens) and the group's id, which `GET /api/groups` won't list for a group you aren't in (#103).
- **The permissions that manage a group are a convenience, not a security boundary.** Someone whose role can manage members can make anyone — themselves included — an Admin, and someone whose role can manage roles and the group can give their own role Manage members. A role meant to "only rename the group" or "only add people" can't stop its holder from taking the rest; give either permission only to people you would trust as an Admin (see [Sharing](./sharing.md#notes-in-a-group)) (#175).
- **After someone's role or its permissions change, pages they already have open may update only the editor; menus and buttons can lag until the page reloads or its tab regains focus.** The note's content, its title and the connection badge follow the role the live-editing connection reports, so they turn read-only, or editable again, within a few seconds. The rest of the page — the **Delete** item in the `⋮` menu, the **Public link** switch in the Share dialog, the **+** next to the group's name in the sidebar — shows what that browser last loaded (the note's own controls also refresh when someone else's edit to the note arrives); only the browser of the person who made the change reloads it right away. Until the holder's page catches up, a control that is still showing can be refused by the server, and one they have just been given stays hidden. Reloading the page, or switching to another tab and back, brings it up to date. The groups offered under **Move to…** and **Copy to…** in a note's `⋮` menu lag the same way: the list is reloaded each time the menu opens, so it is out of date only while the menu stays open (and for a moment after it opens, while it still shows the list loaded earlier); moving or copying into a group where your role can no longer create notes is refused with "We couldn't find that group." This applies to any role change — changing someone's role on the group's member list as well as changing a role's permissions on the **Roles** page of the group's panel (#175).
- **On a group note, two people who can manage its public link can undo each other's link without noticing.** The Share dialog's **Public link** switch shows the state your browser last loaded. If someone else turned the link on after that and you then switch it on too, a new link is made and the one they may already have handed out stops working — switching the link on always makes a new one, the same as **Regenerate link** (#175).
- **If your role can't manage a group note's public link, the Share button and dialog don't tell you the note is public.** They show only the group's access, even when the note also has an anonymous public link; only people whose role can manage public links see it (#175).
- **Leaving a group while one of its notes is open doesn't close that note right away.** Your access ends immediately (see [Sharing](./sharing.md#notes-in-a-group)), but the open page only notices when its live-editing connection re-checks your access — which leaving triggers right away, and which takes at most a few seconds — and then says you no longer have access and takes you back to the home page — the same thing that happens when someone else removes you from the group. ⚠ Derived from reading the code; the case where you leave yourself is not confirmed end to end (#175).
- **Deleting a group with Delete everything can remove a note added to it a moment earlier without telling the people who have it open that it was deleted.** Before anything is deleted, everyone who has one of the group's notes open is taken out of it and told the note has been deleted. A note that is created, moved or copied into the group after that step, but before the deletion itself starts, is deleted along with the rest — but someone who has it open is told "You no longer have access to this note." within a few seconds instead, and what they type in those seconds isn't saved (#175).
- **When deleting a group with Delete everything fails, people who had its notes open may already have been told a note was deleted when it wasn't.** Before anything is deleted, everyone who has one of the group's notes open is taken out of it and told it has been deleted; if the deletion then fails — it was cancelled with "The server is busy" (see below), it ran into some other error, or another admin gave the group's notes to an admin a moment earlier — nothing is deleted, but those people have already seen that message. When it simply failed, the notes are still in the group and can be opened again (reloading the page is enough). When another admin gave the notes away first, they are now that admin's personal notes: the admin who received them can open them again, and everyone else has lost access to them either way (#175).
- **A request a former member had already sent when the group was deleted can still take effect on a note that now belongs to the admin who received it.** Whether a request is allowed is decided from the sender's role in the group when the request arrives, and the write doesn't check again who owns the note. So if the group is deleted with **Give them to an admin** while such a request is in flight, it can still go through on what is now the admin's personal note: deleting the note, editing it through the API or the MCP `edit_note` tool, writing the content of a note they had just created in the group (`POST /api/notes` with `groupId` and `content`, or the MCP `create_note` tool with `group_id` and `content`, first creates the note in the group and then writes its content, and if writing it fails, deletes the note again), reverting an AI edit, uploading an attachment to it — or, if the admin has just turned its public link back on, turning it off again or removing a custom public URL they just gave it. And anything a former member types in an editor they already have open, in the few seconds before it closes, is saved to the admin's note — the same as when someone is removed from a group. The sender was allowed to do each of these when they sent it; the window is only between that check and the write (#175).
- **Deleting a group can occasionally fail with "The server is busy"; trying again works.** If the group is deleted at the same moment as the links between two of its notes are being updated (which happens as someone edits them), or as another group is being given to the same admin, the database can find the two changes waiting on each other and cancel one of them. With transfer, the request also answers `409 server_busy` when the receiving admin's storage space stays busy with other additions for more than 5 seconds, or when that admin's folders stay busy with other changes for more than 5 seconds. Either way, the request can also answer `409 server_busy` when the group's own folders stay busy with other changes for more than 5 seconds. When it cancels the deletion, the request answers `409 server_busy`, nothing is deleted or moved, and the dialog stays open so you can try again (with **Delete everything**, people who had one of its notes open have already been told it was deleted — see above). Every note in the group is handled in that one deletion, so the more notes a group has, the longer it takes (#175).
- **Upgrading a pre-release build that already had groups moves every group note out of its owner's hands, and some old links stop working.** This only applies to servers that ran a build of the `main` branch from after #103 and before migration 0012 — released versions up to 0.4.1 had no groups. Notes that were in a group now belong to the group: their former owners keep only what their group role allows (an ordinary member can no longer delete the note or manage its public link; someone no longer in the group loses access entirely), notes that were read-only for their group became editable by every member, the group's admins can now delete any of its notes, manage their public links and change their URL name (through the API), per-person shares on them were removed, custom public URLs (`/p//`) on them stopped working (the anonymous token link still works), and their previous-name redirects were dropped — only each note's current `/n/…` address forwards to its new `/g/…` address, for a month; a note whose URL name clashed with another note in the same group was renamed, normally with a numeric suffix. The migration can't be undone: back up the database before upgrading (see [Upgrading and rolling back](./self-hosting.md#upgrading-and-rolling-back)); after the migration that pre-release build 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. If you're upgrading from 0.4.1 or earlier, there were no groups, so none of these changes affect your data (#175).
- **A username you rename away from is free for anyone to take — including you — and old links can then lead to the new owner's notes.** Every account has a username (`handle`, shown in **Settings → Account info** as **URL name**; every personal note's URL carries it as `/n//…`). Renaming frees the old name at once: anyone, including the original owner changing their mind, can take it — by renaming to it, by signing up, accepting an invitation or first signing in with a sign-in service when the name derived from their email (or the service's username) is the freed name. Links shared under the old name are not forwarded to your new name. Once someone else has taken the name, those links can open the new owner's notes instead (for `/n/`, only for people who can read them) or give 404. In-app links that were already converted to note ids (#269) are not affected; only addresses shared outside the app, and in-app links that weren't converted (such as `/p//…` links, or links to notes that couldn't be matched), follow the new owner. Renames are limited to 5 **successful** changes per rolling 24 hours — going back to one of your old names counts as one — counted from a record of renames kept in the database, so the quota survives server restarts. Failed attempts consume no quota.
- **Renaming your username breaks your `/n//…` links immediately, and other people's open tabs keep offering the dead links until their caches refresh.** Personal note URLs carry your username, and a rename gives up the old one; nothing forwards those links (see the entry above for what happens once someone else takes the name) — except the old address of a note that moved into a group, which forwards to the group address for a month after the move. Your own session updates immediately; collaborators' cached data — sidebar links, copy-link buttons — only refreshes on their next background refetch (typically on window focus). Old-form `/notes/…` links are unaffected.
- **Accounts created while running a rolled-back (pre-username) server version aren't in the username registry until the next upgrade boots.** Rolling back to an older image after the username migration leaves newly created accounts' names outside the registry. Until the upgraded server's next startup completes its idempotent backfill, picking an automatic name for a new account can't see those accounts' names, but duplicates are still prevented (`users_handle_unique`) and renaming still works without the registry row; at startup the server logs a warning for any account whose name is still outside it.
- **Behind a reverse proxy with `TRUST_PROXY` unset, one client can rate-limit the public share pages for everyone.** The public endpoints throttle unresolvable tokens per client address; with `TRUST_PROXY` unset behind a proxy, every visitor shares the proxy's address and therefore one bucket — anyone sending ~120 garbage tokens a minute makes every anonymous reader see 429s for the next minute (members and editing are unaffected). This is the same shared-bucket failure as the login-lockout case in the [self-hosting guide](./self-hosting.md#deployment-prerequisites): if you run behind a proxy and use public sharing, set `TRUST_PROXY`. The same applies to the per-IP limit on **invalid Bearer attempts** (30/min): unset behind a proxy, one misbehaving client's bad tokens make every *other* invalid or expired token behind that proxy get `429` instead of `401 invalid_token` until the window passes — so those clients can't tell they should re-authorize. Valid tokens are limited per user, not per IP, and are unaffected (#107). Invalid password reset links and invitation links collapse the same way: 30 per minute per IP together, then `429 too_many_requests` instead of `410 reset_link_invalid` or `410 invite_invalid`; valid links are not affected. The three OAuth endpoints' per-IP buckets (`/oauth/register` 30/hour, `/oauth/authorize` 30/min, `/oauth/token` 60/min) collapse the same way behind an unset `TRUST_PROXY`: everyone behind the proxy shares each bucket, so a few people authorizing apps at once can push each other into `429`.
- **API tokens survive a password change.** A Personal API token is a separate credential — changing the account password does not revoke it, the same way personal access tokens work on GitHub and similar services. If a token may have leaked, revoke it in Settings → Authorization → API tokens. See [API tokens](./api-tokens.md#security-notes) (#107).
- **The API-token quota is best-effort.** The 20-active-tokens limit is checked and then the row is inserted, without locking the user; two creations racing each other can briefly leave you at 21. The limit exists for tidiness and UI readability, not as a security boundary. OAuth app authorization shares the same cap and the same race — two apps authorizing at the same instant can also briefly exceed it (#107/#132).
- **Expired API tokens are cleaned up opportunistically, not on a schedule.** Tokens that have been expired for more than 30 days are deleted on any of the six triggers listed in [API tokens](./api-tokens.md#revoking); on an idle account they stay in the list, marked *Expired* and unusable, until then. An *App* row marked *Expired* is different: it already qualifies for removal and goes at the next trigger without a further 30 days, and the app can still reconnect until then (#107).
- **The `WWW-Authenticate` challenge points at a single resource.** Every endpoint that accepts an API token returns the same `resource_metadata` URL, the one for `/api/mcp`, regardless of which endpoint was called. RFC 9728 says a client must discard resource metadata whose `resource` doesn't match the URL it was fetched for, so a strictly conforming OAuth client that starts from, say, `GET /api/notes` will ignore the hint — only `/api/mcp` (the MCP client's actual entry point) yields metadata it will accept. This is deliberate: one protected resource keeps one authorization server and one token audience, and the alternative (only `/api/mcp` advertising metadata) would leave every other token-accepting endpoint's `401` with no discovery information at all. The OAuth authorization server's `resource` parameter (required on `/oauth/authorize` and `/oauth/token`) is checked against this same single identifier — there is one resource identifier for the whole site, not one per endpoint, and tokens are not bound to an audience per request (#107/#132).
- **OAuth for MCP clients only works for clients that run on your own machine.** The built-in authorization server accepts loopback redirect addresses only (`localhost`, `127.0.0.1`, `[::1]` — any port). It is designed for Claude Code connecting directly (needs an `https://` deployment), and for Claude Desktop through a local stdio bridge such as `mcp-remote` (see [How to connect](./mcp.md#how-to-connect)). On a plain-`http://` deployment OAuth is not available; connect with a personal API token instead (#159). The claude.ai web / Desktop "custom connector" cannot work at all: those connect from Anthropic's cloud and cannot reach a LAN instance. Because registration is unauthenticated, accepting remote redirect addresses would let anyone register a nice-looking app name for one-click phishing, so this is deliberate. The flip side is a threat model that assumes one person per machine: on a shared workstation, multi-user Linux box or terminal server, another local user could listen on loopback and collect a code someone else approved — the consent page's loopback warning is there for that reason (#132).
- **Neither the consent screen, nor creating a personal API token, nor changing a credential's scope asks for your password again.** All three act only on your existing browser session — anyone who gets hold of a session you've already unlocked (a shared computer, an unattended browser) can authorize an app, mint a token, or raise an existing token's or app's scope (on its row in **Settings → Authorization**, or with `PATCH /api/auth/tokens/:id`), acting as you across every note the scope reaches — metadata, reading and rewriting note content, with `notes:move` moving notes into groups and copying them there, and with `notes:delete` moving notes to the trash and restoring them — no re-authentication required (#132, #239, #181).
- **App names on the consent page and in Settings are self-reported and unverified.** Dynamic client registration takes whatever `client_name` the app sends (control, bidi-override and zero-width characters are rejected so the name cannot visually push the "not verified" note off the page, and it is rendered bidi-isolated). Trust the redirect address shown on the page, not the name (#132).
- **The authorization server only supports dynamic client registration, which the MCP spec has marked deprecated.** The newer alternative (Client ID Metadata Documents) requires the server to fetch an arbitrary URL supplied by the client, which is a server-side request forgery surface, so it is not offered; the metadata document simply doesn't advertise it and compliant clients fall back to registration. If clients drop the fallback in the future, this will need revisiting (#132).
- **OAuth credentials are not short-lived, and there is no revocation endpoint.** Access tokens last 24 hours and refresh tokens (like personal tokens) never expire; the MCP guidance to issue short-lived tokens is deliberately not followed. Revocation is only ever done from Settings → Authorization, by an admin disabling the account, or by the automatic clean-up of apps unused for 30 days (#132).
- **A replayed authorization code, or a replayed refresh token, is simply rejected — it does not revoke the credential it belonged to.** Codes are single-use and refresh tokens rotate on every use, so a stolen copy fails with `invalid_grant`; but the server cannot tell a replay from a typo (no `used_at` record is kept), so it does not perform the optional "revoke everything derived from this code" reaction. PKCE already makes an intercepted code unusable (#132).
- **A failed token exchange does not consume the authorization code.** If the exchange fails (wrong PKCE verifier, mismatched `client_id` or `redirect_uri`, credential limit), the whole transaction is rolled back and the code stays valid until its 10-minute expiry, so the client can correct the request and retry. During that window PKCE is the only thing standing between an intercepted code and a credential (#132).
- **Denying on the consent page — or hitting the 20-credential limit there — discards the request.** A pending request is single-use whichever way you answer, and the limit check runs after it has been consumed. In both cases you have to start again from the app (after revoking a credential, in the limit case) (#132).
- **Unknown scope values are silently ignored rather than rejected.** An app asking for `notes:read offline_access` gets `notes:read`. `notes:move` and `notes:delete` only come together with `notes:write`, so an app asking for either of them without `notes:write` doesn't get it either: `notes:read notes:move` and `notes:read notes:delete` also get `notes:read`. The granted scope is reported in the token response, which is what clients are supposed to honour (#132).
- **`resource` is required on the authorization request**, stricter than RFC 8707's "may". MCP clients always send it; a hand-written script must too, and it must be exactly `/api/mcp` — no trailing slash, case-sensitive path. Leaving it out entirely gets `invalid_target`, not `invalid_request` (#132).
- **Confidential clients are not supported.** There is no `client_secret`, and the `client_credentials` grant type does not exist — every app is treated as a public client (#132).
- **Scopes cover your whole account.** There is no per-note or per-group scope: `notes:read`, `notes:write`, `notes:move` and `notes:delete` each apply to all the notes your account can reach. On the consent page you choose which of the scopes the app asked for it gets — reading is always included. The first time you authorize an app, **Create and edit notes**, **Move or copy notes into groups** and **Move notes to the trash and restore them** start unticked, so pressing **Allow** straight away gives it read-only access; re-authorizing an app starts from the access it has now (only what it is also asking for this time). That starting point only means the same client registration is asking again — a `client_id` isn't a secret, and any program on your computer can start an authorization with it — not that it is the same program. You still have to press **Allow**, the ticked boxes are only the page's default, and what to go by is still the redirect address and whether you yourself just started that program (see [API tokens](./api-tokens.md#authorizing-an-app-instead-oauth)) (#132, #239).
- **An invalid Bearer token still costs a database lookup once its per-IP bucket is exhausted.** Unlike `/oauth/authorize`, where the rate limit is checked before any database work, a request with an invalid token past its bucket's limit still does one indexed lookup to determine that it's invalid before answering `429` (for a token that has the `knb_` prefix — anything else is rejected before the lookup) — a single indexed lookup is cheap, and a valid token's holder shouldn't be penalized by throttling meant to catch failed attempts (#132). Invalid password reset links and invitation links past their per-IP bucket cost the same: one lookup by the link's hash before the `429` (a link whose token doesn't have the `knbp_` shape — `knbi_` for an invitation — is rejected before the lookup).
- **Matching a `redirect_uri` against a registered one ignores the port** (RFC 8252 §7.3). Harmless today because only loopback addresses are accepted at all; if redirect addresses are ever widened beyond loopback, this comparison needs to go back to an exact match (#132).
- **Re-authorizing an app replaces its previous credential — which logs out any other running copy of that app.** One credential per (user, app) is enforced structurally. A second Claude Code window that shared the old credential gets `401` and a failed refresh, and has to run the flow again (#132).
- **An app the server has forgotten has to be removed and re-added on the client.** A registration is dropped once it is more than 24 hours old and has no live credential and no authorization code on record (even an expired one buys it one more cleanup pass) — which includes a registration whose credential you revoked in Settings, and one whose authorization was never exchanged for a credential; apps unused for 30 days are dropped with their credential too. An app that comes back with its old `client_id` lands on a plain-text page explaining this rather than the consent screen — there is no automatic re-registration on the server side. The client does not notice: it retries with the same `client_id` and waits for an authorization that will never arrive — see [Troubleshooting](./api-tokens.md#troubleshooting) (#132).
- **Changing `PUBLIC_URL` invalidates OAuth apps that cached the old issuer or resource.** The issuer and the resource identifier are derived from it; `oauth_clients` registrations themselves aren't affected — a client keeps its existing registration and just re-runs discovery — but any app that cached the old issuer or resource has to register and authorize again. An app refreshing with the old `resource` gets `invalid_target`. Existing Bearer tokens keep working until they expire, because the resource identifier is not re-checked on every request (#132).
- **Serving the authorization server over plain `http://` breaks the MCP requirement that it be `https://`.** Verified against real clients: Claude Code's built-in OAuth client refuses outright, aborting the token exchange with "Refusing to send credentials to non-https token endpoint …"; `mcp-remote` refuses the same way as of 0.14.2 (`--allow-http` lets the connection through, not the token exchange) (#159). See [How to connect](./mcp.md#how-to-connect). On a plain-http deployment the consent page and the token exchange are exposed to anyone on the same network, exactly like the rest of the site (#132).
- **The automatic clean-up of OAuth leftovers has no scheduler.** Expired requests and codes, registrations older than 24 hours that have no live credential and no authorization code on record (even an expired one buys it one more cleanup pass — this includes a registration whose credential you just revoked in Settings, and one whose authorization was never exchanged for a credential), apps unused for 30 days, and personal tokens expired for more than 30 days are all removed opportunistically, on any of the six triggers listed in [API tokens](./api-tokens.md#revoking). A burst of junk registrations therefore lingers until the next legitimate OAuth activity (#132).
- **Typing that happens in the same couple of seconds as an API write is attributed to that write.** The browser saves a burst of typing once, shortly after it stops. An API write saves immediately when it merges, and that save also flushes whatever human typing was still waiting — so the whole batch is stamped with the API write's author and agent label. No content is lost; only "who edited this last" is coarse at that boundary. Reading a note never has this effect: the read path never opens a writing connection at all (#106, #137).
- **A *rejected* API write can swallow the attribution of human typing that was waiting to be saved.** A write that is refused inside the merge (a fingerprint mismatch) still closes its connection, which is also an immediate save — and that save takes the place of the one the human typing had scheduled. The refused write writes no attribution of its own (it is gated on having actually applied something), so the result is that the human batch's "last edited" is simply not recorded. **The typing itself is saved** — only the attribution is skipped, and the next edit restores it (#106, #137).
- **`replace_all` gives every block in the note a new id.** Anything holding block ids from before — a section id you cached, an in-flight edit record, another program's outline — stops resolving. Prefer section-level operations when something else may be addressing the note; after any write, use the fresh `outline` the response returns (#106, #137).
- **An API write can succeed and still not be recorded.** Content is merged into the live document first and the history row is written afterwards, deliberately — no database transaction is held across the merge. If the row fails to be written, the answer is `500` with the content already in the note and nothing in the edit history, so that change cannot be reverted through the API (a retry is refused by its fingerprint instead of duplicating the edit). **A revert fails differently**: the content is already restored, but the revert row is not written and the original row is not marked. That original row then becomes **non-revertable** and pressing revert again answers `409 stale`. The two operation families arrive there differently — for `replace_all`/`replace_section`/`insert_after`/`append` the fingerprint check does it by itself, since restoring replaced exactly the blocks it compares, while `delete_section` (no fingerprint, and an anchor that survives a restore) is caught by a separate idempotency rule: an edit whose original blocks are back at the top level counts as already restored. Without that rule a retry would insert them a second time and leave two top-level blocks sharing one id. Neither shape is fixable by retrying (#106, #137).
- **`POST /api/notes` with `content` can leave an empty note behind.** If applying the content fails, the note row just created is deleted again on a best-effort basis; if that deletion *also* fails, the answer is still `500` but an empty, untitled-or-titled note stays in your list. Delete it by hand (#106, #137).
- **A note's edit history stores a full copy of what each edit replaced, with no size cap.** Every recorded write keeps the blocks it overwrote so it can be undone, so a note that is repeatedly rewritten in full can hold up to 100 complete snapshots of itself. Nothing trims them other than the 100-row window (#106, #137).
- **`revert_of` cascades, so "the last 100 edits" is really "at most 100".** Each note keeps its 100 most recent history rows, and deleting a row deletes any revert of it. Two consequences: reverting the *oldest* remaining edit pushes the pair out together (101 rows, revert the oldest, 99 remain), and an ordinary write's trim can take out a revert row that is still well inside the window, if the row being trimmed happens to be that revert's original. A revert row's lifetime is bounded by its original's. The second path is **deliberately left untested** — constructing it means driving a note to the trim boundary *and* arranging for the trimmed row to be some revert's original, which costs far more than it guards, since the behaviour is the same mechanism as the first path and that one has two cases holding it (#106, #137).
- **The per-note write queue and the editing runtime are process-local, and there is no cap on how many notes are written at once.** The queue only guarantees that two writes to *the same* note are serialised (see the [single-instance warning](./self-hosting.md#single-instance-warning)); writing N different notes at the same time means N concurrent headless editors, each leaving roughly half a megabyte behind that garbage collection does not reclaim, plus the cost of building it. This is the first path where an **external** request drives that work — reading content also builds one, but under the tighter read budget and without writing. Today's only throttles are the per-user write and token budgets; there is no per-process concurrency gate, so the memory ceiling under many simultaneous writers is set by how many requests arrive at once. Capping it would mean a global semaphore, or moving the runtime into a worker thread (#106, #137). Since #398 reading the written Markdown, putting it into the note and reading a note of 16 KiB or more run in the parse workers, so those editors are bounded by the number of workers; notes under 16 KiB, applying a note version and the one-time link conversion still use an editor in the server's own thread, with no cap.
- **A concurrent write is only protected where the target section has content.** The fingerprint the server re-checks at merge time is a constant for a zero-block section, so `replace_section`/`insert_after` aimed at a `_top` with no blocks, any operation on a note with no blocks at all, and `append` sent without `if_match` have no such protection. Nothing is lost in those cases — content the browser inserted during the window is kept — but where the written content lands *relative* to it is decided by the collaborative document (#106, #137).
- **The wikilink index can briefly lag an API write.** Two causes: an API write recomputes the index from the copy it edited, so a link the browser added in the same window may not be in it yet; and the index update itself can fail (a lost race with a later browser update, a busy write, or a database error — the last is only logged as a warning and deliberately does **not** turn an already-applied, already-recorded write into a `500`, since an AI would retry and duplicate the edit). Both fix themselves the next time that note's link set changes or a browser reconnects to it (#106, #137).
- **`POST /api/notes` now rejects unknown body fields.** A field the server does not recognize used to be ignored; it is now `400 invalid_body`. This is a behaviour change for existing callers — and it is deliberate: with `content` accepted, a request that misspells it would otherwise create an empty note and report success (#106, #137).
- **Merely opening an empty note in the browser editor records you as having edited it.** Mounting the collaborative editor in a browser tab on a note with no blocks normalizes it to a single empty paragraph, and that normalization is written back into the shared document over the live collaboration connection, where it travels like any other edit — so a note you only *opened* there, without typing a character, comes back with its last-edited line naming your username, dated the moment you opened it. The server has its own headless mount of the same editor — the one the read path uses to render a note as Markdown — and it performs the identical normalization, but that mount never joins the collaboration session, so the normalization has nothing to write back to and never becomes an edit; the effect described here is specific to the browser's mount. This is emergent from how the editor and the collaborative document meet, not a defect in the attribution itself. ⚠ Derived from reading the code; not confirmed end to end (#106, #137).
- **An AI's presence is only visible to people who have the note open at that moment, and it fades after 2 minutes.** The cursor is attached to the live collaborative document, so a token reading or writing a note nobody is looking at creates nothing at all — and leaves nothing to look at afterwards; the lasting record is the note's edit history. Presence is refreshed by each token-authenticated content read or write and removed after 2 minutes without one. `GET /api/notes/:id/edits` deliberately does not refresh it, and a cookie-authenticated request never creates it (#106, #138).
- **The presence registry and its counters live in the server process, so a restart leaves an already-open browser unable to see the AI until the page is reloaded.** Each AI cursor carries a counter that only ever moves forward, and a browser silently discards any update whose counter is not higher than the last one it accepted. After a restart the server counts from the beginning again while the browser still remembers the higher number, so that AI simply never reappears in that tab. Reloading the note builds fresh state and fixes it. The same applies across two server instances, which is one more reason [only one is supported](./self-hosting.md#single-instance-warning) (#106, #138).
- **That counter map is never trimmed.** It keeps one small entry per distinct (note, credential) pair the process has ever seen, and nothing removes entries — not presence stopping, not the note unloading, not the registry evicting an old cursor to stay under its 500-cursor cap. Its size is bounded only by the life of the process. This is deliberate: keeping the counter is exactly what lets an AI reappear after an idle stop, and dropping it produces the failure described in the entry above (#106, #138).
- **The AI's name tag is only lit for about two seconds at a time.** The cursor itself stays put, but the name beside it appears for roughly two seconds after each thing the AI does (and after each 10-second keep-alive) and then collapses to a bare caret; hovering the cursor shows it again. This is the editor's behaviour for every collaborator, human or not — there is no special case for API writers, and the one editor option that would pin labels open is the same option that disables the mechanism lighting them up, so there is no setting that shows the name permanently (#106, #138).
- **A token reading a note whose first block has no text puts no AI cursor in the note.** The cursor has to point at text; when the first block is empty, or is an image or a divider, there is none to point at — no matter how much content follows it — so no cursor is sent, and with no cursor the editor draws nothing, not even the name. The most common shape of this is exactly "brand-new empty note, read it, then write it": the read puts no cursor in the note, and the AI's cursor only appears with the write. The AI still appears in the note header's row of people, but clicking it doesn't scroll anywhere while it has no cursor (#106, #138, #293).
- **Viewers have no cursor, so clicking a viewer in the header's row of people doesn't scroll.** Knotebook doesn't share where a viewer is in the note, even while they click into it or select text to copy. Viewers still appear in the row, with their role (#293).
- **Someone who changes their display name keeps showing the old one in the header's row of people until they reopen or reload the note.** The name travels with their collaboration connection, which is set up when the note opens; the same is true of the name on their cursor (#293).
- **Someone whose tab loses its network stays in the header's row of people on other screens for up to about half a minute, and so does someone whose tab crashes after their connection had dropped and come back.** Closing the tab normally removes them at once. The server doesn't notice a lost network right away, and after a reconnect it no longer ties them to their new connection, so in both cases they leave the row only when their presence goes stale — 30 seconds after the last update, checked every 3 seconds (#293).
- **After a revert is refused, that row keeps offering a Revert button until the dialog is reopened.** The **AI edit history** list is refetched on success only, so a `409` — the note has moved on, or the entry was already undone — leaves the stale row on screen with a button that looks usable; pressing it again just fails again. Closing and reopening the dialog shows the true state. It heals itself, but nothing in the UI says so at the time (#106, #138).
- **A fingerprint is concurrency protection, not permission protection.** `if_match` refuses a write built on a stale read — it says nothing about whether the program sending it has read anything. An `append` sent without `if_match` skips the check entirely, by design. What bounds a credential is its scope (`notes:read`, `notes:write`, `notes:move` for moving or copying into a group, and `notes:delete` for moving notes to the trash and restoring them), the sharing on each note (for a group note, your role in its group), and the fact that most writes that put content into a note are recorded in that note's **AI edit history**, where such a write can be undone — while it is still one of the note's 100 most recent recorded writes and nothing has changed those blocks since. A `create_note` with no `content` records nothing at all (#108). Some writes aren't in that history, for example a `rename` (rename it back instead), `move_note_to_group` (a single note can't be moved back out, and the shares, public link and version history it loses don't come back), `copy_note` (the copy starts with no history) and `delete_note` (`restore_note` brings the note back, while it is still in the trash and only for someone who can restore it).
- **A per-minute read limit is not a per-turn context budget.** MCP reads are capped per call — 4000 characters of section text, 100 sections, 100 notes, 200 characters of heading (as written in JSON) — and per minute, at 120 content reads per user by default. Nothing caps the total: an assistant that keeps calling can pull roughly 480 000 characters of note text a minute (or, on a note built entirely of headings, considerably more heading text than that). If that matters for your deployment, the lever is the credential's scope, what is shared with the account and which groups it belongs to, not the rate limit (#108).
- **Notes other people shared with you, and your groups' notes, end up in your assistant's context.** `list_notes` and `search_notes` return everything your account can read, which includes notes shared with you and the notes of your groups; `search_notes` also hands over excerpts of their body text, and `read_note_section` will hand their text to whatever model is driving the client. A credential's reach is your reach — there is no per-note or per-owner scope. Each result carries `owner` and `role` so a well-behaved client can tell whose content it is looking at, but nothing enforces that it does (#108).
- **There is no cross-note view of what an assistant changed.** The **AI edit history** is per note: to review a run of edits across several notes you have to open each one. The closest thing to an overview is the `lastEdited` that comes back with every `list_notes` result: it names the account that touched each note last and, when the write came from a credential rather than a browser session, the agent name it came under (#108).
- **A wrong `PUBLIC_URL` makes MCP requests that carry an `Origin` header answer `403`.** The endpoint's DNS-rebinding guard compares both the request's `Origin` and its `Host` against the host in `PUBLIC_URL`, never against each other — comparing two values that both come from the request would guard nothing. Behind a reverse proxy the `Host` side depends on `TRUST_PROXY` being set correctly too. Command-line MCP clients normally send no `Origin` and are unaffected, so this shows up as "it works from the terminal but not from that one client", answered with a `403` whose body does not mention `PUBLIC_URL` (#108).
- **A client's cached tool list does not shrink on its own.** The set of tools is decided per request from the credential's scope, but `tools.listChanged` is `false` — the server is stateless and has no channel to push a change — so a client that connected with a `notes:write` credential keeps showing the writing tools after you revoke it and switch to a read-only one. Calling one then gets an explanatory "tool not found" result rather than a permission error. Restarting the client refreshes the list (#108). The same goes for changing a credential's scope in place in **Settings → Authorization** (or with `PATCH /api/auth/tokens/:id`), although the change itself applies from the next request: after raising it, the tools it gained only show up once the client is restarted; after lowering it, a tool it lost answers "tool not found" in the same way — except `copy_note`, which a `notes:write` credential keeps, and which then refuses `group_id` with `insufficient_scope`. Tool descriptions stay as the client first fetched them, too: after you add `notes:move`, `copy_note`'s still says that copying into a group needs `notes:move` (#239).
- **`list_notes` pages through live data, not a snapshot.** The cursor is a position in "most recently updated first" order, and only two things move a note in that order: creating one, and renaming it — changing its title or its URL name. Both stamp the note with the current time, which puts it above the cursor you are holding, where no later page reaches it. **Editing a note's content does not move it** — a content write lands in the collaborative document and in the note's own last-edited fields. So the run that silently misses notes is not "list a page, edit those notes, ask for the next page"; it is a run during which somebody creates or renames notes — a person in the browser, or an assistant: `edit_note`'s `rename` moves the note it renames to the top, and a note `copy_note` creates appears at the top. A note that gets shared with you mid-run — directly, because you were added to its group, or because its owner moved it into one of your groups — can be missed the same way, since it joins the listing at its own unchanged position rather than at the top (moving a note into a group, or deleting a group and giving its notes to an admin, can give a note a new URL name, but does not move it in this order). A note restored from the trash mid-run — in the web app, or with `restore_note` — can be missed too: restoring doesn't change its place in this order, so it comes back at its old position rather than at the top. A copy made with **Copy to… → My notes** or **Make a copy**, or copied into a group, is a new note, so it appears at the top. To enumerate everything, page all the way through before you do anything else, and page through again afterwards if other people are working in the same notebook (#108).
- **`read_note_outline` pages through live positions, not a snapshot.** A section's offset is its position in the document, and any edit that adds or removes a section shifts everything after it — so paging an outline while editing the same note can repeat or skip sections. Same remedy: list every page first, then edit (#108).
- **`read_note_section` pages through live text, not a snapshot.** Each call re-reads the note, so a section rewritten between two pages hands back the second half of the *new* text after the first half of the old — the assistant has now read a mixture that never existed as such. The `fingerprint` that arrives with the final page is computed from the section **as the note holds it at that moment** — not from the spliced text the assistant assembled — so a following `edit_note` built on it usually **matches and succeeds**, silently writing that spliced text into the note. `fingerprint_mismatch` only follows if the section is changed *again* after that last page arrived — it catches the next edit, not the one that already happened mid-read (#108).
- **Over the API and MCP, text that looks like HTML is not escaped when read.** Characters that Markdown reads as formatting, and Markdown block markers at the start of a paragraph, are escaped when a note is read (see [AI editing](./ai-editing.md#markdown-round-trip)), but `<` is not: a note whose text literally contains `` reads back with it as it is, so writing it back unchanged turns it into markup or drops an unknown tag — whether or not the text is colored — and a line break followed by such a tag, for example `
`, splits the paragraph there (#379). Spaces at the start or end of a paragraph or a heading don't survive writing what you read back, and in a table cell neither does a space right after a line break. Code formatting can't carry a color, so a colored stretch that comes back as code loses its color (#222). Text that is itself column or size markup reads back the same way, and writing it back unchanged is refused: a paragraph whose text is a column tag line such as `
`, a `
` with `display:flex` or `display:grid` in its `style`, or an element with a `data-node-type` attribute is rejected with `unsupported_columns`, and one that contains an `` with `unsupported_size` (text that is exactly the `
` form of an image turns into that image instead); text that contains an element with an event-handler attribute, such as ``, is rejected with `unsupported_block`. The in-app AI gets the same Markdown, so a result that keeps such text as it is gets refused the same way and isn't applied. Over the API and MCP, text that contains a `