Changelog

Every released version of Kandan, newest first.

0.8.0

New

  • Serve a public API reference for the /v1 API: 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/tokens mints a named token stored as agent:<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 in docs/reference.md.
  • Attach a group to one install of a source instead of every install: POST /groups/{id}/plugins/{pluginId} takes an optional team_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/preferences read 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 as null clears 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 carries team_plugin_id now, 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 with MAX(id) — and the id is a UUID, so Postgres answered function max(uuid) does not exist and every authenticated request returned 500. It now orders by created_at instead. SQLite accepts MAX on 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/login answers { access_token, token_type, user } at the top level — the same shape as /v1/auth/token and /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 through loginWithToken, which is why only password sign-in broke. resetWebhookToken() had the mirror-image mistake: it read the payload unwrapped while the API wraps it in data, 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/checkout returns a hosted checkout URL for the monthly or yearly plan, GET /api/v1/billing/portal returns a customer portal URL, and POST /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 subscriptions table records the plan, status and current period end per user, an admin can mark a user billing_exempt, and /api/v1/me returns subscribed, billing_exempt, plan, subscription_status and current_period_end alongside 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:setup creates 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, --yearly and --currency, in the currency's minor unit — defaulting to EUR 10 a month and EUR 96 a year. It follows POLAR_SANDBOX unless --sandbox forces 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, destination and the untouched provider data under raw — signed as X-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-hex Signature header are gone. PUT /api/v1/forward-destinations/{id} now merges the submitted config into the stored one, so an update that omits the write-only secret keeps 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 teamsUi flag, 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 /subscribe page 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 own hasAccess(), and the API never called the method — it keeps ingesting regardless. Admin and /api/v1/me read billing_exempt and the subscription relation directly, as they already did.
  • billing:setup no longer sends organization_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 and POLAR_ORGANIZATION_ID is gone from the config and the env template.
  • billing:setup survives 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 the POLAR_WEBHOOK_SECRET line to set, an APP_URL that is not https skips registration with the polar listen command 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.store config that panicked on start, an SSE URL missing its /api prefix, 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.avatar to 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 /changelog page, built at build time from the four apps' CHANGELOG files and grouped by version.
dc2adff · 2026-10-21T13:56:42.313Z