ClipSpacesClipSpaces Docs
Back to app

All releases

Paid plans on Paystack, plan caps enforced in Postgres, private AI chats

Added

  • Plan limits are now enforced — and live. Applied to the hosted Supabase (clipspaces) via supabase db push on 2026-07-26; the storage trigger on storage.objects created successfully rather than hitting the ownership fallback. The caps in docs/PRICING.md have been declarative since billing landed — priced, rendered in Settings → Plans, resolvable through lib/billing/entitlements.ts, and applied by nothing. A Free account could create unlimited spaces. 20260725000100_plan_enforcement.sql closes that.

    Why Postgres and not a route. Cards, edges, drawings and comments are written straight from the browser through the Supabase client under RLS; there is no server route to hang a check on. So the check lives where the write lands — before insert triggers on spaces, space_members, space_invites, vault cards, and storage.objects. AI tokens, frontier models and Notion stay route-side, where a route does exist.

    The ladder now exists twice, which is the one real cost of this design: lib/billing/plans.ts prices it and a new private.plan_limits table enforces it, because a trigger cannot import TypeScript. tests/billing-enforcement.test.ts parses the migration and asserts the two match field by field — change a number in one place and the suite fails until you change the other. It also pins the SQL port of the entitlement resolver (entitling statuses, the 3-day grace) to the TypeScript one.

    Nothing is ever deleted to fit a cap. An account already over the space cap keeps every space; the ones beyond it go read-only — oldest-first-wins, ordered by (created_at, id) so the order is total and which spaces are writable can't flip between calls. They still open, read and export; the space list marks them Read-only and canEdit goes false so the tools grey out rather than letting someone edit into a wall of silently discarded writes. DELETE is never blocked, since deleting is how a user gets back under the cap.

    Existing collaborators are never evicted — only the next join is blocked. It's the one cap whose victim would be a third party who did nothing wrong.

    Two subtleties worth recording. Members and pending invites are counted together, or you could invite twenty people and let them accept one at a time, each insert passing a check the others weren't in. But the invitee's own invite is excluded when they accept it, because claim_space_invites inserts the membership while the invite row still exists — counting both meant the last seat on a plan could never be filled and an invited person was permanently locked out of a space they'd been invited to.

    Every rejection raises through one helper with SQLSTATE CS429 and a machine-readable clipspaces.limit:<cap>:<value> message. lib/billing/limit-error.ts is the only thing that parses it, and it returns null for anything else — a database outage must never render as a paywall.

  • Hitting a limit tells you what to do about it. Rejections surface as one toast carrying an Upgrade button that opens Settings → Plans, instead of a silent failure or a raw Postgres message. useToast gains an optional action (any toast may carry one, deliberately singular; actionable toasts also linger 7s instead of 3.8s, since the default isn't long enough to notice a button and reach it). Because the rejections surface inside lib/supabase/*.ts — non-React modules four layers below anything that could render — the data layer emits on a small bus (lib/billing/limit-bus.ts) that UpgradePromptProvider subscribes to, rather than threading an onLimit callback through every signature on the path. The bus throttles to one toast per cap per 10s, or a locked board would bury the screen in identical messages every time the debounced card sync retried.

Changed

  • Clip AI's token allowance is per-plan. lib/ai/credits.ts resolved a single deploy-wide AI_CREDIT_LIMIT for everyone; it now reads the caller's plan through creditPolicyFor() — Free 50k/day, Personal 1M/month, Studio 5M/month. AI_CREDIT_LIMIT and AI_CREDIT_WINDOW_HOURS survive as the self-host override: an operator with no billing wants one budget for everyone, so setting them pins every user to it. Own-key users remain unmetered — they cost the operator nothing.
  • Frontier models on the host key are Studio-only. New isFrontierModel() classifies by a deny-list of economy models rather than an allow-list of premium ones, so a model we haven't heard of costs the operator nothing by default instead of being served to everyone until someone notices the bill. The host's own configured default is always exempt — whatever the operator runs as the base model is the base model. A gated request is refused rather than quietly downgraded: answering as a different model than the one the user picked is worse than saying no. Checked before the credit gate, so the error names the real reason.
  • Notion sync requires a paid plan. Gated on import and export, and on connect so a Free user isn't sent through an OAuth dance the plan can't finish. status and disconnect stay open — a downgraded user must still be able to see and remove an existing connection.

Fixed

  • The token-usage bar had no visible track in light mode. Settings → AI drew the meter's unfilled track in --bg-input, which in the light theme is the same #f1f0ed as the card's --bg-elevated behind it — so at low usage the bar read as a floating stub with no scale to judge it against. The track is now --border-med, a translucent ink that separates from the card in both themes.
  • @-mentions in comments never appeared. The suggestion menu was an absolutely-positioned child of the thread card, which sets overflow: hidden — on a brand-new comment the composer is the card's topmost element, so the menu rendered above the card's edge and was clipped away entirely. Typing @ looked like a dead key. It now renders through a portal positioned off the composer's viewport rect (components/comments/mentions.tsx), so no ancestor can clip it, and it flips below the field when the thread sits near the top of the screen. The mention query also runs to the end of the line instead of stopping at the first non-word character, so people with a space in their name ("Ada Lovelace") are reachable.
  • A stored mention was inert. mentions[] was written into the comment's data blob and never read back — the body rendered as flat text. Mentions now render as accent chips, with your own highlighted in solid accent so being mentioned is visible at a glance.
  • Invited-but-not-joined people looked like a broken feature. Only rows in space_members were ever mentionable, so someone you'd just shared a board with simply wasn't in the list. Pending invites now appear in the picker, greyed out and labelled invite pending (they have no account yet, so there's no user id to store). space_invites SELECT is loosened from owner-only to any space member so every member sees the same list; writes stay owner-only.
  • The AI chat scrolled in from the top on every reopen. The scroll container lives inside {open && …}, so closing the panel destroys it and reopening mounts a fresh one at scrollTop: 0 — but scrolledChatRef survived the close, so the pin-to-bottom effect read "same chat, already settled" and smooth-scrolled from the top, which looked like the history was lazily loading in. The ref is now cleared when the panel closes, so a reopen lands at the newest turn instantly.

Added

  • Paid plans — Paystack billing (sandbox). Settings → Plans is live instead of a "Coming soon" tease: it renders the catalog in lib/billing/plans.ts against the caller's real subscription, with a monthly/yearly toggle, and hands off to Paystack's hosted checkout to upgrade or its hosted Subscription Preferences page to change a card / cancel. Test mode is just a key swap — set PAYSTACK_SECRET_KEY=sk_test_… plus the four PAYSTACK_PLAN_* codes and it's a sandbox; with no key configured, billing is off and the tab degrades to the old read-only catalog.

    Provider choice is forced by entity domicile, not preference: Stripe doesn't onboard merchants in most of Africa, so Paystack (which Stripe owns) is the rail. Every billing table is therefore provider-neutralprovider + opaque provider_*_id — so adding a merchant-of-record later for cross-border VAT is a second adapter, not a migration.

    New public.billing_customers, public.subscriptions and public.billing_events tables (migration 20260724000300_billing), all service-role write only, with users able to read only their own row. New routes under app/api/billing/: subscription (GET current entitlement), checkout, portal, webhook. lib/billing/entitlements.ts is the single resolver turning a user id into caps — including getSpaceLimits(), which resolves through spaces.owner_id, because the model is owner-pays, not seats (there is no org entity to sell seats against, and collaborators cost us almost nothing).

    Robustness details worth keeping: the checkout route never accepts a price, only a plan id; the webhook verifies HMAC-SHA512 over the raw body rather than a re-serialized one (Paystack's own Node sample gets this wrong) and dedupes on the signature, since Paystack events carry no event id; entitlement is granted by charge.success, which fires on renewals too, so a dropped event self-heals; user resolution has three fallbacks so out-of-order delivery can't strand a payment; and the resolver fails to Free rather than to an error, with a 3-day grace past current_period_end so a late webhook can't downgrade a paying customer.

    tests/billing.test.ts (18 tests) locks the catalog contract — an unrecognised plan id must degrade to Free, never to a paid tier — and the signature boundary. vitest.config.ts now aliases server-only to a stub so server modules are unit-testable at all.

    Enforcement is not wired yet: the caps are declared and resolvable, but nothing applies them. docs/PRICING.md §5 lists where each must land — mostly Postgres triggers, since cards/edges/drawings are written client-side under RLS with no server route to guard.

  • AI chats are private by default, and shareable read-only. A space's chats used to be space-scoped: every member could read and post into everyone's threads with no locking, so two people prompting on a shared board interleaved into one history. ai_conversations now carries user_id + shared; RLS returns your own chats plus any a co-member has explicitly published, and ai_messages inserts are restricted to the thread's author. A shared chat is therefore a read-only window onto someone's thread — there is no such thing as a conflicting prompt. The sidebar gets a lock/share toggle per chat, marks other people's chats with their face and an eye icon, and swaps the composer for a read-only notice with a "New chat" escape hatch. ai_messages joins the realtime publication so a shared chat streams in live rather than sitting frozen. Pre-existing chats are handed to the space owner and left shared, so nothing vanishes.

  • Real profile avatars. New profiles.avatar_url, synced from auth.users metadata (Google OAuth supplies one; the trigger coalesces so a provider that stops sending a picture can't wipe it) and backfilled for existing users. One components/UserAvatar.tsx replaces the three hand-rolled avatar implementations that had accumulated in the peers dock, share popover and comment thread — with a shared AvatarStack for overlapping rows, and the initials-on-hashed-color fallback intact for email-OTP accounts and broken image URLs. Faces now appear on comment pins, comment rows, the mention picker, the presence stack, live cursor labels, the share list and shared AI chats, so people on a shared board are actually distinguishable.

Changed

  • Chat-row actions collapsed into one overflow menu. Adding a share toggle had pushed the AI sidebar row to four hover-revealed icon buttons plus two status glyphs, crowding out the chat title they were meant to describe — and the share toggle used --live green, which is reserved for presence, not brand chrome. There is now a single trigger opening a shadcn DropdownMenu (Share with board / Make private · Rename · Export as JSON · Export as Markdown · Delete chat), which also replaces the hand-rolled export popover — one menu surface instead of two, with real keyboard navigation and a portal that can't be clipped by the scrolling sidebar. The remaining shared / read-only markers step down to tertiary ink.
  • Comments redesigned to a cleaner, more familiar layout. Both surfaces now follow the same structure: an avatar gutter, name + timestamp on one line, the body under it, and a quiet action row — instead of the previous cramped stack. The inline thread widens to 320px, indents replies under their root, folds long threads behind See N earlier replies, and gets a proper composer (your avatar, a growing field, a round send button that only lights up when there's something to send). The all-comments Sheet gains a counted title, an Active / Newest sort toggle and Open / Resolved tabs with live counts, replacing the two stacked sections that buried open threads under resolved history. Comment pins now show the author's face rather than an initial, with a reply-count badge.
  • Comment tools are grouped like the other toolbar families. The two loose comment buttons are now one segmented control matching Text and Media: the icon half toggles pin mode, the caret half opens a flyout holding Comment and All comments. The unresolved badge rides on the main half. New ActionGroup in components/Toolbar.tsx — the command-shaped sibling of ToolGroup.

Added

  • Pricing & distribution plans (docs). Two new planning docs. docs/PRICING.md proposes an owner-pays (not seat-based) model — there is no org/team entity in the schema, only per-space space_members, so a space's capabilities resolve from its owner's plan. Free / Personal $6 / Studio $14, with BYO AI keys unlimited on every tier (they cost us nothing) and host-key tokens as the metered axis, extending the existing lib/ai/credits.ts metering. It flags that space/storage/collaborator caps must be enforced by Postgres triggers, since cards, edges and drawings are written client-side under RLS with no server route to guard. (Its payment-provider section originally recommended Paddle; §4 has since been rewritten around Paystack — Stripe and every MoR option need an entity in a supported country.) docs/DISTRIBUTION.md covers the desktop app (Tauri v2 over Electron, with a WebGL/WKWebView spike as the gate) and Docker self-host rules, identifying three blockers: NEXT_PUBLIC_* is inlined at build time so a published image can't carry an operator's config, <Analytics/> is mounted unconditionally in app/layout.tsx, and next.config.mjs lacks output: "standalone".