ClipSpacesClipSpaces Docs
Back to app

All releases

Polar takes the international rail, and the desktop app updates itself

Added

  • Refunds are recorded on the international rail. order.refunded populates payments.refunded_minor, closing half the net-revenue overstatement docs/ADMIN_WIRING.md has recorded since the ledger shipped. The Paystack half is still open, and the doc now says which half is which. The refund deliberately does not touch the entitlement — whether access ends arrives separately as subscription.revoked.

  • The desktop app updates itself, and says so exactly once. 0.1.1 shipped the Rust half — the updater plugin, a real signing key, createUpdaterArtifacts, CI publishing latest.json. Nothing ever called it. The app-side half is here: a bridge in lib/desktop.ts, the policy in lib/desktop-update.ts, and one pill in the title bar.

    • Silent until the restart. First check 20s after launch — launch is the busiest moment the app has and an update check is the least urgent thing happening — then every 6h, downloading in the background with no announcement. The user is interrupted once, at the only moment that costs them anything: their place, for the few seconds of a restart.
    • A bar pill, not a toast. An update that is already downloaded is waiting for the user, not announcing itself, and a toast that auto-dismisses turns that choice into a race. The pill uses cs-bar-pill and the --bar-* tokens, so it de-chromes into the desktop caption for free with no isDesktop() branch in the component. It sits next to the window controls, because restarting is a window action.
    • A failed check is never surfaced. Offline, proxied, and releases-host-down are indistinguishable from inside the app and none of them is something the user can act on mid-sentence, so checkForUpdate resolves null for all of them — the same answer as "you are current". The timer heals it.
    • process:allow-restart is a second, non-obvious permission. updater:default covers check and install; the restart is a different plugin. Grant only the first and the update installs perfectly and the app never comes back — the same silent-denial shape as the core:event:listen bug, now pinned in tests/desktop-capabilities.test.ts along with the signature config itself.
    • Three guards, each a real bug, which is what tests/desktop-update.test.ts is for: never re-download something already staged (the timer keeps firing for days, so the unguarded version re-fetches ~90MB every 6h forever), never run two checks at once (two concurrent installs corrupt the artifact), and never land in ready after a failed download — offering Restart now into a half-written file is worse than no updater.
    • .cs-bar-label is now the shared "drop the word at ≤760px" class, rather than the update pill borrowing .cs-agent-label — a class that means "the Agent toggle's label".
  • The desktop harness can search the web. create_web_links was hardcoded off for the Claude Code engine on the grounds that "on this path there may be no server at all". That is true of the fully-local mode in docs/DISTRIBUTION.md — which src-tauri/src/modes.rs states, in as many words, is not implemented. Both modes that ship (cloud and self-host) load the window from a real server, so the constant was refusing a capability every actual install has.

    • The sandbox was never what blocked it. create_web_links is a deferred op: the CLI emits the intent and the webview resolves it through Canvas.applyAIActions → /api/search. The four harness flags — --tools "" included — are untouched, and the CLI still reaches the network for exactly nothing.
    • It is asked, not assumed. probeWebSearch() calls /api/search with an EMPTY query: the route's existing early return, which runs the rate-limit, sign-in and key checks and then answers {urls: []} without calling the provider. 200 means the verb will really work, 401 means signed out, 501 means the deployment has no key — and finding out costs no search quota. A "yes" is kept for the session; a "no" is re-asked after a minute, so signing in mid-session doesn't need an app restart. The probe fires alongside listener setup so it adds no latency to the turn, and it never throws: a failed check is a "no", never a dead turn.
    • buildHarnessSystemPrompt takes the capability as an argument rather than discovering it, so it stays synchronous and testable. The empty-query probe contract is now written down in app/api/search/route.ts, because tightening it into a 400 would silently turn web search off on every desktop install.

Fixed

  • docs/DISTRIBUTION.md §1.6 no longer claims the updater keypair doesn't exist. It was generated for 0.1.1 — public half in tauri.conf.json, private half in Actions secrets — but the note saying otherwise survived, alongside instructions to generate one. Following them would have produced a second keypair, which does not merely fail to update: every installer already in the wild carries the first public key compiled in and actively rejects anything signed by another. The section now records what is genuinely still unverified — that no update has ever actually been installed by an existing install, which needs two real releases and a copy of the older one to prove.

