From 6d5af4818defcea793be649a8fb6c52012764fe3 Mon Sep 17 00:00:00 2001 From: Neelank Sachan Date: Tue, 29 Sep 2026 00:27:05 +1000 Subject: [PATCH 1/4] Studio under the Functional Source License; the pricing and licensing decision Studio (apps/studio) moves from Apache-2.0 to FSL-1.1-ALv2: source stays public, free to run for your own team or company, no competing hosted service, and each version becomes Apache-2.0 two years after release. README, NOTICE, Studio's landing page, the site's Studio page, docs and terms say so. Decision 0004 records the pricing and licensing plan. Co-Authored-By: Claude Opus 5.5 --- NOTICE | 4 +- README.md | 2 +- apps/site/content/docs/roadmap.md | 2 +- apps/site/content/docs/studio.md | 4 +- apps/site/content/legal/terms.md | 2 +- apps/site/src/studio.html | 2 +- apps/studio/LICENSE | 105 ++++++++++++++ apps/studio/README.md | 2 +- apps/studio/package.json | 1 + apps/studio/src/app/pages/Landing.tsx | 8 +- docs/decisions/0004-pricing-and-licensing.md | 139 +++++++++++++++++++ 11 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 apps/studio/LICENSE create mode 100644 docs/decisions/0004-pricing-and-licensing.md diff --git a/NOTICE b/NOTICE index 51a20b9..f6c98a2 100644 --- a/NOTICE +++ b/NOTICE @@ -2,7 +2,9 @@ Polyxd Copyright 2026 the Polyxd authors This product includes software developed at Polyxd, licensed under the Apache -License, Version 2.0 (see LICENSE). +License, Version 2.0 (see LICENSE), except Polyxd Studio (apps/studio), which is +licensed under the Functional Source License, Version 1.1, ALv2 Future License +(see apps/studio/LICENSE). It also includes material from the projects below. Each is used under its own licence, and each licence is included in full beside the material it covers. diff --git a/README.md b/README.md index b4a9d06..c616ff2 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,6 @@ Before a pull request: `npm run test:all` and `npm run check:licences`. ## Licence -Code is [Apache-2.0](LICENSE); the spec and documentation are [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). [NOTICE](NOTICE) lists every vendored source and its licence. +Code is [Apache-2.0](LICENSE), except Studio (`apps/studio`), which is [FSL-1.1-ALv2](apps/studio/LICENSE): free to run for your own team or company, not as a competing hosted service, and Apache-2.0 two years after each release; the spec and documentation are [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). [NOTICE](NOTICE) lists every vendored source and its licence. The design-system packs are Polyxd's work, reading each system's published tokens. Polyxd is not affiliated with, endorsed by or sponsored by any of those projects or their owners, and each name is the trademark of its owner. diff --git a/apps/site/content/docs/roadmap.md b/apps/site/content/docs/roadmap.md index 1c66e7d..bb266d6 100644 --- a/apps/site/content/docs/roadmap.md +++ b/apps/site/content/docs/roadmap.md @@ -57,4 +57,4 @@ The runtime points at any generator: Claude, GPT or Gemini through their APIs, o ## Open core -The spec, design-system packs, React renderer, runtime, MCP server, verifier and benchmark are meant to be free and open: code under Apache-2.0, and the spec and docs under CC-BY-4.0. [Studio](/docs/studio) exists for teams (hosted at studio.polyxd.com, free for one workspace, and open source to run yourself): design systems, what generated screens may use, rules, whole Design Directions (edited, versioned and fetched by key), authored screens and delivery, and Insights: how each screen does in a team's product, counted from the semantic events the product sends. Reviewing generated screens there is planned. Whether a paid tier comes later is not decided. The intent is that anything that runs inside someone else's product stays free, with no usage metering. +The spec, design-system packs, React renderer, runtime, MCP server, verifier and benchmark are meant to be free and open: code under Apache-2.0, and the spec and docs under CC-BY-4.0. [Studio](/docs/studio) exists for teams (hosted at studio.polyxd.com, free for one workspace, and source-available under the Functional Source License to run yourself): design systems, what generated screens may use, rules, whole Design Directions (edited, versioned and fetched by key), authored screens and delivery, and Insights: how each screen does in a team's product, counted from the semantic events the product sends. Reviewing generated screens there is planned. Paid plans for bigger teams are planned and will be published before they start; one workspace stays free. The intent is that anything that runs inside someone else's product stays free, with no usage metering. diff --git a/apps/site/content/docs/studio.md b/apps/site/content/docs/studio.md index f383b6c..33b0f40 100644 --- a/apps/site/content/docs/studio.md +++ b/apps/site/content/docs/studio.md @@ -7,7 +7,7 @@ order: 23 # Studio -[Studio](https://studio.polyxd.com) is the team's side of Polyxd: open source (Apache-2.0, in `apps/studio`), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio is free for one workspace; a small fee may later cover its storage. +[Studio](https://studio.polyxd.com) is the team's side of Polyxd: source-available (the [Functional Source License](https://fsl.software), in `apps/studio`: free to run for your own team or company, not as a competing hosted service, and each version becomes Apache-2.0 after two years), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio is free for one workspace; paid plans for bigger teams are coming, and will be published before they start. ## Your design system @@ -108,3 +108,5 @@ npm run dev -w @polyxd/studio # http://localhost:8789 ``` For production: a D1 database, an R2 bucket, `SECRETS_KEY` and `AUTH_SECRET` secrets, `RESEND_API_KEY` for email, and `npm run deploy -w @polyxd/studio`. The README in `apps/studio` has the exact steps. + +The [licence](https://github.com/visualfart/polyxd/blob/main/apps/studio/LICENSE) lets you run Studio for your own team or company, change it, and share your changes. It does not let you offer Studio, or something substantially like it, as a service to others. Each version becomes Apache-2.0 two years after its release. diff --git a/apps/site/content/legal/terms.md b/apps/site/content/legal/terms.md index 0e8c917..7c6904a 100644 --- a/apps/site/content/legal/terms.md +++ b/apps/site/content/legal/terms.md @@ -20,7 +20,7 @@ Our [privacy policy](/privacy/) explains what we do with personal data. ## Open-source software -Polyxd's code is open source under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). The specification and the documentation are licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). Those licences, not these terms, govern your use of the code, the specification and the docs, wherever you get them. You can run all of it yourself. +Polyxd's code is open source under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0), except Polyxd Studio, whose source is available under the [Functional Source License 1.1](https://fsl.software) (you may run it for your own team or company, but not offer it as a competing service; each version becomes Apache 2.0 two years after its release). The specification and the documentation are licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). Those licences, not these terms, govern your use of the code, the specification and the docs, wherever you get them. You can run all of it yourself. These terms apply to the services we host for you. diff --git a/apps/site/src/studio.html b/apps/site/src/studio.html index 1276043..afcedf2 100644 --- a/apps/site/src/studio.html +++ b/apps/site/src/studio.html @@ -246,7 +246,7 @@

