Commercial overlay (open-core)
August 4, 2026 · View on GitHub
Strut the app is open source. The official, hosted Strut adds a marketing/product page and paid Pro accounts (Stripe) — code we keep out of this repo. This document explains the seam that makes both true at once: a single Cloudflare Worker serves the open-source app and a private commercial overlay, and a plain clone (no overlay) is the full, free, self-hostable app that phones home to no one.
This mirrors the repo's existing "commercial is opt-in" posture (Umami analytics, BYO-LLM): the seams here are inert until an overlay is supplied.
How it fits together
┌──────────────── ONE Cloudflare Worker ────────────────┐
strut.io/ ───▶│ worker-entry.ts → commercial.fetch() (marketing + │ ← private overlay
strut.io/pricing ──▶│ /api/billing/*), else fall through to the app │
strut.io/app/* ───▶│ TanStack Start `forward` — the app + /app/api/* │ ← this repo
sandbox.strut.io ─▶ │ existing artifact-only host branch │
└───────────────────────┬───────────────────────────────┘
│ getEntitlements(userId)
┌─────────▼─────────┐
│ auth D1 (binding │ users/sessions + `subscription`
│ DB) │
└───────────────────┘
- Pro is pure feature-gating. Decks live in one shared Rindle daemon DB scoped by
owner_id; tier never changes where deck data lives. The Pro entitlement is a row in the auth D1 (never Rindle → never syncs to the browser), read on the server to allow private decks and to lift the hosted volume ceilings (image bytes, image count, deck count, slides per deck). AI is BYOK on every plan, so inference is not a tier axis — what Pro buys is privacy and headroom.
The seam: #commercial
#commercial is a bare module specifier resolved two ways:
- This repo (default):
package.jsonimports+tsconfig.jsonpathsmap it tosrc/commercial/stub.ts, which exportscommercial = null. - Overlay build:
vite.config.tssetsresolve.alias['#commercial']to the overlay entry whenprocess.env.STRUT_COMMERCIALis set (Vite alias wins over the tsconfig fallback).
The contract (shared/commercial.ts):
interface Commercial {
// First crack at a request — marketing pages (apex host) + /api/billing/*. Return a Response to
// handle it, or null to fall through to the app. The overlay decides by host/path itself.
fetch(request: Request, ...rest: unknown[]): Promise<Response | null>
// Server-side entitlement provider backed by the overlay's subscription store. null → COMMUNITY.
entitlements: { get(userId: string): Promise<Entitlements> } | null
// Absolute pricing/upgrade URL seeded to the client account UI. null → no upgrade affordance shows.
upgradeUrl: string | null
}
Two consumers in this repo import #commercial:
src/worker-entry.ts— callscommercial.fetch()before the app handler (host branch).server/entitlements.ts—getEntitlements(userId)delegates tocommercial.entitlementsor returnsCOMMUNITY(the unrestricted self-host defaults).
What the app gates on entitlements
All of these are no-ops under COMMUNITY (a clone/self-host), so nothing changes without an overlay:
| Gate | Where | COMMUNITY behavior |
|---|---|---|
| Private decks | createDeckGuarded / setDeckVisibilityGuarded (server/rindle-api.ts) | private decks allowed |
| Image storage (bytes) | server/upload.ts via server/storage.ts | unlimited |
| Image count | server/upload.ts via server/storage.ts | unlimited |
| Deck count | createDeckGuarded (server/rindle-api.ts) | unlimited |
| Slides per deck | addSlideGuarded (server/rindle-api.ts) | unlimited |
| Shared-deck white label | declared only — whiteLabelShare is not read anywhere yet | Strut attribution shown |
| Pro badge / Upgrade link | src/rindle/AccountControl.tsx (seeded via appSsr.ts) | upgradeUrl: null → hidden |
The two ceilings differ in kind, and an overlay should pick numbers accordingly:
- Meters (
storageLimitBytes,imageLimit) count against a monotonic per-user row in the auth D1. R2 objects are content-addressed and never GC'd, so deleting an image does not give the slot back. - Live counts (
deckLimit,slidesPerDeckLimit) are read from Rindle inside the mutation txn, so deleting a deck or slide frees its slot immediately.
slidesPerDeckLimit is the one to think twice about: it rejects mid-authoring, and the ✨ Generate /
Narrate lanes append slides one mutation at a time, so a deck that crosses the cap lands partially
generated. Prefer bounding deck count.
Building an overlay
An overlay is a directory (its own private repo) cloned in at commercial/ (git-ignored). It provides a
module that export const commercial: Commercial. A reference implementation ships in commercial/
(see commercial/README.md) with:
index.ts— theCommercialimpl (marketing routing + Stripe checkout/portal/webhook + entitlements).subscription.ts— a per-usersubscriptionrow in the auth D1 (mirrorsserver/modelCred.ts's dual D1/better-sqlite3 store, minus crypto; self-creates its table).stripe.ts— Stripe viafetch+ WebCrypto (no SDK, no Node crypto → runs on Workers).marketing.ts— the product/pricing page (plain, theme-aware HTML).plans.ts— the FREE/PRO entitlement sets (pricing/packaging stays private).wrangler.jsonc— a full config serving marketing at/, app at/app, plus auth/Stripe vars.
Build & deploy envs
| Env | Set for | Effect |
|---|---|---|
STRUT_COMMERCIAL | build | Path to the overlay entry; aliases #commercial to it. |
WRANGLER_CONFIG | build | Alternate wrangler config (cloudflare({ configPath })) — the overlay's routes + vars. |
STRUT_APP_BASEPATH | build | Optional override for the hosted app mount path. Commercial builds default to /app. |
One-command deploy (package.json):
pnpm deploy:pro # applies auth migrations, builds app+overlay as one Worker, ships it
pnpm preview:pro # same build, local preview
The base pnpm deploy is unchanged — a clone with no overlay deploys the free app on a single host.
Path-split checklist (official cutover)
Moving the app from the apex to strut.io/app:
- Keep the
strut.iocustom-domain route (done incommercial/wrangler.jsonc); removeapp.strut.io. BETTER_AUTH_URL=https://strut.io; Better Auth is mounted at/app/api/auth.- Update the GitHub/Google OAuth apps — callback URLs to
https://strut.io/app/api/auth/callback/*. - Point the Stripe webhook at
https://strut.io/api/billing/webhook.