Skip to content

Releases: esmworks/leafdesk

Leafdesk 0.5.4

Choose a tag to compare

@erhanerbas erhanerbas released this 17 Oct 12:44

Upgrading from 0.5.3

  • Migrations (0070–0077) run automatically when the container starts. Compose users
    don't need a new docker-compose.yml: this release adds no settings.
  • Every passage is embedded again once. Passages now start with where they are (see
    Changed), so their text changes and the embeddings model sees each of them once more after
    the upgrade: through the background sweep as people search, or all at once with
    pnpm search:index. On large workspaces with a paid embeddings provider, run it at a quiet time.
  • Migrations 0075 and 0077 each rewrite the page table once. 0075 adds the stored search
    vector (see Changed) and fills it for every page, 0077 adds the pieces of Chinese and Japanese
    text to it and keeps it to the first 80,000 characters of each page's text; on a server with
    many pages each takes a while (about 15 seconds for 60,000 pages with 2 KB of text each), and
    pages can't be read or written until it's done: upgrade at a quiet time (from source, run
    pnpm db:migrate then).

Added

  • Eleven more interface languages. Leafdesk now speaks Italian, Brazilian Portuguese, Polish,
    Russian, Ukrainian, Arabic, Hebrew, Persian, Japanese, Korean and Simplified Chinese, next to
    English, Turkish, German, Spanish and French: in the app, emails, built-in templates and the
    editor's menus, picked in My account → Language or taken from the browser. Arabic, Hebrew and
    Persian lay the whole app out right to left, and emails follow. Each paragraph of a page now
    follows the direction of its own text, so a right-to-left paragraph reads correctly in a
    left-to-right interface and the other way round; alignment runs with it too, so a right-to-left
    paragraph starts on the right without being aligned and the align buttons still put it on the side
    they show. An Arabic, Hebrew or Persian paragraph aligned right before this release now sits on
    the left, where its text ends: align it again. CSV import knows the new languages' title column
    names and yes/no words, quick add understands their date words (Japanese and Chinese, written
    without spaces, a character at a time: "来週の金曜日に打ち合わせ"), and the AI chat's search leaves out their
    question words. The new translations haven't been reviewed by native speakers yet; corrections are
    welcome.

  • Apps can't overwrite edits they haven't seen. get_page (MCP) and GET /pages/{id} now give
    the body's version, and update_page / PATCH /pages/{id} take it back as expected_version:
    the page changes only while its body still has that version. When someone edited it after the
    app read it, also in an editor that is open right now, nothing is written; MCP tells the app to
    read the page again, and the REST API answers 409 version_conflict with the current version.
    Body writes report the new version, so an app can make several changes in a row without reading
    the page in between. Comments don't change the version. Without expected_version writes work
    as before. Covered in scripts/mcp-e2e.ts.

  • The AI chat answers from attached PDFs and Word documents. The text of a PDF or .docx shown
    in a page's file block now goes into the page's semantic index, in passages that start with the file name and point
    at the file block, so the chat can answer from inside the document and its
    citation opens the page at the file. A document is read once and its text kept (migration
    0071, file_text); the same file uploaded again isn't read twice. Only the first 50 pages or
    200,000 characters of a document are read (the record says when it was cut), up to 20 documents
    per page, and files over 25 MB are left out; a scan with no text layer, or a damaged or locked
    file, adds nothing. Only files the page's readers may open through the page count, and search
    checks access to the page when it runs, as for the rest of its text. Existing pages are indexed
    again by the next sweep or pnpm search:index; their unchanged passages aren't embedded again.

  • Workspace health. Settings → Health (owners and members) looks over the pages you can open
    and lists what is worth tidying: links and mentions to pages in the trash or deleted for good,
    top-level pages nothing links to, pages with a title and no body, pages nobody edited for 3, 6,
    12 or 24 months, pairs of pages that say nearly the same thing, and database relations to rows in
    the trash or deleted. Duplicates are compared by the pages' embeddings, so they need an
    embeddings model on the server and AI on in the workspace; otherwise the report says they
    weren't checked. Each finding links to its page, and many have a one-click fix: restore the page
    a link leads to, link an orphan where another page already writes its title, move an empty page
    to the trash, or take a dead row out of a relation. Pages someone can't open are never named or
    counted, not even as a broken link. The same report is the new MCP tool check_workspace, which
    changes nothing and says which tools fix each finding. Covered in
    scripts/workspace-check-e2e.ts.

  • More filter conditions. Text, URL, email, phone and title filters (and formulas and rollups
    with a text result) gain Does not contain, which ignores case as Contains does and matches
    empty values. Numbers gain ≥ and ≤, and dates, created and edited times Is on or before and
    Is on or after, which match a date range when any of its days does, as Is before and Is
    after
    do. Selects and statuses gain Is any of and Is none of, and tags, relations and
    people Contains any of and Contains none of: one rule with several values, where "Me" counts
    for whoever looks. They work the same in the app, on published pages, in dashboards, for
    restricted properties and through the API, MCP (query_database, saved views) and the AI chat,
    as does_not_contain, gte, lte, is_any_of and is_none_of (the last two with a list of
    values). Saved views keep working unchanged.

  • AI changes to a page can be kept or undone one by one. After an AI app, the chat or an agent
    writes to a page body, the page shows a bar above the body with how many blocks changed and
    who changed them: Review opens the changes block by block with Keep and Undo for each,
    next to Keep all and Undo all. Undoing goes through the page's shared document, so editors
    open on the page follow at once, and Undo all saves a version first. Writes in a row are
    reviewed together; the bar goes away once every change is decided or someone edits the page.
    Only people who may edit the page see it (migration 0072).

  • Changing one block instead of the whole body. The chat and agents have a new edit_page
    tool that replaces, inserts after, appends or deletes blocks by id, and update_page (MCP) and
    PATCH /pages/{id} take the modes replace_block, insert_after and delete_block with a
    block_id. get_page / GET /pages/{id} give the ids with with_block_ids. Like every AI
    write, these save the page first and can be reviewed on the page. Agents may use edit_page on
    the rows they already may update.

  • Approval cards show what changes. Before the chat changes a page's text, the approval card
    shows the blocks it changes, adds and removes; for a row it shows each property's old value
    next to the new one, and the old title when it is renamed. Covered in scripts/ai-chat-e2e.ts
    and scripts/mcp-e2e.ts.

  • Table cells work from the keyboard and the clipboard. Click a cell (or move with the arrow
    keys) to make it the active cell; Shift with an arrow, Shift+click or a drag selects a range.
    Enter opens the active cell's editor and Esc brings you back to the cell, Delete clears the
    range. Copy puts the range on the clipboard as tab-separated text, the way exports write values,
    and pasting cells copied from a spreadsheet writes them from the active cell on: each value is
    read as the CSV import reads it (numbers like 1.234,5 or 15%, dates, yes/no, option and
    people names), options a select lacks are added, and rows past the last one are added in an
    ungrouped table. One pasted value fills the whole selected range. Cells that don't fit their
    column or that you may not change are left out and counted.

  • Row activity tells AI values apart. A value AI autofill filled in now reads as the AI's in
    the row's activity, naming who it was worked out for: "AI (for Ada) set Category to Billing",
    with a sparkle in place of a face. That person is whoever refreshed the row or updated the
    view's rows, or whose change of the row set the value off; for an automation's change, the
    person it acts as, its own entry for the change sitting right below. AI entries never merge into
    that person's own edits of the value. list_row_activity (MCP) and GET /rows/{id}/activity
    give actor: "ai", with the person in by. Needs migration 0074.

  • Agents read every row and write rows safely. query_database (MCP and POST /databases/{id}/query) now returns next_cursor and has_more: pass the cursor back with the
    same filters and sorts to read the next page, until it is null. Pages follow on from the last row
    returned, so rows added or removed between calls neither repeat nor go missing; a cursor from
    another query is refused. properties narrows each row to the values asked for.
    create_database_row(s) take match_by (the title or a text, number, email, phone, URL, select
    or status property) to upsert: a row with the same value is updated instead of a new one being
    added, so a retried import adds nothing twice, and each row says whether it was created or
    updated. A value two given rows share, or that several existing rows hold, is refused before
    anything is written. update_database_rows takes expect (refuses, writing nothing, unless
    exactly that many rows would change) and dry_run. Covered in `scripts/row-safety-...

Read more

Leafdesk 0.5.3

Choose a tag to compare

@erhanerbas erhanerbas released this 15 Oct 22:32

Upgrading from 0.5.2

  • Migrations (0068–0069) 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: voice notes' STT_PROVIDER, STT_MODEL, STT_BASE_URL,
    STT_API_KEY, STT_MAX_SECONDS, STT_MAX_MB and STT_TIMEOUT_SECONDS, and
    CONNECTION_OAUTH_CLIENTS; without it they stay unset in the container, voice notes stay hidden
    and owners enter their own app for services that need one.
  • Apps reach the AI chat's memory. MCP apps someone connected can now read the notes the chat
    keeps about them and keep new ones. Turn it off per person with Use memory in the Memory
    dialog, or per workspace under Settings → General → AI → AI memory.
  • Event runs may add rows. An agent that a connection's events run may now add rows to
    databases shared with it to edit; each row waits for an owner's approval in the inbox.

Added

  • A catalog of ready-made connections. Settings → Connections now opens on Browse: 147
    services in 16 categories, as cards with each service's logo, from Linear, Atlassian, GitLab,
    Sentry and Stripe to Intercom, Zapier and meeting recorders, with search by name or category.
    Connect on a card fills in its MCP address, how it signs in and, for GitHub, how its events are signed; Add another
    server
    still takes any remote MCP server. Every listed address was checked to sign in a way
    Leafdesk handles: OAuth metadata naming that address, an API key, or no sign-in at all.
    The workspace's own connections are under Connected, and those to a listed service show its
    logo instead of a plug.
  • Connections to services that sign in only registered apps. Slack, HubSpot, Asana, Box,
    Discord, Front, PagerDuty and Xero are in the connection catalog (Slack, Discord and
    their category Messaging & chat are new to it). Their servers don't let Leafdesk register
    itself, so the form asks for the client ID and secret of an app you create at the service, links
    to where it's made and shows the redirect URL to give it; the secret is sealed like a token,
    never shown again, kept when signing out, and must be entered again for a new address. Any
    other OAuth server can take an app the same way, folded under Sign in as an app registered
    with the service
    . An instance can register one app per service for everyone in
    CONNECTION_OAUTH_CLIENTS (slack=CLIENT_ID:CLIENT_SECRET,…). A server that takes neither
    registrations nor an app now says so instead of failing to sign in. ElevenLabs is listed too: it
    takes Leafdesk's client metadata document (/api/connections/oauth/client) as its client ID,
    from an instance on a public https address.
  • Voice notes. Speak, and AI writes what you said up as a clean note: a title, short paragraphs,
    lists kept, filler words gone, and the transcript in a Transcript toggle under it. In the quick
    note (and the installed app's new Voice note shortcut, which starts recording), the note fills
    the fields to check before saving, on a private page or, with the new Save to, as a row of a
    database, whose select and multi-select properties AI fills in from their options. In a page,
    / Voice note puts the note where you are. An audio file works too, where the browser can't
    record. Speech to text is set up apart from the chat model: STT_PROVIDER (openai or
    openai-compatible, e.g. a local Whisper server), STT_MODEL, STT_BASE_URL, STT_API_KEY,
    with STT_MAX_SECONDS, STT_MAX_MB and STT_TIMEOUT_SECONDS as limits; without them voice
    notes stay hidden. The audio is never stored.
  • Quick notes go into databases. Save to in the quick note adds the note as a row of a
    database you may edit, instead of a private page.
  • Voice notes from apps. POST /api/v1/voice-notes takes a recording as multipart form data
    (audio, with workspace_id or database_id), writes it up like the quick note does and saves
    it as a private page or a database row, answering with the note's id, title, transcript and the
    values AI picked. It needs a pages:write token, speech to text on the server and AI on in the
    workspace (else 409 voice_notes_unavailable), and takes files up to STT_MAX_MB. The API's
    reference shows bodies with files as form data.
  • Apps you connect share the AI chat's memory of you. Over MCP, list_memory reads the notes
    the chat keeps about you in a workspace (or in all of them), add_memory keeps one,
    update_memory changes one and delete_memory deletes some or all, with the chat's limits (30
    notes of 300 characters, the same note kept once). The tools tell an app to read the notes at the
    start of a conversation, to keep only what you say about yourself, never what pages or tool
    results say, and to tell you what it kept. Memory shows which app kept or last changed a note
    (Kept by …). With your Use memory switch or the workspace's AI memory switch off, an app
    sees no notes and keeps none, but can still delete them for you.
  • Agents run on a schedule. An agent's new Schedules tab (Settings → Agents) sets it to run
    on its own, every day, week (on chosen weekdays), month or year at a time of day in a time zone,
    with a task and, optionally, a database it looks after: for example every day at 08:00, go
    through the board, mark what is overdue and say what needs me. A run on a database reads it first
    and may change and comment on any of its rows (up to 30 changes, where a run on a row makes 5);
    without one it only reads and uses its connections' tools. Each run is held to the access of the
    owner who set the schedule last as well as the agent's, and ends with a report in that owner's
    inbox (and as a push notification, without the report's text). The run's history names the
    schedule, who set it and its task. A schedule whose last run still waits skips a run; a paused
    agent, a database in the trash or one not shared with the agent skips runs; and a schedule pauses
    when the agent is archived or whoever set it is no longer an owner or can't open the database,
    until an owner sets it again. The tab warns when the agent can't open or change the database (it
    isn't shared on its own). A run over a database with rows no one in the workspace stands behind,
    such as anonymous form answers, asks an owner before each connection tool and proposes no notes.
    Up to 10 schedules per agent. The REST API has GET/POST /agents/{agent_id}/schedules and
    PATCH/DELETE /agents/{agent_id}/schedules/{schedule_id},
    MCP has add_agent_schedule, update_agent_schedule and delete_agent_schedule, and
    get_agent lists an agent's schedules. Migration 0069_agent_schedules adds schedule.agent_id
    and the agent_report notification. Covered in scripts/agent-schedule-e2e.ts.
  • Wins log template. The template gallery has a Wins log: a page with a dashboard (wins in
    all, wins by month stacked by type, by area and by impact) above a database of wins with their
    type, area, date, impact, source link and people, a board by type, a row template and a few
    example rows. Built-in database templates can now bring views of any kind and a dashboard.
  • Wins logger agent. A built-in agent that a connection's events run rather than an
    automation: it reads each event (a Slack message, a GitHub pull request, a CRM deal or an email
    sent through a signed webhook) and adds a row to the wins log when it is a win or praise, and
    nothing otherwise. Its setup picks the log, the connection and the event type, lists which of the
    log's properties it fills, and says so when the workspace has no connection yet.
  • Agents add rows from events. A run a connection's event started may now add rows
    (create_row) to databases shared with the agent to edit, within the run's limit of changes and
    starting no automations. Each row waits for an owner's approval in the inbox and the agent's
    runs, trusted trigger or not, as a connected service's writing tools do.

Changed

  • A page's icon sits beside its title. Pages show their icon at the start of the title's line,
    as databases already did, so the content starts higher; Add icon and Add background show over
    the title's right end on hover (on touch screens as icons after the title). Published pages and
    printouts follow.
  • Pages with a dashboard use the whole width. Adding a dashboard, in the editor or with the MCP
    add_dashboard tool, turns on the page's Full width, so its widgets get the room; the page menu
    turns it off again.
  • Dashboard widgets move by dragging. Editors pick a widget up by its title bar and drop it
    beside another (or above or below one as wide as the dashboard); a line shows where it lands. The
    widget's menu still moves it one place at a time.

Leafdesk 0.5.2

Choose a tag to compare

@erhanerbas erhanerbas released this 15 Oct 08:59

Upgrading from 0.5.1

  • Migrations (0060–0067) run automatically when the container starts. Nothing changes in
    docker-compose.yml or .env.
  • The AI chat's memory is on. Where AI is on, the chat may keep notes about the people who use
    it (asking them first, unless the mode is Auto), and agents' runs may propose notes for owners
    to review. Owners who don't want it turn it off per workspace under Settings → General → AI →
    AI memory; each person can also turn it off for themselves in the Memory dialog.
  • Write tokens and MCP apps reach agents. For a workspace owner, an API token or MCP app with
    pages:write can now change the workspace's agents (pause them, set a monthly token limit) and
    ask them for runs, which use the workspace's AI allowance. Give integrations that only read a
    pages:read token, or let apps only read under Settings → Security.
  • Apps that reach a workspace show when each app or token last reached it from this version
    on; earlier use isn't known.

Added

  • Memory for the AI chat. The chat keeps short notes about each person in a workspace (who
    they are, what they work on, how they want answers) when they tell it about themselves or ask it
    to remember something, and drops one when asked to forget, with new remember and forget tools
    offered in every mode. Unless the mode is Auto, the chat asks the person (Yes or No) before
    it keeps or drops a note, also in Read only, so text it read can't slip in a lasting note; each
    note kept, dropped or declined shows among the answer's steps and is kept with the conversation
    also when the answer fails, and Answer again carries it to the new answer. Only what the
    person says in their questions is kept, never what pages or tool results say. The notes go with
    the person's later questions in that workspace as data, never with anyone else's or in another
    workspace, and never widen what the chat may read or change. The new Memory dialog (the brain
    button in the chat's header and panel) lists them, adds, changes and deletes notes, and forgets
    everything. Up to 30 notes of 300 characters per person and workspace; they are deleted when the
    person leaves the workspace or deletes their account. Migration 0063_ai_memory adds
    ai_memory. Covered in scripts/ai-memory-e2e.ts.
  • Switches for the AI chat's memory. Use memory at the top of the Memory dialog turns the
    memory off for the person in that workspace, and owners can turn it off for the whole workspace
    under Settings → General → AI → AI memory, which also hides the Memory button. While it is
    off, questions carry no notes and the chat isn't offered remember or forget; no note is
    deleted, and owners' changes are in the audit log. Migration 0064_ai_memory_switch adds
    workspace_member.ai_memory. Covered in scripts/ai-memory-e2e.ts and
    scripts/ai-chat-e2e.ts.
  • Memory for agents. Agents keep short notes that go with each of their runs (where things go
    in the workspace, how a recurring case is handled). Because everything a run reads was written by
    someone else, a run only proposes notes, with a new remember tool: each proposal shows among the
    run's steps, with credentials masked, and goes with no run until an owner keeps it. The agent's
    new Memory tab (and a "notes to review" link in the list of agents) shows proposals with the run's
    row and time, to keep as they are or changed, or dismiss; owners also write, change and delete
    notes and make the agent forget everything. A proposal from a row an owner can't open is shown to
    them without its text, and only dismissed. Runs drop notes at once with forget. Runs no member
    stands behind (anonymous or outside form answers, untrusted triggers) get neither tool but still
    get the kept notes, which reach the model as data inside <memory> and never widen what it may
    change. Up to 30 notes of 300 characters per agent and 10 proposals waiting; an unanswered
    proposal goes with its run after 30 days. The workspace's AI memory switch holds for agents
    too: off, runs carry no notes and propose none. Changes are recorded in the audit log, counted. MCP's
    get_agent lists the notes and proposals. Migration 0065_agent_memory adds status and
    agent_run_id to ai_memory. Covered in scripts/agent-memory-e2e.ts.
  • Owners hear of an agent's proposed notes. When a run proposes a note, each owner of the
    workspace gets one inbox item for that run, pushed too (without the note's text) and never
    emailed, whatever their notification choices. It shows the first proposal's text, unless the run
    was on a row that owner can't open, and how many more there are; it opens the agent's Memory
    tab (?tab=agents&agent=<id>&agentTab=memory), can be snoozed, and goes away once every proposal
    of the run is kept or dismissed. Answering an agent's call no longer takes the run's other inbox
    items with it. MCP adds keep_agent_memory and dismiss_agent_memory for proposals, and
    add_agent_memory, update_agent_memory and delete_agent_memory for an owner's own notes,
    each answering with the agent's memory as get_agent shows it; list_notifications summarizes
    the new item. Migration 0066_agent_memory_notice lets agent_memory notifications have no page
    and keeps one per run and owner. Covered in scripts/agent-memory-e2e.ts and scripts/mcp-e2e.ts.
  • Skills. A page or database row can be marked Use as skill in its ⋯ menu (edit access;
    not databases, templates or the pages in them): its body is instructions to reuse, and a bar
    under the title says what it is for. The sidebar's new skills button opens the workspace's
    skills library (searchable, with New skill). In the AI chat, / at the start of the empty box
    picks a skill whose instructions the next question follows, without widening what the chat may
    change; the question keeps it, and Answer again and Edit use it again. MCP clients get
    list_skills, get_skill (which refuses a title several skills share) and set_skill,
    get_page says whether a page is a skill, and every skill the user can open is also a prompt of
    its name, unique across their workspaces. The page
    menu and the library export a skill as a ZIP holding <name>/SKILL.md. Skills follow page
    access everywhere and leave every list while in the trash. Migration 0060_skills adds
    page.is_skill and page.skill_description. Covered in scripts/skills-e2e.ts and
    scripts/mcp-e2e.ts.
  • Skills for agents. An agent's new Skills tab gives it up to 10 skills of the workspace,
    whose instructions each run sends to the model after the agent's own. Only skills no one besides
    the workspace's owners can edit (not members, teamspaces, groups, guests or agents, the agent
    itself included) can be given; a run checks again and leaves out, saying so in its steps, a skill
    someone else can edit by then, one no longer marked as a skill, or one that doesn't fit in what a
    run may read (12,000 characters together), and the tab shows each skill's state. A run started
    by a member's change or a public form leaves out the skills that person can't open, and a run
    that goes on after an approval checks its skills again. MCP clients get set_agent_skill, and
    get_agent lists the agent's skills. Giving and taking a skill is recorded in the audit log.
    Migration 0061_agent_skills adds agent_skill. Covered in scripts/agent-skills-e2e.ts.
  • Apps that reach a workspace. Settings → Security shows its owners the MCP apps the
    workspace's people connected and their REST API tokens for it, with what each may do, who
    connected it and when it last reached this workspace (not before this version), the 100 apps
    and 100 tokens used here most recently first. An owner can
    disconnect one person's app or revoke their token; as both belong to the person, it stops
    reaching all of their workspaces, and this workspace's audit log records it with the owner as
    who did it. Migration 0062_connected_app_use adds connected_app_use. Covered in
    scripts/workspace-apps-e2e.ts.
  • About. Settings (and the account page) have an About tab: the Leafdesk version the server
    runs with a link to its release notes, who makes it, the license, the website, the source code,
    where to report a problem and the third-party notices. The version is read from package.json,
    which the MCP server now reports too. No migration.
  • Copy ID. A page's, database's or row's menu (also in the row panel) has Copy ID, and a
    column's menu, in the table and on a row's page, Copy property ID: the ids the REST API and MCP
    clients take, where a property's name may change. No migration.
  • High contrast. Settings → Preferences → Appearance has Contrast: follow the system (its
    "increase contrast" setting, prefers-contrast: more), Standard or High, kept for the
    browser in a cookie like the theme and carried by <html data-contrast>. High contrast keeps
    the backgrounds and makes, in either theme, secondary text at least 7:1 on the page and 4.5:1 on
    hovered and selected rows, faint text (hints, placeholders) at least 4.5:1 everywhere, lines and
    control edges at least 3:1, the accent and danger colors stronger and the focus ring more opaque; colored and black page backgrounds get the same lines
    and text. The editor follows, as it draws with the same colors. src/lib/contrast.test.ts
    checks the ratios from globals.css. No migration.
  • Agents in the Share menu. Typing in a page's Share menu finds the workspace's agents by name
    too (archived ones aren't offered; a paused one says so) for members with full access to the
    page; one picked starts at Can view, as from the agent's settings. An agent's row shows its
    icon and "Agent", and its levels are view, comment and edit only. The server already let anyone
    with full access to a page s...
Read more

Leafdesk 0.5.1

Choose a tag to compare

@erhanerbas erhanerbas released this 12 Oct 08:06

Upgrading from 0.5.0

  • Migrations (0049–0059) run automatically when the container starts.

  • Docker Compose: download the new docker-compose.yml. It needs POSTGRES_PASSWORD for the
    bundled database (see below) and passes AUTOMATION_DATE_CHECK_SECONDS to the app.

  • Automations that set a date to today use the day in the time zone of whoever saved them
    last. Automations saved before this release count as saved in UTC until someone saves them
    again; before, they used the server's day.

  • TRUSTED_PROXIES behind a proxy with a public address. Unset, the app now trusts only a
    proxy that connects from a private address. If yours connects from a public one (a CDN in front
    of the app, a proxy on another server), set TRUSTED_PROXIES=1 (or 2 for a CDN plus a proxy),
    or every visitor counts as the proxy's address. Proxies on the same machine or Docker network
    need nothing.

  • POSTGRES_PASSWORD is required for the bundled database. Download the new
    docker-compose.yml: with COMPOSE_PROFILES=bundled-db and no EXTERNAL_DATABASE_URL, it
    refuses to start until .env sets POSTGRES_PASSWORD. If you never set one, your database
    has the old default: add POSTGRES_PASSWORD=leafdesk (and change it inside PostgreSQL later if
    you like). An external database needs nothing.

  • Allowed email domains must be verified. A workspace's allowed domains stop letting people
    join, ask to join or find the workspace until an owner proves each one: Settings → Security
    shows the DNS TXT record to add at _leafdesk-sso.<domain> and a Verify button. Domains its
    single sign-on already verified keep working. Tell your workspaces' owners before upgrading. The
    record's value is derived from BETTER_AUTH_SECRET; changing the secret changes it for domains
    not verified yet.

  • Connection events start untrusted. Migration 0053 adds a "Trust what this service sends"
    switch to each event trigger, off for existing ones: an agent run started by an event now waits
    for an owner's approval at its first connection tool call. In Settings → Connections → Events,
    trust only the triggers of services where nobody outside the workspace can make it send events.

  • Pages more than 64 levels deep. Migration 0055 gives no access to a page the access rule
    can't follow to the top level within 64 steps, and sharing changes above such a page may be
    refused while it has entries of its own. Find them before upgrading, and move them or a page
    above them higher:

    WITH RECURSIVE d AS (
      SELECT id, 0 AS depth FROM page WHERE parent_id IS NULL
      UNION ALL
      SELECT p.id, d.depth + 1 FROM page p JOIN d ON p.parent_id = d.id WHERE d.depth < 200
    )
    SELECT id, depth FROM d WHERE depth > 64;
  • Migrations delete stored data nobody used. Migration 0055 clears the access, refresh and ID
    tokens sign-in providers handed over (Leafdesk never read them). Migration 0056 deletes the page
    and property entries, calendar feeds and workspace-only API tokens of people who are no longer
    members of that workspace; none of them worked while they were out, but they came back if the
    person was let in again.

  • Verification links on another device. Someone who signs up and opens the verification email
    in a browser that isn't signed in to the account is signed out everywhere else and asked to
    choose a new password, through a link emailed to the address, at their next password sign-in.

Added

  • Rows open beside the view. Clicking a row or card in a table, board, calendar, gallery, list
    or timeline, also in a database inside a page, opens it in a panel at the right, and the view
    stays usable next to it: title, icon, properties (with "N more properties"), the body edited
    together with who's there, comments (their panel opens alongside) and the row's menu, where
    Duplicate opens the copy in the panel and Move to trash closes it. The panel steps to the
    previous and next row in the view's order (buttons, or K and J outside text, listed under
    Keyboard shortcuts), opens the row as a full page or copies a link to it, and closes with its
    button or Esc, giving focus back to the row. The open row is in the address (?row=): a link or
    a reload opens it again, Back closes it, and moving between rows doesn't add to the history. Its
    width can be dragged (400–1,200 pixels, leaving the view at least 320) and is kept in the
    browser. A row trashed or moved out of its database by someone else, or one the viewer can't
    see, shows that in the panel. A view's layout menu has "Open rows in: Side panel / Full page"
    (side panel unless set), and MCP views take open_rows (panel or page); get_database
    shows open_rows: "page" on views that open full pages. ⌘/Ctrl-click and middle clicks open
    the row in a new tab; phones and offline pages open the full page. Published pages are
    unchanged. No migration.
  • Automations when a date passes, and moving rows to the trash. A new trigger, A date
    passes
    (date_passed), starts an automation for each row whose date is at least a number of
    days past (0 for the day itself): a date property's start, when the row was added, or when it was
    last edited, optionally only for rows whose status is in some groups (to do, in progress, done; a
    row without a status counts as to do). So "rows not edited for 30 days that aren't done → set
    Status to Stale", "rows whose Completed on is 14 days past → move to the trash" and "the day a
    Due date comes, for rows not done → notify the assignee" are each one automation. Dates without
    a time count by the calendar of the time zone of whoever saved the automation last (the
    browser's in the app, time_zone over MCP, UTC without one), and the summary names that zone
    when it isn't the viewer's; times and added/edited times count by the clock. "Today" set by any
    automation is that day in the same time zone (it was the server's), and "whoever made the
    change" isn't offered for a date passing, which nobody's change starts. The server
    looks every 10 minutes (AUTOMATION_DATE_CHECK_SECONDS, one server at a time) and runs each row
    once per value of its date, again only once that date changes and passes again; a row restored
    from the trash doesn't run again for the same date, and the automation's own edit doesn't count
    as one. Rows that already match run at the next check, at most 100 per automation per check, and
    the dialog says how many match before saving. Rows in the trash, templates and databases in the
    trash are left out; a deleted date or status property, or a person who lost full access, pauses
    the automation with the reason shown in its settings. A new action, Move to trash
    (move_to_trash), always last and not with Run an agent, works with every trigger; the rows it
    trashes are restorable as usual, go with the trash retention, and are recorded in the audit log
    as page.trashed by the automation (a new actor kind, shown as "person (automation “name”)").
    Runs show in the run history as "Date passed" with the date; webhooks get the event
    row.date_passed with that date; notifications say the row's date has come. MCP's
    create_automation and update_automation take both and a time_zone, list_automations
    describes them and shows each automation's time_zone and a paused one's check_error,
    list_automation_runs gives each run's date.
    Migration 0058 adds the automation_fire table, automation_run.basis,
    database_automation.time_zone and .check_error, and the audit actor kind. Covered by
    scripts/timed-automation-e2e.ts and unit tests.
  • Row activity. A row page has an Activity section under its properties, closed until opened
    (each browser remembers the choice): who created the row and who changed its title or a value,
    from what to what, newest first, a page at a time, updating while others edit. Every way values
    are written counts (the app, bulk edits, forms, quick add, MCP, the REST API, automations and
    agents, which are marked as such); a new row, also from an import or a template, is one
    "created" entry. Changes by the same person to the same value within five minutes are one entry
    (typing in a text, number or the title), and one that ends where it started leaves none.
    Formulas, rollups and who/when values aren't recorded. Viewers see the entries about values they
    may see in the row; linked rows they can't open read as "No access", and entries about a
    deleted property are left out until it is restored. Text is kept with credentials masked. MCP
    clients read it with list_row_activity, the REST API at GET /api/v1/rows/{id}/activity. The
    activity is kept apart from the audit log, which still leaves row content out, and goes with
    the row when it is deleted for good. Migration 0059.
  • Deleting a workspace. Owners find Settings → General → Deletion. The dialog asks for the
    workspace's name, typed as shown, and the same proof as deleting the account (the password, a
    two-step code, or a recent sign-in); a mistyped name is refused before the proof is checked, so
    it doesn't use up a code. The workspace goes for every member, with its pages, databases,
    comments, teamspaces, groups, agents, connections and audit log; open editors of its pages are
    disconnected, everyone's sign-ins through its single sign-on connection end, and its files are
    removed from storage in the background. Everyone else in it gets an email saying who deleted it,
    in their language (when email is set up); the audit log goes too, so the server log records it.
    Someone left without any workspace gets a new personal one, as after leaving the last one.
  • Health endpoints. GET /api/health answers 200 {"status":"ok"} whenever the server
    handles requests, without readi...
Read more

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,...
Read more

Leafdesk 0.4.0

Choose a tag to compare

@erhanerbas erhanerbas released this 02 Oct 20:33

Upgrading from 0.3.0

  • Migrations (0031–0035) run automatically when the container starts.
  • OpenCode Go: to use it with Docker Compose, download the new docker-compose.yml, which
    passes OPENCODE_API_KEY to the app (or set the key as AI_API_KEY).

Added

  • The AI chat can add and change things, as the person and only where they may edit:
    create_row adds a database row (values and text), update_row changes a row's values or
    title, create_page adds a page under a page (or a private one at the top of the workspace; in
    a page's scope, only under it). A mode under the question box, remembered in the browser, says
    what it may do: Ask (the default) puts each change in the box's place before it's made (what,
    where, the values, the start of the text) with Yes, Yes, and don't ask again (switches to
    Auto) and No (1, 2, 3; Escape says no); Auto doesn't ask; Read only doesn't offer the
    model changes. Changes are checked (target, edit access, values) before the person is asked; the
    answer's stream waits for the decision (decideChangeAction, only the asker's), pinging every
    20 seconds, and stopping, leaving or 15 minutes without a decision write nothing. Changes made,
    declined or failed are steps with links, kept with the conversation even when no answer text
    came, and noted with their ids for later questions. The API takes mode (ask, auto,
    read). Popover takes side="top". The chat route no longer writes to a stream the browser
    has closed. New checks in scripts/ai-chat-e2e.ts (120 in all) and src/server/ai/chat.test.ts.
  • Audit log (#61), in Settings → Audit log for owners (members, guests and everyone else get a
    404): who changed what in the workspace, newest first, 50 to a page, filtered by person (or the
    identity provider, or the server), kind of change and a range of days in the viewer's time zone,
    and downloadable as CSV with the same filters (up to 10,000 events). Recorded: members added,
    joining (join link, email domain, single sign-on, SCIM), removed or leaving, role changes and
    handing over ownership; invitations sent, revoked and accepted, the join link turned on, off or
    replaced, join requests approved or declined; page sharing with a person, everyone or a group
    (and by email), and access requests decided; teamspaces created, changed, archived, restored and
    their members, roles and groups; groups created, renamed, deleted and their members; the
    workspace renamed and its settings, with the before and after of each changed setting; single
    sign-on saved, verified or removed, SCIM tokens created or revoked; pages deleted for good (by a
    person, or by the trash cleanup as the system), published, unpublished or taken off the web by an
    owner, the site saved or removed; API tokens created or revoked and apps connected or
    disconnected (in each workspace they reach); exports (the workspace ZIP, a page as ZIP, Markdown,
    CSV or the print view). Each event names the person, the API token or connected app they acted
    through, or the SCIM token, with the request's address and browser, and reads as a sentence in
    the viewer's language. Recording never fails or holds back a change: it runs in the change's
    transaction (a savepoint) where there is one, right after it otherwise, and a failure is only
    logged. The daily retention cleanup prunes events older than a year. Migration 0031_audit_log
    (audit_event). New checks: scripts/audit-e2e.ts (169), src/lib/audit.test.ts.
  • Reorder columns by dragging. A table's column headers, and the properties in any view's
    properties menu, can be dragged into a new order, which the view keeps (propertyOrder in its
    settings, so each view has its own order and the database's property order stays as it is).
    Boards, galleries, lists, timelines and published pages show properties in that order too.
    Hidden columns keep their place, properties added later show at the end, and a
    view saved by someone who can't see some columns leaves those where they were.
  • Resize table columns by dragging. Each column header, the Name column's too, has a handle on
    its right edge; the column follows the pointer and keeps the width it is let go at, which the
    view saves (columnWidths in its settings, per view). Phones keep the narrow Name column.
  • Customize the sidebar per workspace. "Customize sidebar" at the bottom of the sidebar lists
    its sections (Favorites, Teamspaces, Shared, Private) to reorder by dragging or with the arrows
    and to hide or show. The order, hidden sections and which headings are folded are kept for each
    person in each workspace, on every device, so a personal workspace and a company one can look
    different; folding a heading no longer changes it in every workspace. Migration
    0035_member_sidebar (workspace_member.sidebar).
  • Property access. Someone with full access to a database opens Property access from a
    column's menu and sets what everyone with access to the database may do with that property: edit
    it, edit its values, view them, see only the column, or nothing, with exceptions for people,
    groups and whoever a person or created-by property of the row names. The widest rule wins, nobody
    gets more than their database access, and full access is never restricted; the dialog says who
    still has full access and links to the database's Share panel. Relations and system properties
    can't be restricted. Values a person may not see are left out before formulas run (formulas and
    rollups over them are dropped), and views, filters, sorts, search, the semantic index, autofill,
    exports, print, published pages, MCP (new set_property_access tool) and the REST API never
    reveal them; writes, board drags, bulk edits, schema changes, CSV imports, forms, templates and
    copies follow the same rules. Assignment notices and emails skip people who can't see the person
    property. Changes land in the audit log. Migration 0033_property_access
    (property_permission).
  • What a teamspace's members get on its pages. A teamspace's owners can lower the access its
    members get where a page doesn't decide (full, edit, comment or view). Full stays the default, so
    nothing changes for existing workspaces. The teamspace's owners and the workspace's owners keep
    full access, and whoever adds or moves a page to the top of a lowered teamspace gets full access
    to it. Migration 0034_teamspace_member_level.
  • OpenCode Go as an AI provider: AI_PROVIDER=opencode-go with a Go key (AI_API_KEY or
    OPENCODE_API_KEY) and any model of its catalog, e.g. AI_MODEL=kimi-k3. Each model goes to the
    API it is served over (chat completions, Responses or Anthropic's Messages) with its own context
    window. Requests carry the x-opencode-session header Go asks for (the conversation's id in the
    AI chat, a new one for every other request) and name Leafdesk as their user agent; other
    providers get neither. New checks in src/server/ai/providers.test.ts.

Changed

  • Ask AI sits beside Home in the sidebar, as a ✨ icon next to People, where it took a row of
    its own.
  • Actions under AI chat messages. An answer has Copy (its Markdown, without the citation
    numbers), Answer again (the latest one) and when it was written; a question shows, on hover,
    when it was asked, Edit (the latest one: the edited question is answered in place of it) and
    Copy. Answering again or an edited question replaces the last question and answer once the new
    answer is kept (replaceLast in the API); one that fails leaves them as they were. An answer
    that changed things can't be answered again or edited (it would make the changes twice).
    Messages now carry their time (at). New checks in scripts/ai-chat-e2e.ts (126 in all).
  • Escape closes one thing at a time. In the AI chat panel, Escape in a menu (the mode, the
    history) or while editing a question closed the panel too; now it only closes the menu or the
    edit. Menus (useDismiss) mark the Escape they handle, for whatever else listens.
  • AI chat answers no longer list their sources below. The numbered citations in the text stay:
    they open the page (at the passage) and name it on hover.
  • The AI chat decides what to look at. The question was searched for before the model saw
    it, as a whole ("Bekleyen ne iş var" found nothing, and the model then guessed words to search
    for). Now nothing is searched for it: the question goes with a map of what the person can open in
    the workspace (or under the scope page): the databases with their properties, types and options
    (a status's options by group: to do, in progress, done), and the pages, each with its id and
    where it is, up to 8,000 characters. The model is told to think first: to query a database for
    tasks and records, to search with a few distinctive words rather than the question, and to
    answer greetings without tools; and to write everything, also what it says before using a tool,
    in the language of the question. Six turns per question, where there were five. The full-page
    chat lists a new conversation in the sidebar as soon as it starts, not once it's answered. New
    checks in scripts/ai-chat-e2e.ts and src/server/ai/chat.test.ts.
  • The AI chat can query databases. A new query_database tool lists the rows of a database that
    match filters on its properties (the same filters, operators, groups and sorts as the MCP tool:
    "assigned to me", "status is open", "due in the next 30 days"…), run as the person through the
    same operation, so values they may not see can't be filtered on or returned. Each row found is a
    source the answer can cite. It is refused for databases the person can't open, outside the
    chosen scope or in another workspace, and an invalid filter comes back to the model saying why.
    read_page on a database now lists its properties with their types and ...
Read more

Leafdesk 0.3.0

Choose a tag to compare

@erhanerbas erhanerbas released this 28 Sep 08:37

Upgrading from 0.2.0

  • New name and image: the project is now Leafdesk, published as ghcr.io/esmworks/leafdesk.
    Download the new docker-compose.yml into the same folder as before (the folder name is the
    Compose project name, so the existing volumes are found), and in .env rename
    ESIONAGE_VERSION to LEAFDESK_VERSION if you pinned a version.
  • Keep your database: the bundled PostgreSQL keeps the user, password and database name it was
    created with, esionage, while the new defaults are leafdesk. Keep
    COMPOSE_PROFILES=bundled-db and add
    EXTERNAL_DATABASE_URL=postgres://esionage:<password>@db:5432/esionage to .env, with your
    POSTGRES_PASSWORD (esionage if you never set one). Without it the app can't connect.
  • Migrations (0009–0030) run automatically when the container starts.
  • Uploaded files are stored in the new uploads volume (or S3-compatible storage with
    S3_BUCKET).
  • Behind no reverse proxy: the app now reads visitors' addresses from X-Forwarded-For,
    trusting one proxy by default. Set TRUSTED_PROXIES=0 when clients reach the app directly.

Added

  • Membership policies and join requests (#56), in Settings → Security → Members: who can add
    members
    (owners only, the default; members with an owner's approval; owners and members, members
    adding members only), allowed email domains (subdomains included, public mail domains refused)
    that join as members or ask to join, and who can ask to join (nobody, the allowed domains,
    or anyone with the join link, which then asks instead of admitting). The domain rule runs for
    verified addresses on sign-up, sign-in, email verification (a password reset through the emailed
    link counts, see #59) and email change, once per person and workspace: leaving, removal (by an
    owner or SCIM) and declined requests are remembered, so nobody is pulled back in. Someone who
    joins through a domain on sign-up gets no personal workspace. The workspace switcher lists
    workspaces a person's domain lets them join or ask to join. Owners approve or decline in Settings
    → Members → Requests; each request is in every owner's inbox (new notification kind
    join_request, with its own inbox and email preferences, also in MCP list_notifications) and
    emailed to them; the person who asked gets the decision by email in their language. A member's
    approved request goes out as their invitation. MCP invite_member follows the same rule (a
    member's invitation there answers status: "requested" while approval is on). One pending request
    per person (or invited address) and workspace; 10 requests an hour per person, 100 invitation
    requests an hour per member. Email verification: with email available, email and password
    sign-ups get a verification link (resend it in My account → Profile); without SMTP in production,
    only provider-vouched addresses (GitHub, Google, SSO, SCIM) count as verified.
    scripts/auth-e2e.ts and scripts/account-e2e.ts expect the verification email. Migration
    0030_membership_policies (workspace_join_request, notification join_request_id, page_id
    nullable for join requests). New checks: scripts/membership-e2e.ts (106),
    src/lib/membership-policy.test.ts.
  • Instance administrators (#59): accounts whose verified email is listed in ADMIN_EMAILS
    (comma-separated, any case; no role in the database) get Server administration at /admin,
    a 404 for everyone else. It lists the accounts (name, email, verified, workspaces, last active,
    two-step verification) with a search, signs one account or everyone but the administrator's
    own browser out (closing their live collaboration connections too), and requires a new
    password of one account or of everyone with a password: their sessions end, their next
    password sign-in gets no session, and they choose a new one through an emailed reset link
    (with SMTP) or right on the sign-in page (without; with a two-step code when they have it on).
    The old password can't be chosen again; any password change clears the requirement; accounts
    without a password are not affected. WORKSPACE_CREATION=admins keeps creating workspaces to
    administrators (hidden from others and refused by the server; the personal workspace at sign-up
    is still created). New command pnpm auth:verify-email <email>; a password reset through the
    emailed link now also marks the address verified. Better Auth's admin plugin was not used: it
    needs a role column and has neither required resets nor an instance-wide sign-out. Migration
    0029_instance_admin (user.password_reset_required). MCP: list_users returns joined_at;
    new invite_member tool (owners only, needs write access). New checks: scripts/admin-e2e.ts
    (in CI), src/lib/instance-admin.test.ts, more in mcp-e2e.ts and tools.test.ts.
  • Semantic search (#43; optional, on with AI_EMBEDDINGS_MODEL): search also finds pages by
    meaning, merged with full-text results by reciprocal rank fusion, in the search dialog (marked
    Similar meaning), MCP search and REST GET /search (each result has match: "text" or
    "semantic"). Pages are cut into chunks (title, a row's values, the body's blocks) whose
    embeddings are stored in page_chunk as real[] with their model, dimensions and a content hash
    (only changed chunks are embedded again), ranked by cosine similarity in SQL (embedding_cosine,
    no extension needed; the README describes moving to pgvector for large workspaces). Indexing runs
    in the background a few seconds after pages are edited, created, renamed or restored or a row's
    values change, within AI_CONCURRENCY and AI_WORKSPACE_RATE_LIMIT; a workspace's first search
    sweeps up pages the index missed, and pnpm search:index backfills everything. Access is
    checked when the query runs, before ranking, never stored in the index. Trashed pages drop out
    at once; turning AI off for a workspace deletes its index. New setting
    AI_EMBEDDINGS_MIN_SIMILARITY (default 0.3). Without an embeddings model search is exactly the
    full-text search it was. Migration 0026_semantic_search. New checks:
    scripts/semantic-search-e2e.ts (64, against a stand-in OpenAI-compatible server),
    src/server/semantic-text.test.ts.
  • AI chat (#41): Ask AI in the sidebar opens a panel that answers questions from the pages
    the person can read, citing them as links to the page and block. Each question is searched for
    (hybrid search) and the model may call search_pages and read_page (up to five turns), which
    run through operations.ts as the person with their access checked on every call; answers
    stream (POST /api/ai/chat, NDJSON) and can be stopped. Questions can be kept to the current
    page and its subpages. Conversations are private, kept per person and workspace
    (ai_conversation, migration 0027_ai_chat; 50 per workspace, 40 questions each, 4000
    characters a question), listed and deletable in the panel, and deleted when the person leaves
    the workspace or deletes their account; cited pages they can no longer open lose their title.
    Off while offline and when AI is off, with the reason shown. Links to #block-<id> scroll to
    and highlight the block. New checks: scripts/ai-chat-e2e.ts (62), src/server/ai/chat.test.ts.
  • Single sign-on and SCIM (#38), with Better Auth's SSO plugin (@better-auth/sso 1.7.6):
    an instance-wide OpenID Connect provider from OIDC_ISSUER, OIDC_CLIENT_ID,
    OIDC_CLIENT_SECRET (OIDC_NAME, OIDC_DOMAINS), and one OpenID Connect or SAML 2.0
    connection per workspace, set up by owners in Settings → Security. A workspace connection signs
    people in only for its email domains, once each is verified with a DNS TXT record
    (_leafdesk-sso.<domain>); public mail domains can't be claimed and a domain belongs to one
    connection. "Continue with SSO" on the sign-in page routes an email to its provider. The first
    sign-in creates the account (only in those domains) and joins the workspace as a member;
    two-step verification still asks for its code afterwards. New workspace setting How members
    sign in
    : Any method or Single sign-on only, enforced like "require two-step verification"
    (pages, exports, actions, API routes, live collaboration; a new /sso-required/<id> page), with
    owners and guests exempt. SCIM 2.0 at /scim/v2 with workspace SCIM tokens (hashed, revocable):
    Users list/filter/get/create/replace/patch/delete, where active: false removes someone from
    the workspace and owners can't be deactivated; Groups in the next entry.
    The SSO box lists the workspace ID, OIDC redirect URI, SAML entity ID, ACS and metadata URLs to
    copy. The plugin's own provider management, its shared callback and SAML single logout are off.
    Migration 0024_sso. New checks: scripts/sso-e2e.ts (69, against a mock OIDC provider it
    runs itself), scripts/scim-e2e.ts, src/lib/sso-config.test.ts, src/lib/scim.test.ts,
    src/server/sso.test.ts. Not tried against real identity providers; SAML only in unit tests.
  • SCIM groups (#38, on member groups #37): /scim/v2/Groups lists (filter displayName,
    externalId or id with eq, paging, excludedAttributes=members), gets, creates, replaces,
    patches and deletes the workspace's member groups, including ones made in the app. PATCH
    takes Okta's and Microsoft Entra ID's forms (add/remove/replace on members, remove on
    members[value eq "…"], displayName, externalId, path-less objects, ops in any case); PUT
    keeps members it leaves out. Members must be the workspace's owners or members: guests, unknown
    or outside ids and nested groups are a 400 invalidValue and nothing of that request is
    applied; a taken name is a 409. Changes run through server/groups.ts (a new changeGroup
    that renames and adds and removes people in one transaction, which the settings' rename, add
    a...
Read more