v2.7.0
Two things Lific only sketched are finished in this release. Attachments stop being download chips: you can annotate a screenshot before it uploads, read a text file or a diff or a zip or a database without leaving the issue, and see every file a project holds in one place. And a project's blocking structure becomes something you can look at and edit, on a graph canvas where dragging one issue onto another links them. Around those: instance admins can manage members without shell access, the activity feed shows real diffs, the issue list points at what moved while you were away, and the CLI's remote backend reaches parity with the local one.
The other half of this release is a security and robustness pass, most of it contributed by @mjc across eight pull requests. Account recovery now revokes everything an account can act through rather than just its browser sessions. Every surface that could return an unbounded result, comment threads and search and exports and websocket traffic, is now bounded. And a long list of authorization checks that were being made against the wrong thing are now made against the right one. Several of these change a default or refuse a request that used to succeed, so read the Upgrading section before deploying this one.
Attachments
- One attach composer, everywhere a markdown body is edited. Issue descriptions, page content, comments, and now the new-issue form all share the same control: a file picker, drag and drop, paste, and an Attach button in the toolbar. Attaching on the new-issue form previously was not possible at all, so a screenshot had to wait until after the issue existed; uploads now start immediately, insert into the description as you go, and are linked to the issue when you create it. An upload that never gets referenced is swept after 24 hours.
- Uploads run in parallel with real progress. Up to three transfers at once, each chip showing byte-level progress with cancel and retry, and a failed upload stays on screen as a failure rather than disappearing. An image large enough to be near the instance's cap pauses on its chip before sending and offers to downscale to 2560px first, and remembers your answer for the rest of the session.
- A screenshot can be annotated before it leaves the browser. Paste, drop, or capture an image and a four-second prompt offers the editor: crop, pen, arrows, rectangles, undo, and a pixelation tool that destroys the underlying pixels rather than covering them, so a redaction survives being screenshotted back out. Annotation happens at full resolution on the original file.
- Alt text, from the chip, after the upload settles. Optional, on any image, without opening a dialog or hand-writing markdown.
- On a phone, Attach offers Files, Camera, and Record voice. Camera asks for the rear lens. Voice notes record through
MediaRecorderwith a timer, a level meter, and a preview before you commit, and the affordance is simply absent on a browser that cannot record rather than failing when tapped. - Files are readable in place instead of downloadable. Text renders with ANSI colour, in-file search, line selection, and linkable line anchors; unified diffs render as diffs; CSV and TSV render as tables; JSON renders as a tree; a zip lists its entries; a SQLite database lists its tables and row counts; audio and video play inline with seeking, backed by real byte-range responses. Anything without a viewer still gets the download chip it always had.
- Every project has a Files view at
/{PROJECT}/files, in both the desktop and mobile navigation. It lists every attachment the project holds with filters by type and uploader, sorting, a running total of bytes, and an expandable where-used list for each file. Files can be deleted from there or from an issue's attachment section, and a collapsed section at the bottom collects orphans, the uploads nothing references any more. - Attachments are searchable. By filename everywhere, and by extracted contents for text files up to 512 KiB, through the same search as everything else.
- Uploads are validated by magic bytes, not by what the client claims. The allowlist is PNG, JPEG, GIF, WebP, SVG, PDF, plain text, ZIP, MP4, WebM video and audio, Ogg audio, MPEG audio, and SQLite. The default ceiling is 10 MiB per file, uploads are rate-limited to 30 per user per 10 minutes, and the route itself refuses a body over 64 MiB before reading it.
- Images carry their dimensions and a thumbnail. Rasters are decoded on upload for width and height, and a 480px long-edge WebP thumbnail is stored alongside, so a list of forty files does not mean downloading forty full-size images. Blobs stay content-addressed by SHA-256 and deduplicated, so the same screenshot attached to five issues is stored once.
Dependency graph
- Every project has a Graph view. Issues render as clickable cards, blocking relations as edges, laid out left to right so blockers sit left of the work they hold up. The canvas pans and zooms (drag, pinch, ctrl+wheel, arrow keys), defaults to open issues with a toggle for closed ones, and follows dark and light mode.
- The graph is also where you edit relations. Drag from one card onto another and a menu asks which relation to create (blocks, blocked by, relates to, duplicate); click an edge to reverse or remove it. An Unlinked canvas collects the issues with no relations yet, ready to be wired in. Editing follows project roles, so viewers get a read-only canvas.
- Hovering a node previews the issue, a card with its description clamped to a few lines, so tracing a chain does not mean opening every issue along it. On touch, press and hold does the same for graph nodes, issue rows, and pages, which previously had no path to their hover previews at all.
Instance administration
- Manage members from the Instance settings roster. Instance admins can now create users, promote and demote admins, and deactivate or restore accounts from the web UI, with server-side guard rails: the last remaining admin cannot be demoted or deactivated, and bot identities are not valid targets. Deactivation ends access immediately and atomically, tearing down the account's sessions, API keys, and OAuth tokens in one write, and any bots the account owns stop authenticating until their owner is restored. Five new admin-gated REST endpoints back the UI.
- An instance set up entirely from the browser now has an admin. Fresh installs enforce authorization, but web signup never granted the admin role; the only grant path was
lific user promoteon the server's shell, which nothing in the UI mentions. On an instance with zero users, the first signup now becomes the instance admin, the standard self-hosted bootstrap. Any pre-existing account, CLI-created included, disables the grant. - The audit log can be pruned. A new
audit_retention_dayskey in the[backup]section deletes audit entries older than the window during the existing backup cycle. Unset or 0 keeps everything forever, which remains the default.
Web UI
- The activity feed shows real diffs. Description and content changes render as a per-line diff, added and removed lines tinted, long unchanged stretches folded behind a divider, instead of the previous two full-value blocks.
- The issue list shows what moved while you were away. Rows changed since your last visit get a small accent dot, and a toolbar chip counts them and cycles focus through them; viewing a row clears its dot. An agent closes three issues while you are at lunch, and the list points at exactly those three.
- The issue-list toolbar fits on phones. Below the small breakpoint it now keeps to a single row instead of wrapping into a fourth band of chrome, with saved views, sort, and display folded into an accessible overflow panel. Chrome above the first issue drops from 173px to under 90px on a 360px screen.
- Bottom sheets dismiss by swiping down on their header. The drag-handle pill on mobile sheets was decorative; it now tracks the finger, commits the dismiss past a threshold or on a downward flick, and springs back otherwise.
CLI
--backend httpnow renders exactly like the local backend. Human and JSON output are shared between the two paths for every data command, and remote exports unpack into the same on-disk markdown tree local exports write instead of leaving a ZIP behind. The client validates every server-supplied path before writing, refuses archive entries that try to escape the output directory, and caps archive entry counts and expanded size.
MCP tools
- Agents can work with attachments. Three new tools, bringing the surface to 30:
upload_attachmenttakes a filename and base64 content, optionally links the result to an issue, page, or comment, and returns the attachment id, its metadata, and a markdown snippet ready to embed. Same 10 MiB ceiling and same magic-byte allowlist as the web UI. An upload left unlinked says so, and expires in 24 hours.get_attachmentreads one back. Text comes by line withoffsetandlimit, defaulting to 200 lines and capped at 500, so a large log is paged rather than swallowed. A raster image (PNG, JPEG, GIF, WebP) comes back as viewable image content the model can actually look at. Everything else, SVG and PDF and audio and video included, returns its metadata and a download path rather than megabytes of base64.list_attachmentslists what one issue or page holds, or everything in a project, subject to the same project visibility as every other read.
get_planreturns full step descriptions. They were truncated at 100 characters, which made a plan step with real notes in it unreadable through the tool that exists to rehydrate a plan. Multi-line descriptions now come back whole, indented under their step. The compact echo that mutations return is unchanged.- MCP errors no longer describe the database. A failing tool call used to surface the raw SQLite error, which could carry table and column names, constraint text, and the database's path on disk. Tool errors are now generic, and the detail stays in the server's logs.
bulk_updateis one transaction. It used to commit each issue as it went, so a failure part-way through left some issues updated and some not, with no indication of where it stopped. The whole batch now lands or none of it does.
Identifiers
- Project identifiers resolve case-insensitively.
lific issue list --project lif,get_issue("lif-42"), and every other project, issue, or page lookup across the CLI, REST, and MCP now accept any casing, matching how modules, folders, and usernames have always behaved. As a consequence, creating a projectabcwhenABCexists is now rejected. Existing databases with case-colliding identifiers (only possible via raw SQL) are renamed deterministically on upgrade.
Security: credential transport and imports (PR #27 by @mjc)
- The CLI refuses to send an API key over plaintext HTTP to a remote host. What used to be a warning is now an error, and the error message never echoes the key. Loopback targets still work over plain HTTP, and unauthenticated plain-HTTP connections still warn rather than fail.
- Jira import validates the site name before sending credentials anywhere. The site slug becomes the hostname of every request, so a hostile value could have steered your Atlassian token to a host an attacker controls. It is now constrained to a single DNS-label-shaped slug, and the canonical slug is used consistently for both requests and imported identities.
Security: account recovery (PR #32 by @mjc)
Changing your password used to end your other browser sessions and nothing else. Every other way into the account survived it: the API keys, the OAuth sessions, the connected AI tools, and any long-running agent process already holding a token. Recovery now means what people assume it means.
- Changing your password, or signing out everywhere, revokes everything the account can act through. Sessions, API keys, OAuth access tokens, authorization codes that were issued but not yet exchanged, and device approvals that were granted but not yet collected, for you and for every tool you have connected. It commits as one write, so there is no moment where half of it has happened. A password change still leaves you signed in on the device you changed it from, with a fresh session. Unbound operator keys, which belong to whoever runs the server rather than to any account, are untouched.
lific user set-passwordcarries the same weight. An operator resetting a password for someone who has lost access now performs the same revocation, in one transaction with the password write, and says so in both its human and JSON output.- A long-running MCP agent stops at its next tool call, not at its next restart. A stdio agent authenticates once with
LIFIC_TOKENand can then run for days. That token is now revalidated before every tool call, so revoking the key, resetting the owner's password, or deactivating the account takes effect immediately. The tool does not run, and nothing is written. - An API key can no longer mint another API key or connect a tool. Creating a durable credential now requires a browser session that authenticated in the last 15 minutes. Previously a leaked key was a key factory: an attacker could mint a spare that outlived the revocation of the key they came in on. OAuth tokens are refused on those endpoints too, as of this release (see authorization boundaries below). Web UI behavior is unchanged, and
lific connectis unaffected. - Only a recently signed-in browser session can approve an OAuth connection. An OAuth access token used to be able to approve an authorization request or a device code, which let one connected tool authorize another and re-mint its way past a revocation. Approval now needs a browser session, and one that signed in within the last 15 minutes, since what it hands out is a 30-day credential.
- Every action that hands out lasting access now needs the same recent sign-in. Creating an account, granting instance admin, restoring a deactivated account, changing instance settings, adding someone to a project, raising an existing member's project role, naming a project lead (which grants that person a lead membership), and creating a project led by somebody other than yourself. Creating a project with no lead, or leading it yourself, is unchanged. Each of those leaves access behind that a lockdown on the credential which made it cannot reach, which made them the quiet way to keep a foothold after being locked out.
- Taking access away never asks. Demoting an admin, deactivating an account, lowering a project role, and removing a project member all still work from an API key with no recent sign-in, because those are what you reach for while an incident is in progress and they cannot be used to persist anything.
- Refusing a device connection request never asks for anything. Approving one still needs a recent sign-in, but denying is how you turn away a code you do not recognise, so it works from whatever session you have open and creates no connected-tool identity as a side effect.
- A new endpoint refreshes your own session and only your own.
POST /api/auth/me/refreshtakes the session you are holding and gives back a newer one for the same account, optionally after confirming your password. The web UI uses it to satisfy the recent sign-in requirement without sending you to the login screen. It replaces the previous approach of reusing/auth/auto-login, which mints a session for the instance's first admin and had already set a cookie by the time a multi-admin instance could notice the swap. - Changing your password is rate-limited too, on its own budget separate from signing in. It is the most expensive request in the API (two password hashes), so attempts reserve their slot before that work starts; a change that lands refunds it, and a wrong current password does not.
- Failed sign-ins are counted before the expensive part, not after. The limiter used to check whether an attempt was under the limit and record the failure afterwards, which bounds nothing when requests arrive together: any number could pass the check in the gap before any of them recorded, and all of them would then run the deliberately slow password hash. Attempts now reserve their slot up front, so at most the configured number of verifies can be in flight at once. A successful sign-in gives its slot back, so logging in correctly never eats into the budget that exists to slow down guessing.
- Changing your password no longer stalls the server while it hashes. Both hashes (checking the old password, computing the new one) now run with no database lock held, as login and signup also now do (see Fixes). The change itself still commits atomically, and it now refuses if another password change landed while it was working, rather than overwriting it.
- Signing in is atomic with checking your password. The Argon2 verify deliberately runs without holding a database lock, and a password change landing during those milliseconds used to still produce a full-length session (30 days by default) for the old password. Login now re-checks, in the transaction that mints the session, that the account is still live and the stored password hash is still the one it just verified. Signup is likewise one transaction, so two people racing to be the first account on an empty instance cannot both become its admin, and a failure part-way leaves no account without a session.
- The access-granting paths make their authorization decision from state read inside the transaction that acts on it. An admin demoted, or a project lead removed, while a request was in flight can no longer expand access on the strength of the snapshot taken when the request arrived. The same applies to the destructive side: reaching another account's API key or connected tool, demoting somebody, and deactivating an account all re-check the caller's admin status at the moment they act.
- Deactivating an account closes its live browser connections at once, and those of every tool it owns, instead of leaving them to notice at their next periodic check.
- The web UI asks rather than dead-ending. When one of these actions is refused for a stale sign-in, the connect dialog, the Members roster and a project's member list each show a single password field, name which action is waiting, resume exactly that one once you confirm, and leave your inputs and your session alone if the password is wrong. Passwordless instances refresh silently, and fall back to the password prompt if that does not work or signs in as a different admin. The retry runs once, never in a loop, and a session belonging to another account is never adopted.
- Grants cannot outlive the authorization that produced them. Approving a connection and exchanging the resulting code are each a single transaction, and the exchange re-checks that the identity the grant names may still authenticate. A code or device approval that was invalidated by a recovery returns
invalid_grantoraccess_deniedinstead of quietly minting a 30-day token. - An OAuth grant that names nobody can no longer be exchanged. Authorization codes and device approvals predating 2.1 carry no identity, and exchanging one minted an access token that named nobody, which the server then treated as the operator and which no account recovery could revoke, because a recovery works by identity. Both exchanges now refuse an unbound grant outright.
- Reconnecting a tool after a recovery works again. API key names are unique across the whole instance, and a revoked key kept its name reserved, so reconnecting the tool whose key had just been revoked failed on a database constraint. A name held only by a revoked key is now released when its own owner claims it again. An active key of the same name is still refused, a name another account's revoked key still holds is refused rather than taken, and a connected tool's live credential is never rotated out from under it.
- The web UI no longer strands itself after a password change. It now adopts the replacement session the server returns, states the full consequence next to both actions, and asks for confirmation before signing out everywhere. Connected tools are re-read after a password change so they show as disconnected rather than claiming to still be connected.
Security: authorization boundaries (PR #31 and PR #33 by @mjc)
A set of checks that were being made against the wrong thing: the object rather than the project that owns it, the request's opening snapshot rather than the state at the moment of the write, or the bind address rather than where the instance can actually be reached from.
- Editing or deleting a comment now requires access to the project it lives in, not just authorship of the comment. Comment mutations checked who wrote it and whether the caller was an admin, and stopped there. Someone removed from a project kept the ability to edit and delete every comment they had left in it, and because comment ids are global, could also confirm a comment's existence in a project they had never been in. Both REST and MCP now resolve the parent project and apply the ordinary read gate before the ownership check.
- An upload cannot link itself to something you cannot write to. The entity an upload asked to attach to was taken on trust, so a caller could link a file to any issue or page by id. The target is now authorized before the bytes are stored (maintainer for issues and pages, viewer for comments, instance admin for a page with no project), and a refusal leaves no blob and no row behind. The check happens inside the transaction that writes the link, so it cannot be beaten by having access removed in the gap between the two.
- Attachment references cannot cross a project boundary. An issue, page, or comment body could reference an attachment belonging to a project the reader had no access to, and the where-used, thumbnail, and preview responses would then describe that project back to them. References are now confined to the owning project, and every linked-entity list is filtered by the caller's visibility.
- A plan step cannot reach an issue in a project you cannot see. Plan steps mirror issues, and the link was accepted for any issue id. Through REST, completing the step would then modify that issue; through MCP, reading the plan disclosed the issue's identifier and status. Linking now requires maintainer access to the linked issue's own project, checked both when the link is made and when the step completes, and a parent step from a different plan is rejected outright.
- A search result cannot distinguish a project you cannot see from one that does not exist. Filtering by a project identifier returned a different response depending on whether the project existed, which turns search into a way to enumerate the instance's projects. Both cases now return empty.
- An OAuth token cannot manage credentials. OAuth access tokens are what a connected AI tool holds, and they were accepted on the routes that create and revoke API keys, manage connected tools, change a password, revoke sessions, edit a profile, administer users, and manage OAuth clients and tokens. A tool granted access to your tracker could therefore mint itself a permanent API key that survived disconnecting it. Those routes now answer 403 to an OAuth token. Ordinary reads and writes through MCP are unaffected.
LIFIC_TOKENis bound to an origin. The token followed whatever URL won CLI resolution, so running a command in a directory whoselific.tomlpointed at a different server sent your token to that server. It is now sent only when the target's origin matchesLIFIC_URL; otherwise the CLI uses your stored credentials for that host and says why.- A Mermaid diagram cannot inject HTML through its own parse error. The sanitizer runs on the diagram source, but a parse failure built the error node by interpolating the offending text into
innerHTMLafterwards, which put attacker-controlled markup on the page past the sanitizer. Anyone who could edit a markdown body could use it. The error node is now built withcreateElementandtextContent. - Passwordless mode is guarded on reachability, not on the bind address. Turning authentication off, or enabling web auto-login, was allowed whenever the bind host looked local. That is the wrong question when something in front of the server publishes it: an instance bound to
127.0.0.1and served to the internet through Tailscale Funnel or a same-host reverse proxy passed the check while being fully public. The guard now considerspublic_urlas well, and enabling auto-login on an instance that declares a public URL is refused with a 400 and not persisted. - Authorization is re-read inside the transaction that acts on it. Both the granting side and the taking-away side: an admin demoted while a request was in flight can no longer complete an access-expanding write on the strength of the snapshot taken when the request arrived, and reaching another account's API key, demoting somebody, or deactivating an account all re-check the caller at the moment they act.
Comments (PR #34 by @mjc)
- A comment body is capped at 256 KiB. The limit is checked after Lific normalizes escaped newlines and tabs, so it bounds what actually lands in the database rather than what was typed. A create or edit past it is rejected and leaves the stored comment untouched. Comments already larger than the cap stay readable, and editing one down to a smaller body works.
- No surface returns an unbounded comment thread any more. Every comment list is a page of at most 500. REST keeps its documented
order=ascdefault and now defaults tolimit=50; the MCPlist_commentstool andlific comment listdefault to the 50 newest, which is the half of a long thread anyone actually wants. The MCP tool and the CLI print the exact offset for the next page when one exists, and the CLI says so explicitly when a remote lookup could not determine whether more comments follow, rather than letting a full page read as a finished thread. get_issueno longer loads a whole thread to print three comments. Each comment mode now reads only the rows it renders:noneloads none and reports the count,recentloads three, andinclude_comments='all'is capped at the most recent 500 with a header saying so and pointing atlist_comments. Given that a single comment can be 256 KiB, the tool an agent calls most often was the worst place for an unbounded read.- Reading a busy issue in the web UI no longer means loading every comment. Issue and page detail open on the newest 50, still in reading order, with a "Load older comments" control for the rest. Opening a link to a comment further back walks the pages in automatically, up to 250 comments, and scrolls to it; past that the reader continues by hand, so a link to a deleted comment cannot crawl a whole thread. The preview panel's comment count reads
50+when it is showing a bounded page rather than passing a page length off as a total. - Comment lists accept a keyset cursor.
before_created_atplusbefore_idreturns the comments strictly older than one you have already seen. Offsets drift under a thread that is being written to: one comment posted above a reader shifts every offset by one, so the next page repeats a comment or skips one. The cursor names a position instead, and the web UI pages with it. The parameters are optional, the response shape is unchanged, and existinglimit/offsetclients are untouched. - A background refresh reconciles every comment page on screen, not just the newest one, so a comment somebody else edited or deleted further up a long thread stops being frozen at the moment you first loaded it. The refresh never fetches more than you already had loaded.
Resource limits
The comment bounds above are one instance of a pattern this release works through everywhere: a request whose cost is set by the caller rather than by the server. Three more surfaces had the same shape.
Live updates (PR #28 by @mjc). A single account could open as many websockets as it liked, each one a task, a receiver, and a file descriptor, and per-account accounting would not have bounded the instance anyway. Inbound messages had no size limit, so a large frame was a large allocation in the parser. Worst of all, an outbound send had no timeout, so a peer that stopped reading held its socket, its task, and its permit open indefinitely, and even the close frame could hang.
- Sockets are capped at 16 per user and 1,024 for the instance, held as RAII permits and released when the connection actually ends. A refused connection gets a 429.
- A client message is capped at 16 KiB and a single frame at 4 KiB, and a client may send 64 messages per 10 seconds.
- Every outbound send, the close frame included, times out after 5 seconds and drops the connection.
- Passive clients stop being disconnected. Liveness used to depend on the application sending its own heartbeat, which nothing documented, so a client that simply listened was dropped after 120 seconds. The server now sends protocol-level pings every 30 seconds, and a conforming client stays connected without doing anything.
Search (PR #30 by @mjc). Visibility was applied after ranking and paging rather than before, and combined entity-plus-attachment paging fetched offset + limit rows from both indexes and merged them in memory, which grows with the offset the caller asks for.
- Results that should not have been visible are gone. Search could return hits from projects outside the caller's visible set, and an attachment linked to both a visible and a hidden project rendered the hidden project's identifier and title. Visibility is now part of the query, applied before ranking, sorting, and pagination.
- Page size defaults to 20 and is capped at 500. Offsets clamp at 100,000. A full-text query is capped at 4 KiB and a literal substring query at 256 bytes, with an explicit 400 rather than a slow failure past that.
- A literal search stops at 10,000 matches instead of scanning to the end of the table.
- Combined paging fetches only the shortfall it actually needs.
Exports (PR #36 by @mjc). An export built the whole tree in memory before sending any of it, and an HTTP client that stopped reading held its export slot open, so a couple of stalled downloads could stop anyone else exporting. On the receiving side, extraction trusted the archive's declared entry count and expanded size, which is the classic zip bomb.
- Everything an export materializes is now counted. It previously asked for at most 10,000 issues and then loaded every comment on each of them with no limit at all, so the real ceiling was however much memory the machine had. The bounds are now 10,000 files, 1,000 comments per issue, 100,000 comments per project, 50,000 metadata items, 8 MiB per file, and 128 MiB in total, and an export past them fails with an explicit error instead of trying.
- Two exports run at a time. The HTTP download streams in 64 KiB chunks and is dropped after 30 seconds idle or 30 minutes in total, so a stalled client is reaped rather than parked on a slot.
- A refused export answers 429 with an honest
Retry-After: 30instead of leaving the client to guess. - Extraction accepts at most 10,000 entries expanding to at most 512 MiB, and every output path is checked for containment and for symlinks before anything is written to it.
Database and migrations
- A database written by a newer Lific will not be opened. Migrations only ever ran forwards, so pointing an older binary at an upgraded database found the version already stamped, applied nothing, and came up serving and writing a schema it was never compiled against. Startup now fails with an error naming both the database's schema version and the highest the binary knows, and telling you to use a binary that supports it or restore a backup from before the upgrade. Running the same binary again is still a no-op, as it always was.
- Applied migrations are checksummed. Each migration's SQL is hashed on application (with line endings normalized, so a checkout on Windows does not read as a modification), and a stored hash that no longer matches is a hard startup failure naming the migration and both digests. Editing a migration that has already run somewhere is the failure this catches, and it used to be silent. Existing databases have the column added and their hashes backfilled on first boot, so nothing fails on upgrade.
- Two processes starting at once cannot both run the same migration. Migration now takes an immediate transaction, so discovery and application are serialized across processes rather than racing. Foreign key enforcement is preserved across the rebuild migrations that have to disable it.
- A backup schedule that would have deleted everything is refused.
[backup] retain = 0reads as "keep no history" but meant "keep nothing at all", because rotation runs at the end of each cycle and would delete the archive that cycle had just written.interval_minutes = 0was worse: it panicked the backup task on the first tick, and because the timer is built inside the spawned task, backups died silently while the server carried on serving. Both now fall back to the default (24 archives, every 60 minutes) with a warning naming the value that was ignored. Turning backups off is stillenabled = false.
Rate limiting and proxy trust
server.trusted_proxiesnow defaults to trusting nothing. It shipped in 2.2 defaulting to loopback, which meant anX-Forwarded-Forheader from any process on the same machine was believed. The default is now empty, and a header is honoured only when the immediate TCP peer is in the list, walking the chain from the right. This needs action from anyone whose proxy connects over loopback, which includes Tailscale Serve and Funnel and a same-host nginx: list that proxy explicitly, or every client behind it shares one rate-limit bucket.- The rate-limit table is now actually bounded. Its 10,000-identity constant was only a trigger to sweep expired entries, not a ceiling: the sweep ran and the new identity was then inserted regardless, so a stream of distinct attacker-chosen identities grew the table without limit. It is now a real cap. Expired entries are swept at most once per window, live entries are never evicted (so nobody can flush their own failed attempts by generating traffic), and a full table fails closed on the login path: an identity that cannot be admitted gets the ordinary "too many login attempts" response rather than an untracked free pass. Keys longer than 1,024 bytes are refused outright.
- A rate-limit response with no known retry delay says "try again later" rather than telling you to try again in 0 seconds.
- Login is limited to 5 attempts per 15 minutes, counted per identity and per source IP.
API
GET /api/auth/menow answers 403authentication requiredwhen unauthenticated, like every other endpoint. It was the one endpoint that escaped the v2.6.0 consolidation and still returned a 400.- New attachment endpoints, backing the viewers and the Files view:
GETandPOST /api/attachments,GET,PATCH, andDELETE /api/attachments/{id},GET /api/attachments/{id}/thumbnail,/links, and/preview, andGET /api/projects/{id}/attachmentsand/attachments/orphans. Media requests answer byte ranges, so a video seeks instead of downloading whole. - Five admin-gated endpoints back the instance member roster described above.
POST /api/auth/me/refreshis new, described under account recovery.
Fixes
- Duplicate agent identities can no longer be minted by two simultaneous connects: bot uniqueness per (owner, tool) is now enforced by the database, and upgrading merges any existing duplicates into the oldest bot without losing memberships, groups, or saved views.
- Session validation no longer takes the database's single writer lock on every request. Expired sessions are swept at login and logout instead, so authenticated traffic reads concurrently.
- Signing in and signing up no longer stall the server while they hash. Argon2 is deliberately slow, and it was running while the single writer lock was held, so a burst of logins blocked every unrelated write on the instance. The hash now runs on the blocking pool with no database connection held, and only the lookup and the session insert take the lock.
- Reversing a relation is a single server-side operation. The web UI did it by deleting the old edge and creating the reversed one as two separate calls, so a failure on the second left the relation gone entirely with nothing to undo from. It now swaps direction inside one savepoint, authorized as one action, and answers 404 if the edge is not there.
- A folder that fails to delete stays on screen. The page list removed the folder and reparented its pages locally before the server had agreed, so a refused delete left the sidebar showing a structure the server did not have. The tree now changes only after the write lands, and a failure raises a toast.
- The issue-list overflow control is a real disclosure. It announced itself as a menu without behaving like one, and focus never entered the panel it opened. It now carries disclosure semantics, moves focus into the panel, and returns focus to the trigger on close.
- A very large description no longer makes the activity diff expensive. The line diff bounds its comparison table by area rather than by line count, so a single enormous line still diffs while a pathological pair cannot allocate without limit.
- An IPv6 loopback bind (
host = "::1") now prints valid URLs likehttp://[::1]:7777instead ofhttp://::1:7777in init output,service status, the doctor, and the OAuth issuer. - A swipe-dismissed sheet no longer flashes back into place for a frame before unmounting.
- The single-page-app fallback is no longer served from a stale cache after an upgrade.
- The test suite creates its scratch directories through
tempfile, so an aborted run cleans up after itself instead of poisoning the next one.
Upgrading
Take a backup first. This release adds a downgrade guard, so once an instance has started on 2.7 an older binary will refuse to open its database. That is the intended behaviour, but it means rolling back is a restore rather than a swap.
- Five migrations run automatically on first start: the bot-identity unique constraint (merging any existing duplicates), case-insensitive project identifiers, the account active flag behind deactivation, attachment dimensions and alt text, and the attachment search index. The
_migrationstable also gains a checksum column and has its existing rows backfilled. No manual steps. - Review
server.trusted_proxiesbefore you start 2.7. The default changes from trusting loopback peers to trusting nothing. If your proxy reaches Lific over loopback, which covers Tailscale Serve and Funnel and a reverse proxy on the same host, add it to the list explicitly. Left unset,X-Forwarded-Foris ignored and every client behind that proxy is rate-limited as one identity. - An older binary will not open a 2.7 database. Startup fails with an error naming both schema versions. Recover by going back to the 2.7 binary or restoring the backup you took.
- A modified migration file is now a startup failure. If you have ever hand-edited a migration that already ran on this database, 2.7 will refuse to start and name it. Restore the original file; corrections belong in a new migration.
[backup] retain = 0and[backup] interval_minutes = 0now log a warning and use the defaults (24 archives, hourly) instead of silently deleting every backup or killing the backup task. Setenabled = falseif you want backups off.- If a script drives
--backend httpwith an API key against a remotehttp://URL, it now exits with an error instead of proceeding past a warning. Switch the target tohttps://or a loopback address. LIFIC_TOKENis only sent to the origin inLIFIC_URL. A script that sets the token and then overrides the target with--url, or runs inside a directory holding alific.tomlfor a different server, now falls back to stored credentials for that host instead of forwarding the token. Set both variables to the same origin.- Audit retention stays off unless you set
audit_retention_days; nothing is deleted by default. lific comment listis now bounded and newest-first. A script that relied on it printing an entire thread oldest-first needs--order ascand its own--offsetloop.- A client reading
GET /api/issues/{id}/commentswithout alimitnow gets 50 comments instead of all of them. The ordering is unchanged (asc), so the page is the oldest 50; page withoffset, or passorder=descfor the newest. get_issuewithinclude_comments='all'returns at most the 500 most recent comments. Threads longer than that needlist_commentsto page the remainder.POST /api/auth/keysandPOST /api/auth/botsno longer accept an API key. They require a browser session token created within the last 15 minutes. A script that minted keys by presenting an existing key now gets 403recent authentication required. Mint keys withlific key createon the server, or from the web UI.lific connectwrites to the database directly and is unaffected.- A password change or sign-out-everywhere now revokes API keys, OAuth sessions, and connected tools, for the account and for the tools it owns. Reconnect each tool afterwards (
lific connect, or the Connected tools section of Settings) and replace any API key a script depends on. The same is true oflific user set-password. - A running stdio MCP agent now fails its next tool call after its key is revoked, with a message telling it to run
lific connectand restart. Previously it kept working until the process was restarted. - OAuth access tokens can no longer approve an authorization request or a device code, and the approving browser session must have signed in within the last 15 minutes. Approve from a signed-in browser.
- Access-expanding endpoints now require a browser session created within the last 15 minutes and answer
403 recent authentication requiredto an API key or OAuth token. This covers key and bot creation, account creation/promotion/reactivation, instance settings, project-member additions and role increases, creating a project led by another user, and assigning a project lead later. Scripted provisioning moves to the corresponding local CLI commands. Reductions such as demotion, deactivation, role downgrade and member removal are unchanged. - An OAuth approval from a browser session older than 15 minutes is refused. The page explains that you have to sign out and back in (revisiting the login page will not help, because the old session is still valid) and then restart the connection from your MCP client. The approval is not resumed for you.
POST /api/auth/me/refreshis new. Nothing needs to change to adopt it; the web UI uses it automatically.- An OAuth authorization code or device approval that is not bound to an identity, which only rows stored before 2.1 are, now returns
invalid_grantat exchange. Codes expire within fifteen minutes, so this affects only a client caught mid-flow across the upgrade: authorize again. Access tokens already issued are unaffected; revoke any unbound one and reconnect the tool. - An OAuth access token is refused on the credential-management routes, which is every route that creates or revokes API keys, manages connected tools, changes a password, revokes sessions, edits a profile, administers users, or manages OAuth clients and tokens. A connected tool that was calling those endpoints now gets a 403 and needs a browser session or a local CLI command instead. Ordinary reads and writes through MCP are unchanged.
- Editing or deleting a comment now also requires access to its project. A client acting on comment ids for a project it has since been removed from will start getting refusals. This applies to REST and MCP alike.
- Search is bounded. Page size defaults to 20 and caps at 500, offsets clamp at 100,000, a full-text query over 4 KiB or a literal query over 256 bytes returns 400, and a literal search stops at 10,000 matches. A client that paged past the clamp, or leaned on unbounded literal scans, needs narrower queries. Results from projects the caller cannot see are gone, which may legitimately shrink result counts.
- Exports are bounded and concurrency-limited. The per-project ceilings are 10,000 files, 1,000 comments per issue, 100,000 comments per project, 50,000 metadata items, 8 MiB per file, and 128 MiB total, all well above the previous 500-file and 500-comment limits. Only two exports run at once and the rest get a 429 carrying
Retry-After: 30, so an export client needs to honour it. A stalled download is dropped after 30 seconds idle, and no export runs longer than 30 minutes. - Websocket clients have limits. 16 sockets per user and 1,024 per instance, with a 429 past that; a 16 KiB message and 4 KiB frame cap; 64 messages per 10 seconds; and a 5-second send timeout. A custom client sending oversized or high-rate messages needs adjusting. Clients that only listen benefit: server-side pings every 30 seconds mean they are no longer dropped after 120 seconds for not sending an application heartbeat.
- MCP tool errors are now generic. A script matching on the text of a database error will no longer find it. The detail is in the server log.
- Enabling web auto-login is refused on an instance that declares a
public_url, with a 400, and the setting is not persisted. Passwordless mode is for instances that are genuinely not reachable from elsewhere; a loopback bind published through a tunnel no longer qualifies. - Uploads are validated against the file's actual bytes rather than the declared content type, capped at 10 MiB by default, and rate-limited to 30 per user per 10 minutes. A client uploading a type outside the allowlist will now be refused.