You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Migrations (0036–0048) run automatically when the container starts.
Docker Compose: download the new docker-compose.yml. It passes the settings that are new in
this release to the app (push notifications' VAPID_* and PUSH_ALLOWED_HOSTS, AUTOMATION_WEBHOOK_ALLOWED_HOSTS, CONNECTOR_ALLOWED_HOSTS, LEAFDESK_ENCRYPTION_KEY and LEAFDESK_ENCRYPTION_OLD_KEYS), and API_RATE_LIMIT, MCP_RATE_LIMIT, API_CORS_ORIGINS and TZ, which it left out until now; without it they stay unset in the container.
Open tabs ask to be reloaded. A tab opened before the upgrade stops loading pages and shows
a notice to reload; edits it hadn't synced yet stay in that browser.
BETTER_AUTH_SECRET is checked at startup. In production the server refuses to start with
the example value from .env.example or a secret shorter than 32 characters (make one with openssl rand -base64 32). Changing it signs everyone out; without LEAFDESK_ENCRYPTION_KEY,
connections' tokens were sealed with a key derived from the old secret, so those connections
have to be connected again.
Changing a workspace's SSO identity provider (or removing the connection) now signs out
everyone who signed in through it; they sign in again through the new one.
Push notifications need VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT
(make the keys with pnpm push:keys; under Docker Compose they are passed through from .env).
Without them push stays off and nothing changes. Migration 0047_push_subscriptions.
Added
Security policy.SECURITY.md says how to report a vulnerability privately (GitHub's
private vulnerability reporting), what a useful report contains, how quickly reports are
answered, what is in scope, and that security fixes go into the latest release only.
[[ links to a page. Typing [[ in a page opens a menu of the pages one can open, searched
by title as typed; below them, "New page" creates a page inside the current one with that title
and links to it. Typing [[Title]] out links to the page of that title when there is one and
leaves the text as it is when there isn't; so does pasting text with [[Title]] in it, and
Markdown written through MCP or the API (to pages the writer can open, never in code). The links are the same page mentions @ makes, so
they show under "Linked from" and in the graph. The @ and [[ menus now find titles with the
dotted and dotless i whichever way they are typed ("ışık" finds "Işık"). No migration.
Graph. A new page (the graph icon beside Home in the sidebar, or > in the search box)
draws a workspace's pages, databases and rows as points, joined by links in page bodies,
relations (a two-way one drawn once) and the page tree, and lays them out as they connect. Only
pages the viewer can open are drawn, and no edge leads to one they can't; a relation counts only
where they may see that property. A database with more than 30 rows in view is drawn as one
point with their count inside, its rows' links and relations as one edge per page or database
they lead to, thicker the more it stands for, until "Show rows" brings them out (a focused row
always shows). Hovering a page brings out its neighbours; clicking it selects it and opens a
card with what it is inside and what it is joined to, by kind (with counts), and buttons to
open it, focus on it or show its rows; double-clicking opens it, Esc lets go, and buttons zoom
in, out and to the whole graph. The box at the top focuses on one page and the pages one to
three steps away. "Show in graph" in a page's menu opens that view of the page beside it,
loaded again as pages change, with the full graph a click away. Switches hide the page tree,
rows and pages without connections. Pages and databases are named from the start, rows once
you zoom in (or all of them in a small graph); a name that would cover another, or a page
named, moves to the other side or is left out until there is room. Past 3,000 pages the ones
edited longest ago are left out. Drawn with sigma and graphology (new dependencies). No
migration.
Context and unlinked mentions under "Linked from". Each page in a page's "Linked from" list
shows the text around its link, with the page's live title in place of the link (other pages'
titles stay out of it). Below the list, folded away, are the pages that write the page's title
as whole words without linking to it (last edited first; the trash, templates and pages the
reader can't see left out); "Link" turns the first place a page writes it outside code into a
page mention, for someone who may edit that page, also while it is open in an editor, and shows
only where there is such a place. Titles match whether their letters are written precomposed or
with combining marks. MCP's get_page gives
each linked_from page its context. Migration 0048_page_link_context: links already indexed
get their context when their page is next saved.
Search filters and commands. The search box (Cmd/Ctrl+K) narrows a search with in:"Page title" (that page and everything under it) and type:page, type:database or type:row, which also work on their own to list what they match; in: and the commands find
titles with the dotted and dotless i whichever way they are typed. Before anything is typed it
shows the pages last edited, and after > it runs commands (new page or database, from a
template, import, trash, inbox, settings, keyboard shortcuts). A line under the results shows
the syntax and puts it into the box. MCP and REST search keep their own parameters. No
migration.
Import from Obsidian. Import takes a vault's folder or a ZIP of it. Wikilinks (with labels,
headings, block references and folder paths), Markdown links written by name and notes' aliases
from front matter become page links, resolved across the vault: a path first, then the note of
that name nearest the linking note, then an alias. Embedded images and files are uploaded, a
note embedded on its own line becomes a link-to-page block, and callouts keep a matching color.
Comments, highlight marks and block ids (those a link points to, and generated ones) are left
out, so text such as r ^2 stays; links naming nothing stay as text and are listed in the
result. The import dialog no longer uploads hidden folders such as .obsidian, and a folder
that holds one is still read as a vault.
No migration.
Excel workbooks. A database exports as an Excel workbook (.xlsx) next to CSV, from the page
menu and for the selected rows of a table: the same rows and columns as the CSV export, with
property access applied the same way, numbers as numbers, checkboxes as TRUE/FALSE and dates as
dates (created and edited times with their time, in UTC), the header row bold and frozen. The
import dialog's CSV tab takes .xlsx files too, as a new database or as rows of one, with the
same type guessing, title column and column mapping as CSV; a workbook with several sheets asks
which one to import (/api/import takes sheet, and without it imports the first and reports
the others as left out). A formula's last result is imported, cells with an error come in
empty, and old .xls files and password-protected workbooks are refused with their own message.
The workspace and subpage ZIP exports keep databases as CSV. No migration.
Locked pages. "Lock page" in a page's menu guards it against accidental edits: while it is
locked nobody can change its title, icon, background, style or text, from the app, the history
panel, MCP, the REST API, the AI writing assistant or the AI chat and agents. The page shows Locked in its header with an Unlock button for everyone who can edit it; it is a guard, not
a permission, so any of them can unlock it. Open tabs turn read-only, and editable again after
an unlock, without a reload. A database row locks the same way; its property values stay
editable from views and MCP. Comments, sharing, moving the page, putting it in the trash and
adding sub-pages still work, and a copy starts unlocked. get_page and GET /pages/{id} show locked, and writes to a locked page are refused with a message saying how to unlock it. No
migration.
Keyboard shortcuts. Mod+/ (⌘/ on a Mac, Ctrl+/ elsewhere), ? outside text fields and the
editor, or Keyboard shortcuts in the workspace menu opens a list of the shortcuts Leafdesk
has: general ones, moving around (search, the sidebar, find and replace in a page), text
formatting and blocks in the editor, and databases (opening a row, moving a timeline bar, saving
a formula). Keys show as ⌘ ⌥ ⇧ on a Mac and Ctrl, Alt, Shift elsewhere. Unit tests in src/lib/shortcuts.test.ts. No migration.
Superscript and subscript. Two new text styles, with buttons after strikethrough in the
formatting toolbar and the shortcuts Mod+. and Mod+, (⌘ on a Mac, Ctrl elsewhere); text is one
or the other, so setting one takes the other off. Pasted text whose vertical-align is super
or sub keeps them. In Markdown (search, MCP get_page and update_page, exports and imports)
they are <sup>…</sup> and <sub>…</sub>; published pages, the print view, version previews
and the AI chat's answers show them. A tab still running an older release would erase the whole
text of a block holding one (y-prosemirror drops text with a style its editor lacks); the build
check that keeps such tabs from loading pages covers this too. Unit tests in src/server/text-scripts.test.ts and src/lib/remark-text-scripts.test.ts, and a check in scripts/roundtrip-e2e.ts. No migration.
Number formats. A number property's menu sets how its values show: as a plain number, a
percentage or an amount of money in a currency (Turkish lira, euro, US dollar, pound, Swiss
franc, yen and a few more), with automatic or 0 to 4 decimal places. Amounts follow the
viewer's language: ₺1.234,56 in Turkish, TRY 1,234.56 in English. A percentage stores the
fraction: typing 15 or 15% in a cell, a bulk edit, a form or an automation stores 0.15 and shows
15%, and filters on it compare percent points (15). Cells, row pages, boards, lists, galleries,
calendars, timelines, published pages and print show the format, as do column footers and charts
that sum, average or compare the values (counts and shares stay plain numbers) and rollups doing
the same over a number property, which also filter by what they show. Typed amounts may carry their currency symbol or code. The
format never changes the values: sorting, calculations, CSV export and MCP values stay plain
numbers. A CSV import reads "15%" as 0.15 and "₺1.234,50" as 1234.5, and a new database's column
written all in percentages becomes a percent property. MCP's add_database_property and update_database_property take number_format, and get_database shows it. Formulas show plain
numbers. No migration.
Push notifications. My account > Preferences turns push notifications on for the device in
use ("Push notifications on this device"): new inbox items then show as system notifications,
also while Leafdesk is closed, and opening one brings an open Leafdesk tab to the page (or opens
one). Each kind of notification gets a Push switch next to Inbox and Email; push follows
the inbox, so a kind kept out of the inbox isn't pushed, and every kind can be silenced on its
own. A message says only what the inbox shows (the page, who did what), read with the same
access checks, in the recipient's language. The settings say when the browser blocks
notifications or can't receive them (Safari on iPhone and iPad only in the installed app).
A device's subscription belongs to its sign-in: signing out, or the session being revoked,
deletes it. The server sends with VAPID keys (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT; pnpm push:keys makes a pair) and stays off without them. Messages are
encrypted for the browser, kept a day by push services, and go only to public https
endpoints, checked before each send with the connection pinned to the checked address; PUSH_ALLOWED_HOSTS allows a push service on a private network. A push service saying the
subscription is gone deletes it, and one that keeps failing is dropped after 10 failed sends in a row. Sending
never holds up what caused the notification. New push_subscription table and push columns on user_preference (migration 0047_push_subscriptions); the web-push library encrypts and
signs the messages.
Dependencies. A timeline view's settings turn dependencies on for the database: a two-way
relation of the database with itself, Blocked by and Blocking (or a relation of the database
with itself it already has), lets a row wait for other rows. The timeline draws an arrow from
each row to the rows waiting for it, in red while a waiting row starts before the row it waits
for ends, and dragging from the dot at the end of a bar onto another row makes that row wait for
it. When a row's dates move, by a drag, an edit, a bulk edit, an automation or MCP, the rows
waiting for it follow down the chain by the database's rule: only when they would overlap (the
default), by as much to keep the gap, or not at all; moved rows can skip weekends. A row that
starts waiting for another one is moved past its end. Rows the same write dated keep their
dates, and rows the editor can't change stay where they are. Rows move by the start and end
dates the timeline used when dependencies were turned on, which its settings can change. A row
can't wait for itself or for a row waiting for it. Turning dependencies off keeps both properties
and their links; a copied database keeps them. MCP gets set_dependencies, and get_database
shows dependency_role and the rule. No migration.
Sub-items. A table, list or timeline view's settings turn sub-items on for the database: a
two-way relation of the database with itself, Parent item and Sub-items (or a relation of
the database with itself it already has), lets a row go under another row. A row holds one
parent: picking another moves it, and so does listing it under another row's sub-items. A row
can't go under itself or one of its own sub-items, from either side, one row or many at once.
Table, list and timeline views show sub-items nested under their parent (each viewer opens and
closes rows for themselves, and the browser tab keeps them open), as a flat list, or only the
rows without a parent with how many sub-items each has. Hovering a row offers a "+" to add a
sub-item; on a timeline it starts where its parent does. A sub-item whose parent is filtered out
or in another group shows at the top. Turning sub-items off keeps both properties and their
links. Stored on the relation (role) and the view (subItems); no migration. Over MCP, set_sub_items turns them on or off, views take sub_items (nested, flat, parents), and get_database marks the two properties with sub_items_role.
Running totals and burndowns in charts. A bar or line chart grouped by a date (or created or
edited time) and measuring something that adds up (a count, a sum, counts of values or ticks)
has Over time in its settings: Each period (as before), Running total, or Remaining.
Remaining is a burndown: each row leaves the whole in the period of its date, and rows without
a date stay open, so a chart grouped by a "finished" date (set by an automation when a status
changes to done) shows the work left week by week. Periods run oldest first with quiet ones
carried through; the tooltip shows the running value, the period's own value and the rows
behind the point, which are what the value measures (done so far, or still open). Running
totals aren't sorted by value or stacked, and the no-value group isn't a point of its own.
Stored as chartAccumulate on the view; no migration. Over MCP, create_database_view and update_database_view take accumulate (none, cumulative, remaining) and refuse it
where it can't apply, get_database reports it, and query_database returns each point's
running value with period_value and the chart's total.
Page backgrounds.Add background beside a page's icon (on a database page, over the end of
its title on hover, so the view doesn't move down) fills the page behind its title and body: one
of 7 colors, a light tint in the light theme and a dark one in the dark theme, or black in both;
the header, the page's tables and their lines take the color too. A faint pattern (dots,
crosses, grid or diagonal lines) can go over it, or over the plain page. The same button changes
or removes them. A background is only ever drawn by the page's own styles: it never loads an
image, uploaded or from another site. It is kept on the page (page.background, migration 0044_page_background), so it changes live for everyone with the page open (an icon change now
does too), and travels with copies, templates, published pages (their header too) and copies
made from a published page; printing leaves it out. Over MCP and REST, get_page shows it and update_page / PATCH /pages/{id} set it with background (color:<name>, pattern:<name>
or both separated by a space, null to remove). Not yet in Markdown exports and imports. Pages
that had a cover from an earlier build of this release get the color closest to a gradient
cover, and lose an image cover or image background (0046_page_background_colors_only, which
also has the file trigger stop looking at backgrounds); 0045_drop_page_cover removes the cover
column.
Choosing the light or dark theme. Settings > Preferences > Appearance sets the theme for this
browser: the system's (as before), light or dark. It is kept in a cookie like the interface
language, so the server draws the page in the chosen theme from the start, and the editor,
diagrams and the browser bar follow it; a new choice switches the page, the editor and diagrams
together at once, also offline. The print view stays light. No migration.
The calendar on phones. The month fits the screen instead of scrolling sideways: each day
shows a dot per row (up to three), and the rows of the picked day are listed under the month,
where a row can be added on that day. Rows aren't dragged between days there. Wider screens
keep the full grid.
Repeating templates. The repeat button beside a row template in the menu next to New adds a
row from it on a schedule: daily, weekly on chosen weekdays, monthly or yearly, every N of them
(up to 99), at a time in an IANA time zone, from a first day, with the date added to the title if
asked. The dialog shows when the next row comes. Rows are added as the person who set the repeat
and start "row added" automations; when they can't add rows any more, or their account is gone,
the repeat pauses with the reason shown until someone saves it again. Each server checks every 30
seconds; a schedule is taken with FOR UPDATE SKIP LOCKED and its next run moved on before the
row is added, so several replicas or a crash never add a run twice, and a server that was down
adds one row for what it missed. Up to 200 repeats per workspace. They're kept in a new schedule table (migration 0040_schedules), shared with what will run on a schedule later.
Reordering view tabs. A database's view tabs can be dragged into another order, which
everyone sees. The first tab is the one a published database shows. Not offered on a locked
database or to people who can't edit it. Over MCP, update_database_view moves a tab with before_view_id or after_view_id, and MCP tools refused by a locked database now say so
instead of reporting a server error.
More in a table's column menu. A column's menu filters on it (adding a rule and opening the
view's filters), groups by it or stops grouping, sets its footer calculation, wraps its cells,
adds a property to its left or right in this view, freezes it and the columns before it while
the table scrolls sideways (not on phones or windows too narrow to scroll), and duplicates the
property with its settings, values and access rules right after it (a two-way relation is copied
one way). The Name column gets the items that apply to it. Wrapped and frozen columns are view
settings. No migration.
Changing a property's type. The type under a property's name in its menu opens a list of
types. Values convert in every row, trashed rows and row templates included: options keep their
ids between select, multi-select and status, text becomes options, numbers, dates, people and
links by title, and formulas and rollups keep what they showed. Relations, formulas and rollups
are set up first, and a change that would clear values says how many and asks. Views drop the
filters, sorts, groups and other settings the new type can't have; a two-way relation leaves its
other side one-way. A property with access rules can't become a type they don't apply to. MCP's
new change_database_property_type converts the same way and, with dry_run, only counts what
would convert and what would be cleared. No migration.
Page style. The page's ⋯ menu starts with a typeface for the page (Default, Serif, Mono) and switches for Small text (14px body, headings scale with it; touch screens keep
16px so iOS doesn't zoom in on the text when typing starts) and Full width (the
text starts at the same gutter as a database). The style is kept in the page's shared document,
so it changes live for everyone, shows on pages kept for offline use, and travels with copies,
templates and published pages; no migration. The server reads it on the first render, so a
page opens in its style without a jump. The print view takes the typeface and text size. Shown
to people who can edit pages with a body; databases are unchanged. Published headings are now
sized relative to the body text (the same at the default size).
Agents. An agent is an AI helper of a workspace with a name, an emoji, a description and
instructions of its own, run by a new automation action, Run an agent (run_agent, the agent
by id or name and a task of up to 2000 characters), on the row that started the automation.
Each agent acts as a user of its own: a bot user that can't sign in, a guest of the workspace,
so it sees only the pages shared with it (view, comment or edit, never full access) and its
changes and comments show its name. Saving an automation that runs an agent shares the
database with it at edit access. A run reads what is shared with the agent and changes or
comments on its row, nothing else, in at most 8 model turns, 5 changes and 2 minutes; it needs
AI set up on the server and on for the workspace (otherwise it ends as aiOff), and what it
changes starts no automations. Runs are kept for 30 days with each step, the answer and the
tokens used; a run's steps, answer and error show only to owners who can open its row. In
Settings → Agents, owners create, change, pause, archive and restore agents (at most 50 per
workspace), choose what they may open and look through their runs; members see the list. Three
built-in agents (Ticket router, Request answerer, Duplicate finder) are set up on a database in
one step, with instructions in the owner's language naming the properties picked. An agent
isn't a person: lists of members, guests and people, mentions, notifications and emails leave
it out, it can't be made a member or owner, it can't sign in, and deleting its workspace deletes
its user; where it acted it shows as an agent (person cells, comments, page history, the audit
log's new agent actor kind). Anything shared with an agent can end up in rows it writes,
visible to everyone who can open those rows. MCP tools list_agents, get_agent, create_agent, update_agent, archive_agent, restore_agent, set_agent_access and list_agent_runs; create_automation and update_automation take run_agent actions and list_automations names their agent. No REST endpoints. Migrations 0037_agents (tables workspace_agent and agent_run) and 0038_agent_audit_actor. New
checks in scripts/agents-e2e.ts, scripts/mcp-e2e.ts, scripts/audit-e2e.ts, src/server/mcp/tools.test.ts and src/lib/builtin-agents.test.ts. See the README (Agents).
Connections. Owners link a workspace to remote MCP servers (Slack, GitHub, a CRM, another
Leafdesk...) in Settings → Connections, signing in with OAuth (dynamic client registration and
PKCE), a bearer token, or nothing; at most 20 per workspace. Tokens and event secrets are sealed
with AES-256-GCM under LEAFDESK_ENCRYPTION_KEY (derived from BETTER_AUTH_SECRET when unset; LEAFDESK_ENCRYPTION_OLD_KEYS keeps older keys opening what they sealed) and never come back to
the browser. Connections reach only public https addresses, checked again on connecting and on
each redirect; CONNECTOR_ALLOWED_HOSTS lets through hosts on a private network. A server's
tools are classed read (marked readOnlyHint) or write (everything else), and owners can
change the class; on an agent's new Connections tab they tick the tools it may use. A new
address starts a connection over: its token must be pasted again, and classes and agents' tools
are reset. Read tools run at once; a write tool makes the run wait (awaiting_approval) and
puts an item in every owner's inbox with the agent, the tool and its exact input, to approve,
decline or send back with a note; after 24 hours nothing is sent (approvalTimeout, a later
answer is refused), nor when the tool or connection goes meanwhile (connectionGone), and a run
taken up again after a restart doesn't send an approved call twice. Each connection has a signed
events address, /api/connections/<id>/events, for Leafdesk's own HMAC scheme, Slack's Events
API or GitHub webhooks; events that are unsigned, stale (over five minutes), over 256 KB or
repeated (by delivery id or by signature) are refused, at most 120 signed ones a minute, and the
others start the agents whose triggers match their type, with the trigger's task and the event's
type and body as data; one the server failed on is handled when sent again. The runs list shows
a run's tool calls and answers, and events are kept for 7 days. Every change, tool call and
approval answer is audited. MCP tools list_connections and set_agent_connection_tools; list_notifications and list_agent_runs show approvals. No REST endpoints. Migrations 0041_connections and 0042_connection_event_signature. New checks in scripts/connector-e2e.ts (against a second Leafdesk started as the MCP server) and scripts/mcp-e2e.ts, and unit tests for the address checks, event signatures, slugs and the
secret box. See the README (Connections).
Database automations. When a row is added, or a property changes (optionally only when it
becomes a value: a select or status option, a checkbox state, a person or multi-select option
being added), an automation sets properties on the row (a date to the day it runs, in UTC, a
person to whoever made the change), notifies people (chosen ones and those a person property names, in
the inbox and by email as each chooses, only if they can open the row) or sends the row to a
webhook. People with full access to the database manage them from the ⚡ Automations button in
its toolbar (each with its recent runs, and for webhooks the signing secret, Replace secret and Send test); an automation runs as the person
who saved it last, with their access when it runs, and the changes it makes start no other
automations. Runs are kept for 30 days with how each action went. Webhooks are JSON POSTs
signed with HMAC-SHA256 (X-Leafdesk-Signature: t=<unix>,v1=<hex>, plus X-Leafdesk-Event
and X-Leafdesk-Delivery), retried up to 5 times on network errors, timeouts, 408, 429 and
5xx, and refused for private and local addresses unless the host is listed in the new AUTOMATION_WEBHOOK_ALLOWED_HOSTS (passed through by docker-compose.yml). A new inbox kind, Automations, with its own inbox and email switches. MCP tools list_automations, create_automation, update_automation, delete_automation and list_automation_runs, with
properties by name and people by id, email, name or "me". Migration 0036_automations adds
the tables database_automation and automation_run, notification.automation_id, and user_preference.automation_inbox / automation_emails. See the README (Automations and
webhooks).
Rename from the sidebar. A page's ⋯ menu in the sidebar has Rename: the name turns into
a field (Enter saves, Escape cancels). It writes the title through the page's shared doc, so an
open page and its tab update at once. Until now the only way to rename was the title on the page
itself, and an untitled page showed just a grey "Untitled" that didn't read as editable.
MCP: get_file reads a file uploaded to Leafdesk, by its id, /api/files/<id> path or url
(what get_page and query_database show): text files (Markdown, CSV, JSON, XML, code…) and
PDFs come back as text (a PDF's text layer, read with unpdf; scans have none), cut at 30,000
characters with offset to read on; PNG, JPEG, GIF and WebP images come back as image content.
Other kinds, and files over 5 MB (text, images) or 25 MB (PDF), are described with their link.
It needs no extra permission: the user reads a file when they can see a page showing it, in a
workspace that doesn't hide itself from connected apps (fileForApp). Until now an AI app saw a
file's link but couldn't open it: the file route only knows browser sessions.
MCP: duplicate_page copies a page with everything under it beside the original, as the
page menu's Duplicate does, titled "<title> (copy)" or as given.
MCP: favorites.list_pages with favorites: true lists the user's starred pages of a
workspace, and get_page (and GET /api/v1/pages/{id}) says whether a page is starred
(favorite).
MCP: links as ids. Wherever a tool takes an id (page_id, workspace_id, row_ids, view_id…), a Leafdesk link works too: https://…/w/<workspace>/p/<page>, with ?view=<view>
for a view. Other arguments, such as page mentions in Markdown, are left as they are. New checks
in scripts/mcp-e2e.ts (248 in all), src/server/mcp/tools.test.ts and src/server/mcp/format.test.ts.
Changed
New mark. An ld drawn from the wordmark's own letters, a green l whose foot curves evenly
toward the d, replaces the block-leaf mark in the app and touch icons, the favicon and the
website's preview image. In the logo the mark sits in a dark tile beside the wordmark, so the
logo is drawn a little taller to keep the letters their size. The wordmark and colors stay the
same.
Dependency checks. A weekly workflow (.github/workflows/dependencies.yml, also on changes to
the lockfile) runs pnpm audit --prod and lists outdated direct dependencies. Advisories that
can't reach a running server are reviewed in pnpm-workspace.yaml (auditConfig.ignoreGhsas,
each with its reason); any other one fails the check. lodash-es 4.18.1 (under Mermaid's parser)
and source-map-js 1.2.2 (under Next.js's PostCSS) are pinned by override for their advisories.
Bundle budget. CI fails when the JavaScript a browser downloads grows past its budget: the
largest first load of any route (the page editor, about 700 kB gzipped today, budget 800 kB) and
all client chunks together (about 2,700 kB, budget 3,150 kB). pnpm tsx scripts/bundle-budget.ts
after pnpm build prints the largest routes.
CI runs every end-to-end script. The 34 scripts/*-e2e.ts that only ran by hand (agents,
connections, AI, sub-items, templates, the REST API, SCIM, account security, two-step
verification, single sign-on and the rest) now run on every push and pull request, the ones
that need a server against the production build. Under its sign-in rate limit those scripts
wait out a 429 (scripts/auth-rate-limit.ts); three had fallen behind the app and were brought
up to date: a connection's MCP result has a note after its JSON, a password reset disconnects
connected apps, and removing an SSO connection signs out who signed in through it.
A database's tab title names its view. The browser tab of a database reads
"Customers · By city · Leafdesk": the view it shows, following view switches and renames of the
database or its views, and kept on a reload or a shared ?view= link.
Leafdesk's own colors. Select and status options, board columns, the editor's text and block
colors, chart groups and page history now use one palette built from the same lightness and
saturation per role in each hue, slightly richer than before, with gray leaning toward the brand
green. Every text and background pair reads at 4.5:1 or more in both themes; dark mode also sets
the editor's text colors, which came from BlockNote until now. In dark mode option chips are
mid-tone instead of near-black and board cards take more of their column's hue, so groups and
tags tell apart at a glance.
Trademark notice. The README and the website say that Notion is a trademark of Notion Labs,
Inc. and that Leafdesk is not affiliated with it. The MCP server's instructions describe Leafdesk
on its own terms instead of as "Notion-like".
MCP hardening.
Rate limit: each user may make 120 requests a minute to /mcp, all their connected apps
together (MCP_RATE_LIMIT, 0 turns it off). Beyond it the server answers 429 with Retry-After before it looks anything up, so requests over the limit cost next to nothing.
The MCP end-to-end script checks the limit the server actually has (MCP_E2E_SKIP_RATE_LIMIT=1
for a server without one).
Content is data: the server's instructions say that what the tools return was written by
people, some from outside the workspace, and is never instructions; every read tool's result
ends with a one-line reminder of it.
Password changes: changing the password with Sign out of all other sessions, or resetting
it by email, also disconnects the apps the user connected; they have to be connected again.
Each app disconnected this way shows in the audit log, as one disconnected by hand does; if
that record can't be written, the change still completes. The "password changed" email goes
out before the apps are disconnected.
AI prompts: a closing tag written in capitals or with spaces (</ROW>, < / row >) can no
longer end a data section of an AI prompt early, and a connection's answer can't close the
frame that marks it as outside data.
Fixed
A page opened right after a save could lose later changes. The server unloads a saved page's
document a moment after the save; when the page had been closed and opened again in between,
that unload removed the new document instead, so the people editing it and the next server write
(an MCP or REST write, linking a mention) worked on two copies, and closing the editor saved its
copy over the other's changes. The unload now leaves a document that has replaced the saved one.
View access could rewrite a page's text through MCP and the REST API.update_page (and PATCH /api/v1/pages/{id}) with markdown checked only that the caller could see the page before
replacing or appending to its body, so someone with view access, or a connected app acting for
them, could change it. It now needs edit access, like the title, icon and background already did.
Checked in scripts/access-e2e.ts.
A tab left open from an earlier release could delete newer blocks. Its editor dropped blocks
it didn't know, and the deletion reached everyone. Each build now has an id: the collaboration
server refuses a tab of another build before sending it a page, and the tab doesn't load its
offline copies either. It shows a notice to reload instead; its unsynced edits stay in the
browser.
Formulas:constructor() and other names of built-in objects are reported as unknown
functions.
Security fixes from an audit of the whole app.
Collaboration: a 23-byte update could hold the server (and every request with it) for
seconds while it checked comment threads; any viewer could send one. The check now runs in time
bounded by what an update holds, read-only connections skip it, and websocket messages over
16 MiB are refused. Reconnecting to a page whose comments had changed no longer fails with
"Forbidden". Workspace and database signal channels are read-only, cursor labels carry the
signed-in name and color (a browser's own could restyle others' pages), a collaboration token
stops working once its session ends (a tab fetches a new one), and an owner made a member is
checked again on open pages.
Published pages and the print view: text an editor typed into a link, image, caption or
color could become live HTML, because heading anchors were added by searching the serialized
page. They are set on parsed elements now.
Sign-in policies: any Authorization: Bearer header made a browser request skip "require
two-step verification" and "SSO only"; only requests a token actually authenticated skip them
now. A session a policy holds back can't create API tokens or approve connected apps either.
Accounts: claiming an account someone had signed up for with your address (by a provider
vouching for it or by a reset link) now also removes their passkeys, two-step verification, API
tokens and connected apps. An email verification link no longer signs in past two-step
verification. Pointing a workspace's SSO connection at another identity provider, or removing
it, forgets who signed in through it.
Workspaces: a join link took up a pending invitation's role (owner included) for anyone
signing up with the invited address unverified; only a verified address does now, and the
invitation stays for its owner otherwise.
Import: a ZIP of stored entries sharing their bytes could unpack far past the size limit.
Agents: an automation's agent read with everything shared with it, whoever's change started
the run, so a member could have it copy a page or a property value they can't open into the
row or a comment. A run a member (or guest) started now opens, reads and changes only the pages
and property values both the agent and that person can. On a row no member wrote (an anonymous
form answer, a visitor outside the workspace) every connection tool waits for an owner's
approval, not only the ones that write. Runs started by a connection's events are unchanged.
Smaller: sign-in rate limits count by the address the server works out instead of X-Forwarded-For; /sign-in?next=/\host no longer leaves the site; a connection's
credentials aren't sent along a redirect to another origin; sealed secrets need their full
GCM tag; a page template is copied only into its own workspace from the API and MCP; formula
functions named like built-in object members are unknown functions. Next.js 16.3.8 and sharp
0.35.5 for their security fixes.
Tabs left open across an update could delete new blocks. y-prosemirror deletes from the
shared document any block its editor can't build, and the deletion syncs to everyone, so a tab
still running the previous release removed blocks a newer one added (columns, Mermaid, a table
of contents…) as soon as it showed them, also from its offline copy. Each next build now gets
an id, compiled into the browser bundle and read by the server from .next/BUILD_ID. The
collaboration server refuses a tab of another build (and, in production, one that sends none:
tabs opened before this release, which show "access lost" until reloaded) before sending any
document; a tab checks the build in the collaboration token reply before it loads a page's
offline copy, and offline goes by the build another tab of the browser last met. Such a tab
shows "Leafdesk has been updated" with a Reload button, in front of everything; its edits stay
in the browser and are sent after the reload. No automatic reload, so typing in progress is
never cut off and an offline tab can't loop.
Comments panel over the page. With the comments panel open, a page's text ran under it on
most screens. The page now keeps its place while it clears the panel, moves left when it
doesn't, and narrows once it reaches the sidebar.