Repository navigation
Releases: esmworks/leafdesk
Release list
Leafdesk 0.5.4
Upgrading from 0.5.3
- Migrations (0070–0077) run automatically when the container starts. Compose users
don't need a newdocker-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
pagetable 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:migratethen).
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) andGET /pages/{id}now give
the body'sversion, andupdate_page/PATCH /pages/{id}take it back asexpected_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 409version_conflictwith the current version.
Body writes report the newversion, so an app can make several changes in a row without reading
the page in between. Comments don't change the version. Withoutexpected_versionwrites work
as before. Covered inscripts/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 orpnpm 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 toolcheck_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,
asdoes_not_contain,gte,lte,is_any_ofandis_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, andupdate_page(MCP) and
PATCH /pages/{id}take the modesreplace_block,insert_afteranddelete_blockwith a
block_id.get_page/GET /pages/{id}give the ids withwith_block_ids. Like every AI
write, these save the page first and can be reviewed on the page. Agents may useedit_pageon
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 inscripts/ai-chat-e2e.ts
andscripts/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 like1.234,5or15%, 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) andGET /rows/{id}/activity
giveactor: "ai", with the person inby. Needs migration 0074. -
Agents read every row and write rows safely.
query_database(MCP andPOST /databases/{id}/query) now returnsnext_cursorandhas_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.propertiesnarrows each row to the values asked for.
create_database_row(s)takematch_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 wascreatedor
updated. A value two given rows share, or that several existing rows hold, is refused before
anything is written.update_database_rowstakesexpect(refuses, writing nothing, unless
exactly that many rows would change) anddry_run. Covered in `scripts/row-safety-...
Leafdesk 0.5.3
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_MBandSTT_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 publichttpsaddress. - 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(openaior
openai-compatible, e.g. a local Whisper server),STT_MODEL,STT_BASE_URL,STT_API_KEY,
withSTT_MAX_SECONDS,STT_MAX_MBandSTT_TIMEOUT_SECONDSas 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-notestakes a recording as multipart form data
(audio, withworkspace_idordatabase_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 apages:writetoken, speech to text on the server and AI on in the
workspace (else409 voice_notes_unavailable), and takes files up toSTT_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_memoryreads the notes
the chat keeps about you in a workspace (or in all of them),add_memorykeeps one,
update_memorychanges one anddelete_memorydeletes 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 hasGET/POST /agents/{agent_id}/schedulesand
PATCH/DELETE /agents/{agent_id}/schedules/{schedule_id},
MCP hasadd_agent_schedule,update_agent_scheduleanddelete_agent_schedule, and
get_agentlists an agent's schedules. Migration0069_agent_schedulesaddsschedule.agent_id
and theagent_reportnotification. Covered inscripts/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_dashboardtool, 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
Upgrading from 0.5.1
- Migrations (0060–0067) run automatically when the container starts. Nothing changes in
docker-compose.ymlor.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:writecan 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:readtoken, 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 newrememberandforgettools
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. Migration0063_ai_memoryadds
ai_memory. Covered inscripts/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 offeredrememberorforget; no note is
deleted, and owners' changes are in the audit log. Migration0064_ai_memory_switchadds
workspace_member.ai_memory. Covered inscripts/ai-memory-e2e.tsand
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 newremembertool: 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 withforget. 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_agentlists the notes and proposals. Migration0065_agent_memoryaddsstatusand
agent_run_idtoai_memory. Covered inscripts/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 addskeep_agent_memoryanddismiss_agent_memoryfor proposals, and
add_agent_memory,update_agent_memoryanddelete_agent_memoryfor an owner's own notes,
each answering with the agent's memory asget_agentshows it;list_notificationssummarizes
the new item. Migration0066_agent_memory_noticeletsagent_memorynotifications have no page
and keeps one per run and owner. Covered inscripts/agent-memory-e2e.tsandscripts/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) andset_skill,
get_pagesays 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. Migration0060_skillsadds
page.is_skillandpage.skill_description. Covered inscripts/skills-e2e.tsand
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 getset_agent_skill, and
get_agentlists the agent's skills. Giving and taking a skill is recorded in the audit log.
Migration0061_agent_skillsaddsagent_skill. Covered inscripts/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. Migration0062_connected_app_useaddsconnected_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 frompackage.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 fromglobals.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...
Leafdesk 0.5.1
Upgrading from 0.5.0
-
Migrations (0049–0059) run automatically when the container starts.
-
Docker Compose: download the new
docker-compose.yml. It needsPOSTGRES_PASSWORDfor the
bundled database (see below) and passesAUTOMATION_DATE_CHECK_SECONDSto 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_PROXIESbehind 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), setTRUSTED_PROXIES=1(or2for 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_PASSWORDis required for the bundled database. Download the new
docker-compose.yml: withCOMPOSE_PROFILES=bundled-dband noEXTERNAL_DATABASE_URL, it
refuses to start until.envsetsPOSTGRES_PASSWORD. If you never set one, your database
has the old default: addPOSTGRES_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 fromBETTER_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 takeopen_rows(panelorpage);get_database
showsopen_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_zoneover 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
aspage.trashedby 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_passedwith thatdate; notifications say the row's date has come. MCP's
create_automationandupdate_automationtake both and atime_zone,list_automations
describes them and shows each automation'stime_zoneand a paused one'scheck_error,
list_automation_runsgives each run'sdate.
Migration 0058 adds theautomation_firetable,automation_run.basis,
database_automation.time_zoneand.check_error, and the audit actor kind. Covered by
scripts/timed-automation-e2e.tsand 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 withlist_row_activity, the REST API atGET /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/healthanswers 200{"status":"ok"}whenever the server
handles requests, without readi...
Leafdesk 0.5.0
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_*andPUSH_ALLOWED_HOSTS,
AUTOMATION_WEBHOOK_ALLOWED_HOSTS,CONNECTOR_ALLOWED_HOSTS,LEAFDESK_ENCRYPTION_KEYand
LEAFDESK_ENCRYPTION_OLD_KEYS), andAPI_RATE_LIMIT,MCP_RATE_LIMIT,API_CORS_ORIGINSand
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_SECRETis checked at startup. In production the server refuses to start with
the example value from.env.exampleor a secret shorter than 32 characters (make one with
openssl rand -base64 32). Changing it signs everyone out; withoutLEAFDESK_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_KEYandVAPID_SUBJECT
(make the keys withpnpm push:keys; under Docker Compose they are passed through from.env).
Without them push stays off and nothing changes. Migration0047_push_subscriptions.
Added
- Security policy.
SECURITY.mdsays 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 withsigmaandgraphology(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'sget_pagegives
eachlinked_frompage itscontext. Migration0048_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) andtype:page,type:databaseor
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 asr ^2stays; 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/importtakessheet, 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_pageandGET /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 whosevertical-alignissuper
orsubkeeps them. In Markdown (search, MCPget_pageandupdate_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.tsandsrc/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,...
Leafdesk 0.4.0
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
passesOPENCODE_API_KEYto the app (or set the key asAI_API_KEY).
Added
- The AI chat can add and change things, as the person and only where they may edit:
create_rowadds a database row (values and text),update_rowchanges a row's values or
title,create_pageadds 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 takesmode(ask,auto,
read).Popovertakesside="top". The chat route no longer writes to a stream the browser
has closed. New checks inscripts/ai-chat-e2e.ts(120 in all) andsrc/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. Migration0031_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 (propertyOrderin 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 (columnWidthsin 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 (newset_property_accesstool) 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. Migration0033_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. Migration0034_teamspace_member_level. - OpenCode Go as an AI provider:
AI_PROVIDER=opencode-gowith a Go key (AI_API_KEYor
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 thex-opencode-sessionheader 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 insrc/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 (replaceLastin 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 inscripts/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 inscripts/ai-chat-e2e.tsandsrc/server/ai/chat.test.ts. - The AI chat can query databases. A new
query_databasetool 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_pageon a database now lists its properties with their types and ...
Leafdesk 0.3.0
Upgrading from 0.2.0
- New name and image: the project is now Leafdesk, published as
ghcr.io/esmworks/leafdesk.
Download the newdocker-compose.ymlinto the same folder as before (the folder name is the
Compose project name, so the existing volumes are found), and in.envrename
ESIONAGE_VERSIONtoLEAFDESK_VERSIONif 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 areleafdesk. Keep
COMPOSE_PROFILES=bundled-dband add
EXTERNAL_DATABASE_URL=postgres://esionage:<password>@db:5432/esionageto.env, with your
POSTGRES_PASSWORD(esionageif 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
uploadsvolume (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. SetTRUSTED_PROXIES=0when 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 MCPlist_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. MCPinvite_memberfollows the same rule (a
member's invitation there answersstatus: "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.tsandscripts/account-e2e.tsexpect the verification email. Migration
0030_membership_policies(workspace_join_request, notificationjoin_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=adminskeeps creating workspaces to
administrators (hidden from others and refused by the server; the personal workspace at sign-up
is still created). New commandpnpm 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_usersreturnsjoined_at;
newinvite_membertool (owners only, needs write access). New checks:scripts/admin-e2e.ts
(in CI),src/lib/instance-admin.test.ts, more inmcp-e2e.tsandtools.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), MCPsearchand RESTGET /search(each result hasmatch: "text"or
"semantic"). Pages are cut into chunks (title, a row's values, the body's blocks) whose
embeddings are stored inpage_chunkasreal[]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, withinAI_CONCURRENCYandAI_WORKSPACE_RATE_LIMIT; a workspace's first search
sweeps up pages the index missed, andpnpm search:indexbackfills 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. Migration0026_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 callsearch_pagesandread_page(up to five turns), which
run throughoperations.tsas 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, migration0027_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/sso1.7.6):
an instance-wide OpenID Connect provider fromOIDC_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/v2with workspace SCIM tokens (hashed, revocable):
Userslist/filter/get/create/replace/patch/delete, whereactive: falseremoves someone from
the workspace and owners can't be deactivated;Groupsin 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.
Migration0024_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/Groupslists (filterdisplayName,
externalIdoridwitheq, 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/replaceonmembers,removeon
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 400invalidValueand nothing of that request is
applied; a taken name is a 409. Changes run throughserver/groups.ts(a newchangeGroup
that renames and adds and removes people in one transaction, which the settings' rename, add
a...