What you get.

  • 0 failingNothing publishes while contrast fails.
  • 6 formatsCSS, DTCG, Tailwind, Style Dictionary, Swift, Compose.
  • By keyProducts fetch published screens.
  • -
  • FreeFor one workspace. Open source to run yourself.
  • +
  • FreeFor one workspace. Source-available to run yourself.
  • diff --git a/apps/studio/LICENSE b/apps/studio/LICENSE new file mode 100644 index 0000000..6a7d6d9 --- /dev/null +++ b/apps/studio/LICENSE @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2026 Neelank Sachan + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/apps/studio/README.md b/apps/studio/README.md index 2385067..6414a56 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -1,6 +1,6 @@ # Polyxd Studio -Where a design-system team decides what generated screens may look like, and reviews what they actually look like. Apache-2.0, like the rest of Polyxd. +Where a design-system team decides what generated screens may look like, and reviews what they actually look like. Source-available under the [Functional Source License](LICENSE) (FSL-1.1-ALv2), unlike the rest of Polyxd, which is Apache-2.0: run it for your own team or company, but not as a competing hosted service. Each version becomes Apache-2.0 two years after its release. Run it yourself on your own Cloudflare account, or use the hosted one at studio.polyxd.com (same code; a small fee may cover its storage later). diff --git a/apps/studio/package.json b/apps/studio/package.json index c55b43d..b35ec15 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -2,6 +2,7 @@ "name": "@polyxd/studio", "version": "0.0.1", "private": true, + "license": "FSL-1.1-ALv2", "description": "Polyxd Studio: where a design-system team decides what generated screens may look like, and reviews what they actually look like", "type": "module", "scripts": { diff --git a/apps/studio/src/app/pages/Landing.tsx b/apps/studio/src/app/pages/Landing.tsx index 2e91768..c707079 100644 --- a/apps/studio/src/app/pages/Landing.tsx +++ b/apps/studio/src/app/pages/Landing.tsx @@ -121,8 +121,8 @@ export function Landing() {
    -

    Open source, or hosted

    -

    Studio is Apache-2.0, in apps/studio of the Polyxd repository, running on Cloudflare Workers with D1 and R2. Run it on your own account, or use the hosted one at studio.polyxd.com. Same code either way.

    +

    Run it yourself, or hosted

    +

    Studio's source is in apps/studio of the Polyxd repository, under the Functional Source License: run it free for your own team or company on Cloudflare Workers with D1 and R2, or use the hosted one at studio.polyxd.com. Same code either way.

    @@ -131,7 +131,7 @@ export function Landing() {

    Hosted

    -

    Free for one workspace. A small fee may cover its storage later.

    +

    Free for one workspace. Paid plans for bigger teams are coming.

    {signedIn ? Open Studio : Create a workspace}
    @@ -149,7 +149,7 @@ export function Landing() { Polyxd GitHub - © 2026 Polyxd · Apache-2.0 code, CC-BY-4.0 spec + © 2026 Polyxd · Studio under FSL-1.1, everything else Apache-2.0, spec CC-BY-4.0
    diff --git a/docs/decisions/0004-pricing-and-licensing.md b/docs/decisions/0004-pricing-and-licensing.md new file mode 100644 index 0000000..75192b3 --- /dev/null +++ b/docs/decisions/0004-pricing-and-licensing.md @@ -0,0 +1,139 @@ +# 0004 — Pricing and licensing + +Status: **planned, not built** (28 Sep 2026). Build tonight in the order under [Build plan](#build-plan). + +## Decision + +**Everything that runs on your machine or in your app is free and open. We charge for what we host and for what a team needs to work on its own design system together.** + +- No "commercial use" licence and no per-render or runtime fees. Apache code can't enforce them, and they'd stop adoption of the spec. +- The product we sell is **Studio**, priced per editor. Viewers are free. +- Hosted services (publishing screens by key, private packs through the hosted MCP) are part of Studio plans, not metered separately. +- Screens, packs and Directions can always be exported as JSON on every plan, so there's no lock-in. + +## Licensing + +Neelank is the sole copyright holder (every commit is his; AI co-authors hold no copyright). 0.3.0 and earlier stay Apache-2.0 for good. Every later version can be licensed however we choose. + +| Part | From 0.4.0 | Why | +|---|---|---| +| `spec`, `core`, `react`, `web`, `polyxd` CLI, `a2ui`, `python-spec`, all `ds-*` packs | Apache-2.0 (unchanged) | Adoption. Anyone must be able to implement the spec and render it. | +| `verifier`, `runtime`, `server`, `mcp` | Apache-2.0 for now | We can move them to FSL-1.1-Apache-2.0 in a later version if a hosted competitor appears. | +| `apps/studio` | FSL-1.1-ALv2 (done 28 Sep 2026) | Source stays public. Free to self-host for your own team or company; no competing hosted service. Each version becomes Apache-2.0 two years after release. Commits before the change remain Apache-2.0. | +| `apps/vscode` | Apache-2.0 (unchanged) | Funnel into Studio. | + +**CLA (Contributor License Agreement).** Before any outside contribution is merged, require a CLA. It keeps our right to relicense future versions. Use CLA Assistant (a GitHub app, which the user installs) with a CLA text in the repo. + +**Trademark policy.** "Polyxd", the mark, and "Polyxd Conformant" belong to us: +- Anyone may say their renderer is *Polyxd Conformant* if it passes the conformance suite. This is free. +- Nobody may name a product or hosted service "Polyxd …" without permission. + +**Never charge for the packs modelled on named design systems** (Material 3, Carbon, Polaris, …). Their token licences allow it (all MIT or Apache, see NOTICE), but selling them invites trademark trouble. + +## Plans + +Prices and limits are proposals. **The user sets the final numbers.** Lowered on 29 Sep 2026 from $20 and $45 per editor, which the user judged too much for individuals and small teams. + +| | Free | Pro | Team | Enterprise | +|---|---|---|---|---| +| For | trying it, side projects | one designer | small and growing teams | companies with security and procurement needs | +| Price, monthly | $0 | $8 / month | $12 / editor / month | custom, from about $10k / year | +| Price, yearly (2 months free) | $0 | $80 / year | $120 / editor / year | custom | +| Workspaces | 1 | 3 | unlimited | unlimited | +| Editors (owner, design-system, designer, product, engineer) | 2 | 1 | unlimited | unlimited | +| Viewers | unlimited | unlimited | unlimited | unlimited | +| Custom design systems (import or from template) | 1 | unlimited | unlimited | unlimited | +| Directions | 1 | unlimited | unlimited | unlimited | +| Published screens | 10 | unlimited | unlimited | unlimited | +| Fetches by key (screens, Directions, tokens) per month | 10k | 250k | 1M | 10M or more, with an SLA | +| Private packs, screens and Directions through hosted MCP | — | ✓ | ✓ | ✓ | +| Version history | last 10 | full | full | full | +| Approval before publish | — | — | ✓ | ✓ | +| Google sign-in, shared libraries across workspaces | — | — | ✓ | ✓ | +| SAML/SCIM, audit log, support for self-hosted Studio, SLA, indemnity | — | — | — | ✓ | +| Export everything as JSON | ✓ | ✓ | ✓ | ✓ | + +**Why these numbers.** Studio sits beside a team's design tool, not in place of it, so it must cost well under a design tool's paid seat. $8 is an easy personal expense. A five-person team pays $60 a month, which a team lead can approve without procurement. Free keeps 2 editors so a pair can try Studio together; Pro is for one person who needs more than Free's limits. + +**Founding offer.** The first 100 paying workspaces keep half price for as long as they stay subscribed ($4 for Pro, $6 per editor for Team). It rewards early users while there are none yet, and the cap makes it end on its own. + +**Always free, no plan needed:** every open package, the CLI, the local verifier, the VS Code extension, self-hosting `@polyxd/server` and `@polyxd/mcp`, and the hosted MCP for public packs (no sign-in; per-IP rate limit only to stop abuse). + +**Limits are soft where production depends on them.** Going over the fetch quota never breaks a live app. We warn in Studio and by email at 80% and 100%, and after a 7-day grace period fetches stay up but editing is locked until the workspace upgrades. Hard limits (editors, design systems, Directions, published screens) only block creating new ones. + +**Services:** "We build your design-system pack", a fixed fee (proposal: $3–5k). It leads into Team or Enterprise. + +## Build plan + +Each workstream is its own branch **from `origin/main`**, not local main, because other sessions commit to main. The rule that docs stay current applies: `apps/site/scripts/check-docs.ts` must pass, and every agent brief names the docs it must update. + +### 1. Licensing files (small, do first) + +- `CONTRIBUTING.md`: how to contribute, the CLA requirement, DCO sign-off for small fixes. +- `CLA.md`: individual CLA text (based on the Apache ICLA). +- `TRADEMARKS.md`: the policy above. +- ~~`apps/studio/LICENSE`~~: done. FSL-1.1-ALv2, `"license": "FSL-1.1-ALv2"`, README, NOTICE and site docs updated. +- README: a "Licence" section that says what's open, what Studio is, and links to the trademark policy. +- `PLAN.md`: replace "A paid hosted API" under *Not in scope for v1* with a link to this decision. +- **User:** install CLA Assistant on the repo. + +### 2. Studio plans and limits + +- Migration `apps/studio/migrations/0007_plans.sql`: + - add to `workspaces`: `plan` (`free` | `pro` | `team` | `enterprise`, default `free`), `plan_status`, `stripe_customer_id`, `stripe_subscription_id`, `period_end`, `over_quota_since` + - `usage (workspace_id, metric, period, count)`: monthly totals rolled up from the fetch counter +- `apps/studio/src/worker/plans.ts`: the single table of limits per plan and helpers (`limitsFor(plan)`, `assertCanCreate(ws, kind)`, `editorCount(ws)`). Roles map to seats: `viewer` is free; every other role is an editor. +- Enforcement points in `apps/studio/src/worker/index.ts`: + - `POST /api/workspaces`: Free users own one workspace + - `POST /api/w/:slug/invites` and invite accept: editor seats (viewers always allowed) + - `POST …/design-systems/import` and `…/from-template`: custom design-system count + - `POST …/directions`: Direction count + - `POST …/screens/:key/versions/:n/publish`: published-screen count + - version-history pruning for Free +- Fetch metering: count `GET` by API key for screens, Directions and tokens. Use Workers Analytics Engine for the raw count (no D1 write per request), and a scheduled Worker (cron) that rolls up into `usage` daily and sets `over_quota_since`. +- Errors: a 402 with `{ code: "plan_limit", limit, plan }`, which the app turns into an upgrade prompt. +- Tests beside the existing Studio worker tests. + +### 3. Billing (Stripe) + +- Worker routes: + - `POST /api/w/:slug/billing/checkout`: Stripe Checkout (Pro: one flat price; Team: quantity = editor seats; monthly or yearly; a founding coupon at 50% off, limited to 100 redemptions, `duration: forever`) + - `POST /api/w/:slug/billing/portal`: Stripe Customer Portal + - `POST /api/billing/webhook`: verify the signature; handle `checkout.session.completed`, `customer.subscription.updated|deleted`, `invoice.payment_failed`; set `plan`, `plan_status` and `period_end` +- Seat sync: when editors are added or removed, update the subscription quantity (prorated). +- App: a Billing page in workspace settings (plan, seats, usage bars, upgrade and manage buttons) and an upgrade dialog shown on any 402. +- Build and test against **Stripe test mode** only. +- **User:** create the Stripe account, the business details and tax settings, and the products and prices. Then run `wrangler secret put STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and the price IDs (or put the price IDs in `wrangler.jsonc` vars). We never enter keys. + +### 4. Site + +- `/pricing`: picture-first, following the site design rules (one headline, one line, CTA right under it). Four plan cards, the "always free" list, a short FAQ ("Can I use Polyxd commercially? Yes, free." · "What's open?" · "What happens if I go over?" · "Can I leave?"). Enterprise and the pack-building service get a contact button that emails through Resend. +- `/trademarks`: the trademark policy. +- `/terms` (on `feat/legal-listing`, not yet approved): add plans, billing, renewals, refunds, over-quota handling, and the enterprise contract precedence. +- Nav and footer links; a Studio sign-up CTA from pricing; `check-docs.ts` coverage for the new pages; a social card for `/pricing`. +- **User:** approve the terms; OK the deploy. + +### 5. Hosted MCP: sign-in for private resources + +Depends on `feat/mcp-remote` merging. + +- Public packs stay authless. Add a per-IP limit with the Workers Rate Limiting binding. +- Studio as the OAuth provider, using better-auth's MCP/OIDC plugin (Studio already uses better-auth). A signed-in MCP client can list and fetch its workspace's design systems, screens and Directions. Only Pro and above; Free gets a clear message saying which plan unlocks it. +- Update `packages/mcp/listing` and the directory submission notes: sign-in is optional. + +### 6. Enterprise (later; tonight only the contact path) + +- Self-hosting Studio is free under FSL. Enterprise sells what self-hosters need on top: SAML/SCIM through better-auth plugins, an audit log table, a support SLA and indemnity. A packaged self-host build (Docker, D1 swapped for SQLite or Postgres) when the first enterprise lead asks. + +## Later, only when people ask + +- Hosted verification in CI (a GitHub check running the full pack × width × light/dark grid), priced by render-minutes. +- Hosted generation with Polyxd paying the model, sold as credits. It conflicts with "no bundled model", so only on demand. +- A pack marketplace with a revenue share for pack authors. + +## Open decisions for the user + +1. Final prices and Free limits (the table above is a proposal). +2. The legal entity for Stripe and the terms. +3. ~~Self-hosted Studio~~: decided 28 Sep. Free under FSL; Enterprise sells SAML, audit, SLA and indemnity. +4. CLA Assistant, or DCO only (DCO is lighter but doesn't give relicensing rights as clearly). From a798f13d410e4754ff358dc9d7dafd8120e142e6 Mon Sep 17 00:00:00 2001 From: Neelank Sachan Date: Tue, 29 Sep 2026 00:36:18 +1000 Subject: [PATCH 2/4] Studio plans, limits, fetch metering and Stripe billing in the Worker Plans and limits apply only when BILLING is on (the hosted Studio); a self-hosted Studio has none. One table of limits per plan (plans.ts), 402 plan_limit at workspace create, editor invites and accepts, design systems, Directions and published screens, and Free's history kept to its last ten versions. Fetches by API key are counted in Workers Analytics Engine, rolled up hourly by the cron into usage, and seven days over quota locks editing (never fetching). Stripe through fetch: checkout (founding coupon, Team seats), portal, and a signed webhook that sets the plan; Team's seats follow editors joining and leaving. Co-Authored-By: Claude Opus 5.5 --- apps/studio/migrations/0007_plans.sql | 23 ++ apps/studio/src/worker/auth.ts | 23 +- apps/studio/src/worker/billing.ts | 186 ++++++++++++ apps/studio/src/worker/index.ts | 163 +++++++++- apps/studio/src/worker/plans.ts | 279 +++++++++++++++++ apps/studio/test/billing.worker.test.ts | 381 ++++++++++++++++++++++++ apps/studio/test/support/worker.ts | 21 +- apps/studio/wrangler.jsonc | 6 + 8 files changed, 1069 insertions(+), 13 deletions(-) create mode 100644 apps/studio/migrations/0007_plans.sql create mode 100644 apps/studio/src/worker/billing.ts create mode 100644 apps/studio/src/worker/plans.ts create mode 100644 apps/studio/test/billing.worker.test.ts diff --git a/apps/studio/migrations/0007_plans.sql b/apps/studio/migrations/0007_plans.sql new file mode 100644 index 0000000..5be0b9b --- /dev/null +++ b/apps/studio/migrations/0007_plans.sql @@ -0,0 +1,23 @@ +-- Plans and billing (docs/decisions/0004-pricing-and-licensing.md). A workspace is on a plan; +-- Stripe says which, through the webhook. Only the hosted Studio (BILLING=on) reads any of this: +-- a self-hosted one has no limits, and every workspace stays 'free' without it mattering. +ALTER TABLE workspaces ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'; -- free | pro | team | enterprise +ALTER TABLE workspaces ADD COLUMN plan_status TEXT; -- Stripe's: active | trialing | past_due | canceled | … +ALTER TABLE workspaces ADD COLUMN billing_interval TEXT; -- month | year +ALTER TABLE workspaces ADD COLUMN seats INTEGER; -- editor seats billed (Team) +ALTER TABLE workspaces ADD COLUMN stripe_customer_id TEXT; +ALTER TABLE workspaces ADD COLUMN stripe_subscription_id TEXT; +ALTER TABLE workspaces ADD COLUMN period_end TEXT; -- when the paid period renews or ends +ALTER TABLE workspaces ADD COLUMN over_quota_since TEXT; -- first rollup that found fetches over the plan's +CREATE INDEX workspaces_stripe_customer ON workspaces(stripe_customer_id); + +-- Monthly totals, rolled up by the scheduled handler from the fetch counter (Workers Analytics +-- Engine), so no request writes to D1 to be counted. period is 'YYYY-MM' in UTC. +CREATE TABLE usage ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + metric TEXT NOT NULL, -- fetches + period TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, metric, period) +); diff --git a/apps/studio/src/worker/auth.ts b/apps/studio/src/worker/auth.ts index 2dfefa0..c2f4c78 100644 --- a/apps/studio/src/worker/auth.ts +++ b/apps/studio/src/worker/auth.ts @@ -23,6 +23,24 @@ export interface Env extends AnalyticsEnv { EMAIL_FROM?: string; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; + /** + * "on" only on the hosted Studio: plans, limits, fetch metering and Stripe (src/worker/plans.ts, + * billing.ts). Unset, as on every self-hosted Studio, there are no limits and no billing. + */ + BILLING?: string; + STRIPE_SECRET_KEY?: string; + STRIPE_WEBHOOK_SECRET?: string; + STRIPE_PRICE_PRO_MONTH?: string; + STRIPE_PRICE_PRO_YEAR?: string; + STRIPE_PRICE_TEAM_MONTH?: string; + STRIPE_PRICE_TEAM_YEAR?: string; + /** The founding offer: a coupon at 50% off, forever, for at most 100 redemptions. */ + STRIPE_COUPON_FOUNDING?: string; + /** Workers Analytics Engine: one data point per fetch by key, rolled up by the cron. */ + FETCHES?: AnalyticsEngineDataset; + /** For the rollup to read Analytics Engine back: the account, and a token with Account Analytics Read. */ + CF_ACCOUNT_ID?: string; + CF_ANALYTICS_TOKEN?: string; } export interface User { @@ -109,9 +127,10 @@ export async function userFromRequest(c: Ctx, auth: Auth): Promise<{ user: User const bearer = c.req.header("authorization")?.match(/^Bearer (pxs_[a-f0-9]+)$/)?.[1]; if (bearer) { const hash = await sha256(bearer); - const key = await c.env.DB.prepare("SELECT id, workspace_id, created_by FROM api_keys WHERE key_hash = ?").bind(hash).first<{ id: string; workspace_id: string; created_by: string }>(); + const key = await c.env.DB.prepare("SELECT id, workspace_id, created_by, last_used_at FROM api_keys WHERE key_hash = ?").bind(hash).first<{ id: string; workspace_id: string; created_by: string; last_used_at: string | null }>(); if (!key) return { user: null, apiWorkspace: null }; - await c.env.DB.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?").bind(now(), key.id).run(); + // A product fetches by key on every request; "last used" to the hour is enough, and saves a write each time. + if (!key.last_used_at || Date.parse(key.last_used_at) < Date.now() - 3600e3) await c.env.DB.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?").bind(now(), key.id).run(); const user = await c.env.DB.prepare("SELECT id, email, name FROM user WHERE id = ?").bind(key.created_by).first(); return { user, apiWorkspace: key.workspace_id }; } diff --git a/apps/studio/src/worker/billing.ts b/apps/studio/src/worker/billing.ts new file mode 100644 index 0000000..5605de6 --- /dev/null +++ b/apps/studio/src/worker/billing.ts @@ -0,0 +1,186 @@ +/** + * Stripe, through its REST API with fetch (no SDK: a form-encoded POST is all Checkout, the + * Customer Portal and a seat change need). Checkout makes the subscription; the webhook is the + * only thing that changes a workspace's plan. Prices and the founding coupon come from env, so + * test mode and live mode differ only in configuration. + */ +import type { Env } from "./auth.ts"; +import { billingOn, editorCount, overQuotaSince, periodOf, type Plan } from "./plans.ts"; + +export type Interval = "month" | "year"; +export const stripeOn = (env: Env) => billingOn(env) && !!env.STRIPE_SECRET_KEY; + +export class StripeError extends Error { + status: number; + code?: string; + param?: string; + constructor(status: number, message: string, code?: string, param?: string) { + super(message); + this.status = status; + this.code = code; + this.param = param; + } +} + +/** Stripe's form encoding: nested objects and lists as metadata[workspace_id], line_items[0][price]. */ +export function form(params: Record, into = new URLSearchParams(), prefix = ""): URLSearchParams { + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null) continue; + const key = prefix ? `${prefix}[${k}]` : k; + if (Array.isArray(v)) v.forEach((item, i) => (typeof item === "object" && item !== null ? form(item as Record, into, `${key}[${i}]`) : into.append(`${key}[${i}]`, String(item)))); + else if (typeof v === "object") form(v as Record, into, key); + else into.append(key, String(v)); + } + return into; +} + +export async function stripe>(env: Env, method: "GET" | "POST", path: string, params?: Record): Promise { + const encoded = params ? form(params).toString() : ""; + const url = `https://api.stripe.com/v1${path}${method === "GET" && encoded ? `?${encoded}` : ""}`; + const r = await fetch(url, { + method, + headers: { authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, ...(method === "POST" ? { "content-type": "application/x-www-form-urlencoded" } : {}) }, + body: method === "POST" ? encoded : undefined, + }); + const data = (await r.json().catch(() => ({}))) as { error?: { message?: string; code?: string; param?: string } }; + if (!r.ok) throw new StripeError(r.status, data.error?.message ?? `Stripe answered ${r.status}`, data.error?.code, data.error?.param); + return data as T; +} + +export function priceFor(env: Env, plan: "pro" | "team", interval: Interval): string | undefined { + return { pro: { month: env.STRIPE_PRICE_PRO_MONTH, year: env.STRIPE_PRICE_PRO_YEAR }, team: { month: env.STRIPE_PRICE_TEAM_MONTH, year: env.STRIPE_PRICE_TEAM_YEAR } }[plan][interval]; +} + +export function planOfPrice(env: Env, price: string): { plan: Plan; interval: Interval } | null { + const table: [string | undefined, Plan, Interval][] = [ + [env.STRIPE_PRICE_PRO_MONTH, "pro", "month"], + [env.STRIPE_PRICE_PRO_YEAR, "pro", "year"], + [env.STRIPE_PRICE_TEAM_MONTH, "team", "month"], + [env.STRIPE_PRICE_TEAM_YEAR, "team", "year"], + ]; + const hit = table.find(([id]) => id && id === price); + return hit ? { plan: hit[1], interval: hit[2] } : null; +} + +const hex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); + +/** + * Stripe-Signature: t=,v1=[,v1=…]. Checked with Web + * Crypto against the endpoint's secret, compared in constant time, and refused when older than + * the tolerance so a captured request can't be replayed later. + */ +export async function verifySignature(payload: string, header: string | undefined, secret: string, tolerance = 300, nowSec = Math.floor(Date.now() / 1000)): Promise { + if (!header) return false; + const parts = header.split(",").map((p) => p.trim().split("=")); + const t = Number(parts.find(([k]) => k === "t")?.[1]); + const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); + if (!Number.isFinite(t) || !sigs.length || Math.abs(nowSec - t) > tolerance) return false; + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const expected = hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${payload}`))); + return sigs.some((s) => { + if (s.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < s.length; i++) diff |= s.charCodeAt(i) ^ expected.charCodeAt(i); + return diff === 0; + }); +} + +/** Signs a payload as Stripe would, for tests and for trying the webhook locally. */ +export async function signPayload(payload: string, secret: string, t = Math.floor(Date.now() / 1000)): Promise { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return `t=${t},v1=${hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${payload}`)))}`; +} + +export interface Subscription { + id: string; + customer: string; + status: string; + metadata?: Record; + current_period_end?: number; + items: { data: { id: string; quantity?: number; current_period_end?: number; price: { id: string } }[] }; +} + +/** Statuses in which the workspace has what it pays for. past_due keeps it while Stripe retries. */ +const PAYING = new Set(["active", "trialing", "past_due"]); + +/** + * A subscription as Stripe describes it, onto its workspace: the plan from its price, the status, + * the interval, the seats and the end of the period. A subscription that has ended puts the + * workspace back on Free, unless the workspace has since moved to another one. + */ +export async function applySubscription(env: Env, sub: Subscription, workspaceId?: string, ended = false): Promise { + const id = + workspaceId ?? + sub.metadata?.workspace_id ?? + (await env.DB.prepare("SELECT id FROM workspaces WHERE stripe_subscription_id = ?").bind(sub.id).first<{ id: string }>())?.id ?? + (await env.DB.prepare("SELECT id FROM workspaces WHERE stripe_customer_id = ?").bind(sub.customer).first<{ id: string }>())?.id; + if (!id) { + console.error(`Stripe subscription ${sub.id} matches no workspace`); + return null; + } + const w = await env.DB.prepare("SELECT id, plan, stripe_subscription_id, over_quota_since FROM workspaces WHERE id = ?").bind(id).first<{ id: string; plan: string; stripe_subscription_id: string | null; over_quota_since: string | null }>(); + if (!w) return null; + if (w.stripe_subscription_id && w.stripe_subscription_id !== sub.id && (ended || !PAYING.has(sub.status))) return id; + const item = sub.items?.data?.[0]; + const priced = item ? planOfPrice(env, item.price.id) : null; + if (!ended && PAYING.has(sub.status) && !priced) { + console.error(`Stripe subscription ${sub.id} has price ${item?.price.id}, which no STRIPE_PRICE_* names`); + return null; + } + const paying = !ended && PAYING.has(sub.status) && priced; + // An enterprise workspace is set by hand and stays so whatever Stripe says. + const plan = w.plan === "enterprise" ? "enterprise" : paying ? priced.plan : "free"; + const end = sub.current_period_end ?? item?.current_period_end; + const fetches = (await env.DB.prepare("SELECT count FROM usage WHERE workspace_id = ? AND metric = 'fetches' AND period = ?").bind(id, periodOf(new Date())).first<{ count: number }>())?.count ?? 0; + await env.DB.prepare( + "UPDATE workspaces SET plan = ?, plan_status = ?, billing_interval = ?, seats = ?, stripe_customer_id = ?, stripe_subscription_id = ?, period_end = ?, over_quota_since = ? WHERE id = ?", + ).bind( + plan, + ended ? "canceled" : sub.status, + paying ? priced.interval : null, + paying ? (item?.quantity ?? 1) : null, + sub.customer, + ended ? null : sub.id, + paying && end ? new Date(end * 1000).toISOString() : null, + overQuotaSince(plan, fetches, w.over_quota_since, new Date()), + id, + ).run(); + return id; +} + +/** + * Team is billed per editor: after someone joins or leaves in an editor role, the subscription's + * quantity follows, prorated. Never throws; a failed sync is logged and the next change retries. + */ +export async function syncSeats(env: Env, workspaceId: string): Promise { + if (!stripeOn(env)) return; + try { + const w = await env.DB.prepare("SELECT plan, stripe_subscription_id FROM workspaces WHERE id = ?").bind(workspaceId).first<{ plan: string; stripe_subscription_id: string | null }>(); + if (!w?.stripe_subscription_id || w.plan !== "team") return; + const seats = Math.max(1, await editorCount(env, workspaceId)); + const sub = await stripe(env, "GET", `/subscriptions/${w.stripe_subscription_id}`); + const item = sub.items.data[0]; + if (!item || item.quantity === seats) return; + await stripe(env, "POST", `/subscription_items/${item.id}`, { quantity: seats, proration_behavior: "create_prorations" }); + await env.DB.prepare("UPDATE workspaces SET seats = ? WHERE id = ?").bind(seats, workspaceId).run(); + } catch (e) { + console.error(`seat sync for ${workspaceId}`, e); + } +} + +/** The Checkout parameters for a plan: Team's quantity is the editor count, Pro's is one. */ +export function checkoutParams(env: Env, o: { workspaceId: string; slug: string; plan: "pro" | "team"; interval: Interval; seats: number; customer: string | null; email: string; coupon: boolean }) { + return { + mode: "subscription", + line_items: [{ price: priceFor(env, o.plan, o.interval), quantity: o.plan === "team" ? Math.max(1, o.seats) : 1 }], + client_reference_id: o.workspaceId, + metadata: { workspace_id: o.workspaceId }, + subscription_data: { metadata: { workspace_id: o.workspaceId } }, + ...(o.customer ? { customer: o.customer } : { customer_email: o.email }), + ...(o.coupon && env.STRIPE_COUPON_FOUNDING ? { discounts: [{ coupon: env.STRIPE_COUPON_FOUNDING }] } : { allow_promotion_codes: true }), + success_url: `${env.APP_URL}/w/${o.slug}/billing?upgraded=1`, + cancel_url: `${env.APP_URL}/w/${o.slug}/billing`, + }; +} + +export const isPaidPlan = (p: unknown): p is "pro" | "team" => p === "pro" || p === "team"; diff --git a/apps/studio/src/worker/index.ts b/apps/studio/src/worker/index.ts index c082d70..5e5ae47 100644 --- a/apps/studio/src/worker/index.ts +++ b/apps/studio/src/worker/index.ts @@ -23,6 +23,8 @@ import { checkDirection } from "../direction/schema.ts"; import { capture, ingest, posthogConfig, type Properties } from "./analytics.ts"; import { CORS, ingestEvents, newIngestKey, preflight } from "./insights.ts"; import { RANGES, RETENTION_DAYS, detail, range, summarise, type StoredRow } from "../insights/report.ts"; +import { PlanLimit, assertCanCreate, assertCanEdit, billingOn, countFetch, isEditor, planSummary, pruneHistory, rollUp } from "./plans.ts"; +import { StripeError, applySubscription, checkoutParams, isPaidPlan, priceFor, stripe, stripeOn, syncSeats, verifySignature, type Subscription } from "./billing.ts"; type Vars = { user: User | null; apiWorkspace: string | null }; const app = new Hono<{ Bindings: Env; Variables: Vars }>(); @@ -42,6 +44,7 @@ class Fail extends Error { } app.onError((e, c) => { if (e instanceof Fail) return c.json({ error: e.message }, e.status as 500); + if (e instanceof PlanLimit) return c.json({ error: e.message, ...e.data }, 402); console.error(e); return c.json({ error: "Something went wrong on our side" }, 500); }); @@ -132,6 +135,8 @@ interface Workspace { slug: string; name: string; role: string; + plan: string; + over_quota_since: string | null; } /** The workspace in the URL, and the caller's role in it. */ @@ -139,7 +144,7 @@ async function ws(c: Ctx, allowed?: ReadonlySet): Promise { const user = need(c); const slug = c.req.param("slug"); const row = await c.env.DB.prepare( - "SELECT w.id, w.slug, w.name, m.role FROM workspaces w JOIN memberships m ON m.workspace_id = w.id WHERE w.slug = ? AND m.user_id = ?", + "SELECT w.id, w.slug, w.name, m.role, w.plan, w.over_quota_since FROM workspaces w JOIN memberships m ON m.workspace_id = w.id WHERE w.slug = ? AND m.user_id = ?", ).bind(slug, user.id).first(); if (!row) throw new Fail(404, "No such workspace, or you're not in it"); // An API key is scoped to one workspace, and to what a machine does: whatever its creator can @@ -154,20 +159,40 @@ async function ws(c: Ctx, allowed?: ReadonlySet): Promise { if (!importing && !reading) throw new Fail(403, "An API key can import and read design systems, and read screens and Directions, only"); } if (allowed && !allowed.has(row.role)) throw new Fail(403, `Your role (${row.role}) can't do that`); + // Over the fetch quota for longer than the grace period: reading and paying still work, changes don't. + if (c.req.method !== "GET" && !new URL(c.req.url).pathname.includes("/billing/")) assertCanEdit(c.env, row); return row; } +/** Work that may finish after the answer (a seat sync): handed to the runtime when there is one. */ +const later = async (c: Ctx, p: Promise) => { + try { + c.executionCtx.waitUntil(p); + } catch { + await p; + } +}; + +/** A pruned design-system version's files, after its rows. */ +async function dropVersionFiles(env: Env, ids: string[]) { + for (const id of ids) { + const list = await env.FILES.list({ prefix: `versions/${id}/` }); + for (const o of list.objects) await env.FILES.delete(o.key); + } +} + // ---------------------------------------------------------------- sign in and out app.get("/api/me", async (c) => { const user = c.get("user"); const signIn = { google: !!(c.env.GOOGLE_CLIENT_ID && c.env.GOOGLE_CLIENT_SECRET), emailVerification: !isLocal(c.env) }; - if (!user) return c.json({ user: null, workspaces: [], signIn }); - const workspaces = await c.env.DB.prepare("SELECT w.id, w.slug, w.name, m.role FROM workspaces w JOIN memberships m ON m.workspace_id = w.id WHERE m.user_id = ? ORDER BY w.name") + // billing: whether this Studio has plans at all (the hosted one); a self-hosted one shows none. + if (!user) return c.json({ user: null, workspaces: [], signIn, billing: billingOn(c.env) }); + const workspaces = await c.env.DB.prepare("SELECT w.id, w.slug, w.name, m.role, w.plan FROM workspaces w JOIN memberships m ON m.workspace_id = w.id WHERE m.user_id = ? ORDER BY w.name") .bind(user.id).all(); // The app's analytics are on only for signed-in people, and only when the Worker has a key (the public one). const config = posthogConfig(c.env); - return c.json({ user, workspaces: workspaces.results, signIn, ...(config ? { analytics: { key: config.key, ui: config.ui } } : {}) }); + return c.json({ user, workspaces: workspaces.results, signIn, billing: billingOn(c.env), ...(config ? { analytics: { key: config.key, ui: config.ui } } : {}) }); }); // ---------------------------------------------------------------- workspaces, members, invites @@ -180,6 +205,7 @@ app.post("/api/workspaces", async (c) => { if (!name?.trim() || !/^[a-z0-9][a-z0-9-]{1,39}$/.test(s)) throw new Fail(400, "A name, and an address of letters, digits and dashes"); const taken = await c.env.DB.prepare("SELECT 1 FROM workspaces WHERE slug = ?").bind(s).first(); if (taken) throw new Fail(409, `studio.polyxd.com/${s} is taken`); + await assertCanCreate(c.env, null, { kind: "workspace", userId: user.id }); const id = crypto.randomUUID(); await c.env.DB.batch([ c.env.DB.prepare("INSERT INTO workspaces (id, slug, name, created_at) VALUES (?, ?, ?, ?)").bind(id, s, name.trim(), now()), @@ -208,6 +234,8 @@ app.post("/api/w/:slug/invites", async (c) => { const members = await c.env.DB.prepare("SELECT u.email FROM memberships m JOIN user u ON u.id = m.user_id WHERE m.workspace_id = ?").bind(w.id).all<{ email: string }>(); const already = list.filter((e) => members.results.some((m) => m.email === e)); if (already.length) throw new Fail(409, `${already.join(", ")} ${already.length === 1 ? "is" : "are"} already in this workspace`); + // An editor invite holds a seat until it is used or expires; viewers are always free. + if (isEditor(role)) await assertCanCreate(c.env, w, { kind: "editors", adding: list.length, pending: true }); const made = []; for (const email of list) { const id = crypto.randomUUID(); @@ -239,14 +267,38 @@ app.post("/api/invites/:id/accept", async (c) => { if (inv.email !== user.email) throw new Fail(403, "This invite is for a different email address"); const member = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(inv.workspace_id, user.id).first(); if (member) throw new Fail(409, "You're already in this workspace; an invite can't change your role"); + const w = await c.env.DB.prepare("SELECT id, slug, plan, over_quota_since FROM workspaces WHERE id = ?").bind(inv.workspace_id).first<{ id: string; slug: string; plan: string; over_quota_since: string | null }>(); + // Checked again on accepting: the plan may have changed, or other invites been used, since. + if (w && isEditor(inv.role)) await assertCanCreate(c.env, w, { kind: "editors", adding: 1, pending: false }); await c.env.DB.batch([ c.env.DB.prepare("INSERT INTO memberships (workspace_id, user_id, role, created_at) VALUES (?, ?, ?, ?)").bind(inv.workspace_id, user.id, inv.role, now()), c.env.DB.prepare("UPDATE invites SET accepted_at = ? WHERE id = ?").bind(now(), inv.id), ]); - const w = await c.env.DB.prepare("SELECT slug FROM workspaces WHERE id = ?").bind(inv.workspace_id).first<{ slug: string }>(); + if (isEditor(inv.role)) await later(c as Ctx, syncSeats(c.env, inv.workspace_id)); return c.json({ slug: w?.slug }); }); +/** An open invite withdrawn, which frees the seat it held. */ +app.delete("/api/w/:slug/invites/:id", async (c) => { + const w = await ws(c as Ctx, new Set(["owner", "design-system"])); + await c.env.DB.prepare("DELETE FROM invites WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL").bind(c.req.param("id"), w.id).run(); + return c.json({ ok: true }); +}); + +/** Someone taken out of the workspace by an owner. The last owner stays. */ +app.delete("/api/w/:slug/members/:user", async (c) => { + const w = await ws(c as Ctx, new Set(["owner"])); + const m = await c.env.DB.prepare("SELECT role FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, c.req.param("user")).first<{ role: string }>(); + if (!m) throw new Fail(404, "They're not in this workspace"); + if (m.role === "owner") { + const owners = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM memberships WHERE workspace_id = ? AND role = 'owner'").bind(w.id).first<{ n: number }>(); + if ((owners?.n ?? 0) <= 1) throw new Fail(409, "A workspace needs an owner; make someone else owner first"); + } + await c.env.DB.prepare("DELETE FROM memberships WHERE workspace_id = ? AND user_id = ?").bind(w.id, c.req.param("user")).run(); + if (isEditor(m.role)) await later(c as Ctx, syncSeats(c.env, w.id)); + return c.json({ ok: true }); +}); + // ---------------------------------------------------------------- registries and API keys app.get("/api/w/:slug/registries", async (c) => { @@ -436,6 +488,7 @@ app.post("/api/w/:slug/design-systems/import", async (c) => { const last = await c.env.DB.prepare("SELECT MAX(number) AS n FROM ds_versions WHERE design_system_id = ?").bind(into).first<{ n: number | null }>(); number = (last?.n ?? 0) + 1; } else { + await assertCanCreate(c.env, w, { kind: "designSystem" }); const first = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM design_systems WHERE workspace_id = ?").bind(w.id).first<{ n: number }>(); statements.push( c.env.DB.prepare("INSERT INTO design_systems (id, workspace_id, name, source, is_default, created_at) VALUES (?, ?, ?, ?, ?, ?)") @@ -449,6 +502,7 @@ app.post("/api/w/:slug/design-systems/import", async (c) => { await c.env.FILES.put(graphKey(versionId), JSON.stringify(graph), { httpMetadata: { contentType: "application/json" } }); if (original) await c.env.FILES.put(`versions/${versionId}/original`, original.bytes, { customMetadata: { name: original.name } }); await c.env.DB.batch(statements); + if (into) await dropVersionFiles(c.env, await pruneHistory(c.env, w, "ds_versions", dsId)); return c.json({ designSystemId: dsId, versionId, number, scan: { ...summary, picked }, url: `${c.env.APP_URL}/w/${w.slug}/design-systems/${dsId}/versions/${versionId}/scan` }, 201); }); @@ -573,6 +627,7 @@ app.post("/api/w/:slug/design-systems/from-template", async (c) => { const { template, name } = await body<{ template?: string; name?: string }>(c as Ctx); if (!isStartName(template)) throw new Fail(400, `Which template? One of ${[BLANK, ...TEMPLATE_NAMES].join(", ")}`); if (name !== undefined) text(name, 80, "Name"); + await assertCanCreate(c.env, w, { kind: "designSystem" }); const graph = templateGraph(template); const summary = scan(graph); const display = template === BLANK ? "Blank" : (TEMPLATE_PACKS[template].manifest.displayName ?? template); @@ -635,6 +690,7 @@ app.post("/api/w/:slug/design-systems/:id/versions/:v/edit", async (c) => { .bind(versionId, ds.id, number, `edited in Studio from v${v.number}`, JSON.stringify({ ...summary, picked: [], edited: changes.length, from: v.number }), v.package_name, v.package_version, b.notes?.trim() || `${changes.length} token${changes.length === 1 ? "" : "s"} changed`, v.template, user.id, now()), ...carried.map((o) => c.env.DB.prepare("INSERT INTO role_overrides (version_id, role, token_path, decided_by, decided_at) VALUES (?, ?, ?, ?, ?)").bind(versionId, o.role, o.token, user.id, now())), ]); + await dropVersionFiles(c.env, await pruneHistory(c.env, w, "ds_versions", ds.id)); const rows = mapRoles(next, CONTRACT, carried); return c.json({ versionId, number, changes: changes.length, fails: rows.filter((r) => r.status === "fails").length, scan: summary, url: `${c.env.APP_URL}/w/${w.slug}/design-systems/${ds.id}/versions/${versionId}/edit` }, 201); }); @@ -653,6 +709,7 @@ app.get("/api/w/:slug/design-systems/:id/versions/:v/export", async (c) => { const mapping = Object.fromEntries(rows.map((r) => [r.role, r.status === "off" ? null : r.token])); const extras = await c.env.FILES.get(extrasKey(v.id)); const file = exportDesignSystem(format, { name: ds.name, version: v.number, status: v.status, graph, mapping, contract: CONTRACT, extras: extras ? await extras.text() : undefined }); + if (c.get("apiWorkspace")) countFetch(c.env, w.id, "tokens"); c.header("Content-Type", file.contentType); c.header("Content-Disposition", `${c.req.query("download") === "1" ? "attachment" : "inline"}; filename="${file.fileName}"`); c.header("X-Polyxd-Design-System-Version", String(v.number)); @@ -936,6 +993,7 @@ app.get("/api/w/:slug/screens/:key", async (c) => { const doc = JSON.parse(v.document_json) as Doc; // A screen a person made says so, so the mark a product shows can too. doc.surface.origin ??= "authored"; + if (c.get("apiWorkspace")) countFetch(c.env, w.id, "screen"); c.header("X-Polyxd-Screen-Version", String(v.number)); return c.json(doc); }); @@ -995,6 +1053,7 @@ app.post("/api/w/:slug/screens/:key/versions", async (c) => { c.env.DB.prepare("INSERT INTO screen_versions (id, screen_id, number, document_json, notes, status, issues_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?)").bind(crypto.randomUUID(), s.id, number, JSON.stringify(document), b.notes ?? "", JSON.stringify(result), user.id, now()), c.env.DB.prepare("UPDATE screens SET intent = ?, updated_at = ? WHERE id = ?").bind(intent, now(), s.id), ]); + await pruneHistory(c.env, w, "screen_versions", s.id); return c.json({ number, ...result }, 201); }); @@ -1007,6 +1066,7 @@ app.post("/api/w/:slug/screens/:key/versions/:n/publish", async (c) => { const result = checkDocument(JSON.parse(v.document_json), { rules: await workspaceRules(c.env, w.id) }); const errors = result.issues.filter((i) => i.severity === "error"); if (errors.length) throw new Fail(409, `v${v.number} has ${errors.length} error${errors.length === 1 ? "" : "s"}: ${errors.slice(0, 3).map((e) => e.message).join("; ")}${errors.length > 3 ? "; …" : ""}. Fix them before publishing.`); + await assertCanCreate(c.env, w, { kind: "publishedScreen", screenId: s.id }); await c.env.DB.batch([ c.env.DB.prepare("UPDATE screen_versions SET status = 'draft', issues_json = ? WHERE screen_id = ? AND status = 'published'").bind(JSON.stringify(result), s.id), c.env.DB.prepare("UPDATE screen_versions SET status = 'published', issues_json = ? WHERE id = ?").bind(JSON.stringify(result), v.id), @@ -1102,6 +1162,7 @@ app.post("/api/w/:slug/directions", async (c) => { if (b.notes !== undefined) text(b.notes, 500, "Notes"); const taken = await c.env.DB.prepare("SELECT 1 FROM directions WHERE workspace_id = ? AND key = ?").bind(w.id, key).first(); if (taken) throw new Fail(409, `A Direction with the key ${key} already exists`); + await assertCanCreate(c.env, w, { kind: "direction" }); const snapshot = b.direction === undefined ? { direction: blankDirection(key), patterns: [] } : parseSnapshot(b); const { stored, issues } = checkSnapshot(snapshot, key, await directionRulesOf(c.env, w.id)); if (issues.length) return refuse(issues); @@ -1123,6 +1184,7 @@ app.get("/api/w/:slug/directions/:key", async (c) => { const d = await directionByKey(c as Ctx, w); const v = await c.env.DB.prepare("SELECT direction_json, patterns_json, number FROM direction_versions WHERE direction_id = ? AND status = 'published'").bind(d.id).first<{ direction_json: string; patterns_json: string; number: number }>(); if (!v) throw new Fail(404, `${d.name} has no published version yet`); + if (c.get("apiWorkspace")) countFetch(c.env, w.id, "direction"); c.header("X-Polyxd-Direction-Version", String(v.number)); return c.json(toExport(snapshotOf(v), d.key)); }); @@ -1193,6 +1255,7 @@ app.post("/api/w/:slug/directions/:key/versions", async (c) => { c.env.DB.prepare("INSERT INTO direction_versions (id, direction_id, number, direction_json, patterns_json, notes, status, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?)").bind(crypto.randomUUID(), d.id, number, JSON.stringify(stored.direction), JSON.stringify(stored.patterns), b.notes ?? "", user.id, now()), c.env.DB.prepare("UPDATE directions SET updated_at = ? WHERE id = ?").bind(now(), d.id), ]); + await pruneHistory(c.env, w, "direction_versions", d.id); return c.json({ number, snapshot: stored }, 201); }); @@ -1269,7 +1332,95 @@ app.delete("/api/w/:slug/insights", async (c) => { return c.json({ ok: true, deleted: r.meta.changes }); }); +// ---------------------------------------------------------------- plans and billing (hosted Studio only) + +const OWNERS = new Set(["owner"]); + +/** The workspace's plan and what it uses, for the Billing page; { enabled: false } on a self-hosted Studio. */ +app.get("/api/w/:slug/billing", async (c) => { + const w = await ws(c as Ctx); + if (!billingOn(c.env)) return c.json({ enabled: false }); + return c.json({ ...(await planSummary(c.env, w.id)), canManage: w.role === "owner" }); +}); + +const needStripe = (env: Env) => { + if (!billingOn(env)) throw new Fail(404, "This Studio has no billing: it runs without plans or limits"); + if (!stripeOn(env)) throw new Fail(503, "Billing isn't set up on this Studio yet"); +}; + +app.post("/api/w/:slug/billing/checkout", async (c) => { + const w = await ws(c as Ctx, OWNERS); + const user = need(c as Ctx); + needStripe(c.env); + const { plan, interval } = await body<{ plan?: string; interval?: string }>(c as Ctx); + if (!isPaidPlan(plan)) throw new Fail(400, "plan: pro or team"); + if (interval !== "month" && interval !== "year") throw new Fail(400, "interval: month or year"); + const price = priceFor(c.env, plan, interval); + if (!price) throw new Fail(503, `No ${plan} price is set up for paying by the ${interval}`); + const row = await c.env.DB.prepare("SELECT stripe_customer_id, stripe_subscription_id, plan_status FROM workspaces WHERE id = ?").bind(w.id).first<{ stripe_customer_id: string | null; stripe_subscription_id: string | null; plan_status: string | null }>(); + if (row?.stripe_subscription_id) throw new Fail(409, "This workspace already has a plan. Change it from Manage billing."); + const editors = (await c.env.DB.prepare("SELECT COUNT(*) AS n FROM memberships WHERE workspace_id = ? AND role != 'viewer'").bind(w.id).first<{ n: number }>())?.n ?? 1; + if (plan === "pro" && editors > 1) throw new Fail(409, `Pro is for one editor, and this workspace has ${editors}. Team fits, or make the others viewers first.`); + const params = (coupon: boolean) => checkoutParams(c.env, { workspaceId: w.id, slug: w.slug, plan, interval, seats: editors, customer: row?.stripe_customer_id ?? null, email: user.email, coupon }); + let session: { url: string }; + try { + session = await stripe<{ url: string }>(c.env, "POST", "/checkout/sessions", params(true)); + } catch (e) { + // The founding coupon runs out after 100 workspaces; then it's the full price. + if (!(e instanceof StripeError) || !c.env.STRIPE_COUPON_FOUNDING || e.status >= 500) throw e; + session = await stripe<{ url: string }>(c.env, "POST", "/checkout/sessions", params(false)); + } + return c.json({ url: session.url }); +}); + +app.post("/api/w/:slug/billing/portal", async (c) => { + const w = await ws(c as Ctx, OWNERS); + needStripe(c.env); + const row = await c.env.DB.prepare("SELECT stripe_customer_id FROM workspaces WHERE id = ?").bind(w.id).first<{ stripe_customer_id: string | null }>(); + if (!row?.stripe_customer_id) throw new Fail(409, "This workspace hasn't paid for a plan yet; pick one first"); + const session = await stripe<{ url: string }>(c.env, "POST", "/billing_portal/sessions", { customer: row.stripe_customer_id, return_url: `${c.env.APP_URL}/w/${w.slug}/billing` }); + return c.json({ url: session.url }); +}); + +/** Stripe tells Studio what changed. The only way a workspace's plan changes, besides by hand for Enterprise. */ +app.post("/api/billing/webhook", async (c) => { + if (!billingOn(c.env) || !c.env.STRIPE_WEBHOOK_SECRET) throw new Fail(404, "No such endpoint"); + const payload = await c.req.text(); + if (!(await verifySignature(payload, c.req.header("stripe-signature"), c.env.STRIPE_WEBHOOK_SECRET))) throw new Fail(400, "Bad signature"); + const event = JSON.parse(payload) as { type: string; data: { object: Record } }; + const o = event.data.object; + switch (event.type) { + case "checkout.session.completed": { + if (o.mode !== "subscription" || typeof o.subscription !== "string") break; + const id = (o.client_reference_id as string | null) ?? (o.metadata as Record | undefined)?.workspace_id; + if (!id) break; + await c.env.DB.prepare("UPDATE workspaces SET stripe_customer_id = ?, stripe_subscription_id = ? WHERE id = ?").bind(o.customer, o.subscription, id).run(); + await applySubscription(c.env, await stripe(c.env, "GET", `/subscriptions/${o.subscription}`), id); + break; + } + case "customer.subscription.created": + case "customer.subscription.updated": + await applySubscription(c.env, o as unknown as Subscription); + break; + case "customer.subscription.deleted": + await applySubscription(c.env, o as unknown as Subscription, undefined, true); + break; + case "invoice.payment_failed": + // Stripe retries and says so again with customer.subscription.updated; this shows it at once. + if (typeof o.customer === "string") await c.env.DB.prepare("UPDATE workspaces SET plan_status = 'past_due' WHERE stripe_customer_id = ? AND stripe_subscription_id IS NOT NULL").bind(o.customer).run(); + break; + } + return c.json({ received: true }); +}); + app.get("/api/contract", (c) => c.json({ roles: Object.keys(CONTRACT.tokens).length, contrastPairs: CONTRACT.contrast.length })); app.all("/api/*", (c) => c.json({ error: "No such endpoint" }, 404)); -export default app; +export { app }; +export default { + fetch: app.fetch, + /** The cron (wrangler.jsonc triggers): fetches rolled up into usage, and quotas checked. Does nothing without BILLING. */ + async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext) { + ctx.waitUntil(rollUp(env).then((r) => console.log(`usage rollup: ${r.workspaces} workspaces counted, ${r.over} over quota`))); + }, +} satisfies ExportedHandler; diff --git a/apps/studio/src/worker/plans.ts b/apps/studio/src/worker/plans.ts new file mode 100644 index 0000000..4bd4840 --- /dev/null +++ b/apps/studio/src/worker/plans.ts @@ -0,0 +1,279 @@ +/** + * Plans and their limits (docs/decisions/0004-pricing-and-licensing.md), in one table, and the + * checks the routes make against it. None of it applies unless BILLING is "on", which only the + * hosted Studio sets: a self-hosted Studio, free under its licence, has no limits at all. + * + * Hard limits (editors, design systems, Directions, published screens, workspaces owned) only + * stop something new being made. Fetches are soft: going over never breaks a product; after + * seven days over, editing is locked until the workspace upgrades, and fetches carry on. + */ +import type { Env } from "./auth.ts"; + +export const PLANS = ["free", "pro", "team", "enterprise"] as const; +export type Plan = (typeof PLANS)[number]; + +/** null is unlimited. */ +export interface Limits { + workspaces: number | null; + editors: number | null; + designSystems: number | null; + directions: number | null; + publishedScreens: number | null; + fetches: number | null; + /** Versions kept per screen, Direction or design system; the published one is always kept. */ + history: number | null; + privateMcp: boolean; + approvals: boolean; + sharedLibraries: boolean; +} + +export const LIMITS: Record = { + free: { workspaces: 1, editors: 2, designSystems: 1, directions: 1, publishedScreens: 10, fetches: 10_000, history: 10, privateMcp: false, approvals: false, sharedLibraries: false }, + pro: { workspaces: 3, editors: 1, designSystems: null, directions: null, publishedScreens: null, fetches: 250_000, history: null, privateMcp: true, approvals: false, sharedLibraries: false }, + team: { workspaces: null, editors: null, designSystems: null, directions: null, publishedScreens: null, fetches: 1_000_000, history: null, privateMcp: true, approvals: true, sharedLibraries: true }, + enterprise: { workspaces: null, editors: null, designSystems: null, directions: null, publishedScreens: null, fetches: 10_000_000, history: null, privateMcp: true, approvals: true, sharedLibraries: true }, +}; + +/** Dollars, for the app to show; Stripe's prices are the ones charged. Team is per editor. */ +export const PRICES = { pro: { month: 8, year: 80 }, team: { month: 12, year: 120 } } as const; +export const PLAN_NAMES: Record = { free: "Free", pro: "Pro", team: "Team", enterprise: "Enterprise" }; + +/** Days a workspace may stay over its fetch quota before editing locks. */ +export const GRACE_DAYS = 7; + +export const billingOn = (env: Env) => env.BILLING === "on"; +export const isPlan = (p: unknown): p is Plan => typeof p === "string" && (PLANS as readonly string[]).includes(p); +export const limitsFor = (plan: string): Limits => LIMITS[isPlan(plan) ? plan : "free"]; +/** Viewers are free; every other role takes a seat. */ +export const isEditor = (role: string) => role !== "viewer"; +/** 'YYYY-MM' in UTC: the period usage is counted in. */ +export const periodOf = (d: Date) => d.toISOString().slice(0, 7); + +/** A 402 the app turns into an upgrade prompt. */ +export class PlanLimit extends Error { + status = 402; + data: { code: "plan_limit" | "over_quota"; limit: string; plan: string; current?: number; max?: number | null }; + constructor(message: string, data: PlanLimit["data"]) { + super(message); + this.data = data; + } +} + +export interface PlanWorkspace { + id: string; + plan: string; + over_quota_since: string | null; +} + +/** People in editor roles, and (with invites) the editor invites still open, which hold a seat too. */ +export async function editorCount(env: Env, workspaceId: string, withInvites = false): Promise { + const r = await env.DB.prepare( + `SELECT (SELECT COUNT(*) FROM memberships WHERE workspace_id = ? AND role != 'viewer') AS members, + (SELECT COUNT(*) FROM invites WHERE workspace_id = ? AND role != 'viewer' AND accepted_at IS NULL AND expires_at > ?) AS invited`, + ).bind(workspaceId, workspaceId, new Date().toISOString()).first<{ members: number; invited: number }>(); + return (r?.members ?? 0) + (withInvites ? (r?.invited ?? 0) : 0); +} + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; +const upgrade = (plan: string) => (plan === "free" ? "Upgrade to Pro or Team for more." : plan === "pro" ? "Team has no limit." : ""); + +export type Creatable = + | { kind: "workspace"; userId: string } + | { kind: "editors"; adding: number; pending: boolean } + | { kind: "designSystem" } + | { kind: "direction" } + | { kind: "publishedScreen"; screenId: string }; + +/** + * Throws a 402 when the workspace's plan has no room for one more of something. Workspaces are + * counted per owner: the most generous plan among the workspaces a person owns sets how many + * they may own (a Free person owns one). + */ +export async function assertCanCreate(env: Env, w: PlanWorkspace | null, what: Creatable): Promise { + if (!billingOn(env)) return; + const fail = (limit: keyof Limits, plan: string, current: number, max: number, words: string) => { + throw new PlanLimit(`${PLAN_NAMES[isPlan(plan) ? plan : "free"]} has room for ${words}. ${upgrade(plan)}`.trim(), { code: "plan_limit", limit, plan, current, max }); + }; + if (what.kind === "workspace") { + const owned = await env.DB.prepare("SELECT w.plan FROM workspaces w JOIN memberships m ON m.workspace_id = w.id WHERE m.user_id = ? AND m.role = 'owner'").bind(what.userId).all<{ plan: string }>(); + const allowances = owned.results.map((r) => limitsFor(r.plan).workspaces); + const max = allowances.includes(null) ? null : Math.max(LIMITS.free.workspaces!, ...(allowances as number[])); + const best = owned.results.reduce((b, r) => (PLANS.indexOf(r.plan as Plan) > PLANS.indexOf(b as Plan) ? r.plan : b), "free"); + if (max !== null && owned.results.length >= max) fail("workspaces", best, owned.results.length, max, `${plural(max, "workspace")} of your own`); + return; + } + if (!w) return; + const limits = limitsFor(w.plan); + if (what.kind === "editors") { + if (limits.editors === null) return; + const current = await editorCount(env, w.id, what.pending); + if (current + what.adding > limits.editors) fail("editors", w.plan, current, limits.editors, `${plural(limits.editors, "editor")} (viewers are free)`); + return; + } + const count = async (sql: string, ...args: string[]) => (await env.DB.prepare(sql).bind(...args).first<{ n: number }>())?.n ?? 0; + if (what.kind === "designSystem" && limits.designSystems !== null) { + const n = await count("SELECT COUNT(*) AS n FROM design_systems WHERE workspace_id = ?", w.id); + if (n >= limits.designSystems) fail("designSystems", w.plan, n, limits.designSystems, plural(limits.designSystems, "design system")); + } + if (what.kind === "direction" && limits.directions !== null) { + const n = await count("SELECT COUNT(*) AS n FROM directions WHERE workspace_id = ?", w.id); + if (n >= limits.directions) fail("directions", w.plan, n, limits.directions, plural(limits.directions, "Direction")); + } + if (what.kind === "publishedScreen" && limits.publishedScreens !== null) { + // Publishing a new version of a screen that is already published adds nothing. + const n = await count("SELECT COUNT(*) AS n FROM screens WHERE workspace_id = ? AND status = 'published' AND id != ?", w.id, what.screenId); + if (n >= limits.publishedScreens) fail("publishedScreens", w.plan, n, limits.publishedScreens, plural(limits.publishedScreens, "published screen")); + } +} + +/** When the grace period ends for a workspace over its fetch quota, or null. */ +export const lockedFrom = (w: PlanWorkspace): Date | null => (w.over_quota_since ? new Date(Date.parse(w.over_quota_since) + GRACE_DAYS * 86400e3) : null); + +/** Throws a 402 once a workspace has been over its fetch quota for longer than the grace period. */ +export function assertCanEdit(env: Env, w: PlanWorkspace, at = new Date()): void { + if (!billingOn(env)) return; + const from = lockedFrom(w); + if (from && from <= at) { + throw new PlanLimit(`This workspace has been over its monthly fetches for more than ${GRACE_DAYS} days, so editing is paused. Products still get their screens. Upgrade to edit again.`, { code: "over_quota", limit: "fetches", plan: w.plan }); + } +} + +/** + * Keeps a Free workspace's history to its last versions, the published or live one always among + * those kept. Returns the ids removed, so a design system's files can go too. + */ +export async function pruneHistory(env: Env, w: PlanWorkspace, table: "screen_versions" | "direction_versions" | "ds_versions", parentId: string): Promise { + const keep = limitsFor(w.plan).history; + if (!billingOn(env) || keep === null) return []; + const parent = table === "screen_versions" ? "screen_id" : table === "direction_versions" ? "direction_id" : "design_system_id"; + const old = await env.DB.prepare( + `SELECT id FROM ${table} WHERE ${parent} = ? AND status NOT IN ('published', 'live') + AND number NOT IN (SELECT number FROM ${table} WHERE ${parent} = ? ORDER BY number DESC LIMIT ?)`, + ).bind(parentId, parentId, keep).all<{ id: string }>(); + const ids = old.results.map((r) => r.id); + if (ids.length) await env.DB.batch(ids.map((id) => env.DB.prepare(`DELETE FROM ${table} WHERE id = ?`).bind(id))); + return ids; +} + +// ---------------------------------------------------------------- fetch metering + +export const FETCH_DATASET = "polyxd_studio_fetches"; + +/** + * Counts one fetch by key (a screen, a Direction, a design system's tokens). One Analytics Engine + * data point, written without waiting and never to D1; the rollup below adds them up. + */ +export function countFetch(env: Env, workspaceId: string, kind: "screen" | "direction" | "tokens"): void { + if (!billingOn(env) || !env.FETCHES) return; + try { + env.FETCHES.writeDataPoint({ indexes: [workspaceId], blobs: [kind], doubles: [1] }); + } catch (e) { + console.error("fetch metering", e); + } +} + +const sqlTime = (d: Date) => `toDateTime('${d.toISOString().slice(0, 19).replace("T", " ")}')`; + +/** A month's fetches per workspace, read back from Analytics Engine's SQL API. */ +export async function readFetches(env: Env, period: string): Promise> { + const [y, m] = period.split("-").map(Number); + const from = new Date(Date.UTC(y, m - 1, 1)); + const to = new Date(Date.UTC(y, m, 1)); + const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/analytics_engine/sql`, { + method: "POST", + headers: { authorization: `Bearer ${env.CF_ANALYTICS_TOKEN}` }, + // _sample_interval: Analytics Engine may sample at volume; each point stands for that many. + body: `SELECT index1 AS workspace, SUM(_sample_interval) AS fetches FROM ${FETCH_DATASET} WHERE timestamp >= ${sqlTime(from)} AND timestamp < ${sqlTime(to)} GROUP BY index1`, + }); + if (!r.ok) throw new Error(`Analytics Engine answered ${r.status}: ${(await r.text()).slice(0, 300)}`); + const body = (await r.json()) as { data?: { workspace: string; fetches: number | string }[] }; + return new Map((body.data ?? []).map((row) => [row.workspace, Math.round(Number(row.fetches))])); +} + +/** Whether a count is over a plan's fetches, and so what over_quota_since should become. */ +export const overQuotaSince = (plan: string, count: number, since: string | null, at: Date): string | null => { + const limit = limitsFor(plan).fetches; + return limit !== null && count > limit ? (since ?? at.toISOString()) : null; +}; + +/** + * The scheduled rollup: this month's (and last month's, which may still be settling) fetches into + * `usage`, as totals so a run can repeat safely, and over_quota_since set or cleared from this + * month's. A new month starts from zero, so a lock lifts on the first of the month too. + * TODO: email owners at 80% and 100% (the Billing page shows both already). + */ +export async function rollUp(env: Env, at = new Date()): Promise<{ workspaces: number; over: number }> { + if (!billingOn(env) || !env.CF_ACCOUNT_ID || !env.CF_ANALYTICS_TOKEN) return { workspaces: 0, over: 0 }; + const period = periodOf(at); + const previous = periodOf(new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth() - 1, 1))); + const stamp = at.toISOString(); + const statements: D1PreparedStatement[] = []; + const known = new Set((await env.DB.prepare("SELECT id FROM workspaces").all<{ id: string }>()).results.map((r) => r.id)); + let current = new Map(); + for (const p of [previous, period]) { + const counts = await readFetches(env, p); + if (p === period) current = counts; + for (const [id, n] of counts) { + if (!known.has(id)) continue; + statements.push(env.DB.prepare("INSERT INTO usage (workspace_id, metric, period, count, updated_at) VALUES (?, 'fetches', ?, ?, ?) ON CONFLICT (workspace_id, metric, period) DO UPDATE SET count = excluded.count, updated_at = excluded.updated_at").bind(id, p, n, stamp)); + } + } + const rows = await env.DB.prepare("SELECT id, plan, over_quota_since FROM workspaces").all(); + let over = 0; + for (const w of rows.results) { + const next = overQuotaSince(w.plan, current.get(w.id) ?? 0, w.over_quota_since, at); + if (next) over++; + if (next !== w.over_quota_since) statements.push(env.DB.prepare("UPDATE workspaces SET over_quota_since = ? WHERE id = ?").bind(next, w.id)); + } + for (let i = 0; i < statements.length; i += 100) await env.DB.batch(statements.slice(i, i + 100)); + return { workspaces: current.size, over }; +} + +// ---------------------------------------------------------------- what the app shows + +export interface BillingRow extends PlanWorkspace { + plan_status: string | null; + billing_interval: string | null; + seats: number | null; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + period_end: string | null; +} + +/** A workspace's plan, its limits and how much of each it uses, for the Billing page. */ +export async function planSummary(env: Env, workspaceId: string, at = new Date()) { + const w = await env.DB.prepare("SELECT id, plan, plan_status, billing_interval, seats, stripe_customer_id, stripe_subscription_id, period_end, over_quota_since FROM workspaces WHERE id = ?").bind(workspaceId).first(); + if (!w) throw new Error("no such workspace"); + const n = async (sql: string) => (await env.DB.prepare(sql).bind(workspaceId).first<{ n: number }>())?.n ?? 0; + const usage = { + editors: await editorCount(env, workspaceId), + pendingEditors: (await editorCount(env, workspaceId, true)) - (await editorCount(env, workspaceId)), + viewers: await n("SELECT COUNT(*) AS n FROM memberships WHERE workspace_id = ? AND role = 'viewer'"), + designSystems: await n("SELECT COUNT(*) AS n FROM design_systems WHERE workspace_id = ?"), + directions: await n("SELECT COUNT(*) AS n FROM directions WHERE workspace_id = ?"), + publishedScreens: await n("SELECT COUNT(*) AS n FROM screens WHERE workspace_id = ? AND status = 'published'"), + fetches: (await env.DB.prepare("SELECT count FROM usage WHERE workspace_id = ? AND metric = 'fetches' AND period = ?").bind(workspaceId, periodOf(at)).first<{ count: number }>())?.count ?? 0, + }; + const limits = limitsFor(w.plan); + const locked = lockedFrom(w); + return { + enabled: true as const, + plan: isPlan(w.plan) ? w.plan : "free", + status: w.plan_status, + interval: w.billing_interval, + seats: w.seats, + periodEnd: w.period_end, + subscribed: !!w.stripe_subscription_id, + customer: !!w.stripe_customer_id, + limits, + usage, + period: periodOf(at), + fetchesPercent: limits.fetches ? Math.round((usage.fetches / limits.fetches) * 100) : null, + overQuotaSince: w.over_quota_since, + lockedFrom: locked?.toISOString() ?? null, + locked: !!locked && locked <= at, + prices: PRICES, + founding: !!env.STRIPE_COUPON_FOUNDING, + checkout: !!env.STRIPE_SECRET_KEY, + }; +} diff --git a/apps/studio/test/billing.worker.test.ts b/apps/studio/test/billing.worker.test.ts new file mode 100644 index 0000000..0d97eb7 --- /dev/null +++ b/apps/studio/test/billing.worker.test.ts @@ -0,0 +1,381 @@ +/** + * Plans, limits, fetch metering and Stripe, through the Worker itself (test/support/worker.ts). + * Two Studios: a self-hosted one (no BILLING), which has no limits and no billing at all, and + * the hosted one (BILLING=on), where the plan table applies. Stripe and the Analytics Engine + * SQL API are never called: global fetch is replaced for the requests that would go to them. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { startWorker } from "./support/worker.ts"; +import { form, signPayload, verifySignature } from "../src/worker/billing.ts"; +import { LIMITS } from "../src/worker/plans.ts"; + +type Worker = Awaited>; + +const STRIPE = { + BILLING: "on", + STRIPE_SECRET_KEY: "sk_test_fake", + STRIPE_WEBHOOK_SECRET: "whsec_test_fake", + STRIPE_PRICE_PRO_MONTH: "price_pro_month", + STRIPE_PRICE_PRO_YEAR: "price_pro_year", + STRIPE_PRICE_TEAM_MONTH: "price_team_month", + STRIPE_PRICE_TEAM_YEAR: "price_team_year", + STRIPE_COUPON_FOUNDING: "FOUNDING", +}; + +/** Someone signed up, without a workspace of their own. */ +async function person(worker: Worker, email: string) { + const p = worker.client(); + const r = await p.call("POST", "/api/auth/sign-up/email", { name: email.split("@")[0], email, password: `test-${crypto.randomUUID()}` }); + assert.equal(r.status, 200); + return p; +} + +/** An invite made and accepted; the accept's answer, and the person who joined. */ +async function join(worker: Worker, owner: Awaited>, slug: string, email: string, role: string) { + const inv = await owner.call("POST", `/api/w/${slug}/invites`, { emails: [email], role }); + assert.equal(inv.status, 201, JSON.stringify(inv.body)); + const them = await person(worker, email); + return { ...(await them.call("POST", `/api/invites/${inv.body.invites[0].id}/accept`)), as: them }; +} + +const setPlan = (worker: Worker, slug: string, plan: string) => worker.env.DB.prepare("UPDATE workspaces SET plan = ? WHERE slug = ?").bind(plan, slug).run(); +const row = (worker: Worker, slug: string) => worker.env.DB.prepare("SELECT * FROM workspaces WHERE slug = ?").bind(slug).first>(); + +/** Replaces fetch for the duration of fn; `answer` sees each outbound request. */ +async function withFetch(answer: (url: string, init: RequestInit) => Response | Promise, fn: () => Promise) { + const real = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => answer(String(input), init)) as typeof fetch; + try { + await fn(); + } finally { + globalThis.fetch = real; + } +} +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +test("a self-hosted Studio has no limits and no billing", async () => { + const worker = await startWorker(); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const w = "/api/w/harbourline"; + assert.equal((await mira.call("GET", "/api/me")).body.billing, false); + assert.deepEqual((await mira.call("GET", `${w}/billing`)).body, { enabled: false }); + // More of everything than Free allows. + for (const name of ["Second", "Third"]) assert.equal((await mira.call("POST", "/api/workspaces", { name })).status, 201); + assert.equal((await mira.call("POST", `${w}/invites`, { emails: ["a@x.test", "b@x.test", "c@x.test"], role: "designer" })).status, 201); + for (const t of ["mono", "civic"]) assert.equal((await mira.call("POST", `${w}/design-systems/from-template`, { template: t })).status, 201); + for (const name of ["One", "Two"]) assert.equal((await mira.call("POST", `${w}/directions`, { name })).status, 201); + // No billing routes: checkout and the webhook aren't there. + assert.equal((await mira.call("POST", `${w}/billing/checkout`, { plan: "pro", interval: "month" })).status, 404); + assert.equal((await worker.client().call("POST", "/api/billing/webhook", {})).status, 404); + // History is kept whole. + assert.equal((await mira.call("POST", `${w}/screens`, { name: "Send" })).status, 201); + for (let i = 0; i < 11; i++) assert.equal((await mira.call("POST", `${w}/screens/send/versions`, { document: (await mira.call("GET", `${w}/screens/send/versions/1`)).body.document })).status, 201); + assert.equal((await mira.call("GET", `${w}/screens/send/versions`)).body.versions.length, 12); +}); + +test("Free: one workspace owned, two editors, one design system, one Direction, ten published screens", async () => { + const worker = await startWorker({ BILLING: "on" }); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const w = "/api/w/harbourline"; + assert.equal((await mira.call("GET", "/api/me")).body.billing, true); + + // Workspaces: a Free owner owns one. + const second = await mira.call("POST", "/api/workspaces", { name: "Second" }); + assert.equal(second.status, 402); + assert.equal(second.body.code, "plan_limit"); + assert.equal(second.body.limit, "workspaces"); + assert.equal(second.body.plan, "free"); + assert.match(second.body.error, /Free has room for 1 workspace of your own/); + // Owning a Pro workspace allows three. + await setPlan(worker, "harbourline", "pro"); + assert.equal((await mira.call("POST", "/api/workspaces", { name: "Second" })).status, 201); + assert.equal((await mira.call("POST", "/api/workspaces", { name: "Third" })).status, 201); + assert.equal((await mira.call("POST", "/api/workspaces", { name: "Fourth" })).status, 402); + await setPlan(worker, "harbourline", "free"); + + // Editors: the owner is one; an open invite holds a seat; viewers are free. + const two = await mira.call("POST", `${w}/invites`, { emails: ["a@x.test", "b@x.test"], role: "designer" }); + assert.equal(two.status, 402); + assert.deepEqual([two.body.limit, two.body.current, two.body.max], ["editors", 1, 2]); + assert.equal((await mira.call("POST", `${w}/invites`, { emails: ["a@x.test"], role: "designer" })).status, 201); + assert.equal((await mira.call("POST", `${w}/invites`, { emails: ["b@x.test"], role: "engineer" })).status, 402); + assert.equal((await mira.call("POST", `${w}/invites`, { emails: ["v1@x.test", "v2@x.test", "v3@x.test"], role: "viewer" })).status, 201); + // Withdrawing the open invite frees its seat. + const open = (await mira.call("GET", w)).body.invites.find((i: { email: string }) => i.email === "a@x.test"); + assert.equal((await mira.call("DELETE", `${w}/invites/${open.id}`)).status, 200); + assert.equal((await join(worker, mira, "harbourline", "b@x.test", "engineer")).status, 200); + + // Design systems: import or template, one in all. + assert.equal((await mira.call("POST", `${w}/design-systems/from-template`, { template: "mono" })).status, 201); + const ds = await mira.call("POST", `${w}/design-systems/from-template`, { template: "civic" }); + assert.equal(ds.status, 402); + assert.equal(ds.body.limit, "designSystems"); + + // Directions. + assert.equal((await mira.call("POST", `${w}/directions`, { name: "One" })).status, 201); + const dir = await mira.call("POST", `${w}/directions`, { name: "Two" }); + assert.equal(dir.status, 402); + assert.equal(dir.body.limit, "directions"); + + // Published screens: ten; a new version of a published one isn't another. + for (let i = 1; i <= 11; i++) assert.equal((await mira.call("POST", `${w}/screens`, { name: `Screen ${i}` })).status, 201); + for (let i = 1; i <= 10; i++) assert.equal((await mira.call("POST", `${w}/screens/screen-${i}/versions/1/publish`)).status, 200); + const eleventh = await mira.call("POST", `${w}/screens/screen-11/versions/1/publish`); + assert.equal(eleventh.status, 402); + assert.equal(eleventh.body.limit, "publishedScreens"); + const doc = (await mira.call("GET", `${w}/screens/screen-1/versions/1`)).body.document; + assert.equal((await mira.call("POST", `${w}/screens/screen-1/versions`, { document: doc })).status, 201); + assert.equal((await mira.call("POST", `${w}/screens/screen-1/versions/2/publish`)).status, 200); + + // History: the last ten versions, and the published one wherever it is. + for (let i = 0; i < 12; i++) await mira.call("POST", `${w}/screens/screen-1/versions`, { document: doc }); + const kept = (await mira.call("GET", `${w}/screens/screen-1/versions`)).body.versions.map((v: { number: number }) => v.number); + assert.deepEqual(kept, [14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 2]); + + // The Billing page's numbers. + const b = (await mira.call("GET", `${w}/billing`)).body; + assert.equal(b.enabled, true); + assert.equal(b.plan, "free"); + assert.equal(b.canManage, true); + assert.deepEqual([b.usage.editors, b.usage.viewers, b.usage.designSystems, b.usage.directions, b.usage.publishedScreens], [2, 0, 1, 1, 10]); + assert.equal(b.limits.fetches, LIMITS.free.fetches); + + // Team: no editor limit. + await setPlan(worker, "harbourline", "team"); + assert.equal((await mira.call("POST", `${w}/invites`, { emails: ["c@x.test", "d@x.test"], role: "designer" })).status, 201); + assert.equal((await mira.call("POST", `${w}/directions`, { name: "Two" })).status, 201); +}); + +test("accepting an editor invite is checked again, in case the plan changed", async () => { + const worker = await startWorker({ BILLING: "on" }); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + await setPlan(worker, "harbourline", "team"); + const inv = await mira.call("POST", "/api/w/harbourline/invites", { emails: ["a@x.test", "b@x.test"], role: "designer" }); + const viewerInv = await mira.call("POST", "/api/w/harbourline/invites", { emails: ["v@x.test"], role: "viewer" }); + await setPlan(worker, "harbourline", "free"); + const a = await person(worker, "a@x.test"); + assert.equal((await a.call("POST", `/api/invites/${inv.body.invites[0].id}/accept`)).status, 200); + const b = await person(worker, "b@x.test"); + const refused = await b.call("POST", `/api/invites/${inv.body.invites[1].id}/accept`); + assert.equal(refused.status, 402); + assert.equal(refused.body.limit, "editors"); + const v = await person(worker, "v@x.test"); + assert.equal((await v.call("POST", `/api/invites/${viewerInv.body.invites[0].id}/accept`)).status, 200); + // An owner can take someone out, which frees the seat; the last owner stays. + const members = (await mira.call("GET", "/api/w/harbourline")).body.members as { id: string; email: string }[]; + assert.equal((await mira.call("DELETE", `/api/w/harbourline/members/${members.find((m) => m.email === "a@x.test")!.id}`)).status, 200); + assert.equal((await b.call("POST", `/api/invites/${inv.body.invites[1].id}/accept`)).status, 200); + assert.equal((await mira.call("DELETE", `/api/w/harbourline/members/${members.find((m) => m.email === "mira@harbourline.test")!.id}`)).status, 409); +}); + +test("fetches by key are counted without D1, rolled up by the cron, and a week over quota locks editing but never fetching", async () => { + const points: { indexes?: unknown[]; blobs?: unknown[] }[] = []; + const worker = await startWorker({ BILLING: "on", FETCHES: { writeDataPoint: (p: { indexes?: unknown[]; blobs?: unknown[] }) => points.push(p) }, CF_ACCOUNT_ID: "acc", CF_ANALYTICS_TOKEN: "tok" }); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const w = "/api/w/harbourline"; + const id = (await row(worker, "harbourline"))!.id as string; + assert.equal((await mira.call("POST", `${w}/screens`, { name: "Send" })).status, 201); + assert.equal((await mira.call("POST", `${w}/screens/send/versions/1/publish`)).status, 200); + const key = (await mira.call("POST", `${w}/api-keys`, { name: "web" })).body.key; + const product = worker.client({ key }); + + // A product's fetch is one data point; a person looking in Studio isn't a fetch. + assert.equal((await product.call("GET", `${w}/screens/send`)).status, 200); + assert.equal((await mira.call("GET", `${w}/screens/send`)).status, 200); + assert.deepEqual(points, [{ indexes: [id], blobs: ["screen"], doubles: [1] }]); + + // The rollup asks Analytics Engine for this month and last, and writes totals. + const asked: string[] = []; + let count = 12_000; + const ae = (url: string, init: RequestInit) => { + assert.equal(url, "https://api.cloudflare.com/client/v4/accounts/acc/analytics_engine/sql"); + assert.equal((init.headers as Record).authorization, "Bearer tok"); + asked.push(String(init.body)); + return json({ data: [{ workspace: id, fetches: String(count) }, { workspace: "gone", fetches: 5 }] }); + }; + await withFetch(ae, () => worker.scheduled()); + assert.equal(asked.length, 2); + assert.match(asked[1], /SUM\(_sample_interval\).*FROM polyxd_studio_fetches.*GROUP BY index1/); + const since = (await row(worker, "harbourline"))!.over_quota_since as string; + assert.ok(since, "over quota from this run"); + const billing = (await mira.call("GET", `${w}/billing`)).body; + assert.equal(billing.usage.fetches, 12_000); + assert.equal(billing.fetchesPercent, 120); + assert.equal(billing.locked, false); + + // Within the grace period, editing works; a second run keeps the first date. + assert.equal((await mira.call("POST", `${w}/screens`, { name: "Grace" })).status, 201); + await withFetch(ae, () => worker.scheduled()); + assert.equal((await row(worker, "harbourline"))!.over_quota_since, since); + + // Eight days over: changes are refused with a 402; fetches and reads carry on; billing still opens. + await worker.env.DB.prepare("UPDATE workspaces SET over_quota_since = ? WHERE id = ?").bind(new Date(Date.now() - 8 * 86400e3).toISOString(), id).run(); + const locked = await mira.call("POST", `${w}/screens`, { name: "Locked" }); + assert.equal(locked.status, 402); + assert.equal(locked.body.code, "over_quota"); + assert.equal((await product.call("GET", `${w}/screens/send`)).status, 200); + assert.equal((await mira.call("GET", `${w}/screens`)).status, 200); + assert.equal((await mira.call("POST", `${w}/billing/checkout`, { plan: "pro", interval: "month" })).status, 503, "not locked, just not set up"); + assert.equal((await mira.call("GET", `${w}/billing`)).body.locked, true); + + // Under quota again (a new month, or an upgrade): the lock lifts. + count = 900; + await withFetch(ae, () => worker.scheduled()); + assert.equal((await row(worker, "harbourline"))!.over_quota_since, null); + assert.equal((await mira.call("POST", `${w}/screens`, { name: "Unlocked" })).status, 201); +}); + +test("checkout: owners only, the plan's price, Team's seats, the founding coupon until it runs out", async () => { + const worker = await startWorker(STRIPE); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const w = "/api/w/harbourline"; + const sent: URLSearchParams[] = []; + let couponLeft = true; + const stripe = (url: string, init: RequestInit) => { + assert.equal(url, "https://api.stripe.com/v1/checkout/sessions"); + assert.equal((init.headers as Record).authorization, "Bearer sk_test_fake"); + const p = new URLSearchParams(String(init.body)); + sent.push(p); + if (p.get("discounts[0][coupon]") && !couponLeft) return json({ error: { message: "Coupon expired", code: "coupon_expired" } }, 400); + return json({ id: "cs_1", url: "https://checkout.stripe.com/c/pay/cs_1" }); + }; + await withFetch(stripe, async () => { + const r = await mira.call("POST", `${w}/billing/checkout`, { plan: "pro", interval: "year" }); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.url, "https://checkout.stripe.com/c/pay/cs_1"); + const p = sent[0]; + assert.equal(p.get("mode"), "subscription"); + assert.equal(p.get("line_items[0][price]"), "price_pro_year"); + assert.equal(p.get("line_items[0][quantity]"), "1"); + assert.equal(p.get("discounts[0][coupon]"), "FOUNDING"); + assert.equal(p.get("customer_email"), "mira@harbourline.test"); + const id = (await row(worker, "harbourline"))!.id; + assert.equal(p.get("client_reference_id"), id); + assert.equal(p.get("subscription_data[metadata][workspace_id]"), id); + assert.equal(p.get("success_url"), "http://localhost:8789/w/harbourline/billing?upgraded=1"); + + // Two editors: Pro is for one, Team bills both. + const leo = await join(worker, mira, "harbourline", "leo@harbourline.test", "designer"); + assert.equal(leo.status, 200); + assert.equal((await mira.call("POST", `${w}/billing/checkout`, { plan: "pro", interval: "month" })).status, 409); + couponLeft = false; + const team = await mira.call("POST", `${w}/billing/checkout`, { plan: "team", interval: "month" }); + assert.equal(team.status, 200); + const [withCoupon, without] = sent.slice(-2); + assert.equal(withCoupon.get("discounts[0][coupon]"), "FOUNDING"); + assert.equal(without.get("discounts[0][coupon]"), null, "retried at full price"); + assert.equal(without.get("allow_promotion_codes"), "true"); + assert.equal(without.get("line_items[0][price]"), "price_team_month"); + assert.equal(without.get("line_items[0][quantity]"), "2"); + + // Only an owner pays; nonsense is refused before Stripe is asked. + const count = sent.length; + assert.equal((await leo.as.call("POST", `${w}/billing/checkout`, { plan: "team", interval: "month" })).status, 403); + assert.equal((await leo.as.call("POST", `${w}/billing/portal`)).status, 403); + assert.equal((await leo.as.call("GET", `${w}/billing`)).body.canManage, false); + assert.equal(sent.length, count); + assert.equal((await mira.call("POST", `${w}/billing/checkout`, { plan: "enterprise", interval: "month" })).status, 400); + assert.equal((await mira.call("POST", `${w}/billing/portal`)).status, 409, "no customer yet"); + }); +}); + +test("the webhook: signature checked, plan set from the subscription, seats synced, back to Free when it ends", async () => { + const worker = await startWorker(STRIPE); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const id = (await row(worker, "harbourline"))!.id as string; + const hook = worker.client(); + const send = async (event: unknown, secret = STRIPE.STRIPE_WEBHOOK_SECRET) => { + const payload = JSON.stringify(event); + const res = await worker.app.request("http://localhost:8789/api/billing/webhook", { method: "POST", headers: { "content-type": "application/json", "stripe-signature": await signPayload(payload, secret) }, body: payload }, worker.env); + return res.status; + }; + const end = Math.floor(Date.now() / 1000) + 30 * 86400; + const sub = (price: string, quantity: number, status = "active") => ({ id: "sub_1", customer: "cus_1", status, metadata: { workspace_id: id }, items: { data: [{ id: "si_1", quantity, current_period_end: end, price: { id: price } }] } }); + + // A request Stripe didn't sign is refused. + assert.equal(await send({ type: "customer.subscription.updated", data: { object: sub("price_team_month", 9) } }, "whsec_wrong"), 400); + assert.equal((await hook.call("POST", "/api/billing/webhook", { type: "x" })).status, 400); + assert.equal((await row(worker, "harbourline"))!.plan, "free"); + + // Checkout finished: Studio reads the subscription and sets the plan. + const stripeCalls: { url: string; body: string }[] = []; + let quantity = 1; + await withFetch((url, init) => { + stripeCalls.push({ url, body: String(init.body ?? "") }); + if (url === "https://api.stripe.com/v1/subscriptions/sub_1") return json(sub("price_team_month", quantity)); + if (url === "https://api.stripe.com/v1/subscription_items/si_1") return json({ id: "si_1" }); + return json({ error: { message: "unexpected" } }, 500); + }, async () => { + assert.equal(await send({ type: "checkout.session.completed", data: { object: { mode: "subscription", client_reference_id: id, customer: "cus_1", subscription: "sub_1" } } }), 200); + let w = (await row(worker, "harbourline"))!; + assert.deepEqual([w.plan, w.plan_status, w.billing_interval, w.seats, w.stripe_customer_id, w.stripe_subscription_id], ["team", "active", "month", 1, "cus_1", "sub_1"]); + assert.equal(w.period_end, new Date(end * 1000).toISOString()); + const billing = (await mira.call("GET", "/api/w/harbourline/billing")).body; + assert.equal(billing.plan, "team"); + assert.equal(billing.subscribed, true); + + // A new editor joins: the subscription's quantity follows, prorated. A viewer doesn't count. + assert.equal((await join(worker, mira, "harbourline", "leo@harbourline.test", "designer")).status, 200); + const change = stripeCalls.find((c) => c.url.endsWith("/subscription_items/si_1"))!; + assert.deepEqual(Object.fromEntries(new URLSearchParams(change.body)), { quantity: "2", proration_behavior: "create_prorations" }); + assert.equal((await row(worker, "harbourline"))!.seats, 2); + quantity = 2; + const before = stripeCalls.length; + assert.equal((await join(worker, mira, "harbourline", "ana@harbourline.test", "viewer")).status, 200); + assert.equal(stripeCalls.length, before, "no Stripe call for a viewer"); + + // Checkout again while subscribed is refused; the portal opens. + assert.equal((await mira.call("POST", "/api/w/harbourline/billing/checkout", { plan: "team", interval: "year" })).status, 409); + w = (await row(worker, "harbourline"))!; + assert.equal(w.plan, "team"); + }); + + // Changed in the portal to Pro yearly. + assert.equal(await send({ type: "customer.subscription.updated", data: { object: sub("price_pro_year", 1) } }), 200); + let w = (await row(worker, "harbourline"))!; + assert.deepEqual([w.plan, w.billing_interval], ["pro", "year"]); + + // A failed payment shows at once; Stripe keeps retrying, so the plan stays. + assert.equal(await send({ type: "invoice.payment_failed", data: { object: { customer: "cus_1" } } }), 200); + w = (await row(worker, "harbourline"))!; + assert.deepEqual([w.plan, w.plan_status], ["pro", "past_due"]); + + // A price no STRIPE_PRICE_* names changes nothing. + assert.equal(await send({ type: "customer.subscription.updated", data: { object: sub("price_other", 1) } }), 200); + assert.equal((await row(worker, "harbourline"))!.plan, "pro"); + + // Cancelled: back to Free, the customer kept for next time. + assert.equal(await send({ type: "customer.subscription.deleted", data: { object: sub("price_pro_year", 1, "canceled") } }), 200); + w = (await row(worker, "harbourline"))!; + assert.deepEqual([w.plan, w.plan_status, w.stripe_subscription_id, w.stripe_customer_id, w.period_end], ["free", "canceled", null, "cus_1", null]); + + // The portal, for a workspace with a customer. + await withFetch((url, init) => { + assert.equal(url, "https://api.stripe.com/v1/billing_portal/sessions"); + const p = new URLSearchParams(String(init.body)); + assert.equal(p.get("customer"), "cus_1"); + assert.equal(p.get("return_url"), "http://localhost:8789/w/harbourline/billing"); + return json({ url: "https://billing.stripe.com/p/session/1" }); + }, async () => { + const r = await mira.call("POST", "/api/w/harbourline/billing/portal"); + assert.equal(r.status, 200); + assert.equal(r.body.url, "https://billing.stripe.com/p/session/1"); + }); +}); + +test("Stripe's signature and form encoding", async () => { + const payload = '{"id":"evt_1"}'; + const t = 1_800_000_000; + const header = await signPayload(payload, "whsec_x", t); + assert.equal(await verifySignature(payload, header, "whsec_x", 300, t + 10), true); + assert.equal(await verifySignature(payload, header, "whsec_x", 300, t + 301), false, "too old"); + assert.equal(await verifySignature(payload + " ", header, "whsec_x", 300, t), false, "body changed"); + assert.equal(await verifySignature(payload, header, "whsec_y", 300, t), false, "another secret"); + assert.equal(await verifySignature(payload, `t=${t},v1=00,${header.split(",")[1]}`, "whsec_x", 300, t), true, "any v1 may match"); + assert.equal(await verifySignature(payload, undefined, "whsec_x"), false); + assert.equal( + decodeURIComponent(form({ mode: "subscription", line_items: [{ price: "p", quantity: 2 }], metadata: { workspace_id: "w" }, skip: undefined }).toString()), + "mode=subscription&line_items[0][price]=p&line_items[0][quantity]=2&metadata[workspace_id]=w", + ); +}); diff --git a/apps/studio/test/support/worker.ts b/apps/studio/test/support/worker.ts index 30e7541..bbf2bbc 100644 --- a/apps/studio/test/support/worker.ts +++ b/apps/studio/test/support/worker.ts @@ -107,14 +107,14 @@ class R2 { export const APP_URL = "http://localhost:8789"; -/** A fresh Worker with its own empty database, and a caller that keeps a session's cookie. */ -export async function startWorker() { - const { default: app } = await import("../../src/worker/index.ts"); +/** A fresh Worker with its own empty database, and a caller that keeps a session's cookie. `vars` are extra env (BILLING, Stripe's). */ +export async function startWorker(vars: Record = {}) { + const { app, default: worker } = await import("../../src/worker/index.ts"); const DB = new D1(); const migrations = new URL("../../migrations/", import.meta.url); for (const f of readdirSync(migrations).filter((f) => f.endsWith(".sql")).sort()) DB.db.exec(readFileSync(new URL(f, migrations), "utf8")); // A secret made for this run only, so sessions sign as they would anywhere. - const env = { DB, FILES: new R2(), ASSETS: { fetch: async () => new Response("", { status: 404 }) }, APP_URL, AUTH_SECRET: randomBytes(32).toString("hex") }; + const env = { DB, FILES: new R2(), ASSETS: { fetch: async () => new Response("", { status: 404 }) }, APP_URL, AUTH_SECRET: randomBytes(32).toString("hex"), ...vars }; const silence = console.log; /** One caller: a browser session (cookies kept) or an API key (a bearer header). */ @@ -149,5 +149,16 @@ export async function startWorker() { if (w.status !== 201) throw new Error(`workspace: ${w.status} ${JSON.stringify(w.body)}`); return person; }; - return { app, env, client, signUp }; + /** Runs the cron handler once, as the scheduler would. */ + const scheduled = async () => { + const waits: Promise[] = []; + console.log = () => undefined; + try { + await worker.scheduled({} as ScheduledController, env as never, { waitUntil: (p: Promise) => waits.push(p), passThroughOnException: () => undefined } as unknown as ExecutionContext); + await Promise.all(waits); + } finally { + console.log = silence; + } + }; + return { app, env, client, signUp, scheduled }; } diff --git a/apps/studio/wrangler.jsonc b/apps/studio/wrangler.jsonc index 788082d..a6e6bd5 100644 --- a/apps/studio/wrangler.jsonc +++ b/apps/studio/wrangler.jsonc @@ -23,11 +23,17 @@ // The top level is local development: `wrangler dev` uses a local database and bucket, no // email goes out, and an account works as soon as it's created. Production is `production`. "vars": { "APP_URL": "http://localhost:8789", "EMAIL_FROM": "Polyxd Studio " }, + // Plans and billing run only where BILLING is "on" (the hosted Studio sets it as a secret, with + // Stripe's). Without it the fetch counter and the hourly rollup do nothing, and there are no limits. + "analytics_engine_datasets": [{ "binding": "FETCHES", "dataset": "polyxd_studio_fetches" }], + "triggers": { "crons": ["17 * * * *"] }, "env": { "production": { "routes": [{ "pattern": "studio.polyxd.com", "custom_domain": true }], "d1_databases": [{ "binding": "DB", "database_name": "studio", "database_id": "0d0c8972-4d26-4897-8115-471304e74ead", "migrations_dir": "migrations" }], "r2_buckets": [{ "binding": "FILES", "bucket_name": "polyxd-studio-files" }], + "analytics_engine_datasets": [{ "binding": "FETCHES", "dataset": "polyxd_studio_fetches" }], + "triggers": { "crons": ["17 * * * *"] }, "vars": { "APP_URL": "https://studio.polyxd.com", "EMAIL_FROM": "Polyxd Studio " }, "workers_dev": false, "preview_urls": false, From 8668528324389781527cc5cde97f6b22bf717593 Mon Sep 17 00:00:00 2001 From: Neelank Sachan Date: Tue, 29 Sep 2026 00:39:14 +1000 Subject: [PATCH 3/4] Studio app: a Billing page, an upgrade dialog on any plan limit, and team seats freed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Billing page (Workspace → Billing, shown only where the Studio has plans) gives the plan, seats, usage bars that turn amber at 80%, notices for over quota, a paused workspace and a failed payment, a monthly or yearly toggle, and upgrade or manage buttons for owners. Any 402 opens a dialog that says what ran out and links to the plans. Team gains Remove for owners and Withdraw for open invites, which hold a seat. Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/app/App.tsx | 37 ++++++- apps/studio/src/app/api.ts | 40 +++++++- apps/studio/src/app/pages/Billing.tsx | 138 ++++++++++++++++++++++++++ apps/studio/src/app/pages/Team.tsx | 18 +++- 4 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 apps/studio/src/app/pages/Billing.tsx diff --git a/apps/studio/src/app/App.tsx b/apps/studio/src/app/App.tsx index ea7452b..b90e403 100644 --- a/apps/studio/src/app/App.tsx +++ b/apps/studio/src/app/App.tsx @@ -1,6 +1,6 @@ import { createContext, lazy, Suspense, useContext, useEffect, useState, type ReactNode } from "react"; import { Link, Navigate, NavLink, Route, Routes, useNavigate, useParams } from "react-router-dom"; -import { api, type Me } from "./api.ts"; +import { api, PLAN_LIMIT_EVENT, type Me, type PlanLimitError } from "./api.ts"; import { startAnalytics, stopAnalytics } from "./analytics.ts"; import { SignIn } from "./pages/SignIn.tsx"; import { Workspaces } from "./pages/Workspaces.tsx"; @@ -14,6 +14,7 @@ import { Components } from "./pages/Components.tsx"; import { Rules } from "./pages/Rules.tsx"; import { Team } from "./pages/Team.tsx"; import { Invite } from "./pages/Invite.tsx"; +import { Billing } from "./pages/Billing.tsx"; import { Mark, StudioLockup } from "./mark.tsx"; // The screen pages carry the renderer, the schema and the spec's examples; they load when opened. const Screens = lazy(() => import("./pages/Screens.tsx").then((m) => ({ default: m.Screens }))); @@ -81,7 +82,7 @@ const NAV: { group?: string; items: { to: string; label: string }[] }[] = [ { group: "Foundations", items: [{ to: "design-systems", label: "Design systems" }, { to: "components", label: "Components" }] }, { group: "Direction", items: [{ to: "directions", label: "Directions" }, { to: "rules", label: "Rules" }] }, { group: "Product", items: [{ to: "screens", label: "Screens" }, { to: "insights", label: "Insights" }] }, - { group: "Workspace", items: [{ to: "team", label: "Team" }] }, + { group: "Workspace", items: [{ to: "team", label: "Team" }, { to: "billing", label: "Billing" }] }, ]; function Shell() { @@ -106,7 +107,8 @@ function Shell() { {NAV.map((g, i) => (
    {g.group &&
    {g.group}
    } - {g.items.map((it) => ( + {/* Billing only where there are plans: a self-hosted Studio has none. */} + {g.items.filter((it) => it.to !== "billing" || me.billing).map((it) => ( {it.label} @@ -145,13 +147,42 @@ function Shell() { } /> } /> } /> + } />

    That page isn't here

    The link may be from another workspace.

    } /> + ); } +/** Shown on any 402 from the Worker: what ran out, and the way to more. Viewers are always free. */ +function UpgradeDialog({ slug }: { slug: string }) { + const [hit, setHit] = useState(null); + const navigate = useNavigate(); + useEffect(() => { + const on = (e: Event) => setHit((e as CustomEvent).detail); + window.addEventListener(PLAN_LIMIT_EVENT, on); + return () => window.removeEventListener(PLAN_LIMIT_EVENT, on); + }, []); + if (!hit) return null; + const locked = hit.code === "over_quota"; + return ( + <> +
    setHit(null)} /> +
    +

    {locked ? "Editing is paused" : "That's your plan's limit"}

    +

    {hit.error}

    + {hit.limit === "editors" &&

    Viewers are always free: invite people as viewers, or make someone a viewer, to stay on this plan.

    } +
    + + +
    +
    + + ); +} + export type Ws = Me["workspaces"][number]; export function Page({ crumbs, title, lede, actions, children, meta }: { crumbs: string[]; title: string; lede?: string; actions?: ReactNode; meta?: ReactNode; children: ReactNode }) { diff --git a/apps/studio/src/app/api.ts b/apps/studio/src/app/api.ts index 33f54cf..2a4bf7a 100644 --- a/apps/studio/src/app/api.ts +++ b/apps/studio/src/app/api.ts @@ -20,16 +20,54 @@ export async function api(method: string, path: string, body?: unkn const r = await fetch(path, init); const text = await r.text(); const data = text ? JSON.parse(text) : {}; + // A plan's limit, wherever it was hit: the shell shows the upgrade dialog (App.tsx), and the caller still gets the error. + if (r.status === 402) window.dispatchEvent(new CustomEvent(PLAN_LIMIT_EVENT, { detail: { error: data.error ?? "Your plan's limit", ...data } })); if (!r.ok) throw new ApiError(r.status, data.error ?? `${r.status} ${r.statusText}`, data); return data as T; } +export const PLAN_LIMIT_EVENT = "studio:plan-limit"; +/** The Worker's 402: what ran out, on which plan (src/worker/plans.ts). */ +export interface PlanLimitError { + error: string; + code?: "plan_limit" | "over_quota"; + limit?: string; + plan?: string; + current?: number; + max?: number | null; +} + export interface Me { user: { id: string; email: string; name: string } | null; - workspaces: { id: string; slug: string; name: string; role: string }[]; + workspaces: { id: string; slug: string; name: string; role: string; plan?: string }[]; signIn?: { google: boolean; emailVerification: boolean }; /** Present only when the Worker has a PostHog key: the project's public key and PostHog's app for its region. */ analytics?: { key: string; ui: string }; + /** Whether this Studio has plans (the hosted one). A self-hosted Studio has no limits and no Billing page. */ + billing?: boolean; +} + +type Limit = number | null; +export interface Billing { + enabled: true; + plan: "free" | "pro" | "team" | "enterprise"; + status: string | null; + interval: "month" | "year" | null; + seats: number | null; + periodEnd: string | null; + subscribed: boolean; + customer: boolean; + limits: { workspaces: Limit; editors: Limit; designSystems: Limit; directions: Limit; publishedScreens: Limit; fetches: Limit; history: Limit; privateMcp: boolean; approvals: boolean; sharedLibraries: boolean }; + usage: { editors: number; pendingEditors: number; viewers: number; designSystems: number; directions: number; publishedScreens: number; fetches: number }; + period: string; + fetchesPercent: number | null; + overQuotaSince: string | null; + lockedFrom: string | null; + locked: boolean; + prices: { pro: { month: number; year: number }; team: { month: number; year: number } }; + founding: boolean; + checkout: boolean; + canManage: boolean; } export interface Scan { diff --git a/apps/studio/src/app/pages/Billing.tsx b/apps/studio/src/app/pages/Billing.tsx new file mode 100644 index 0000000..6adf63f --- /dev/null +++ b/apps/studio/src/app/pages/Billing.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { api, type Billing as BillingInfo } from "../api.ts"; +import { Page, useSession, type Ws } from "../App.tsx"; + +type Paid = "pro" | "team"; +const NAMES = { free: "Free", pro: "Pro", team: "Team", enterprise: "Enterprise" } as const; +const date = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +const count = (n: number | null, word: string) => (n === null ? `Unlimited ${word}s` : `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`); + +/** What each plan gives, in the words of the plan table (docs/decisions/0004). */ +const FEATURES: Record<"free" | Paid, string[]> = { + free: ["2 editors", "1 design system and 1 Direction", "10 published screens", "10,000 fetches a month", "The last 10 versions of everything"], + pro: ["1 editor, up to 3 workspaces", "Unlimited design systems, Directions and screens", "250,000 fetches a month", "Full version history", "Private packs and screens through the hosted MCP"], + team: ["Unlimited editors and workspaces", "Everything in Pro", "1,000,000 fetches a month", "Approval before publish", "Shared libraries across workspaces"], +}; + +/** One line of usage: what, how much of how much, and a bar that turns amber at 80% and red once over. */ +function Usage({ label, used, max, note }: { label: string; used: number; max: number | null; note?: string }) { + const share = max ? used / max : 0; + return ( +
  • + + {label} + {note &&
    {note}
    } +
    + {used.toLocaleString()}{max === null ? "" : ` of ${max.toLocaleString()}`} + {max === null ? no limit : 1 ? "bad" : share >= 0.8 ? "warn" : ""}`}>} +
  • + ); +} + +export function Billing({ ws }: { ws: Ws }) { + const { toast } = useSession(); + const [params] = useSearchParams(); + const [b, setB] = useState(null); + const [interval, setPayEvery] = useState<"month" | "year">("year"); + const [busy, setBusy] = useState(false); + useEffect(() => { + api("GET", `/api/w/${ws.slug}/billing`).then((r) => { + setB(r); + if (r.enabled && r.interval) setPayEvery(r.interval); + }); + }, [ws.slug]); + if (!b) return null; + if (!b.enabled) return

    No plans here

    This Studio runs without plans or limits.

    ; + + const go = async (path: string, payload?: unknown) => { + setBusy(true); + try { + const r = await api<{ url: string }>("POST", `/api/w/${ws.slug}/billing/${path}`, payload); + location.assign(r.url); + } catch (e) { + toast((e as Error).message, "bad"); + setBusy(false); + } + }; + const upgrade = (plan: Paid) => go("checkout", { plan, interval }); + const manage = () => go("portal"); + + const u = b.usage; + const seats = Math.max(1, u.editors); + const price = (plan: Paid) => b.prices[plan][interval]; + const lede = + b.plan === "free" ? `${ws.name} is on Free.` + : `${ws.name} is on ${NAMES[b.plan]}${b.plan === "team" && b.seats ? `, ${b.seats} editor${b.seats === 1 ? "" : "s"}` : ""}${b.interval ? `, paid ${b.interval === "year" ? "yearly" : "monthly"}` : ""}.${b.periodEnd ? ` ${b.status === "canceled" ? "Ends" : "Renews"} ${date(b.periodEnd)}.` : ""}`; + + return ( + Manage billing : undefined}> +
    + {params.get("upgraded") &&
    Thanks, the payment went through.Your plan changes here as soon as Stripe confirms it, usually within a minute.
    } + {b.locked ? ( +
    Editing is paused{ws.name} has been over its monthly fetches since {date(b.overQuotaSince!)}. Products still get their screens, Directions and tokens. Upgrade to edit again.
    + ) : b.overQuotaSince ? ( +
    Over this month's fetchesNothing stops working. If {ws.name} is still over on {date(b.lockedFrom!)}, editing pauses until it upgrades; fetches carry on.
    + ) : b.fetchesPercent !== null && b.fetchesPercent >= 80 ? ( +
    {b.fetchesPercent}% of this month's fetches usedGoing over never breaks a product. After 7 days over, editing pauses until the workspace upgrades.
    + ) : null} + {b.status === "past_due" &&
    The last payment didn't go throughStripe will try again. {b.canManage ? "Check the card in Manage billing." : "Ask an owner to check the card."}
    } + +
    +

    This month

    Viewers are always free; everyone else is an editor.

    +
      + + + + + +
    +
    + + {b.plan !== "enterprise" && ( +
    +
    +

    Plans

    {b.founding &&

    Founding offer: the first 100 paying workspaces pay half, for as long as they stay.

    }
    +
    + + +
    +
    +
    + {(["free", "pro", "team"] as const).map((plan) => { + const current = b.plan === plan; + return ( +
    +
    +

    {NAMES[plan]}

    +
    {plan === "free" ? "$0" : `$${price(plan)}`}{plan === "free" ? "for trying it and side projects" : `${plan === "team" ? "per editor, " : ""}a ${interval}${plan === "team" && seats > 1 ? ` · $${(price(plan) * seats).toLocaleString()} for your ${seats} editors` : ""}`}
    +
    +
      {FEATURES[plan].map((f) =>
    • {f}
    • )}
    +
    + {current ? ( + Your plan + ) : plan === "free" ? ( + b.canManage && b.customer ? : null + ) : !b.canManage ? ( + Only an owner can change the plan. + ) : b.subscribed ? ( + + ) : plan === "pro" && u.editors > 1 ? ( + Pro is for one editor; {ws.name} has {u.editors}. Team fits. + ) : !b.checkout ? ( + Paying isn't set up on this Studio yet. + ) : ( + + )} +
    +
    + ); + })} +
    +

    Enterprise: 10 million fetches or more, single sign-on, an audit log and an SLA, on a contract. Write to us from polyxd.com. Everything can be exported as JSON on every plan.

    +
    + )} +
    +
    + ); +} diff --git a/apps/studio/src/app/pages/Team.tsx b/apps/studio/src/app/pages/Team.tsx index e8c6360..dc9f3fc 100644 --- a/apps/studio/src/app/pages/Team.tsx +++ b/apps/studio/src/app/pages/Team.tsx @@ -13,7 +13,7 @@ const CAN_MANAGE_INGEST = new Set(["owner", "engineer", "design-system", "produc const ROLES: [string, string][] = [["design-system", "Design system: tokens, components, rules, releases"], ["designer", "Designer: direction, reviews, exemplars"], ["product", "Product: capabilities, journeys, insights"], ["engineer", "Engineer: components, capabilities, integrations"], ["viewer", "Viewer: everything, read only"]]; export function Team({ ws }: { ws: Ws }) { - const { toast } = useSession(); + const { toast, me } = useSession(); const [d, setD] = useState(null); const [keys, setKeys] = useState([]); const [inviting, setInviting] = useState(false); @@ -59,16 +59,26 @@ export function Team({ ws }: { ws: Ws }) { setNewKey(r.key); load(); }; + const act = async (method: string, path: string, done: string) => { + try { + await api(method, `/api/w/${ws.slug}/${path}`); + toast(done); + load(); + } catch (e) { + toast((e as Error).message, "bad"); + } + }; + const canInvite = ws.role === "owner" || ws.role === "design-system"; if (!d) return null; return ( setInviting(true)}>Invite people}>
    - + - {d.members.map((m) => )} - {d.invites.map((i) => )} + {d.members.map((m) => )} + {d.invites.map((i) => )}
    PersonRoleJoined
    PersonRoleJoined
    {m.name || m.email}
    {m.email}
    {m.role}{new Date(m.created_at).toLocaleDateString("en-GB")}
    {i.email}
    invited
    {i.role}expires {new Date(i.expires_at).toLocaleDateString("en-GB")}
    {m.name || m.email}
    {m.email}
    {m.role}{new Date(m.created_at).toLocaleDateString("en-GB")}{ws.role === "owner" && m.id !== me.user?.id && }
    {i.email}
    invited
    {i.role}expires {new Date(i.expires_at).toLocaleDateString("en-GB")}{canInvite && }
    From 0e3feaaa39849b1b6e98bfb5fd81baeb02b9d3f9 Mon Sep 17 00:00:00 2001 From: Neelank Sachan Date: Tue, 29 Sep 2026 00:40:44 +1000 Subject: [PATCH 4/4] Docs: Studio's plans and limits, billing setup for the hosted one, and what 0004 built The Studio README says how to turn billing on (Stripe products, prices, coupon, webhook events, secrets, the Analytics Engine token) and that a self-hosted Studio has no limits; the site's Studio page gives the plans in plain words; the pricing decision ticks off workstreams 2 and 3. Co-Authored-By: Claude Opus 5.5 --- apps/site/content/docs/studio.md | 27 +++++++++-- apps/studio/README.md | 37 ++++++++++++-- apps/studio/src/worker/index.ts | 16 ++---- docs/decisions/0004-pricing-and-licensing.md | 51 +++++++++++--------- package-lock.json | 1 + 5 files changed, 90 insertions(+), 42 deletions(-) diff --git a/apps/site/content/docs/studio.md b/apps/site/content/docs/studio.md index 33b0f40..13d0b4e 100644 --- a/apps/site/content/docs/studio.md +++ b/apps/site/content/docs/studio.md @@ -7,7 +7,7 @@ order: 23 # Studio -[Studio](https://studio.polyxd.com) is the team's side of Polyxd: source-available (the [Functional Source License](https://fsl.software), in `apps/studio`: free to run for your own team or company, not as a competing hosted service, and each version becomes Apache-2.0 after two years), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio is free for one workspace; paid plans for bigger teams are coming, and will be published before they start. +[Studio](https://studio.polyxd.com) is the team's side of Polyxd: source-available (the [Functional Source License](https://fsl.software), in `apps/studio`: free to run for your own team or company, not as a competing hosted service, and each version becomes Apache-2.0 after two years), running on Cloudflare Workers with D1 and R2, and the same code whether you use the hosted one or your own. The hosted Studio has a Free plan and paid ones ([Plans](#plans)); a Studio you run yourself has no plans and no limits. ## Your design system @@ -97,7 +97,28 @@ An ingest key is publishable, like the key a web analytics tool puts in a page. ## Team -Workspaces, invites with roles (design-system, designer, product, engineer, viewer), sign-in through [better-auth](https://www.better-auth.com) (email and password with verification, Google when configured), API keys, and ingest keys for Insights. +Workspaces, invites with roles (design-system, designer, product, engineer, viewer), sign-in through [better-auth](https://www.better-auth.com) (email and password with verification, Google when configured), API keys, and ingest keys for Insights. Owners can take someone out of a workspace, and an invite can be withdrawn before it is used. + +## Plans + +Plans apply to the hosted Studio only. You pay per editor: a viewer is always free, and every other role (owner, design-system, designer, product, engineer) is an editor. + +| | Free | Pro | Team | Enterprise | +|---|---|---|---|---| +| Price | $0 | $8 a month, or $80 a year | $12 per editor a month, or $120 a year | talk to us | +| Workspaces you own | 1 | 3 | unlimited | unlimited | +| Editors | 2 | 1 | unlimited | unlimited | +| Design systems | 1 | unlimited | unlimited | unlimited | +| Directions | 1 | unlimited | unlimited | unlimited | +| Published screens | 10 | unlimited | unlimited | unlimited | +| Fetches by key a month (screens, Directions, tokens) | 10,000 | 250,000 | 1,000,000 | 10 million or more | +| Version history | last 10 | all | all | all | + +- **Reaching a limit** stops only the new thing: another design system, another published screen, another editor. Studio says which plan has room. Everything you already have keeps working. +- **Going over your fetches never breaks your product.** Studio warns you at 80% and 100% on the Billing page. If a workspace stays over for 7 days, editing pauses until it upgrades or the month turns; products still get their screens, Directions and tokens. +- **Billing** is under Workspace → Billing: your plan, your seats, what you've used this month, and the buttons to upgrade or manage your subscription (owners only; payment is through Stripe). On Team, adding or removing an editor changes your seats, prorated. +- **The founding offer**: the first 100 paying workspaces pay half, for as long as they stay subscribed. +- **You can always leave**: every design system, screen and Direction exports as JSON on every plan. ## Run it yourself @@ -107,6 +128,6 @@ npm run db:migrate -w @polyxd/studio # local D1 npm run dev -w @polyxd/studio # http://localhost:8789 ``` -For production: a D1 database, an R2 bucket, `SECRETS_KEY` and `AUTH_SECRET` secrets, `RESEND_API_KEY` for email, and `npm run deploy -w @polyxd/studio`. The README in `apps/studio` has the exact steps. +For production: a D1 database, an R2 bucket, `SECRETS_KEY` and `AUTH_SECRET` secrets, `RESEND_API_KEY` for email, and `npm run deploy -w @polyxd/studio`. The README in `apps/studio` has the exact steps. Your own Studio has no plans and no limits: as many editors, design systems, Directions, screens and fetches as you like, and no Billing page. The [licence](https://github.com/visualfart/polyxd/blob/main/apps/studio/LICENSE) lets you run Studio for your own team or company, change it, and share your changes. It does not let you offer Studio, or something substantially like it, as a service to others. Each version becomes Apache-2.0 two years after its release. diff --git a/apps/studio/README.md b/apps/studio/README.md index 6414a56..fdb9e95 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -2,12 +2,13 @@ Where a design-system team decides what generated screens may look like, and reviews what they actually look like. Source-available under the [Functional Source License](LICENSE) (FSL-1.1-ALv2), unlike the rest of Polyxd, which is Apache-2.0: run it for your own team or company, but not as a competing hosted service. Each version becomes Apache-2.0 two years after its release. -Run it yourself on your own Cloudflare account, or use the hosted one at studio.polyxd.com (same code; a small fee may cover its storage later). +Run it yourself on your own Cloudflare account, free and with no limits, or use the hosted one at studio.polyxd.com (same code), which has a Free plan and paid ones ([plans and billing](#plans-and-billing-the-hosted-studio)). ## What works - A landing page at `/` for anyone signed out (and at `/welcome` for anyone signed in): what Studio does, in the site's voice, with the spec's send-money example drawn live by `@polyxd/react` and cycled through three design systems, product images captured from Studio itself (`public/landing/`), and the sign-in split beside it (`/signin`, `?mode=signup` or `?mode=forgot` open that form). -- Sign up and sign in through [better-auth](https://www.better-auth.com), open source and running inside the Worker: email and password with verification, password reset, Google when a client is configured; two-step verification and SAML/OIDC single sign-on are its plugins, to add when a customer needs them. Workspaces, invites with roles, API keys. +- Sign up and sign in through [better-auth](https://www.better-auth.com), open source and running inside the Worker: email and password with verification, password reset, Google when a client is configured; two-step verification and SAML/OIDC single sign-on are its plugins, to add when a customer needs them. Workspaces, invites with roles (withdrawn from the Team page), owners taking people out, API keys. +- On the hosted Studio only, plans and billing: a Billing page (plan, seats, usage, upgrade and manage), limits per plan, fetch metering, Stripe ([below](#plans-and-billing-the-hosted-studio)). - Import a design system as it is, from an npm package (public, or private through a read-only registry token kept encrypted, an uploaded `npm pack` tarball, or `polyxd studio push` from inside your network), a Tokens Studio file, a W3C DTCG file, or CSS custom properties. - Or **start from a template**: one of the twelve original template packs (`packages/ds-{mono,civic,sketch,wireframe,editorial,pastel,health,finance,glass,terminal,brutalist,neon}`, bundled into the Worker as JSON), or a blank one (Mono's structure with a grey ramp). Each is shown with one line of character and a strip of swatches from its own tokens. It becomes a design system of the workspace with its tokens copied, scanned, and every role mapped to the pack's semantic token of the same name (exact matches accepted), ready to tune and publish. - A scan of what was found: tokens by tier and type, modes, broken and circular references, deprecated tokens. @@ -117,7 +118,35 @@ The landing's product images are captured from a running Studio: against a local 3. Secrets, with `npx wrangler secret put --env production`: `AUTH_SECRET` (a long random string; signs sessions), `SECRETS_KEY` (another; encrypts registry tokens), `RESEND_API_KEY` (verification, reset and invite emails; without it, links are logged), and optionally `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` for "Continue with Google" (redirect URI: `/api/auth/callback/google`). Analytics are off unless you also set `POSTHOG_KEY` (and `POSTHOG_HOST` for PostHog's EU cloud); `docs/analytics.md` says what they send. 4. `npm run deploy -w @polyxd/studio`. -The hosted one at studio.polyxd.com is this same configuration. +The hosted one at studio.polyxd.com is this same configuration, plus billing (below). Leave `BILLING` unset and your Studio has no plans, no limits and no Billing page: every workspace can have as many editors, design systems, Directions, published screens and fetches as you like, and no request to Stripe or Analytics Engine is ever made. + +## Plans and billing (the hosted Studio) + +Only a Studio whose `BILLING` is `on` has plans. The limits live in one table, `LIMITS` in `src/worker/plans.ts`, from [the pricing decision](../../docs/decisions/0004-pricing-and-licensing.md): + +| | Free | Pro | Team | Enterprise | +|---|---|---|---|---| +| Workspaces a person owns | 1 | 3 | unlimited | unlimited | +| Editors (every role but `viewer`; viewers are free) | 2 | 1 | unlimited | unlimited | +| Design systems (imported or from a template) | 1 | unlimited | unlimited | unlimited | +| Directions | 1 | unlimited | unlimited | unlimited | +| Published screens | 10 | unlimited | unlimited | unlimited | +| Fetches by API key a month | 10,000 | 250,000 | 1,000,000 | 10,000,000 | +| Versions kept per screen, Direction or design system | last 10 | all | all | all | + +- **Hard limits** only stop something new: creating a workspace, inviting or accepting an invite as an editor (an open editor invite holds a seat; withdraw it on the Team page to free it), importing or starting a design system, creating a Direction, publishing a screen that isn't published yet. The answer is a 402 `{ error, code: "plan_limit", limit, plan, current, max }`, and the app shows an upgrade dialog. On Free, saving a version prunes all but the last 10 (the published or live one is always kept). +- **Fetches** are soft. A product's `GET` by API key of a published screen, a Direction or a design system's export writes one data point to Workers Analytics Engine (`FETCHES`, dataset `polyxd_studio_fetches`), never a D1 row. The hourly cron (`triggers` in `wrangler.jsonc`) reads this month's and last month's totals back through the Analytics Engine SQL API into `usage`, and sets `over_quota_since` on a workspace over its plan's fetches (or clears it). Going over never blocks a fetch. Seven days over, every change in the workspace answers 402 `{ code: "over_quota" }` until it upgrades or the month turns; reads, fetches and billing keep working. The Billing page warns at 80% and 100%; email warnings are still to do. +- **Stripe**, through its REST API (`src/worker/billing.ts`): `POST /api/w//billing/checkout` with `{ plan: "pro" | "team", interval: "month" | "year" }` opens Checkout (Team's quantity is the workspace's editors; the founding coupon is applied while it lasts, then the full price), `POST …/billing/portal` opens the Customer Portal, and only owners may call either. `POST /api/billing/webhook` checks the `Stripe-Signature` with Web Crypto and is the only thing that changes a workspace's plan: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted` (back to Free) and `invoice.payment_failed` (shown as past due while Stripe retries). A Team subscription's quantity follows editors joining and leaving, prorated. Enterprise is set by hand: `UPDATE workspaces SET plan = 'enterprise' WHERE slug = '…'`. + +To turn it on: + +1. In Stripe (test mode first): a product **Pro** with prices of $8 a month and $80 a year, a product **Team** with per-unit prices of $12 a month and $120 a year, and a coupon of 50% off, duration forever, max redemptions 100. In the Customer Portal settings, allow switching between those prices, changing Team's quantity and cancelling. +2. A webhook endpoint at `https:///api/billing/webhook` for the five events above. +3. An Analytics Engine API token: a Cloudflare API token with *Account Analytics: Read*. +4. Secrets, with `npx wrangler secret put --env production`: `BILLING` (`on`), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_PRO_MONTH`, `STRIPE_PRICE_PRO_YEAR`, `STRIPE_PRICE_TEAM_MONTH`, `STRIPE_PRICE_TEAM_YEAR`, `STRIPE_COUPON_FOUNDING` (optional), `CF_ACCOUNT_ID` and `CF_ANALYTICS_TOKEN`. +5. `npm run db:migrate:remote -w @polyxd/studio` for `migrations/0007_plans.sql`, then deploy. + +Locally, `npx wrangler dev --var BILLING:on` shows the limits and the Billing page without Stripe; `test/billing.worker.test.ts` runs both kinds of Studio with Stripe and Analytics Engine stood in for. ## Layout @@ -130,7 +159,7 @@ The hosted one at studio.polyxd.com is this same configuration. | `src/templates/` | The twelve template packs bundled as JSON (`packs.ts`), their extras stylesheets as text (`extras.ts`), and each as a graph with a swatch summary | | `src/tokens/` | Editing: a pack as a graph, edits applied with aliases re-checked, the OKLCH ramp and rebrand, values as CSS and as one line | | `src/export/` | The six export formats | -| `src/worker/` | The API on Workers: auth, workspaces, design systems, templates, editing, export, components, rules, screens, Directions, and Insights' ingest endpoint (`insights.ts`) | +| `src/worker/` | The API on Workers: auth, workspaces, design systems, templates, editing, export, components, rules, screens, Directions, Insights' ingest endpoint (`insights.ts`); `plans.ts` the plan table, limits, fetch metering and the rollup, `billing.ts` Stripe | | `src/screens/` | Screens, shared by the Worker and the app: the schema read directly (the editor's shapes, and problems placed at the prop they are about), the checker with the spec's shell rules, the tree edits | | `src/insights/` | Insights, shared by the Worker and the app: the event check and the counting (`events.ts`), and the report the page reads (`report.ts`) | | `src/direction/` | Directions, shared by the Worker and the app: what a version stores and what a product gets (`model.ts`), the schema check in plain words (`schema.ts`), the field-by-field diff (`diff.ts`), the voice sample (`voice.ts`), and every setting's words (`labels.ts`) | diff --git a/apps/studio/src/worker/index.ts b/apps/studio/src/worker/index.ts index 5e5ae47..bcef5f4 100644 --- a/apps/studio/src/worker/index.ts +++ b/apps/studio/src/worker/index.ts @@ -64,12 +64,13 @@ const text = (v: unknown, max: number, what: string): string => { const MAX_UPLOAD = 25 * 1024 * 1024; -/** Work that may finish after the answer: the Worker waits for it, a test just lets it run. */ -function later(c: Ctx, work: Promise) { +/** Work that may finish after the answer (analytics, a seat sync): handed to the runtime when + * there is one, and awaited when there isn't, so a test sees it finish. */ +async function later(c: Ctx, work: Promise) { try { c.executionCtx.waitUntil(work); } catch { - void work; + await work; } } /** An analytics event from the Worker (src/worker/analytics.ts), when POSTHOG_KEY is set; otherwise nothing. */ @@ -164,15 +165,6 @@ async function ws(c: Ctx, allowed?: ReadonlySet): Promise { return row; } -/** Work that may finish after the answer (a seat sync): handed to the runtime when there is one. */ -const later = async (c: Ctx, p: Promise) => { - try { - c.executionCtx.waitUntil(p); - } catch { - await p; - } -}; - /** A pruned design-system version's files, after its rows. */ async function dropVersionFiles(env: Env, ids: string[]) { for (const id of ids) { diff --git a/docs/decisions/0004-pricing-and-licensing.md b/docs/decisions/0004-pricing-and-licensing.md index 75192b3..5a90bbc 100644 --- a/docs/decisions/0004-pricing-and-licensing.md +++ b/docs/decisions/0004-pricing-and-licensing.md @@ -1,6 +1,6 @@ # 0004 — Pricing and licensing -Status: **planned, not built** (28 Sep 2026). Build tonight in the order under [Build plan](#build-plan). +Status: **being built** (29 Sep 2026): Studio plans, limits and billing (2 and 3) are built on `feat/studio-billing`; the rest in the order under [Build plan](#build-plan). ## Decision @@ -79,31 +79,36 @@ Each workstream is its own branch **from `origin/main`**, not local main, becaus ### 2. Studio plans and limits -- Migration `apps/studio/migrations/0007_plans.sql`: - - add to `workspaces`: `plan` (`free` | `pro` | `team` | `enterprise`, default `free`), `plan_status`, `stripe_customer_id`, `stripe_subscription_id`, `period_end`, `over_quota_since` - - `usage (workspace_id, metric, period, count)`: monthly totals rolled up from the fetch counter -- `apps/studio/src/worker/plans.ts`: the single table of limits per plan and helpers (`limitsFor(plan)`, `assertCanCreate(ws, kind)`, `editorCount(ws)`). Roles map to seats: `viewer` is free; every other role is an editor. -- Enforcement points in `apps/studio/src/worker/index.ts`: - - `POST /api/workspaces`: Free users own one workspace - - `POST /api/w/:slug/invites` and invite accept: editor seats (viewers always allowed) - - `POST …/design-systems/import` and `…/from-template`: custom design-system count - - `POST …/directions`: Direction count - - `POST …/screens/:key/versions/:n/publish`: published-screen count - - version-history pruning for Free -- Fetch metering: count `GET` by API key for screens, Directions and tokens. Use Workers Analytics Engine for the raw count (no D1 write per request), and a scheduled Worker (cron) that rolls up into `usage` daily and sets `over_quota_since`. -- Errors: a 402 with `{ code: "plan_limit", limit, plan }`, which the app turns into an upgrade prompt. -- Tests beside the existing Studio worker tests. +Built on `feat/studio-billing` (29 Sep 2026). Everything here applies only when the Worker's `BILLING` is `on`, which only the hosted Studio sets: a self-hosted Studio has no limits and no Billing page (tested both ways). + +- ~~Migration `apps/studio/migrations/0007_plans.sql`~~: done. + - `workspaces` gains `plan` (default `free`), `plan_status`, `billing_interval`, `seats`, `stripe_customer_id`, `stripe_subscription_id`, `period_end`, `over_quota_since` + - `usage (workspace_id, metric, period, count, updated_at)`: monthly totals, `period` as `YYYY-MM` +- ~~`apps/studio/src/worker/plans.ts`~~: done. `LIMITS` (the one table), `limitsFor`, `editorCount`, `assertCanCreate`, `assertCanEdit`, `pruneHistory`, `planSummary` (what the Billing page shows). `viewer` is free; every other role is an editor. +- ~~Enforcement points~~: done. + - `POST /api/workspaces`: a person may own as many workspaces as the most generous plan among the ones they own allows (Free 1, Pro 3) + - invites and invite accept: editor seats, with open editor invites holding a seat; viewers always allowed. New: `DELETE /api/w/:slug/invites/:id` and `DELETE /api/w/:slug/members/:user` (owners), so a seat can be freed + - design-system import (a new design system, not a new version of one) and from-template + - `POST …/directions` + - screen publish (republishing a published screen doesn't count) + - Free keeps the last 10 versions of each screen, Direction and design system on save; the published or live one is always kept +- ~~Fetch metering~~: done. A `GET` by API key of a published screen, a Direction or a design-system export writes one Workers Analytics Engine data point (`FETCHES`, dataset `polyxd_studio_fetches`). The cron runs **hourly** (fresher warnings than daily, and the query is cheap), reads this month and last month back through the Analytics Engine SQL API into `usage`, and sets or clears `over_quota_since`. Seven days over: every change answers 402 `over_quota` until the workspace upgrades or the month turns; fetches, reads and billing keep working. The API key's `last_used_at` is now written at most hourly too. +- ~~Errors~~: done. 402 `{ error, code: "plan_limit", limit, plan, current, max }`. +- ~~Tests~~: done, `apps/studio/test/billing.worker.test.ts`. +- Still to do: warning emails at 80% and 100% (the Billing page shows both); approval before publish and shared libraries (Team) are flags in the table, not features yet. ### 3. Billing (Stripe) -- Worker routes: - - `POST /api/w/:slug/billing/checkout`: Stripe Checkout (Pro: one flat price; Team: quantity = editor seats; monthly or yearly; a founding coupon at 50% off, limited to 100 redemptions, `duration: forever`) - - `POST /api/w/:slug/billing/portal`: Stripe Customer Portal - - `POST /api/billing/webhook`: verify the signature; handle `checkout.session.completed`, `customer.subscription.updated|deleted`, `invoice.payment_failed`; set `plan`, `plan_status` and `period_end` -- Seat sync: when editors are added or removed, update the subscription quantity (prorated). -- App: a Billing page in workspace settings (plan, seats, usage bars, upgrade and manage buttons) and an upgrade dialog shown on any 402. -- Build and test against **Stripe test mode** only. -- **User:** create the Stripe account, the business details and tax settings, and the products and prices. Then run `wrangler secret put STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` and the price IDs (or put the price IDs in `wrangler.jsonc` vars). We never enter keys. +Built on `feat/studio-billing` (29 Sep 2026), with Stripe's REST API through fetch (no SDK), tested with Stripe stood in for; not yet run against Stripe test mode. + +- ~~Worker routes~~: done. + - `POST /api/w/:slug/billing/checkout` (owners): `{ plan: "pro" | "team", interval: "month" | "year" }`. Team's quantity is the editor count; Pro is refused for a workspace with more than one editor. The founding coupon (`STRIPE_COUPON_FOUNDING`) is applied while Stripe accepts it, then Checkout opens at full price with promotion codes allowed. + - `POST /api/w/:slug/billing/portal` (owners) + - `POST /api/billing/webhook`: `Stripe-Signature` checked with Web Crypto (HMAC-SHA256, constant-time, 5-minute tolerance); `checkout.session.completed`, `customer.subscription.created|updated|deleted`, `invoice.payment_failed`. Sets `plan`, `plan_status`, `billing_interval`, `seats` and `period_end` from the subscription's price; a deleted subscription puts the workspace back on Free. Enterprise is set by hand and left alone. + - `GET /api/w/:slug/billing`: the plan, limits and usage for the app +- ~~Seat sync~~: done. An editor joining or leaving a Team workspace sets the subscription item's quantity, `proration_behavior: create_prorations`. +- ~~App~~: done. Workspace → Billing (plan, seats, usage bars, notices for 80%, over quota, paused and past due, monthly or yearly, upgrade and manage for owners) and an upgrade dialog on any 402. Team has Remove and Withdraw. +- **User:** create the Stripe account, the business details and tax settings; in test mode first, the products and prices (Pro $8/month and $80/year; Team per unit $12/month and $120/year), the founding coupon (50% off, `duration: forever`, max redemptions 100), the Customer Portal settings (switch between those prices, change quantity, cancel), and a webhook endpoint at `https://studio.polyxd.com/api/billing/webhook` for the five events above. A Cloudflare API token with Account Analytics Read. Then `wrangler secret put … --env production` for `BILLING` (`on`), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_PRO_MONTH`, `STRIPE_PRICE_PRO_YEAR`, `STRIPE_PRICE_TEAM_MONTH`, `STRIPE_PRICE_TEAM_YEAR`, `STRIPE_COUPON_FOUNDING`, `CF_ACCOUNT_ID`, `CF_ANALYTICS_TOKEN`, and apply migration 0007 remotely. We never enter keys. ### 4. Site diff --git a/package-lock.json b/package-lock.json index f5f9509..69b1c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "apps/studio": { "name": "@polyxd/studio", "version": "0.0.1", + "license": "FSL-1.1-ALv2", "dependencies": { "@polyxd/analytics": "^0.4.1", "@polyxd/ds-kit": "^0.4.1",