Changelog
Every released version of Kandan, newest first.
0.8.0
New
- Serve a public API reference for the
/v1API: Scalar UI at/docs, OpenAPI JSON at/docs/api.json.
0.7.1
Fixes
- The API container now lets FrankenPHP grow to 64 PHP threads on demand, so open notification streams no longer make every other request wait 10-25 seconds.
- Lists now say they are loading until their data arrives, instead of briefly showing "All caught up!" or another empty message. When a load fails they say so and offer Retry. The events panel reads "Connecting…" until the live feed connects.
- A notification's title now links to its source (the action URL) and opens it in a new tab. Only http and https links are followed; anything else stays plain text.
0.7.0
New
- Every source page now opens with a setup checklist: Connect, the webhook URL and each required secret, worked out from the plugin's manifest, with each step's action inline and the next one highlighted. It collapses to "Set up ✓" once everything is done, and the existing sections sit below it under Settings. The webhook step ticks once the first delivery arrives; resetting the token unticks it.
Fixes
- Cirrux: remove the "Workspace API key" field from the source page. Cirrux rejects workspace keys on its email endpoints, so Connect is now the only way Kandan fetches mail.
0.6.1
Fixes
- Desktop: fix the tray app freezing when a notification arrived while the tray window was talking to the app. The stream now hands each event to the main thread instead of waiting on it.
0.6.0
New
- Sources now have icons. Each plugin ships a default (GitHub, Gmail, Outlook, Cirrux, Kandan) and every install, generic webhooks included, can set its own https icon URL on its source page. The icon shows next to the source on the web and as the thumbnail on desktop banners once the desktop has cached it; a banner never waits for its icon, so a broken or unreachable one simply shows no thumbnail.
0.5.1
Fixes
- Desktop: tray links to the web app (Settings…, a source that needs attention, Subscribe) now open the browser already signed in. The desktop hands the browser a single-use, 60-second code, never its token; a browser that is already signed in keeps its own session.
- Desktop: recent notifications now sit in a "Recent" submenu in the tray instead of filling the top-level menu.
- Desktop: add a "Settings…" item to the tray menu that opens your account page in the browser.
- Desktop: keep running when the hidden window closes, start at login, and write a log to ~/Library/Logs/com.kandan.desktop/ so an unexpected quit leaves a trace.
0.5.0
New
- An agent can get its own API token.
POST /v1/me/tokensmints a named token stored asagent:<name>that may only install sources and manage groups; every other authenticated route now requires a full-access token, which is what login, PKCE and register have always issued. Installing, uninstalling and configuring a plugin also check the team permission, so a viewer can no longer install one. Installs and groups record which token made them, and every activity row the token writes carries its id and name. The recipe is indocs/reference.md. - Attach a group to one install of a source instead of every install:
POST /groups/{id}/plugins/{pluginId}takes an optionalteam_plugin_id, and notifications from that install file under the narrowed group rather than the plugin-wide one. - Snooze duration and which buttons a desktop banner carries are now stored preferences instead of fixed choices. Three new endpoints under
/v1/preferencesread and write them: a value for you, and an override for a single installed source, so a deploy webhook and a digest webhook of the same kind can behave differently. Sending a key asnullclears that one override; anything outside the allowed values, or under a key that does not exist, is rejected rather than stored. The SSE notification frame also carriesteam_plugin_idnow, so a client can tell which install raised an item without a second lookup. - Notification banners carry actions. Clicking the banner body still opens the linked page; the buttons mark the item read or snooze it, and the tray count follows either way. Which buttons appear and how long a snooze lasts come from the web app, per source, defaulting to Snooze for an hour. Banners are now Alerts, so they stay on screen with their actions reachable instead of sliding away after a few seconds.
- Sources that need attention are hard to miss. The web app shows a banner above every page while any source has an open problem, naming it and linking to its page; several collapse into "N sources need attention". The tray icon turns orange while a source has a problem, with a "Needs attention" menu item per source, and red while the app cannot reach the API. A disconnected source now raises a native banner that opens its page when clicked, instead of opening the browser on its own.
- The sessions page mints agent tokens. Name one, hit Create, and the plaintext is shown once with a copy button; tokens named
agent:*carry an Agent badge in the list and are revoked like any other session. - Account → Preferences gains two settings for desktop banners: "Snooze for", choosing how long the Snooze button puts a notification away — 15 minutes through 8 hours — and "Banner button", choosing whether a banner offers Snooze, Mark read, or both. Each source page carries the same pair, starting on "Use my default", so one noisy source can differ from everything else and go back to the default by picking it again. The inbox follows the setting as well: a snooze button now says how long it will snooze for — "snooze 15m" — and uses that source's own duration instead of a fixed hour, and "Snooze All 1h" is now "Snooze all", each item taking its own.
Fixes
- Signed-in requests no longer fail on Postgres. The subscription relation used
latestOfMany(), which breaks ties withMAX(id)— and the id is a UUID, so Postgres answeredfunction max(uuid) does not existand every authenticated request returned 500. It now orders bycreated_atinstead. SQLite acceptsMAXon a text column, which is why the tests never caught it. - The tray now follows read, snooze and undeny changes made elsewhere, such as marking an item read in the web app, instead of staying stale until the next new notification.
- Email and password sign-in works again.
ApiClient.login()was typed and read as{ data: { token } }, but/v1/auth/loginanswers{ access_token, token_type, user }at the top level — the same shape as/v1/auth/tokenand/v1/auth/register— so the web app threw "Cannot read properties of undefined (reading 'token')" and never stored a token. Google and GitHub sign-in go throughloginWithToken, which is why only password sign-in broke.resetWebhookToken()had the mirror-image mistake: it read the payload unwrapped while the API wraps it indata, so resetting a webhook token blanked the displayed URL instead of showing the new one. Both now match the API.
0.4.0
New
- The admin panel can see and change who pays. A user's edit form carries a billing exempt toggle, the list and detail views show the subscription status as a badge alongside the plan and renewal date, and the list query eager loads the subscription so the column costs no extra query per row.
- Billing gets its endpoints.
POST /api/v1/billing/checkoutreturns a hosted checkout URL for themonthlyoryearlyplan,GET /api/v1/billing/portalreturns a customer portal URL, andPOST /api/v1/hooks/billing/{provider}takes subscription events — verified against the Standard Webhooks signature before anything is written, logged like every other webhook, and answered with 202 whether it was applied, ignored or aimed at a customer we do not know. - Kandan now keeps subscription state of its own. A
subscriptionstable records the plan, status and current period end per user, an admin can mark a userbilling_exempt, and/api/v1/mereturnssubscribed,billing_exempt,plan,subscription_statusandcurrent_period_endalongside the existing fields. Payment processors sit behind a two-method seam — make a checkout URL, turn a webhook into a status change — with Polar as the one implementation. php artisan billing:setupcreates the two personal plan products in Polar and registers the webhook endpoint, then prints the product ids to paste into the environment. Prices are command options —--monthly,--yearlyand--currency, in the currency's minor unit — defaulting to EUR 10 a month and EUR 96 a year. It followsPOLAR_SANDBOXunless--sandboxforces it, and asks before it creates anything in a production organization.- Forwarded webhooks now send a normalized envelope —
id,source,title,body,actor,priority,url,created_at,forwarded_at,destinationand the untouched provider data underraw— signed asX-Kandan-Signature: sha256=<hex HMAC-SHA256>over the exact bytes sent, the same format incoming generic webhooks use. Breaking for existing webhook receivers: the old flat keys (notification_id,plugin_id,action_url,payload) and the old bare-hexSignatureheader are gone.PUT /api/v1/forward-destinations/{id}now merges the submittedconfiginto the stored one, so an update that omits the write-onlysecretkeeps it rather than dropping it. - The tray asks for a subscription. An account with neither a subscription nor an admin exemption gets a menu offering the web subscribe page and a sign out, instead of the inbox — and the tray now refreshes the signed-in user along with the counts, so a plan bought in the browser reaches the menu bar without signing out and back in.
- The front page says what Kandan costs: one personal plan at EUR 10 a month or EUR 96 a year, with the invite-only status note kept where it was.
- Manage forward destinations in the app: a Destinations page next to Rules and Groups lists every webhook, Slack and Discord destination with its failure count and auto-disabled state, and can create, edit, enable, disable, test and delete one — a failed test shows the delivery error inline. A rule's Forward action now picks its destination from a dropdown instead of asking for a raw ID, and the rules list names the destination it forwards to.
- Nothing in the app mentions teams while Kandan sells one personal plan. The Team Ownership blocks on Profile and Delete Account, and the Teams and Members panels on the debug dashboard, sit behind a single
teamsUiflag, and the delete-account warning no longer lists teams among what goes. The components, routes and API are untouched — flipping the flag brings all of it back. - The web app asks for a subscription. A new
/subscribepage offers the monthly and yearly personal plan and sends the customer to the processor's checkout, returning to/subscribe?done=1, which keeps asking for the session until the webhook lands rather than claiming the payment failed. Signed-in accounts with neither a subscription nor an admin exemption are redirected there from the app, and Account gains a Billing tab showing the plan, its status and renewal date, with a link into the processor's portal.
Fixes
User::hasAccess()is gone. The subscribe wall lives in the clients, which ask@repo/api-client's ownhasAccess(), and the API never called the method — it keeps ingesting regardless. Admin and/api/v1/mereadbilling_exemptand the subscription relation directly, as they already did.billing:setupno longer sendsorganization_id. Polar rejects the field when the request carries an organization access token, which is the only kind Kandan uses, so the product and webhook-endpoint calls now let the token name the organization andPOLAR_ORGANIZATION_IDis gone from the config and the env template.billing:setupsurvives Polar's webhook endpoint rules. A secret shorter than the 32 characters Polar demands — or none at all — is replaced with a generated one and printed as thePOLAR_WEBHOOK_SECRETline to set, anAPP_URLthat is not https skips registration with thepolar listencommand to use locally instead of failing, and the two product ids are printed as soon as the products exist, so a webhook step that goes wrong no longer costs them.- Only delivery frames on the notification stream carry an
id. State changes and connection events used to carry their own, so a client resuming after the five-minute reconnect could pick an older id as its resume point and get every newer delivery replayed as a fresh notification, which the desktop showed as duplicate banners. - Kandan gets its mark: a source dot and two arcs on the diagonal. The desktop app icon and menu-bar template use it, and the web app and site carry it as a favicon that follows light and dark mode.
- Desktop REST calls go through Rust. WebKit suspends networking for the hidden tray window, so the tray count and Recent list never refreshed after a notification arrived; the app now fetches via the Tauri HTTP plugin. The queries themselves also stalled: TanStack Query pauses retries for a window it thinks is backgrounded, which the tray window always is, and the first request went out before the stored token had loaded.
- The menu-bar glyph draws again and sits at the size of the system glyphs beside it. The count reads 0 instead of disappearing when the inbox is clear, and the stream starts before the tray is built so a tray failure can no longer take the banners down with it.
- The subscribe page stops asking eventually. After a checkout it polls for the payment confirmation every two seconds for a minute, then says so in one line and offers a Refresh that restarts the poll once, instead of leaving the tab refetching the session forever when a webhook never arrives.
- The authed layout asks
wallDecision()whether to wait for the session, send the visitor to/subscribe, or render the app, instead of spelling the rule out inline. Same behaviour, now a pure function the tests can hold to account.
0.3.1
Fixes
- Attach a notification's auto group to an existing group of the same slug instead of failing the webhook with a unique-constraint violation.
- The tray menu shows the app version, and the Tauri bundle version now follows
package.json, so it moves with every release instead of staying at 0.1.0.
0.3.0
New
- Native macOS banners for every notification the stream delivers, with the notification's action URL opening in the browser when the banner is clicked. Getting there also fixed four things that stopped the tray app working at all: a stale
plugins.storeconfig that panicked on start, an SSE URL missing its/apiprefix, a 401 on an anonymous request wiping the stored session, and no reconnect after the API's five-minute stream window closed.
Fixes
- Stream state changes that land in the same second as the SSE poll's checkpoint, so a "Mark all read" reaches open clients live instead of waiting for a reload.
- Widen
users.avatarto text: Google now returns avatar URLs longer than 255 characters, which made the first login of every new Google account fail with a 500 on the callback. - Read the notification stream in Rust instead of the webview. WebKit suspends the page whenever the tray window is off screen, so the stream stopped after its first frame and neither the tray counts nor the banners updated on their own. The frame handling in the webview is unchanged.
0.2.0
New
- Add a
/changelogpage, built at build time from the four apps' CHANGELOG files and grouped by version.