Changed

  • The international rail is Polar, not Paddle. Same job — merchant of record, USD, everyone outside Paystack's six African markets — and the swap needed no migration: the billing tables were already provider + opaque provider_* text, which is the second time that decision has paid for itself.
    • It is not a fee win, and docs/PRICING.md §4b says so. Polar's Starter tier is 5% + $0.40 with +1.5% on international cards, and on this rail essentially every card is international by definition — budget ~6.5% + $0.40 against Paddle's flat 5% + $0.50. Roughly a wash. The reason to move is the shape of the integration, not the rate.
    • Three things deleted code. Polar hosts its own checkout, so app/checkout/page.tsx, components/PaddleCheckout.tsx and the public client token are gone — a redirect is now just a redirect, exactly like the Paystack path. external_customer_id is our Supabase user id, so the create-customer-then-recover-on-409 dance is gone and the portal can be minted for someone whose webhook hasn't landed — the one failure that used to look exactly like being cheated. And money arrives as integers, so the string parser is gone.
    • The amount check compares subtotal_amount, not the total. Polar has three tax modes and defaults to location-based: US/CA/IN pay tax on top of the list price, everyone else has it carved out of the list price. subtotal_amount is the only field that equals the catalog under both, so guarding on the total would refuse correct payments from one half of the world. On an inclusive charge a $14 plan settles $14.00 gross → $2.33 tax → $11.67 net — docs/PRICING.md §4b.4 works through what that does to margin, and the revenue_by_month gap it opens.
    • The webhook key is the raw secret, not a base64-decoded one. Polar follows Standard Webhooks, but its SDK base64-encodes the secret before handing it to the spec library, which decodes it again — the two cancel out. Reading the spec alone leads you to decode a secret that was never encoded, producing a well-formed signature that matches nothing and fails identically to a wrong key. Pinned by its own case in tests/billing-polar.test.ts.
    • POLAR_ENV has no fallback sniff. Sandbox and production tokens share the polar_oat_ prefix, so unlike Paddle's sdbx there is nothing honest to guess from. Unset means production; a wrong guess 401s rather than charging, so both halves fail closed.
    • customer.state_changed is deliberately not subscribed to. It reads like a free reconciler, but it fires on the same changes order.paid and the subscription.* events already cover — a third writer of one subscriptions row is three ways to disagree.
    • The return trip is ?checkout_id= (Polar's {CHECKOUT_ID} placeholder) rather than ?_ptxn=, and /api/billing/verify resolves it through the order rather than granting off the checkout, so the ledger keeps one row per payment instead of two.
  • MCP still has no web search, and now says why. server-ops.ts filed create_web_links under "needs a Storage bucket, a provider key, or image decoding" — none of which is true of it: searchWeb() is server-side already and runDeferred mints link cards a few lines below. The real reason is about who calls that path. External assistants over MCP have their own web search, so the right shape is for them to search with their own tools and send create_link with the real URL — the choice of page then sits with the agent the user is actually talking to, instead of spending the operator's search quota inside an unattended loop. Recorded in the comment so nobody wires it in by mistake.
  • Web search moved from Brave to Serper. Brave retired its free Search API tier in February 2026 — every plan now wants a card on file and bills $5 per 1,000 queries. Serper gives 2,500 queries on signup with no card and charges $1 per 1,000 after, so the same feature got five times cheaper and stopped needing a payment method to try.
    • The swap is confined to lib/search.ts. /api/search, the create_web_links op, the canvas resolver, the SSRF guard and the prompt gating are all untouched, because none of them ever knew who the provider was — the module's whole contract is "a phrase in, public URLs out". A provider change that costs one file is what that contract was for.
    • The result shape barely matters, and that is the point. Serper returns a whole SERP — answer boxes, knowledge graph, "people also ask", related searches. We read organic[].link and drop every other block on the floor. Search results are attacker-authored prose, and this module's job is to not carry prose; a test now asserts that a URL appearing only in an answer box never reaches the board.
    • Over-fetch is capped at 10, which is a billing decision. Serper charges one credit for up to 10 results and two for 11–100, so the old want * 2 (up to 20) would have doubled the price of every large search to buy one spare candidate.
    • BRAVE_SEARCH_API_KEY → SERPER_API_KEY. The stale "2,000 free queries a month" promise is corrected in .env.example, docs/AI_SURFACE.md, docs/USE_CASES.md and docs/ADMIN_WIRING.md.