Skip to content

Leafdesk 0.5.0

Choose a tag to compare

@erhanerbas erhanerbas released this 09 Oct 20:12

Upgrading from 0.4.0

  • 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.