diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a70b2d1e..980dcd62a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Cloud license boundary run: pnpm check:cloud-boundary + - name: Plugin license policy (0196) + run: pnpm check:plugin-licenses + # The heavy half of the old lint job: build the workspace and typecheck it. # typecheck genuinely needs `^build` (turbo builds dependencies first), so it # stays coupled to the build. Runs in parallel with `lint`, `test`, and diff --git a/docs/explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md b/docs/explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md new file mode 100644 index 000000000..06a4f878d --- /dev/null +++ b/docs/explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md @@ -0,0 +1,795 @@ +# Paying Plugin Authors: A Monetized Marketplace, Stripe, And Delayed-Open Licensing + +## Problem Statement + +The user wants **financial incentives to build and maintain xNet tooling** — an +"App Store for plugins" where an author can charge a one-time fee or a monthly +subscription, and xNet makes the billing "really seamless and easy to set up … +and manage all that for you." Verbatim, the ask braids three sub-questions that +have to be answered together: + +1. **The storefront.** Let a plugin author price their work (one-time _or_ + recurring) and have xNet handle checkout, receipts, renewals, and refunds — + like the App Store, but without the 30% Apple tax. A "small fee, like 10 or + 15%." + +2. **The money plumbing.** Two instincts that _seem_ to conflict: + - "Use your own Stripe account … I think that's part of the xNet ethos" (the + author is the merchant; xNet doesn't custody their revenue). + - "Have some sort of xNet fee for hosting on our marketplace and maybe we can + capture that" — possibly via Stripe Connect, _or_ "ask Stripe to pay us some + commission on every transaction. I don't know if that's viable." + + > **The crux finding (Section "Key Findings"): you can have both, but only + > through Stripe Connect _Standard_ accounts.** There is **no** Stripe + > mechanism by which a third party skims a commission off a _standalone_ + > account it doesn't control. Connect Standard _is_ "bring your own Stripe" — + > the author keeps their own dashboard, Stripe does their KYC/payouts/disputes + > — and it is also the only path that lets xNet take an `application_fee` on + > each charge. The two instincts reconcile into one product. + +3. **The license.** Paid plugins should still be _source-available_ and + eventually open: "like xNet Cloud, where after two years it becomes MIT or + Apache." xNet **already ships exactly this license** — + [`packages/cloud/LICENSE`](../../packages/cloud/LICENSE) is **FSL-1.1-ALv2** + (Functional Source License, 2-year conversion to Apache-2.0). The job is to + make it the _blanket, pre-approved_ license for paid plugins. + +This exploration is the deep-dive on the monetization phase that +[0192](./0192_[_]_PLUGIN_ECOSYSTEM_MARKETPLACE_DX_AND_TRUST.md) explicitly +deferred ("**Monetization & lifecycle (❌ absent): no paid plugins**") and that +[0194](./0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md) left as future +work. It is grounded in three subsystems that **already exist and fit together +almost suspiciously well**: the billing engine, the entitlements signer, and the +FSL license. + +## Executive Summary + +1. **xNet has ~70% of the parts already in the tree.** A provider-agnostic + **billing engine** ([`packages/billing`](../../packages/billing), PR #106), a + **signed-entitlement** signer/verifier pattern + ([`packages/entitlements`](../../packages/entitlements)), a real **FSL + license** ([`packages/cloud/LICENSE`](../../packages/cloud/LICENSE)), a + **marketplace index + client** ([`packages/plugins/src/ecosystem/marketplace.ts`](../../packages/plugins/src/ecosystem/marketplace.ts)), + a **capability-gated install flow** + ([`packages/plugins/src/registry.ts`](../../packages/plugins/src/registry.ts)), + and a **DID identity** for every user. No incumbent (Apple, Shopify, Freemius) + has a DID-rooted local-first identity to anchor licenses to. The work is + **wiring + 2 new fields + 1 new MIT package + a Connect extension to the + Stripe adapter**, not greenfield. + +2. **Resolve "own Stripe vs. capture a fee" with Stripe Connect _Standard_.** + The author connects their _existing_ Stripe account via OAuth (1 click, Stripe + owns their KYC/disputes/payouts — minimal xNet liability), and xNet attaches + `application_fee_percent` (recommend **10%**) to each charge. This is genuinely + "your own Stripe" _and_ a captured marketplace fee. The dead-end the user + half-suspected — Stripe paying xNet a commission on a standalone account — is + **not viable**; Connect is the supported version of that same wish. + +3. **Offer a "fully sovereign" escape hatch for the purists.** An author who + refuses Connect can still list a **BYO-billing** plugin: xNet takes **0%**, the + author handles their own checkout/license issuance, and xNet only verifies the + resulting license token. This keeps faith with the local-first/sovereignty + ethos — you can always go fully independent; you just forgo _managed_ billing + and don't owe the fee. + +4. **Enforcement is a signed, DID-scoped license token verified offline.** Mirror + the existing `signEntitlements`/`verifyEntitlements` + ([`packages/entitlements/src/entitlements.ts`](../../packages/entitlements/src/entitlements.ts)) + pattern, but with **Ed25519, not HMAC** — the client is the adversary here, so + verification must be asymmetric (hub holds the private key; the plugin runtime + embeds the public key). A new MIT `@xnetjs/licenses` package mints/verifies a + `PluginLicense` token bound to `{ pluginId, buyerDID, expiry, grace }`. The + `PluginRegistry` install/activate gate checks it offline before running paid + code. + +5. **FSL is the right license and xNet already uses it.** Adopt **FSL-1.1-MIT** + / **FSL-1.1-Apache-2.0** as the _pre-approved_ paid-plugin license (blanket + policy = no per-plugin legal review, unlike BUSL's per-instance Additional Use + Grant). Add a `license` field to the manifest, a CI check (clone of + [`scripts/check-cloud-boundary.sh`](../../scripts/check-cloud-boundary.sh)), + and an `LICENSE` template in the scaffolder. The 2-year auto-conversion is a + feature, not a footnote. + +6. **The managed-billing brain belongs in `@xnetjs/cloud` (FSL); the contract is + MIT.** The Connect platform secret, fee capture, and seller payouts are a + _hosted_ xNet feature → they live behind the existing open-core boundary in + FSL `@xnetjs/cloud`. The manifest fields, the license verifier, the install + gate, and BYO-billing are **MIT** so self-hosters keep a working (un-monetized + or BYO-monetized) marketplace. This maps cleanly onto the + [0181](./0181_[x]_CONSOLIDATE_CLOUD_INTO_ONE_PACKAGE.md) cloud boundary. + +7. **Recommendation:** a 5-phase build — **(0)** paid-aware catalog + FSL policy + (no money yet), **(1)** the `@xnetjs/licenses` entitlement spine + install gate + (works with manual fulfillment), **(2)** managed billing via Connect Standard + + fee capture, **(3)** the BYO-billing sovereign path, **(4)** lifecycle + (updates, revocation, payouts dashboard, the 2-year auto-open job). Each phase + ships value alone. + +## Current State In The Repository + +### Billing engine — provider-agnostic, single-account today + +[`packages/billing`](../../packages/billing) (MIT, PR #106) is a clean port: + +- **The port:** [`PaymentProvider`](../../packages/billing/src/provider.ts) — + `createCheckout(req)`, `parseWebhook(rawBody, headers)`, `normalize(event)`, + optional `createPortalSession(req)`. Stripe / BTCPay / fake all implement it; + swapping is an env var (`billingProviderFromEnv`). +- **Money model:** [`types.ts`](../../packages/billing/src/types.ts) — `Customer`, + `Subscription`, `Invoice`, `Payment`, all amounts **integer minor units**, every + row `did`-scoped. +- **The store:** [`MemoryBillingStore`](../../packages/billing/src/store.ts) + + durable [`SqliteBillingStore`](../../packages/hub/src/services/billing-store.ts) + (`billing.db`), idempotent on provider event id, LWW by `updatedAt`, with a + _pending buffer_ that replays mutations once a `customerRef → did` mapping + arrives. +- **Hub routes:** [`createBillingRoutes`](../../packages/hub/src/routes/billing.ts) + — `POST /billing/webhook` (unauth, signature-verified), `POST /billing/checkout` + (auth; **server injects `did`**, never the client), `GET /billing/me`, + `GET /billing/entitlements`, `POST /billing/portal`. +- **Client:** [`useBilling()`](../../packages/react/src/hooks/useBilling.ts) + + `XNetConfig.billing { apiBase?, publishableKey? }` + ([`context.ts`](../../packages/react/src/context.ts)). + +**The Stripe adapter today is single-account.** In +[`packages/billing/src/providers/stripe.ts`](../../packages/billing/src/providers/stripe.ts) +the checkout `POST /v1/checkout/sessions` injects the buyer's DID into +`client_reference_id` / `metadata[did]` but sets **no** `application_fee_amount`, +`transfer_data[destination]`, `on_behalf_of`, or `Stripe-Account` header. Every +charge lands in the _operator's_ one Stripe account. This is precisely the seam a +marketplace must extend. + +### Entitlements — the signed-token pattern to mirror + +[`packages/entitlements`](../../packages/entitlements) (MIT, the "hub-only +contract" from [0181](./0181_[x]_CONSOLIDATE_CLOUD_INTO_ONE_PACKAGE.md)) already +encodes "sign a capability claim, verify it elsewhere": + +```ts +// packages/entitlements/src/entitlements.ts +export function signEntitlements(e: PlanEntitlements, secret: string): string // base64url(json).base64url(HMAC-SHA256) +export function verifyEntitlements(token: string, secret: string): PlanEntitlements +``` + +And [`plans.ts`](../../packages/entitlements/src/plans.ts) maps a Stripe +`priceRef → PlanEntitlements`. The hub already bridges billing→entitlements in +[`billing-entitlements.ts`](../../packages/hub/src/services/billing-entitlements.ts) +via `XNET_BILLING_PRICE_PLANS`. A **plugin license is the same shape** — except +the verifier (the plugin runtime) is untrusted, so HMAC must become Ed25519. + +### Plugin ecosystem — capability-gated, but money-blind + +- **Manifest:** [`FeatureModule`](../../packages/plugins/src/feature-module.ts) / + [`XNetExtension`](../../packages/plugins/src/manifest.ts). `author` is a bare + **string** (no DID); there is **no `pricing` and no `license` field**. +- **Marketplace:** [`MarketplaceEntry` + `MarketplaceClient`](../../packages/plugins/src/ecosystem/marketplace.ts) + fetch a `registry.json` from an `indexUrl` and cache it. Entries carry + `installs`/`stars`/`provenance` but **no price/license**. A `PluginRating +{ authorDID }` type exists but isn't wired in. No publish path, no registry + backend. +- **Install gate:** [`PluginRegistry.install`](../../packages/plugins/src/registry.ts) + runs validate → platform → dup → host-version → deps (topo) → + **capability consent** ([`evaluateInstallConsent`](../../packages/plugins/src/ecosystem/consent.ts)) + → persist node → activate. There is **no entitlement/license step**. This is + exactly where a paid-license gate slots in. +- **Trust:** [`@xnetjs/trust`](../../packages/trust/src/index.ts) derives tiers + from provenance; [`provenance.ts`](../../packages/plugins/src/ecosystem/provenance.ts) + has a fail-closed Sigstore-style verifier with a `builderDID` (CI identity, not + a _publisher_ account). + +### Licensing — FSL is already in the repo + +- [`packages/cloud/LICENSE`](../../packages/cloud/LICENSE): **"Functional Source + License, Version 1.1, ALv2 Future License … Copyright 2026 Chris Smothers"** — + Change Date = "the second anniversary of the date we make the Software + available", Change License = Apache-2.0. +- [`packages/cloud/package.json`](../../packages/cloud/package.json) / + [`apps/cloud/package.json`](../../apps/cloud/package.json): + `"license": "FSL-1.1-Apache-2.0"`. **Everything else in the monorepo is MIT.** +- [`scripts/check-cloud-boundary.sh`](../../scripts/check-cloud-boundary.sh) (run + in the `lint` CI job) asserts the FSL package has a real `LICENSE` file and that + the MIT hub never imports the FSL package — the **exact precedent** for a + per-plugin license check. + +```mermaid +graph TD + subgraph have["Already in-tree (reuse)"] + B["@xnetjs/billing
PaymentProvider port"] + E["@xnetjs/entitlements
sign/verify token"] + F["packages/cloud/LICENSE
FSL-1.1 (2yr → Apache)"] + M["ecosystem/marketplace.ts
MarketplaceEntry + Client"] + R["registry.ts
capability install gate"] + D["DID identity per user"] + end + subgraph gap["The gap (this doc)"] + P1["manifest: pricing + license fields"] + P2["@xnetjs/licenses
Ed25519 PluginLicense"] + P3["Stripe Connect in adapter
application_fee + Stripe-Account"] + P4["seller onboarding (Connect OAuth)"] + P5["install-time license gate"] + end + B --> P3 + E --> P2 + F --> P1 + M --> P1 + R --> P5 + D --> P2 + style have fill:#e8f5e9 + style gap fill:#fff3e0 +``` + +## External Research + +### Stripe: how a platform takes a cut (and the one thing that's impossible) + +- **Connect account types** decide who carries the burden: + - **Standard** — seller owns a full Stripe Dashboard; **Stripe handles their + KYC, disputes, and payouts**; platform liability is minimal; **no $2/mo + per-active-account fee**. This is the "bring your own Stripe" model. + - **Express** — platform creates/manages accounts, Stripe-hosted lite + dashboard, platform assists with disputes; **$2/mo per active account + + $0.25/payout**. + - **Custom** — platform builds everything and owns all compliance/liability. +- **Taking the fee** (any account type): **direct charges** with + `application_fee_amount` / `application_fee_percent`, or **destination charges** + with `transfer_data[destination]` (+ optional `application_fee_amount`), or + **separate charges & transfers**. For **subscriptions**, + `application_fee_percent` on the Subscription applies to every cycle invoice + (but **not** mid-cycle proration invoices — those need a manual + `application_fee_amount` set on `invoice.created`). +- **`on_behalf_of`** makes the connected account the business-of-record (its + statement descriptor, its settlement currency). +- **The dead end:** there is **no supported way to capture a commission from a + seller's _standalone_ Stripe account** you don't control. `application_fee_*` + only exists inside a Connect relationship. The Stripe **Partner Program** pays a + _one-time_ referral, not a per-transaction cut; the App Marketplace OAuth scopes + don't let you create charges or take fees. So "ask Stripe to pay us a commission + on every transaction" → **only via Connect**, which requires the seller to link + the account. + +```mermaid +flowchart TD + Start{"How does the
author take payment?"} + Start -->|"Connect Standard
(authorize xNet)"| C1["Buyer pays → Stripe
application_fee_percent → xNet
remainder → author"] + Start -->|"Standalone Stripe
(no Connect)"| C2["Buyer pays → author
❌ xNet CANNOT auto-capture a fee"] + Start -->|"xNet as Merchant of Record
(Lemon Squeezy / Paddle / self)"| C3["Buyer pays → xNet
xNet remits VAT, pays out author
fee = platform spread"] + C1 --> Good["✅ own Stripe + captured fee
(RECOMMENDED)"] + C2 --> Sov["✅ fully sovereign, 0% fee
(escape hatch)"] + C3 --> Mor["⚠️ simplest tax story,
but xNet custodies funds"] + style Good fill:#e8f5e9 + style Sov fill:#e3f2fd + style Mor fill:#fff3e0 +``` + +### App-store fee models (prior art) + +| Platform | Platform cut | Who's the merchant? | BYO processor? | +| ---------------------- | ----------------------- | ----------------------- | ------------------------------ | +| Apple App Store | 30% (15% < $1M) | Apple (MoR) | ❌ | +| Google Play | 30% one-time / 15% subs | Google (MoR) | ❌ | +| Steam | 30 → 25 → 20% tiered | Valve (MoR) | ❌ | +| Shopify App Store | 0% first $1M, then 15% | Shopify Payments | ❌ | +| **Ghost** | **0%** | **Seller's own Stripe** | ✅ (Ghost charges _you_ a sub) | +| **Freemius** | ~5–10% (→0.5% at scale) | Freemius (MoR) | ❌ (purpose-built for plugins) | +| Gumroad | 10% flat | Gumroad (MoR) | ❌ | +| Lemon Squeezy / Paddle | 5% + $0.50 | them (MoR) | ❌ | + +xNet's stated **10–15%** sits below Apple/Steam/Google and is in the band of +Freemius/Gumroad. **Ghost is the spiritual model**: seller connects their own +Stripe; the platform monetizes elsewhere — except xNet _also_ wants the per-sale +fee, which Connect Standard provides and Ghost forgoes. + +### Source-available licensing (FSL vs BUSL vs Fair Source) + +- **FSL** (Sentry, 2023): two flavors **FSL-1.1-MIT** / **FSL-1.1-Apache-2.0**. + Forbids only **Competing Use** (a product/service that competes with the + software). **Every version auto-converts to MIT/Apache exactly 2 years after + release** — unconditional, per-version. **Standardized** terms → a platform can + approve "FSL" once, globally. Not OSI-approved (intentionally; it's "Fair + Source"). +- **BUSL 1.1** (MariaDB/HashiCorp): up to **4-year** change date, change license + must be GPL-compatible, and a **per-instance Additional Use Grant** → every + adopter's license text differs → **per-plugin legal review**. Worse fit for a + blanket marketplace policy. +- **Fair Source / DOSP**: the umbrella (fair.io) for "public source + time-limited + restriction + committed open conversion." FSL is the flagship. +- **Caveat for _local-first_ code:** FSL's Competing-Use clause is tuned for SaaS + (API exposure, hosted substitutes). For a desktop plugin it gives _weaker_ + anti-clone protection than a hard EULA. xNet's real anti-piracy lever is the + **license token + install gate**, not the copyright license. The license governs + _redistribution & eventual openness_; the token governs _who can run it now_. + +### Offline license enforcement + +Modern practice (Keygen, Cryptlex, JetBrains): **asymmetric-signed license +certificates** (Ed25519 preferred — 64-byte sigs, fast, timing-safe), embedded +public key, **fully offline verification**, short lifetime (≈30 days) with silent +online refresh, an embedded **grace period** for connectivity gaps, and an +optional CDN **revocation list**. Bind to an _identity_, not hardware, where you +can — and xNet uniquely _has_ a portable identity: the **DID**. A DID-bound +license is portable across the user's devices and revocable hub-side, with no +hardware-fingerprint support pain. + +## Key Findings + +1. **"Own Stripe" and "capture a fee" are the same product: Connect Standard.** + The user framed these as competing options; they aren't. Connect Standard _is_ + bring-your-own-Stripe with an authorized platform fee. This is the single most + important reframing in this doc. + +2. **The "Stripe pays us a commission on a standalone account" idea is not + viable.** Worth stating plainly so it's not re-litigated: no `application_fee` + without Connect; the Partner Program is a one-time referral, not per-tx. + +3. **The Stripe adapter is ~30 lines from Connect-ready.** It already form-encodes + the checkout body; adding `payment_intent_data[application_fee_amount]` / + `subscription_data[application_fee_percent]` + a `Stripe-Account` header (direct + charges) or `payment_intent_data[transfer_data][destination]` (destination + charges) is a localized change behind the existing `PaymentProvider` port. + +4. **The license token is a near-copy of `signEntitlements` — but must be + asymmetric.** HMAC works for hub↔hub (`HUB_PLAN`); it fails when the _client_ + verifies, because the client would hold the secret and could forge tokens. Use + Ed25519 (the hub signs, the runtime verifies with a baked-in public key). + +5. **FSL is already shipping in this repo** — adopting it for plugins is a + _policy + template + CI check_, not a legal R&D project. Reuse + [`packages/cloud/LICENSE`](../../packages/cloud/LICENSE) verbatim with the + author's copyright line. + +6. **The open-core boundary tells us where each piece lives.** Managed fee capture + = hosted xNet value = **FSL `@xnetjs/cloud`**. Manifest fields + verifier + + install gate + BYO billing = **MIT** so self-hosters keep a functional + marketplace. `check-cloud-boundary.sh` already enforces this seam. + +7. **DID-rooted licensing is a genuine differentiator.** No surveyed competitor + can issue a license bound to a portable cryptographic identity the user already + owns. It removes device-binding friction _and_ gives clean hub-side revocation. + +## Options And Tradeoffs + +### A. Payment topology + +| Option | "Own Stripe" ethos | xNet captures fee | Seller burden | xNet liability | Tax/VAT | +| ------------------------------------------ | ------------------------ | ---------------------------- | ---------------------- | -------------------------------- | -------------------------------- | +| **A1. Connect Standard** ⭐ | ✅ yes (own dashboard) | ✅ `application_fee_percent` | 1-click OAuth | low (Stripe owns disputes/KYC) | seller's problem (or Stripe Tax) | +| A2. Connect Express | ⚠️ Stripe-lite dashboard | ✅ | xNet manages account | medium ($2/mo, payout liability) | seller/Stripe Tax | +| A3. Standalone Stripe (BYO) | ✅✅ maximal | ❌ impossible | author does everything | none | author | +| A4. xNet as MoR (Paddle/LemonSqueezy/self) | ❌ xNet custodies funds | ✅ spread | minimal | high (MoR = tax + chargebacks) | ✅ handled | + +**Recommendation: A1 as the default, A3 as the sovereign escape hatch.** Defer A4 +(MoR) as a future "we'll handle your global taxes" premium — it contradicts the +ethos but is the best answer for indie authors drowning in VAT, so keep the door +open without building it now. + +### B. License enforcement strength + +| Option | Offline? | Forge-resistant | Revocable | UX friction | +| --------------------------------------------------- | -------- | ------------------------ | ------------------ | ------------------------- | +| B1. Honor system (no token) | ✅ | ❌ | n/a | none | +| B2. HMAC token (like entitlements) | ✅ | ❌ (client holds secret) | weak | low | +| **B3. Ed25519 token, DID-bound, 30-day + grace** ⭐ | ✅ | ✅ | ✅ (refresh fails) | low | +| B4. Mandatory online activation per launch | ❌ | ✅ | ✅ instant | high (breaks local-first) | + +**Recommendation: B3.** It's the only option that's both forge-resistant _and_ +local-first-friendly. Start fulfillment with B1 (manual) in Phase 1 so the token +plumbing ships before the billing does. + +### C. License (legal) + +| Option | Blanket-approvable | Converts to open | Anti-clone strength | Community optics | +| ----------------------------------------------- | ----------------------- | ---------------- | ------------------- | -------------------------- | +| **C1. FSL-1.1-MIT/Apache** ⭐ | ✅ (fixed terms) | ✅ 2 yr | ⚠️ weak for desktop | good (Fair Source) | +| C2. BUSL 1.1 | ❌ (per-instance grant) | ✅ ≤4 yr (GPL) | ⚠️ | mixed (HashiCorp backlash) | +| C3. Proprietary EULA | ✅ | ❌ never | ✅ strong | poor (closed) | +| C4. Author's free choice (MIT…AGPL…proprietary) | ❌ chaos | varies | varies | author-friendly | + +**Recommendation: C1 as the _pre-approved_ default**, with C4 allowed for authors +who declare an OSI-approved license (MIT/Apache/AGPL) — those need no review +either. Disallow opaque proprietary EULAs in the _managed_ marketplace (they can +still BYO-host). Anti-clone protection comes from the token (B3), not the license. + +### D. Package boundary + +- **MIT (ships to self-hosters):** new `@xnetjs/licenses` (sign is hub-side but + the _verify_ + types are MIT and bundled client-side), `pricing`/`license` + manifest fields, the install-gate, BYO-billing. +- **FSL `@xnetjs/cloud`:** Connect platform secret, seller-onboarding OAuth, + fee-capture config, payout/ledger views, the hosted marketplace registry write + path. Enforced by `check-cloud-boundary.sh`. + +## Recommendation + +Build the **"Plugin Storefront"** in five phases. Default payment = **Stripe +Connect Standard with a 10% `application_fee_percent`**; default license = +**FSL-1.1-MIT** (pre-approved); enforcement = **Ed25519 DID-bound `PluginLicense` +token** checked at install/activate. Keep a **0%-fee sovereign BYO-billing path**. + +```mermaid +sequenceDiagram + autonumber + participant Buyer as Buyer (DID) + participant Web as xNet client + participant Hub as Hub / @xnetjs/cloud + participant Stripe as Stripe (Connect) + participant Author as Author's Stripe (Standard) + + Buyer->>Web: Click "Buy" on listing + Web->>Hub: POST /marketplace/checkout {pluginId} + Hub->>Hub: look up listing → price + author connectedAccountId + fee% + Hub->>Stripe: create Checkout Session
application_fee_percent=10
(direct: Stripe-Account=acct_author) + Stripe-->>Web: redirect to hosted checkout + Buyer->>Stripe: pay + Stripe->>Author: net (90%) to author balance + Stripe->>Hub: fee (10%) to platform balance + Stripe-->>Hub: webhook checkout.session.completed {metadata.did, pluginId} + Hub->>Hub: billing store (LWW, idempotent) + Hub->>Hub: mint PluginLicense (Ed25519, sign with platform privkey) + Hub-->>Web: license synced to buyer (node or /licenses/me) + Web->>Web: PluginRegistry.install → gate verifies license offline → activate +``` + +```mermaid +sequenceDiagram + autonumber + participant Author + participant Web as xNet client + participant Hub as @xnetjs/cloud + participant Stripe as Stripe Connect + + Author->>Web: "Become a publisher" → set price + pick FSL license + Web->>Hub: POST /marketplace/connect/start + Hub->>Stripe: create Standard account link (OAuth) + Stripe-->>Author: Stripe-hosted onboarding (KYC, payout bank) + Stripe-->>Hub: callback → connectedAccountId + Hub->>Hub: store Publisher{ did, connectedAccountId, payoutsEnabled } + Author->>Web: Publish listing (manifestUrl + provenance + price + license) + Web->>Hub: POST /marketplace/listings (signed by author DID) + Hub->>Hub: validate license ∈ allowed; append to registry.json +``` + +### Data model (additive — extends billing, not a fork) + +```mermaid +erDiagram + PUBLISHER ||--o{ LISTING : sells + LISTING ||--o{ PRICE : "has" + LISTING ||--o{ LICENSE : "grants" + CUSTOMER ||--o{ LICENSE : "holds" + LISTING }o--|| PLUGIN_MANIFEST : "points to" + + PUBLISHER { + string did PK + string connectedAccountId "acct_… (null if BYO)" + bool payoutsEnabled + int feeBps "default 1000 = 10%" + } + LISTING { + string pluginId PK + string publisherDid FK + string license "FSL-1.1-MIT | MIT | …" + string manifestUrl + string provenanceRef + } + PRICE { + string id PK + string pluginId FK + string mode "payment | subscription" + int amountMinor + string currency + string stripePriceRef + } + LICENSE { + string id PK + string pluginId FK + string buyerDid FK + string mode + int issuedAt + int expiresAt + int graceSec + string token "Ed25519-signed" + string status "active | past_due | revoked" + } +``` + +### License lifecycle + +```mermaid +stateDiagram-v2 + [*] --> None + None --> Active: checkout.session.completed → mint token + Active --> Active: silent refresh (≤30d) while sub active + Active --> PastDue: invoice.payment_failed + PastDue --> Active: payment recovers + PastDue --> Grace: token expired, within graceSec + Grace --> Revoked: grace elapsed / refund / dispute + Active --> Revoked: refund / chargeback / manual + Revoked --> Active: re-purchase + Active --> OpenSource: 2yr after version release (FSL → MIT/Apache) + note right of OpenSource + Code becomes freely usable; + token no longer required for + that version. New paid versions + restart their own 2yr clock. + end note +``` + +## Example Code + +### 1. Manifest gains `pricing` + `license` (MIT, additive) + +```ts +// packages/plugins/src/feature-module.ts (additive fields) +export interface PluginPricing { + mode: 'free' | 'one-time' | 'subscription' + amountMinor?: number // integer minor units; omit for free + currency?: string // ISO-4217 + billing?: 'managed' | 'byo' // managed = xNet Connect; byo = author-hosted + trialDays?: number +} + +export interface XNetExtension { + // …existing… + /** SPDX id. Paid plugins must be FSL-1.1-* or an OSI id; default 'MIT'. */ + license?: string + pricing?: PluginPricing + /** Publisher's DID — supersedes the bare `author` string for paid plugins. */ + publisherDid?: string +} +``` + +```ts +// MarketplaceEntry mirrors it (ecosystem/marketplace.ts) +export interface MarketplaceEntry { + // …existing id/name/version/author/capabilities/manifestUrl/provenance… + license: string + pricing: PluginPricing + publisherDid?: string +} +``` + +### 2. `@xnetjs/licenses` — Ed25519 PluginLicense (NEW, MIT) + +```ts +// packages/licenses/src/token.ts +import { ed25519Sign, ed25519Verify } from '@xnetjs/crypto' + +export interface PluginLicenseClaims { + pluginId: string + buyerDid: string + mode: 'one-time' | 'subscription' + issuedAt: number // epoch ms + expiresAt: number // epoch ms (one-time: far future) + graceSec: number // keep running this long past expiry + v: 1 +} + +/** Hub-side ONLY: signs with the platform private key. */ +export function signPluginLicense(claims: PluginLicenseClaims, privateKey: Uint8Array): string { + const payload = b64url(JSON.stringify(claims)) + const sig = b64url(ed25519Sign(privateKey, utf8(payload))) + return `${payload}.${sig}` +} + +/** Client/runtime-safe: verifies with the embedded public key. Offline. */ +export function verifyPluginLicense( + token: string, + publicKey: Uint8Array, + now: number +): { ok: true; claims: PluginLicenseClaims } | { ok: false; reason: string } { + const [payload, sig] = token.split('.') + if (!payload || !sig) return { ok: false, reason: 'malformed' } + if (!ed25519Verify(publicKey, utf8(payload), unb64url(sig))) + return { ok: false, reason: 'bad-signature' } + const claims = JSON.parse(utf8d(unb64url(payload))) as PluginLicenseClaims + if (now > claims.expiresAt + claims.graceSec * 1000) return { ok: false, reason: 'expired' } + return { ok: true, claims } +} +``` + +> Why not reuse `signEntitlements`? It's **HMAC** — fine for hub↔hub (`HUB_PLAN`), +> unsafe here because the verifying client would need the secret and could mint +> its own licenses. Asymmetric is mandatory when the verifier is the adversary. + +### 3. The install-time gate (extends `PluginRegistry.install`) + +```ts +// packages/plugins/src/registry.ts (new step, after capability consent) +if (manifest.pricing && manifest.pricing.mode !== 'free') { + const token = await this.licenses?.tokenFor(manifest.id, this.viewerDid) + const v = token + ? verifyPluginLicense(token, MARKETPLACE_PUBKEY, Date.now()) + : ({ ok: false, reason: 'no-license' } as const) + if (!v.ok) { + throw new LicenseRequiredError(manifest.id, v.reason) // UI → "Buy" / "Restore purchase" + } +} +``` + +### 4. Stripe adapter: capture the fee (extends the existing form-encode) + +```ts +// packages/billing/src/providers/stripe.ts (createCheckout, when connect config present) +if (req.connect) { + if (req.mode === 'subscription') { + body.set('subscription_data[application_fee_percent]', String(req.connect.feePercent)) + body.set('subscription_data[transfer_data][destination]', req.connect.connectedAccountId) + } else { + body.set('payment_intent_data[application_fee_amount]', String(req.connect.feeMinor)) + body.set('payment_intent_data[transfer_data][destination]', req.connect.connectedAccountId) + } +} +// `req.connect` is populated by @xnetjs/cloud's marketplace route, never the client. +``` + +### 5. FSL-for-plugins CI check (clone of `check-cloud-boundary.sh`) + +```bash +# scripts/check-plugin-licenses.sh — fail CI if a paid listing has a disallowed license +ALLOWED='FSL-1.1-MIT FSL-1.1-Apache-2.0 MIT Apache-2.0 AGPL-3.0-only' +# for each entry in registry.json where pricing.mode != free: +# assert entry.license ∈ ALLOWED +# assert FSL entries ship a real LICENSE file at manifestUrl's repo root +``` + +## Risks And Open Questions + +- **Connect onboarding friction.** Even 1-click OAuth + Stripe KYC is a wall for a + hobbyist who just wants $3. Mitigate: free plugins need _no_ Connect; only show + the publisher flow when an author sets a non-zero price. +- **Refunds & chargebacks revoke a _running_ license.** A 30-day token means up to + 30 days of post-refund use. Accept it (matches industry) or add a CDN revocation + list for fast-revoke. Don't break offline use chasing the tail. +- **Tax/VAT under Connect Standard is the _author's_ obligation**, not xNet's. + Document this loudly; offer Stripe Tax as opt-in; keep MoR (Option A4) as the + future "we handle taxes" upsell. +- **FSL's weak anti-clone for desktop code.** Someone could fork an FSL plugin and + relist it. Defenses: the license token (forked copy has no valid token for + buyers), publisher-DID provenance + verified badges, and marketplace ToS + ("Competing Use" includes relisting). The token, not the copyright, is the moat. +- **`@xnetjs/crypto` Ed25519 availability** — confirm it exposes + sign/verify (it backs DID identity, so almost certainly yes); otherwise vendor a + zero-dep Ed25519 (e.g. `@noble/ed25519`) into `@xnetjs/licenses`. +- **Where does the platform private key live, and key rotation?** Hub-side secret + (`XNET_LICENSE_PRIVKEY`), public key baked into client builds. Rotation needs a + `kid` in the token + a small published JWKS-style pubkey set. Design for it in v1 + (add `kid` to claims) even if there's one key. +- **Subscription proration fees** aren't covered by `application_fee_percent` — the + hub must set `application_fee_amount` on `invoice.created` for mid-cycle changes. +- **Marketplace registry as a write target.** Today `registry.json` is read-only + fetch. A _publish_ path needs an authenticated, DID-signed write — scope: a + GitHub-PR-backed registry (per [0047](./0047_[_]_PLUGIN_MARKETPLACE.md)) vs. a + hub-hosted registry. Likely hub-hosted for paid listings (needs auth anyway). +- **Self-hoster experience.** With no Connect platform, self-hosted hubs get the + catalog + BYO-billing + license _verification_ but cannot _mint_ managed + licenses. Confirm that degradation is graceful (free + BYO plugins still work). +- **Number collision.** Per repo convention, recompute the `NNNN` at PR time — + 0193/0194 already have duplicate-numbered siblings. + +## Implementation Checklist + +**Phase 0 — Paid-aware catalog + FSL policy (MIT, no money)** ✅ shipped + +- [x] Add `license`, `pricing`, `publisherDid` to `XNetExtension` + ([manifest.ts](../../packages/plugins/src/manifest.ts)) + `PluginPricing` + type + `isPaidPricing` + validation. +- [x] Mirror them on `MarketplaceEntry` + ([marketplace.ts](../../packages/plugins/src/ecosystem/marketplace.ts)). + _(Marketplace-UI badges still pending — no marketplace view exists yet.)_ +- [x] Adopt **FSL-1.1-MIT / FSL-1.1-Apache-2.0** as the pre-approved paid license; + `LICENSE` generation in the scaffolder via + [license-policy.ts](../../packages/plugins/src/ecosystem/license-policy.ts) + + [scaffold.ts](../../packages/plugins/src/ecosystem/scaffold.ts). +- [x] Add `scripts/check-plugin-licenses.mjs` (the CI license gate); wired into + the `lint` CI job + `pnpm check:plugin-licenses`. +- [x] Write [`docs/guides/sell-a-plugin.md`](../guides/sell-a-plugin.md). + +**Phase 1 — Entitlement spine (MIT; manual fulfillment)** + +- [x] New [`@xnetjs/licenses`](../../packages/licenses) package: `signPluginLicense` + (hub), `verifyPluginLicense` (client), `mintPluginLicense`/`checkLicenseFor`, + `PluginLicenseClaims` with `kid`. +- [x] Standardize on Ed25519 via [`@xnetjs/crypto`](../../packages/crypto); public + key transported as hex (`publicKeyFromHex`) for client bake-in. +- [x] Add the `LicenseRequiredError` gate to + [`PluginRegistry.install`](../../packages/plugins/src/registry.ts) (after + capability consent) via an injected `checkLicense` callback (fail-closed). +- [ ] Hub route to _manually_ mint a license (admin/grant) + a client license + store/sync path (license-as-node or `GET /licenses/me`). _(Deferred — hub.)_ + +**Phase 2 — Managed billing via Connect Standard** + +- [ ] Seller onboarding: `POST /marketplace/connect/start` → Stripe Standard + account link; store `Publisher { did, connectedAccountId, feeBps }`. _(Deferred — needs live Stripe.)_ +- [x] Extend the Stripe adapter + ([stripe.ts](../../packages/billing/src/providers/stripe.ts)) with optional + `CheckoutRequest.connect` → `application_fee_*` + `transfer_data[destination]`, + plus fee math ([connect.ts](../../packages/billing/src/connect.ts)). +- [ ] `POST /marketplace/checkout`: resolve listing → price + connected account + + fee; `application_fee_percent = 10`. _(Deferred — hub.)_ +- [ ] Webhook handler: on `checkout.session.completed` / + `customer.subscription.*`, mint/refresh the `PluginLicense`; reuse the + idempotent LWW [billing store](../../packages/hub/src/services/billing-store.ts). _(Deferred — hub.)_ +- [ ] Handle subscription proration fee on `invoice.created`. _(Deferred — hub.)_ + +**Phase 3 — Sovereign BYO-billing path (MIT)** + +- [ ] `pricing.billing = 'byo'`: author hosts checkout + mints their own license + (publish their pubkey in provenance); xNet takes 0% and only _verifies_. +- [ ] Spec the per-publisher pubkey trust (provenance-rooted, DID-signed). + +**Phase 4 — Lifecycle** + +- [ ] Refund/chargeback → revoke; optional CDN revocation list. +- [ ] Plugin updates honor existing licenses; new _major_ paid versions can + re-price (restart the 2-yr FSL clock). +- [ ] Publisher payout/earnings dashboard (reads Connect balance). +- [ ] Ratings: wire `PluginRating { authorDid }` into entries; gate reviews on a + verified purchase license. +- [ ] **2-year auto-open job:** track each version's release date; surface/relabel + versions whose FSL Change Date has passed as MIT/Apache. + +## Validation Checklist + +- [x] **Fee math + Connect routing:** unit tests assert a 10% (1000 bps) fee → + `$1.00` on a `$10` charge and the correct `application_fee_*` / + `transfer_data[destination]` form fields for subs + one-time + ([connect.test.ts](../../packages/billing/src/connect.test.ts), + [stripe.test.ts](../../packages/billing/src/providers/stripe.test.ts)). + _(End-to-end test-mode capture against real Connect accounts: deferred.)_ +- [x] **Offline gate:** a valid token activates; a tampered token + (`bad-signature`) and an expired-past-grace token are rejected; a + fresh-but-expiring token inside grace still activates + ([token.test.ts](../../packages/licenses/src/token.test.ts)). +- [x] **Forge resistance:** a token signed by any other key is rejected; the + verifier holds only the public key, so a client cannot mint a license + (asymmetric — proven in `token.test.ts`). +- [x] **DID portability:** `checkLicenseFor` accepts the buyer's DID regardless of + device and rejects a different `buyerDid` (`wrong-buyer`). +- [ ] **Revocation:** after a simulated refund, the next token refresh fails and + the plugin deactivates after grace. _(Deferred — needs the hub refresh path.)_ +- [ ] **Subscription proration fee** on `invoice.created`. _(Deferred — hub.)_ +- [x] **License policy CI:** a paid listing with `license: "Proprietary"` fails + `check-plugin-licenses`; an FSL/MIT listing passes (verified against fixtures). +- [x] **Open-core boundary intact:** `check-cloud-boundary.sh` stays green — the + new MIT packages never import `@xnetjs/cloud`. +- [x] **Free plugins unaffected + paid fail-closed:** a free plugin installs with + no license check; a priced plugin with no provider is blocked + ([ecosystem-install-gates.test.ts](../../packages/plugins/src/__tests__/ecosystem-install-gates.test.ts)). +- [ ] **Sovereign / self-host degradation paths.** _(Deferred — Phase 3 + hub.)_ +- [x] `@xnetjs/licenses` has unit coverage for sign/verify/expiry/grace/tamper/mint + (executes every fn — `maxCrap` gate); package + plugins + billing typecheck + and tests green. + +## References + +**In-repo** + +- [`packages/billing`](../../packages/billing) — PaymentProvider port, Stripe/BTCPay/fake adapters, store +- [`packages/billing/src/providers/stripe.ts`](../../packages/billing/src/providers/stripe.ts) — the single-account checkout to extend for Connect +- [`packages/entitlements/src/entitlements.ts`](../../packages/entitlements/src/entitlements.ts) — `signEntitlements`/`verifyEntitlements` (HMAC) pattern to mirror (→ Ed25519) +- [`packages/cloud/LICENSE`](../../packages/cloud/LICENSE) + [`packages/cloud/package.json`](../../packages/cloud/package.json) — FSL-1.1-ALv2 / FSL-1.1-Apache-2.0 in-tree +- [`scripts/check-cloud-boundary.sh`](../../scripts/check-cloud-boundary.sh) — precedent for the license CI check +- [`packages/plugins/src/ecosystem/marketplace.ts`](../../packages/plugins/src/ecosystem/marketplace.ts) — `MarketplaceEntry`/`MarketplaceClient` +- [`packages/plugins/src/registry.ts`](../../packages/plugins/src/registry.ts) — install/consent gate to extend +- [`packages/plugins/src/feature-module.ts`](../../packages/plugins/src/feature-module.ts) / [`manifest.ts`](../../packages/plugins/src/manifest.ts) — manifest fields +- [0192](./0192_[_]_PLUGIN_ECOSYSTEM_MARKETPLACE_DX_AND_TRUST.md) (deferred "monetization phase"), [0194](./0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md), [0181](./0181_[x]_CONSOLIDATE_CLOUD_INTO_ONE_PACKAGE.md) (cloud boundary), [0047](./0047_[_]_PLUGIN_MARKETPLACE.md) (GitHub-backed registry), [0187 billing](./0187_[x]_PLUG_AND_PLAY_BILLING_STRIPE_AND_BITCOIN.md) + +**External** + +- Stripe Connect account types — https://docs.stripe.com/connect/accounts +- Stripe Connect charge types (direct/destination/separate, `application_fee_*`, `transfer_data`, `on_behalf_of`) — https://docs.stripe.com/connect/charges +- Stripe Connect subscriptions (`application_fee_percent`) — https://docs.stripe.com/connect/subscriptions +- Stripe Connect pricing — https://stripe.com/connect/pricing +- Stripe Partner Ecosystem (referral, not per-tx) — https://stripe.com/partners/become-a-partner +- Functional Source License — https://fsl.software/ ; intro: https://blog.sentry.io/introducing-the-functional-source-license-freedom-without-free-riding/ +- Business Source License 1.1 — https://mariadb.com/bsl11/ +- Fair Source / DOSP — https://fair.io/about/ +- Freemius (plugin-marketplace MoR + licensing) — https://freemius.com/wordpress/software-licensing/ +- Lemon Squeezy / Paddle (merchant of record) — https://docs.lemonsqueezy.com/help/payments/merchant-of-record , https://www.paddle.com/pricing +- Ghost (own-Stripe, 0% model) — https://ghost.org/ +- Keygen offline licenses (Ed25519, grace, revocation) — https://keygen.sh/docs/choosing-a-licensing-model/offline-licenses/ +- JetBrains floating/offline licenses — https://www.jetbrains.com/help/ide-services/floating-licenses.html +- Apple Small Business Program (15% tier) — https://developer.apple.com/app-store/small-business-program/ +- Steam revenue tiers — https://steamcommunity.com/groups/steamworks/announcements/detail/1697191267930157838 diff --git a/docs/guides/sell-a-plugin.md b/docs/guides/sell-a-plugin.md new file mode 100644 index 000000000..479bb47a2 --- /dev/null +++ b/docs/guides/sell-a-plugin.md @@ -0,0 +1,101 @@ +# Sell a plugin + +xNet lets a plugin author **charge for their work** — a one-time fee or a monthly +subscription — through an App-Store-like marketplace, without a 30% tax and +without giving up the bring-your-own-Stripe ethos +([exploration 0196](../explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md)). + +This guide covers the three decisions you make as a seller: **how you get paid**, +**which license you ship under**, and **how the license is enforced**. + +## 1. Price your plugin + +Add a `pricing` block (and a `license`) to your manifest. The scaffolder +(`xnet plugin scaffold`) emits these for you: + +```ts +import { defineFeatureModule } from '@xnetjs/plugins' + +export const ProModule = defineFeatureModule({ + id: 'com.acme.pro', + name: 'Acme Pro', + version: '1.0.0', + publisherDid: 'did:key:zYourPublisherDid', + license: 'FSL-1.1-MIT', // source-available; auto-opens to MIT after 2 years + pricing: { + mode: 'subscription', // 'free' | 'one-time' | 'subscription' + amountMinor: 500, // $5.00 — integer minor units + currency: 'USD', + billing: 'managed' // 'managed' = xNet Connect (we take the fee); 'byo' = your own + }, + contributes: { + /* … */ + } +}) +``` + +`free` plugins need none of this. Paid plugins are **gated at install** — xNet +will not activate paid code without a valid license (see §3). + +## 2. How you get paid + +### Managed (recommended): your own Stripe + a small marketplace fee + +Connect your **own** Stripe account once via Stripe Connect **Standard** (one +click — Stripe keeps your dashboard, your payouts, your KYC, your dispute +handling). xNet attaches a small **application fee** (default **10%**, far below +the App Store's 30%) to each charge and routes the rest to you: + +```mermaid +flowchart LR + Buyer -->|pays| Stripe + Stripe -->|"90%"| You["Your Stripe account"] + Stripe -->|"10% application fee"| xNet + Stripe -->|webhook| Hub --> License["mint PluginLicense (Ed25519, DID-bound)"] +``` + +This is genuinely "use your own Stripe" — you are the merchant of record for your +sales — plus an automatically-captured marketplace fee. (There is **no** way for +a platform to skim a fee from a _standalone_ Stripe account it doesn't control; +Connect is the supported version of that.) + +### BYO (fully sovereign): your own checkout, 0% fee + +Set `pricing.billing: 'byo'` and xNet takes **nothing**. You run your own +checkout and mint your own license tokens (publishing your public key in the +listing's provenance); xNet only _verifies_ the resulting license at install. + +## 3. Licensing + enforcement + +Paid plugins must declare a **marketplace-approved** license — enforced in CI by +`pnpm check:plugin-licenses`: + +| License | Source-available | Converts to open | +| -------------------------------------- | ---------------- | ---------------------------- | +| `FSL-1.1-MIT` _(default)_ | ✅ | ✅ MIT, after 2 years | +| `FSL-1.1-Apache-2.0` | ✅ | ✅ Apache-2.0, after 2 years | +| `MIT` / `Apache-2.0` / `AGPL-3.0-only` | ✅ | already open | + +**FSL** (the Functional Source License, same one [`@xnetjs/cloud`](../../packages/cloud/LICENSE) +uses) keeps your source published, forbids only a _competing_ marketplace, and +auto-converts each version to MIT/Apache exactly two years after it ships. The +scaffolder writes the matching `LICENSE` file automatically. + +Enforcement is a signed **`PluginLicense`** token, not the copyright license. On +purchase the hub mints an **Ed25519-signed, DID-bound** token +([`@xnetjs/licenses`](../../packages/licenses)); the plugin runtime verifies it +**offline** at install/activate. Because it is bound to the buyer's **DID** (not +a device), one purchase works across all their devices and can be revoked +hub-side. The token is the anti-piracy moat; the license governs redistribution +and the eventual open-sourcing. + +## 4. Publish + +1. `xnet plugin scaffold` → set `license` + `pricing` (above). +2. Connect Stripe (managed) from the marketplace's "Become a publisher" flow, or + wire your own checkout (BYO). +3. Submit your manifest URL to the marketplace registry. CI validates the license + policy; the listing shows a price + license badge. + +See the exploration for the full architecture, the Connect vs. merchant-of-record +tradeoffs, and the lifecycle (updates, refunds/revocation, the 2-year auto-open). diff --git a/package.json b/package.json index c5c2f8451..37e79652e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:stories": "storybook dev --ci --no-open --host 127.0.0.1 --port 6006", "test": "pnpm --filter xnet-desktop run deps:node && vitest run", "check:cloud-boundary": "bash scripts/check-cloud-boundary.sh", + "check:plugin-licenses": "node scripts/check-plugin-licenses.mjs", "test:editor": "pnpm --filter @xnetjs/editor test", "test:stories": "storybook test --url http://127.0.0.1:6006", "test:watch": "vitest", diff --git a/packages/billing/src/connect.test.ts b/packages/billing/src/connect.test.ts new file mode 100644 index 000000000..32143d7de --- /dev/null +++ b/packages/billing/src/connect.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { + DEFAULT_MARKETPLACE_FEE_BPS, + applicationFeeMinor, + feeBpsToPercent, + sellerNetMinor +} from './connect' + +describe('applicationFeeMinor', () => { + it('defaults to 10% of the charge', () => { + expect(DEFAULT_MARKETPLACE_FEE_BPS).toBe(1000) + expect(applicationFeeMinor(1000, DEFAULT_MARKETPLACE_FEE_BPS)).toBe(100) // $10.00 → $1.00 + }) + + it('rounds to the nearest minor unit', () => { + expect(applicationFeeMinor(999, 1000)).toBe(100) // 99.9 → 100 + expect(applicationFeeMinor(994, 1000)).toBe(99) // 99.4 → 99 + }) + + it('handles zero fee and zero amount', () => { + expect(applicationFeeMinor(5000, 0)).toBe(0) + expect(applicationFeeMinor(0, 1500)).toBe(0) + }) + + it('rejects invalid inputs', () => { + expect(() => applicationFeeMinor(9.99, 1000)).toThrow(/non-negative integer/) + expect(() => applicationFeeMinor(-1, 1000)).toThrow(/non-negative integer/) + expect(() => applicationFeeMinor(1000, 10001)).toThrow(/0\.\.10000/) + expect(() => applicationFeeMinor(1000, -5)).toThrow(/0\.\.10000/) + }) +}) + +describe('feeBpsToPercent / sellerNetMinor', () => { + it('converts bps to percent', () => { + expect(feeBpsToPercent(1000)).toBe(10) + expect(feeBpsToPercent(1500)).toBe(15) + expect(feeBpsToPercent(250)).toBe(2.5) + }) + + it('computes the seller net after the fee', () => { + expect(sellerNetMinor(1000, 1000)).toBe(900) // $10 − $1 = $9 + expect(sellerNetMinor(2000, 1500)).toBe(1700) // $20 − $3 = $17 + }) +}) diff --git a/packages/billing/src/connect.ts b/packages/billing/src/connect.ts new file mode 100644 index 000000000..b83f8e960 --- /dev/null +++ b/packages/billing/src/connect.ts @@ -0,0 +1,41 @@ +/** + * @xnetjs/billing — marketplace fee math (exploration 0196). + * + * The platform marketplace fee is expressed in basis points (bps): 1000 bps = + * 10%. `applicationFeeMinor` turns a price + fee rate into the integer minor-unit + * `application_fee_amount` a one-time Connect charge carries. Subscriptions use + * `application_fee_percent` directly (see `feeBpsToPercent`). + */ + +/** Default marketplace fee: 10% (1000 bps). Below Apple/Steam (30%); the user's stated band. */ +export const DEFAULT_MARKETPLACE_FEE_BPS = 1000 + +/** Compute the platform fee in minor units for a one-time charge. Rounds to the nearest unit. */ +export function applicationFeeMinor(amountMinor: number, feeBps: number): number { + assertMinor(amountMinor) + assertBps(feeBps) + return Math.round((amountMinor * feeBps) / 10000) +} + +/** Convert basis points to the percent value Stripe's `application_fee_percent` wants. */ +export function feeBpsToPercent(feeBps: number): number { + assertBps(feeBps) + return feeBps / 100 +} + +/** The seller's net (minor units) after the platform fee, for previews/receipts. */ +export function sellerNetMinor(amountMinor: number, feeBps: number): number { + return amountMinor - applicationFeeMinor(amountMinor, feeBps) +} + +function assertMinor(amountMinor: number): void { + if (!Number.isInteger(amountMinor) || amountMinor < 0) { + throw new Error('amountMinor must be a non-negative integer (minor units)') + } +} + +function assertBps(feeBps: number): void { + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10000) { + throw new Error('feeBps must be an integer in 0..10000') + } +} diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts index f344d5583..2981761f9 100644 --- a/packages/billing/src/index.ts +++ b/packages/billing/src/index.ts @@ -25,7 +25,20 @@ export type { } from './types' export { BillingSignatureError } from './provider' -export type { PaymentProvider, CheckoutRequest, CheckoutSession, PortalRequest } from './provider' +export type { + PaymentProvider, + CheckoutRequest, + CheckoutSession, + PortalRequest, + ConnectCharge +} from './provider' + +export { + DEFAULT_MARKETPLACE_FEE_BPS, + applicationFeeMinor, + feeBpsToPercent, + sellerNetMinor +} from './connect' export { MemoryBillingStore, isActiveSubscription, pickCurrentSubscription } from './store' export type { BillingStore } from './store' diff --git a/packages/billing/src/provider.ts b/packages/billing/src/provider.ts index 46798c68e..0938251df 100644 --- a/packages/billing/src/provider.ts +++ b/packages/billing/src/provider.ts @@ -17,6 +17,21 @@ export class BillingSignatureError extends Error { } } +/** + * Stripe Connect routing for a marketplace charge (exploration 0196). When + * present, funds settle to the connected (seller) account and the platform keeps + * an application fee — this is "bring your own Stripe" (Connect Standard) plus a + * captured marketplace fee. Server-set only; never trusted from a client body. + */ +export interface ConnectCharge { + /** Connected account id (`acct_…`) that receives the funds. */ + destination: string + /** Platform fee as a percent of each invoice (subscriptions). */ + feePercent?: number + /** Platform fee in integer minor units (one-time payments). */ + feeMinor?: number +} + export interface CheckoutRequest { /** The DID to bind the checkout to. Server-set — NEVER trusted from a client body. */ did: DID @@ -26,6 +41,11 @@ export interface CheckoutRequest { successUrl: string cancelUrl: string customerEmail?: string + /** + * Marketplace routing (Stripe only). Set by the hub from a paid listing's + * seller account + fee; routes funds to the seller and keeps the platform fee. + */ + connect?: ConnectCharge } export interface CheckoutSession { diff --git a/packages/billing/src/providers/stripe.test.ts b/packages/billing/src/providers/stripe.test.ts index b17247f86..d31a45a90 100644 --- a/packages/billing/src/providers/stripe.test.ts +++ b/packages/billing/src/providers/stripe.test.ts @@ -146,6 +146,52 @@ describe('createStripeProvider.createCheckout', () => { expect(body).toContain('line_items%5B0%5D%5Bprice%5D=price_pro') }) + it('routes a subscription to a connected account with an application fee percent', async () => { + const fetchImpl = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'cs_2', url: 'https://checkout/y' }), { status: 200 }) + ) + const provider = createStripeProvider({ + secretKey: 'sk_test', + webhookSecret: 'whsec', + fetchImpl: fetchImpl as unknown as typeof fetch + }) + await provider.createCheckout({ + did: 'did:key:alice', + priceRef: 'price_pro', + mode: 'subscription', + successUrl: 'https://app/ok', + cancelUrl: 'https://app/cancel', + connect: { destination: 'acct_seller', feePercent: 10 } + }) + const body = String((fetchImpl.mock.calls[0][1] as RequestInit).body) + expect(body).toContain('subscription_data%5Btransfer_data%5D%5Bdestination%5D=acct_seller') + expect(body).toContain('subscription_data%5Bapplication_fee_percent%5D=10') + }) + + it('routes a one-time payment to a connected account with an application fee amount', async () => { + const fetchImpl = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ id: 'cs_3', url: 'https://checkout/z' }), { status: 200 }) + ) + const provider = createStripeProvider({ + secretKey: 'sk_test', + webhookSecret: 'whsec', + fetchImpl: fetchImpl as unknown as typeof fetch + }) + await provider.createCheckout({ + did: 'did:key:alice', + priceRef: 'price_once', + mode: 'payment', + successUrl: 'https://app/ok', + cancelUrl: 'https://app/cancel', + connect: { destination: 'acct_seller', feeMinor: 100 } + }) + const body = String((fetchImpl.mock.calls[0][1] as RequestInit).body) + expect(body).toContain('payment_intent_data%5Btransfer_data%5D%5Bdestination%5D=acct_seller') + expect(body).toContain('payment_intent_data%5Bapplication_fee_amount%5D=100') + }) + it('throws when Stripe returns an error status', async () => { const fetchImpl = vi.fn( async (_url: string | URL | Request, _init?: RequestInit) => diff --git a/packages/billing/src/providers/stripe.ts b/packages/billing/src/providers/stripe.ts index 8228555de..a34bc4aac 100644 --- a/packages/billing/src/providers/stripe.ts +++ b/packages/billing/src/providers/stripe.ts @@ -7,7 +7,13 @@ * dependency-free. The secret key is used only here, server-side. */ -import type { CheckoutRequest, CheckoutSession, PaymentProvider, PortalRequest } from '../provider' +import type { + CheckoutRequest, + CheckoutSession, + ConnectCharge, + PaymentProvider, + PortalRequest +} from '../provider' import type { BillingMutation, InvoiceStatus, @@ -184,6 +190,28 @@ export function normalizeStripeEvent(event: ProviderEvent, now: number): Billing return STRIPE_NORMALIZERS[event.type]?.(asObj(event.data), event.data, now) ?? [] } +/** + * Add Connect destination + application-fee fields to a checkout form. Uses + * destination charges (`transfer_data[destination]`): the charge is made on the + * platform account and the seller's cut is transferred to their connected + * account, with the platform keeping the application fee. + */ +function applyConnect( + form: URLSearchParams, + mode: CheckoutRequest['mode'], + connect: ConnectCharge +): void { + const root = mode === 'subscription' ? 'subscription_data' : 'payment_intent_data' + form.set(`${root}[transfer_data][destination]`, connect.destination) + if (mode === 'subscription') { + if (connect.feePercent !== undefined) { + form.set('subscription_data[application_fee_percent]', String(connect.feePercent)) + } + } else if (connect.feeMinor !== undefined) { + form.set('payment_intent_data[application_fee_amount]', String(connect.feeMinor)) + } +} + export function createStripeProvider(config: StripeProviderConfig): PaymentProvider { const apiBase = config.apiBase ?? 'https://api.stripe.com' const doFetch = config.fetchImpl ?? fetch @@ -220,6 +248,10 @@ export function createStripeProvider(config: StripeProviderConfig): PaymentProvi else form.set('payment_intent_data[metadata][did]', req.did) if (req.customerEmail) form.set('customer_email', req.customerEmail) + // Marketplace (Connect) routing: settle to the seller's connected account + // and keep the platform application fee (exploration 0196). + if (req.connect) applyConnect(form, req.mode, req.connect) + const json = await post('/v1/checkout/sessions', form) const url = str(json.url) const id = str(json.id) diff --git a/packages/cli/src/commands/plugin.test.ts b/packages/cli/src/commands/plugin.test.ts index 24bea300c..bc17fb102 100644 --- a/packages/cli/src/commands/plugin.test.ts +++ b/packages/cli/src/commands/plugin.test.ts @@ -38,6 +38,7 @@ describe('writeScaffoldFiles', () => { const { io } = fakeIO() const paths = writeScaffoldFiles(files, '/tmp/x', io).sort() expect(paths).toEqual([ + 'LICENSE', 'README.md', 'package.json', 'src/index.test.ts', diff --git a/packages/licenses/README.md b/packages/licenses/README.md new file mode 100644 index 000000000..a99e970fa --- /dev/null +++ b/packages/licenses/README.md @@ -0,0 +1,45 @@ +# @xnetjs/licenses + +Ed25519-signed, **DID-bound** plugin license tokens — the offline-verifiable +entitlement spine for the paid plugin marketplace ([exploration +0196](../../docs/explorations/0196_[_]_PAID_PLUGIN_MARKETPLACE_MONETIZATION_AND_LICENSING.md)). + +A paid plugin is unlocked by a compact token bound to the **buyer's DID** (not a +device). The hub holds the platform **private** key and mints a token on +purchase; the plugin runtime embeds the **public** key and verifies it **fully +offline**. Asymmetric on purpose — the verifying client must not hold a secret +it could use to forge a license (that is why this is not the HMAC +`@xnetjs/entitlements` shape). + +```ts +import { + generateLicenseKeypair, + mintPluginLicense, + checkLicenseFor, + publicKeyFromHex, + privateKeyFromHex +} from '@xnetjs/licenses' + +// Once, on the platform — store privateKeyHex as a hub secret, ship publicKeyHex. +const { publicKeyHex, privateKeyHex } = generateLicenseKeypair() + +// Hub, on a successful Stripe webhook: +const token = mintPluginLicense( + { pluginId: 'com.acme.kanban', buyerDid, mode: 'one-time', now: Date.now() }, + privateKeyFromHex(privateKeyHex) +) + +// Client, at install/activate time (offline): +const decision = checkLicenseFor(token, { + pluginId: 'com.acme.kanban', + buyerDid, + publicKey: publicKeyFromHex(publicKeyHex), + now: Date.now() +}) +if (!decision.ok) { + // surface "Buy" / "Restore purchase" depending on decision.reason +} +``` + +Token format: `base64url(JSON claims) + "." + base64url(Ed25519 signature)`. +One dependency: `@xnetjs/crypto`. diff --git a/packages/licenses/package.json b/packages/licenses/package.json new file mode 100644 index 000000000..a02558166 --- /dev/null +++ b/packages/licenses/package.json @@ -0,0 +1,32 @@ +{ + "name": "@xnetjs/licenses", + "version": "0.0.1", + "description": "Ed25519-signed, DID-bound plugin license tokens — the offline-verifiable entitlement spine for the paid plugin marketplace (exploration 0196).", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@xnetjs/crypto": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^4.0.0" + }, + "private": true +} diff --git a/packages/licenses/src/index.ts b/packages/licenses/src/index.ts new file mode 100644 index 000000000..0c89b1d4d --- /dev/null +++ b/packages/licenses/src/index.ts @@ -0,0 +1,34 @@ +/** + * @xnetjs/licenses — Ed25519-signed, DID-bound plugin license tokens. + * + * The offline-verifiable entitlement spine for the paid plugin marketplace + * (exploration 0196). The hub mints a token on purchase with the platform + * private key; the plugin runtime verifies it offline with the embedded public + * key. MIT + a single `@xnetjs/crypto` dependency, so it bundles cleanly into + * both the hub and the client. + */ + +export type { PluginLicenseClaims, LicenseFailureReason, LicenseVerifyResult } from './token' +export { signPluginLicense, verifyPluginLicense } from './token' + +export type { LicenseKeypairHex } from './keys' +export { + generateLicenseKeypair, + publicKeyFromHex, + privateKeyFromHex, + publicKeyHexFromPrivateHex +} from './keys' + +export type { + MintLicenseInput, + LicenseCheckReason, + LicenseDecision, + LicenseRequirement +} from './mint' +export { + mintPluginLicense, + checkLicenseFor, + PERPETUAL_EXPIRY_MS, + DEFAULT_GRACE_SEC, + DEFAULT_SUBSCRIPTION_TTL_MS +} from './mint' diff --git a/packages/licenses/src/keys.ts b/packages/licenses/src/keys.ts new file mode 100644 index 000000000..957024477 --- /dev/null +++ b/packages/licenses/src/keys.ts @@ -0,0 +1,43 @@ +/** + * @xnetjs/licenses — platform signing keys. + * + * The marketplace signs licenses with an Ed25519 keypair. The **private** key + * lives only on the hub (e.g. `XNET_LICENSE_PRIVKEY`); the **public** key is + * baked into client builds (`XNET_LICENSE_PUBKEY`) so the runtime can verify + * offline. Keys are exchanged as hex strings for easy env-var transport. + */ + +import { + generateSigningKeyPair, + getSigningPublicKeyFromPrivate, + bytesToHex, + hexToBytes +} from '@xnetjs/crypto' + +export interface LicenseKeypairHex { + /** Baked into client builds; used to verify tokens. */ + publicKeyHex: string + /** Hub-only secret; used to mint tokens. */ + privateKeyHex: string +} + +/** Generate a fresh platform signing keypair (run once; store the private half as a secret). */ +export function generateLicenseKeypair(): LicenseKeypairHex { + const { publicKey, privateKey } = generateSigningKeyPair() + return { publicKeyHex: bytesToHex(publicKey), privateKeyHex: bytesToHex(privateKey) } +} + +/** Decode a hex public key into bytes for {@link verifyPluginLicense}. */ +export function publicKeyFromHex(hex: string): Uint8Array { + return hexToBytes(hex) +} + +/** Decode a hex private key into bytes for {@link signPluginLicense}. */ +export function privateKeyFromHex(hex: string): Uint8Array { + return hexToBytes(hex) +} + +/** Recover the public key from a private key (sanity-check a configured secret). */ +export function publicKeyHexFromPrivateHex(privateHex: string): string { + return bytesToHex(getSigningPublicKeyFromPrivate(hexToBytes(privateHex))) +} diff --git a/packages/licenses/src/mint.ts b/packages/licenses/src/mint.ts new file mode 100644 index 000000000..e58619824 --- /dev/null +++ b/packages/licenses/src/mint.ts @@ -0,0 +1,96 @@ +/** + * @xnetjs/licenses — minting + requirement checking. + * + * `mintPluginLicense` is what a Stripe webhook (or a manual grant) calls after a + * successful purchase; `checkLicenseFor` is what the install gate calls to + * decide whether a paid plugin may run for a given buyer. + */ + +import { + signPluginLicense, + verifyPluginLicense, + type LicenseFailureReason, + type PluginLicenseClaims +} from './token' + +/** 2100-01-01 — a one-time license never expires in practice. */ +export const PERPETUAL_EXPIRY_MS = 4102444800000 +/** Default connectivity slack past expiry: 7 days. */ +export const DEFAULT_GRACE_SEC = 7 * 24 * 60 * 60 +/** Default subscription token lifetime when no period end is supplied: 31 days. */ +export const DEFAULT_SUBSCRIPTION_TTL_MS = 31 * 24 * 60 * 60 * 1000 + +export interface MintLicenseInput { + pluginId: string + pluginVersion?: string + buyerDid: string + mode: 'one-time' | 'subscription' + /** Issuance time, epoch ms (injected for determinism). */ + now: number + /** For subscriptions: when the current paid period ends (epoch ms). */ + periodEnd?: number + /** Override the connectivity grace (seconds). */ + graceSec?: number + /** Signing-key id (for rotation). */ + kid?: string +} + +/** + * Build + sign a license token for a completed purchase. One-time purchases get a + * perpetual expiry; subscriptions expire at `periodEnd` (or `now + 31d`) and are + * re-minted on each successful renewal webhook. + */ +export function mintPluginLicense(input: MintLicenseInput, privateKey: Uint8Array): string { + const expiresAt = + input.mode === 'one-time' + ? PERPETUAL_EXPIRY_MS + : (input.periodEnd ?? input.now + DEFAULT_SUBSCRIPTION_TTL_MS) + const claims: PluginLicenseClaims = { + v: 1, + pluginId: input.pluginId, + ...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}), + buyerDid: input.buyerDid, + mode: input.mode, + issuedAt: input.now, + expiresAt, + graceSec: input.graceSec ?? DEFAULT_GRACE_SEC, + ...(input.kid ? { kid: input.kid } : {}) + } + return signPluginLicense(claims, privateKey) +} + +/** Why a token is not acceptable for a specific plugin + buyer. */ +export type LicenseCheckReason = + | LicenseFailureReason + | 'wrong-plugin' + | 'wrong-buyer' + | 'no-license' + +export type LicenseDecision = + | { ok: true; claims: PluginLicenseClaims } + | { ok: false; reason: LicenseCheckReason } + +export interface LicenseRequirement { + pluginId: string + buyerDid: string + /** The platform public key bytes (see `publicKeyFromHex`). */ + publicKey: Uint8Array + /** epoch ms. */ + now: number +} + +/** + * Verify a token AND confirm it unlocks this plugin for this buyer. Returns a + * typed decision the install gate can surface ("Buy" vs "Restore purchase"). + */ +export function checkLicenseFor( + token: string | undefined | null, + req: LicenseRequirement +): LicenseDecision { + if (!token) return { ok: false, reason: 'no-license' } + const verified = verifyPluginLicense(token, req.publicKey, req.now) + if (!verified.ok) return verified + if (verified.claims.pluginId !== req.pluginId) return { ok: false, reason: 'wrong-plugin' } + if (verified.claims.buyerDid !== req.buyerDid) return { ok: false, reason: 'wrong-buyer' } + return verified +} diff --git a/packages/licenses/src/token.test.ts b/packages/licenses/src/token.test.ts new file mode 100644 index 000000000..4c2598dc5 --- /dev/null +++ b/packages/licenses/src/token.test.ts @@ -0,0 +1,200 @@ +import { generateSigningKeyPair } from '@xnetjs/crypto' +import { describe, it, expect } from 'vitest' +import { + generateLicenseKeypair, + publicKeyFromHex, + privateKeyFromHex, + publicKeyHexFromPrivateHex +} from './keys' +import { mintPluginLicense, checkLicenseFor, PERPETUAL_EXPIRY_MS, DEFAULT_GRACE_SEC } from './mint' +import { signPluginLicense, verifyPluginLicense, type PluginLicenseClaims } from './token' + +const NOW = 1_700_000_000_000 +const BUYER = 'did:key:zBuyer' +const PLUGIN = 'com.acme.kanban' + +function freshClaims(over: Partial = {}): PluginLicenseClaims { + return { + v: 1, + pluginId: PLUGIN, + buyerDid: BUYER, + mode: 'one-time', + issuedAt: NOW, + expiresAt: PERPETUAL_EXPIRY_MS, + graceSec: DEFAULT_GRACE_SEC, + ...over + } +} + +describe('signPluginLicense / verifyPluginLicense', () => { + it('round-trips a signed license', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + const token = signPluginLicense(freshClaims(), privateKey) + const result = verifyPluginLicense(token, publicKey, NOW) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.claims.pluginId).toBe(PLUGIN) + expect(result.claims.buyerDid).toBe(BUYER) + } + }) + + it('rejects a token signed by a different key (forgery)', () => { + const platform = generateSigningKeyPair() + const attacker = generateSigningKeyPair() + const token = signPluginLicense(freshClaims(), attacker.privateKey) + const result = verifyPluginLicense(token, platform.publicKey, NOW) + expect(result).toEqual({ ok: false, reason: 'bad-signature' }) + }) + + it('rejects a tampered payload', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + const token = signPluginLicense(freshClaims(), privateKey) + const [payload, sig] = token.split('.') + // Flip a character in the payload — signature no longer matches. + const mutated = `${payload.slice(0, -1)}${payload.slice(-1) === 'A' ? 'B' : 'A'}.${sig}` + expect(verifyPluginLicense(mutated, publicKey, NOW).ok).toBe(false) + }) + + it('rejects malformed tokens', () => { + const { publicKey } = generateSigningKeyPair() + expect(verifyPluginLicense('no-dot', publicKey, NOW)).toEqual({ + ok: false, + reason: 'malformed' + }) + expect(verifyPluginLicense('.sig', publicKey, NOW)).toEqual({ ok: false, reason: 'malformed' }) + expect(verifyPluginLicense('payload.', publicKey, NOW)).toEqual({ + ok: false, + reason: 'malformed' + }) + }) + + it('rejects an unsupported token version', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + // Cast through unknown to forge a v2 claim the current verifier rejects. + const token = signPluginLicense(freshClaims({ v: 2 as unknown as 1 }), privateKey) + expect(verifyPluginLicense(token, publicKey, NOW)).toEqual({ + ok: false, + reason: 'unsupported-version' + }) + }) + + it('enforces expiry with a grace window', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + const expiresAt = NOW + const graceSec = 100 + const token = signPluginLicense( + freshClaims({ mode: 'subscription', expiresAt, graceSec }), + privateKey + ) + // Inside grace: still valid. + expect(verifyPluginLicense(token, publicKey, expiresAt + 50 * 1000).ok).toBe(true) + // Past grace: expired. + expect(verifyPluginLicense(token, publicKey, expiresAt + 200 * 1000)).toEqual({ + ok: false, + reason: 'expired' + }) + }) +}) + +describe('keys', () => { + it('generates a keypair and round-trips hex encoding', () => { + const { publicKeyHex, privateKeyHex } = generateLicenseKeypair() + const token = signPluginLicense(freshClaims(), privateKeyFromHex(privateKeyHex)) + expect(verifyPluginLicense(token, publicKeyFromHex(publicKeyHex), NOW).ok).toBe(true) + }) + + it('recovers the public key from the private key', () => { + const { publicKeyHex, privateKeyHex } = generateLicenseKeypair() + expect(publicKeyHexFromPrivateHex(privateKeyHex)).toBe(publicKeyHex) + }) +}) + +describe('mintPluginLicense', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + + it('mints a perpetual one-time license', () => { + const token = mintPluginLicense( + { pluginId: PLUGIN, buyerDid: BUYER, mode: 'one-time', now: NOW }, + privateKey + ) + const result = verifyPluginLicense(token, publicKey, NOW) + expect(result.ok).toBe(true) + if (result.ok) expect(result.claims.expiresAt).toBe(PERPETUAL_EXPIRY_MS) + }) + + it('mints a subscription license that expires at the period end', () => { + const periodEnd = NOW + 60 * 1000 + const token = mintPluginLicense( + { + pluginId: PLUGIN, + buyerDid: BUYER, + mode: 'subscription', + now: NOW, + periodEnd, + graceSec: 10 + }, + privateKey + ) + expect(verifyPluginLicense(token, publicKey, periodEnd + 5 * 1000).ok).toBe(true) + expect(verifyPluginLicense(token, publicKey, periodEnd + 20 * 1000).ok).toBe(false) + }) + + it('defaults a subscription lifetime when no period end is given', () => { + const token = mintPluginLicense( + { pluginId: PLUGIN, buyerDid: BUYER, mode: 'subscription', now: NOW }, + privateKey + ) + const result = verifyPluginLicense(token, publicKey, NOW) + expect(result.ok).toBe(true) + if (result.ok) expect(result.claims.expiresAt).toBeGreaterThan(NOW) + }) +}) + +describe('checkLicenseFor', () => { + const { publicKey, privateKey } = generateSigningKeyPair() + const token = mintPluginLicense( + { pluginId: PLUGIN, buyerDid: BUYER, mode: 'one-time', now: NOW }, + privateKey + ) + + it('accepts a matching plugin + buyer', () => { + expect( + checkLicenseFor(token, { pluginId: PLUGIN, buyerDid: BUYER, publicKey, now: NOW }).ok + ).toBe(true) + }) + + it('reports a missing token', () => { + expect( + checkLicenseFor(undefined, { pluginId: PLUGIN, buyerDid: BUYER, publicKey, now: NOW }) + ).toEqual({ ok: false, reason: 'no-license' }) + }) + + it('rejects a token issued for a different plugin', () => { + expect( + checkLicenseFor(token, { pluginId: 'com.other.thing', buyerDid: BUYER, publicKey, now: NOW }) + ).toEqual({ ok: false, reason: 'wrong-plugin' }) + }) + + it('rejects a token issued to a different buyer', () => { + expect( + checkLicenseFor(token, { + pluginId: PLUGIN, + buyerDid: 'did:key:zSomeoneElse', + publicKey, + now: NOW + }) + ).toEqual({ ok: false, reason: 'wrong-buyer' }) + }) + + it('propagates a verification failure (bad signature)', () => { + const attacker = generateSigningKeyPair() + expect( + checkLicenseFor(token, { + pluginId: PLUGIN, + buyerDid: BUYER, + publicKey: attacker.publicKey, + now: NOW + }) + ).toEqual({ ok: false, reason: 'bad-signature' }) + }) +}) diff --git a/packages/licenses/src/token.ts b/packages/licenses/src/token.ts new file mode 100644 index 000000000..19c9ebd46 --- /dev/null +++ b/packages/licenses/src/token.ts @@ -0,0 +1,116 @@ +/** + * @xnetjs/licenses — the PluginLicense token. + * + * A paid plugin is unlocked by a compact, **Ed25519-signed** token bound to the + * buyer's **DID** (not a device): the hub holds the platform private key and + * mints a token on purchase; the plugin runtime embeds the public key and + * verifies it **fully offline**. This mirrors `@xnetjs/entitlements`'s + * sign/verify shape, but is intentionally **asymmetric**: the verifier (a + * potentially-adversarial client) must not hold a secret it could use to forge a + * license. HMAC is right for hub↔hub (`HUB_PLAN`); it is wrong here. + * + * Token format: base64url(JSON claims) + "." + base64url(Ed25519 signature) + * The signature is computed over the *base64url payload string bytes*, so a + * verifier never has to canonicalize JSON. + */ + +import { sign, verify, bytesToBase64url, base64urlToBytes } from '@xnetjs/crypto' + +/** The claims carried by a license token. Version `1`. */ +export interface PluginLicenseClaims { + /** Token format version. */ + v: 1 + /** Reverse-domain plugin id this license unlocks. */ + pluginId: string + /** Plugin version (or range) the purchase covers; informational. */ + pluginVersion?: string + /** The buyer's DID. A license is bound to an identity, not a device, so it is + * portable across all of the buyer's devices and revocable hub-side. */ + buyerDid: string + /** Whether this was a one-time purchase or a subscription. */ + mode: 'one-time' | 'subscription' + /** When the license was issued (epoch ms). */ + issuedAt: number + /** When the license expires (epoch ms). One-time licenses use a far-future value. */ + expiresAt: number + /** Seconds of slack past `expiresAt` before the gate refuses (connectivity grace). */ + graceSec: number + /** Signing-key id, so the platform can rotate keys without invalidating old tokens. */ + kid?: string +} + +/** Why a token failed verification. */ +export type LicenseFailureReason = 'malformed' | 'bad-signature' | 'expired' | 'unsupported-version' + +export type LicenseVerifyResult = + | { ok: true; claims: PluginLicenseClaims } + | { ok: false; reason: LicenseFailureReason } + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +function encodeClaims(claims: PluginLicenseClaims): string { + return bytesToBase64url(encoder.encode(JSON.stringify(claims))) +} + +/** + * Mint a signed license token from explicit claims. **Hub-side only** — needs the + * platform Ed25519 private key. Most callers want {@link mintPluginLicense}. + */ +export function signPluginLicense(claims: PluginLicenseClaims, privateKey: Uint8Array): string { + const payload = encodeClaims(claims) + const signature = sign(encoder.encode(payload), privateKey) + return `${payload}.${bytesToBase64url(signature)}` +} + +function isClaims(value: unknown): value is PluginLicenseClaims { + if (!value || typeof value !== 'object') return false + const c = value as Record + return ( + typeof c.pluginId === 'string' && + typeof c.buyerDid === 'string' && + (c.mode === 'one-time' || c.mode === 'subscription') && + typeof c.issuedAt === 'number' && + typeof c.expiresAt === 'number' && + typeof c.graceSec === 'number' + ) +} + +/** + * Verify a license token against the platform public key, fully offline. Returns + * the claims on success, or a typed failure reason. Does **not** check that the + * token is for a particular plugin/buyer — see {@link checkLicenseFor}. + * + * @param now epoch ms (injected so it is deterministic + resume-safe). + */ +export function verifyPluginLicense( + token: string, + publicKey: Uint8Array, + now: number +): LicenseVerifyResult { + const dot = token.indexOf('.') + if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: 'malformed' } + const payload = token.slice(0, dot) + const sigPart = token.slice(dot + 1) + + let signature: Uint8Array + try { + signature = base64urlToBytes(sigPart) + } catch { + return { ok: false, reason: 'malformed' } + } + if (!verify(encoder.encode(payload), signature, publicKey)) { + return { ok: false, reason: 'bad-signature' } + } + + let parsed: unknown + try { + parsed = JSON.parse(decoder.decode(base64urlToBytes(payload))) + } catch { + return { ok: false, reason: 'malformed' } + } + if (!isClaims(parsed)) return { ok: false, reason: 'malformed' } + if (parsed.v !== 1) return { ok: false, reason: 'unsupported-version' } + if (now > parsed.expiresAt + parsed.graceSec * 1000) return { ok: false, reason: 'expired' } + return { ok: true, claims: parsed } +} diff --git a/packages/licenses/tsconfig.json b/packages/licenses/tsconfig.json new file mode 100644 index 000000000..90d76d7e8 --- /dev/null +++ b/packages/licenses/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 072b3e343..1b303575f 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -49,6 +49,7 @@ "devDependencies": { "@tiptap/core": "^2.0.0", "@types/react": "^18.2.0", + "@xnetjs/licenses": "workspace:*", "jsdom": "^26.0.0", "react": "^18.3.1", "tsup": "^8.0.0", diff --git a/packages/plugins/src/__tests__/ecosystem-install-gates.test.ts b/packages/plugins/src/__tests__/ecosystem-install-gates.test.ts index 7aa62afd1..0b9cf147f 100644 --- a/packages/plugins/src/__tests__/ecosystem-install-gates.test.ts +++ b/packages/plugins/src/__tests__/ecosystem-install-gates.test.ts @@ -4,10 +4,18 @@ */ import type { ExtensionContext } from '../context' +import { + generateLicenseKeypair, + mintPluginLicense, + checkLicenseFor, + publicKeyFromHex, + privateKeyFromHex +} from '@xnetjs/licenses' import { describe, it, expect, vi } from 'vitest' import { CapabilityError } from '../ecosystem/capability-guard' import { createTestPluginHarness } from '../ecosystem/testing' import { defineFeatureModule } from '../feature-module' +import { LicenseRequiredError } from '../registry' const NOTE = 'xnet://xnet.fyi/Note@1.0.0' as const const SECRET = 'xnet://xnet.fyi/Secret@1.0.0' as const @@ -95,6 +103,71 @@ describe('consent gate', () => { }) }) +describe('paid-license gate (0196)', () => { + const paidPlugin = defineFeatureModule({ + id: 'com.acme.pro', + name: 'Acme Pro', + version: '1.0.0', + license: 'FSL-1.1-MIT', + pricing: { mode: 'one-time', amountMinor: 999, currency: 'USD' } + }) + const buyerDid = 'did:key:zBuyer' + + it('blocks a paid install when no license provider is wired in (fail-closed)', async () => { + const h = createTestPluginHarness() + await expect( + h.registry.install(paidPlugin, { provenance: 'marketplace' }) + ).rejects.toBeInstanceOf(LicenseRequiredError) + expect(h.registry.has('com.acme.pro')).toBe(false) + }) + + it('blocks when the buyer has no valid license', async () => { + const h = createTestPluginHarness() + const { publicKeyHex } = generateLicenseKeypair() + const checkLicense = vi.fn(() => + checkLicenseFor(undefined, { + pluginId: paidPlugin.id, + buyerDid, + publicKey: publicKeyFromHex(publicKeyHex), + now: 1_700_000_000_000 + }) + ) + await expect( + h.registry.install(paidPlugin, { provenance: 'marketplace', checkLicense }) + ).rejects.toThrow(/no-license/) + expect(checkLicense).toHaveBeenCalledOnce() + }) + + it('installs a paid plugin when the buyer holds a valid minted license', async () => { + const h = createTestPluginHarness() + const { publicKeyHex, privateKeyHex } = generateLicenseKeypair() + const now = 1_700_000_000_000 + const token = mintPluginLicense( + { pluginId: paidPlugin.id, buyerDid, mode: 'one-time', now }, + privateKeyFromHex(privateKeyHex) + ) + const checkLicense = vi.fn((manifest) => + checkLicenseFor(token, { + pluginId: manifest.id, + buyerDid, + publicKey: publicKeyFromHex(publicKeyHex), + now + }) + ) + await h.registry.install(paidPlugin, { provenance: 'marketplace', checkLicense }) + expect(h.registry.get('com.acme.pro')?.status).toBe('active') + }) + + it('does not run the license check for free plugins', async () => { + const h = createTestPluginHarness() + const checkLicense = vi.fn(() => ({ ok: true })) + const free = defineFeatureModule({ id: 'com.acme.free', name: 'Free', version: '1.0.0' }) + await h.registry.install(free, { checkLicense }) + expect(checkLicense).not.toHaveBeenCalled() + expect(h.registry.get('com.acme.free')?.status).toBe('active') + }) +}) + describe('capability enforcement end-to-end', () => { function writerModule(targetSchema: `xnet://${string}/${string}`) { let writeError: unknown diff --git a/packages/plugins/src/__tests__/ecosystem-license-policy.test.ts b/packages/plugins/src/__tests__/ecosystem-license-policy.test.ts new file mode 100644 index 000000000..231d445e1 --- /dev/null +++ b/packages/plugins/src/__tests__/ecosystem-license-policy.test.ts @@ -0,0 +1,55 @@ +/** + * Paid-plugin license policy (exploration 0196). + */ + +import { describe, it, expect } from 'vitest' +import { + ALLOWED_PLUGIN_LICENSES, + DEFAULT_PLUGIN_LICENSE, + isAllowedPluginLicense, + pluginLicenseText +} from '../ecosystem/license-policy' + +describe('isAllowedPluginLicense', () => { + it('accepts the pre-approved set and rejects others', () => { + for (const spdx of ALLOWED_PLUGIN_LICENSES) { + expect(isAllowedPluginLicense(spdx)).toBe(true) + } + expect(isAllowedPluginLicense('Proprietary')).toBe(false) + expect(isAllowedPluginLicense('BUSL-1.1')).toBe(false) + expect(isAllowedPluginLicense('')).toBe(false) + }) + + it('defaults to FSL-1.1-MIT', () => { + expect(DEFAULT_PLUGIN_LICENSE).toBe('FSL-1.1-MIT') + expect(isAllowedPluginLicense(DEFAULT_PLUGIN_LICENSE)).toBe(true) + }) +}) + +describe('pluginLicenseText', () => { + it('renders FSL-1.1-MIT with the MIT future license', () => { + const text = pluginLicenseText('FSL-1.1-MIT', 2026, 'Acme Inc') + expect(text).toContain('Functional Source License, Version 1.1, MIT Future License') + expect(text).toContain('FSL-1.1-MIT') + expect(text).toContain('Copyright 2026 Acme Inc') + expect(text).toContain('grant you an additional license to use the Software under\nthe MIT') + }) + + it('renders FSL-1.1-Apache-2.0 with the Apache future license', () => { + const text = pluginLicenseText('FSL-1.1-Apache-2.0', 2026, 'Acme Inc') + expect(text).toContain('ALv2 Future License') + expect(text).toContain('Apache License, Version 2.0') + }) + + it('renders a standard MIT license', () => { + const text = pluginLicenseText('MIT', 2026, 'Acme Inc') + expect(text).toContain('MIT License') + expect(text).toContain('Copyright (c) 2026 Acme Inc') + expect(text).toContain('THE SOFTWARE IS PROVIDED "AS IS"') + }) + + it('returns null for licenses with no bundled template', () => { + expect(pluginLicenseText('Apache-2.0', 2026, 'Acme')).toBeNull() + expect(pluginLicenseText('GPL-3.0-only', 2026, 'Acme')).toBeNull() + }) +}) diff --git a/packages/plugins/src/__tests__/ecosystem-scaffold.test.ts b/packages/plugins/src/__tests__/ecosystem-scaffold.test.ts index c5b9e0ca5..79de6141b 100644 --- a/packages/plugins/src/__tests__/ecosystem-scaffold.test.ts +++ b/packages/plugins/src/__tests__/ecosystem-scaffold.test.ts @@ -16,9 +16,10 @@ describe('pascalCase / packageName', () => { }) describe('scaffoldPlugin', () => { - it('produces the expected project files', () => { + it('produces the expected project files (incl. a LICENSE for the default FSL license)', () => { const { files } = scaffoldPlugin({ id: 'com.acme.kanban', name: 'Kanban', template: 'client' }) expect(Object.keys(files).sort()).toEqual([ + 'LICENSE', 'README.md', 'package.json', 'src/index.test.ts', @@ -27,6 +28,44 @@ describe('scaffoldPlugin', () => { ]) }) + it('defaults to FSL-1.1-MIT and emits its LICENSE + manifest license field', () => { + const { files } = scaffoldPlugin({ + id: 'com.acme.kanban', + name: 'Kanban', + template: 'client', + author: 'Acme Inc', + year: 2026 + }) + expect(JSON.parse(files['package.json']).license).toBe('FSL-1.1-MIT') + expect(files['src/index.ts']).toContain("license: 'FSL-1.1-MIT'") + expect(files['LICENSE']).toContain('Functional Source License, Version 1.1, MIT Future License') + expect(files['LICENSE']).toContain('Copyright 2026 Acme Inc') + expect(files['LICENSE']).toContain('second anniversary') + }) + + it('embeds pricing + publisherDid for a paid plugin', () => { + const { files } = scaffoldPlugin({ + id: 'com.acme.pro', + name: 'Pro', + template: 'client', + pricing: { mode: 'one-time', amountMinor: 999, currency: 'USD' }, + publisherDid: 'did:key:zPub' + }) + expect(files['src/index.ts']).toContain('pricing: {"mode":"one-time"') + expect(files['src/index.ts']).toContain("publisherDid: 'did:key:zPub'") + }) + + it('omits the LICENSE for an unrecognized license (author supplies their own)', () => { + const { files } = scaffoldPlugin({ + id: 'com.acme.x', + name: 'X', + template: 'client', + license: 'GPL-3.0-only' + }) + expect(files.LICENSE).toBeUndefined() + expect(JSON.parse(files['package.json']).license).toBe('GPL-3.0-only') + }) + it('emits a valid package.json named after the id', () => { const { files } = scaffoldPlugin({ id: 'com.acme.kanban', name: 'Kanban', template: 'client' }) const pkg = JSON.parse(files['package.json']) diff --git a/packages/plugins/src/__tests__/manifest-pricing.test.ts b/packages/plugins/src/__tests__/manifest-pricing.test.ts new file mode 100644 index 000000000..0a519dc43 --- /dev/null +++ b/packages/plugins/src/__tests__/manifest-pricing.test.ts @@ -0,0 +1,82 @@ +/** + * Manifest pricing/license/publisherDid validation + isPaidPricing (0196). + */ + +import { describe, it, expect } from 'vitest' +import { + validateManifest, + defineExtension, + isPaidPricing, + PluginValidationError +} from '../manifest' + +const base = { id: 'com.acme.pro', name: 'Acme Pro', version: '1.0.0' } as const + +describe('isPaidPricing', () => { + it('is false for undefined and free', () => { + expect(isPaidPricing(undefined)).toBe(false) + expect(isPaidPricing({ mode: 'free' })).toBe(false) + }) + it('is true for one-time and subscription', () => { + expect(isPaidPricing({ mode: 'one-time', amountMinor: 500, currency: 'USD' })).toBe(true) + expect(isPaidPricing({ mode: 'subscription', amountMinor: 500, currency: 'USD' })).toBe(true) + }) +}) + +describe('validateManifest — pricing/license/publisherDid', () => { + it('accepts a well-formed paid manifest', () => { + expect(() => + defineExtension({ + ...base, + license: 'FSL-1.1-MIT', + publisherDid: 'did:key:zPub', + pricing: { mode: 'one-time', amountMinor: 999, currency: 'USD', billing: 'managed' } + }) + ).not.toThrow() + }) + + it('accepts a free manifest with no pricing', () => { + expect(() => defineExtension({ ...base })).not.toThrow() + }) + + it('rejects an unknown pricing mode', () => { + expect(() => validateManifest({ ...base, pricing: { mode: 'rental' } })).toThrow( + PluginValidationError + ) + }) + + it('requires a currency when amountMinor > 0', () => { + expect(() => + validateManifest({ ...base, pricing: { mode: 'one-time', amountMinor: 500 } }) + ).toThrow(/currency is required/) + }) + + it('rejects a non-integer amount', () => { + expect(() => + validateManifest({ + ...base, + pricing: { mode: 'one-time', amountMinor: 9.99, currency: 'USD' } + }) + ).toThrow(/non-negative integer/) + }) + + it('rejects a malformed currency', () => { + expect(() => + validateManifest({ + ...base, + pricing: { mode: 'one-time', amountMinor: 5, currency: 'dollars' } + }) + ).toThrow(/ISO-4217/) + }) + + it('rejects an unknown billing kind', () => { + expect(() => + validateManifest({ ...base, pricing: { mode: 'free', billing: 'paypal' } }) + ).toThrow(/billing must be/) + }) + + it('rejects an empty license and non-string publisherDid', () => { + expect(() => validateManifest({ ...base, license: '' })).toThrow(/license must be/) + expect(() => validateManifest({ ...base, publisherDid: 42 })).toThrow(/publisherDid must be/) + }) +}) diff --git a/packages/plugins/src/ecosystem/index.ts b/packages/plugins/src/ecosystem/index.ts index 3c7d42429..9ae877b55 100644 --- a/packages/plugins/src/ecosystem/index.ts +++ b/packages/plugins/src/ecosystem/index.ts @@ -75,6 +75,15 @@ export type { FetchLike } from './network-endowment' export { scaffoldPlugin, pascalCase, packageName, ScaffoldError } from './scaffold' export type { ScaffoldTemplate, ScaffoldSpec, ScaffoldResult } from './scaffold' +// Paid-plugin license policy (exploration 0196) — allowed SPDX set + LICENSE text. +export { + ALLOWED_PLUGIN_LICENSES, + DEFAULT_PLUGIN_LICENSE, + isAllowedPluginLicense, + pluginLicenseText +} from './license-policy' +export type { AllowedPluginLicense } from './license-policy' + // AI-authored plugin transform — validated generated script → installable plugin. export { scriptToPluginManifest, AiAuthoringError } from './ai-authoring' export type { diff --git a/packages/plugins/src/ecosystem/license-policy.ts b/packages/plugins/src/ecosystem/license-policy.ts new file mode 100644 index 000000000..052ed3455 --- /dev/null +++ b/packages/plugins/src/ecosystem/license-policy.ts @@ -0,0 +1,155 @@ +/** + * @xnetjs/plugins — paid-plugin license policy (exploration 0196). + * + * The marketplace pre-approves a small, fixed set of licenses so a paid plugin + * needs no per-listing legal review. The default is **FSL-1.1-MIT** — source- + * available, forbids only a competing marketplace, and auto-converts to MIT two + * years after each version ships (mirrors `@xnetjs/cloud`'s FSL). Plain OSI + * licenses are allowed too. This module is the single source of truth for the + * allowed set and for generating the `LICENSE` file the scaffolder emits; + * `scripts/check-plugin-licenses.sh` enforces the same set in CI. + */ + +/** SPDX ids a paid plugin may declare. */ +export const ALLOWED_PLUGIN_LICENSES = [ + 'FSL-1.1-MIT', + 'FSL-1.1-Apache-2.0', + 'MIT', + 'Apache-2.0', + 'AGPL-3.0-only' +] as const + +export type AllowedPluginLicense = (typeof ALLOWED_PLUGIN_LICENSES)[number] + +/** The default license suggested by the scaffolder for a new plugin. */ +export const DEFAULT_PLUGIN_LICENSE: AllowedPluginLicense = 'FSL-1.1-MIT' + +/** True if `spdx` is one of the marketplace-approved licenses. */ +export function isAllowedPluginLicense(spdx: string): spdx is AllowedPluginLicense { + return (ALLOWED_PLUGIN_LICENSES as readonly string[]).includes(spdx) +} + +/** The "future license" an FSL variant converts to, or null for non-FSL. */ +function fslFutureLicense(spdx: string): 'MIT' | 'Apache License, Version 2.0' | null { + if (spdx === 'FSL-1.1-MIT') return 'MIT' + if (spdx === 'FSL-1.1-Apache-2.0') return 'Apache License, Version 2.0' + return null +} + +function mitLicenseText(year: number, holder: string): string { + return `MIT License + +Copyright (c) ${year} ${holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` +} + +function fslLicenseText(spdx: string, future: string, year: number, holder: string): string { + const abbrev = spdx === 'FSL-1.1-MIT' ? 'FSL-1.1-MIT' : 'FSL-1.1-ALv2' + const heading = + spdx === 'FSL-1.1-MIT' + ? 'Functional Source License, Version 1.1, MIT Future License' + : 'Functional Source License, Version 1.1, ALv2 Future License' + return `# ${heading} + +## Abbreviation + +${abbrev} + +## Notice + +Copyright ${year} ${holder} + +## 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. + +### 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. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the ${future} 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 ${future}. +` +} + +/** + * Generate the `LICENSE` file body for a plugin, or `null` for an unrecognized + * license (the scaffolder then omits the file and the author supplies their own). + */ +export function pluginLicenseText(spdx: string, year: number, holder: string): string | null { + const future = fslFutureLicense(spdx) + if (future) return fslLicenseText(spdx, future, year, holder) + if (spdx === 'MIT') return mitLicenseText(year, holder) + return null +} diff --git a/packages/plugins/src/ecosystem/marketplace.ts b/packages/plugins/src/ecosystem/marketplace.ts index 6dc3f4870..d0d49b85b 100644 --- a/packages/plugins/src/ecosystem/marketplace.ts +++ b/packages/plugins/src/ecosystem/marketplace.ts @@ -9,6 +9,7 @@ */ import type { ModuleCapabilities } from '../feature-module' +import type { PluginPricing } from '../manifest' import type { InstallProvenance } from './provenance-trust' /** A single entry in the marketplace index (`registry.json`). */ @@ -30,6 +31,12 @@ export interface MarketplaceEntry { installs?: number /** GitHub stars / community signal. */ stars?: number + /** SPDX license id (exploration 0196) — shown as a badge; gated by CI policy. */ + license?: string + /** Monetization (exploration 0196). Absent = free. */ + pricing?: PluginPricing + /** Publisher identity for paid listings (exploration 0196). */ + publisherDid?: string /** Provenance reference for verification (see `./provenance`). */ provenance?: { sigstoreBundleUrl?: string; sourceRepo?: string; sourceCommit?: string } } diff --git a/packages/plugins/src/ecosystem/scaffold.ts b/packages/plugins/src/ecosystem/scaffold.ts index cd44a3718..37c1b5386 100644 --- a/packages/plugins/src/ecosystem/scaffold.ts +++ b/packages/plugins/src/ecosystem/scaffold.ts @@ -12,6 +12,8 @@ */ import type { ModuleCapabilities } from '../feature-module' +import type { PluginPricing } from '../manifest' +import { DEFAULT_PLUGIN_LICENSE, pluginLicenseText } from './license-policy' export type ScaffoldTemplate = 'client' | 'two-sided' | 'ai-script' @@ -26,6 +28,14 @@ export interface ScaffoldSpec { description?: string /** Declared capability grant (two-sided templates surface this in the manifest). */ capabilities?: ModuleCapabilities + /** SPDX license id (exploration 0196). Defaults to FSL-1.1-MIT. */ + license?: string + /** Monetization (exploration 0196). When paid, the manifest declares `pricing`. */ + pricing?: PluginPricing + /** Publisher DID for paid plugins (exploration 0196). */ + publisherDid?: string + /** Copyright year for the generated LICENSE (defaults supplied by the caller). */ + year?: number } export interface ScaffoldResult { @@ -74,6 +84,7 @@ function packageJson(spec: ScaffoldSpec): string { name: packageName(spec.id), version: '0.1.0', description: spec.description ?? `${spec.name} — an xNet plugin`, + license: spec.license ?? DEFAULT_PLUGIN_LICENSE, type: 'module', main: 'src/index.ts', scripts: { test: 'vitest run', typecheck: 'tsc --noEmit' }, @@ -148,6 +159,9 @@ const MODULE_BODIES: Record string> = { function indexSource(spec: ScaffoldSpec): string { const ctor = pascalCase(spec.id) + const license = spec.license ?? DEFAULT_PLUGIN_LICENSE + const pricing = spec.pricing ? `\n pricing: ${JSON.stringify(spec.pricing)},` : '' + const publisher = spec.publisherDid ? `\n publisherDid: '${spec.publisherDid}',` : '' return `import { defineFeatureModule } from '@xnetjs/plugins' export const ${ctor}Module = defineFeatureModule({ @@ -155,6 +169,7 @@ export const ${ctor}Module = defineFeatureModule({ name: '${spec.name}', version: '0.1.0',${spec.author ? `\n author: '${spec.author}',` : ''} description: '${spec.description ?? `${spec.name} — an xNet plugin`}', + license: '${license}',${pricing}${publisher} ${MODULE_BODIES[spec.template](spec)} }) ` @@ -200,13 +215,21 @@ publish to the marketplace or share the manifest directly. */ export function scaffoldPlugin(spec: ScaffoldSpec): ScaffoldResult { validateSpec(spec) - return { - files: { - 'package.json': packageJson(spec), - 'tsconfig.json': tsconfig(), - 'src/index.ts': indexSource(spec), - 'src/index.test.ts': testSource(spec), - 'README.md': readme(spec) - } + const files: Record = { + 'package.json': packageJson(spec), + 'tsconfig.json': tsconfig(), + 'src/index.ts': indexSource(spec), + 'src/index.test.ts': testSource(spec), + 'README.md': readme(spec) } + // Emit a real LICENSE for the recognized licenses (FSL variants + MIT) so a + // published plugin satisfies the marketplace license-policy CI check (0196). + const year = spec.year ?? new Date().getFullYear() + const license = pluginLicenseText( + spec.license ?? DEFAULT_PLUGIN_LICENSE, + year, + spec.author ?? spec.name + ) + if (license) files['LICENSE'] = license + return { files } } diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 0a1d59827..4206ef550 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -35,8 +35,8 @@ export type { } from './canvas-permissions' // Manifest -export type { XNetExtension, PluginContributions } from './manifest' -export { validateManifest, defineExtension, PluginValidationError } from './manifest' +export type { XNetExtension, PluginContributions, PluginPricing } from './manifest' +export { validateManifest, defineExtension, PluginValidationError, isPaidPricing } from './manifest' // Contributions export type { @@ -163,8 +163,8 @@ export type { export { createExtensionContext } from './context' // Registry -export type { PluginStatus, RegisteredPlugin, InstallOptions } from './registry' -export { PluginRegistry, PluginError } from './registry' +export type { PluginStatus, RegisteredPlugin, InstallOptions, LicenseCheckResult } from './registry' +export { PluginRegistry, PluginError, LicenseRequiredError } from './registry' // Ecosystem platform layer (exploration 0192) — capability enforcement, // provenance/trust, install consent, version compatibility, dependency @@ -218,6 +218,11 @@ export { pascalCase, packageName, ScaffoldError, + // Paid-plugin license policy (0196) + ALLOWED_PLUGIN_LICENSES, + DEFAULT_PLUGIN_LICENSE, + isAllowedPluginLicense, + pluginLicenseText, // AI-authored plugin transform scriptToPluginManifest, AiAuthoringError @@ -248,6 +253,7 @@ export type { ScaffoldTemplate, ScaffoldSpec, ScaffoldResult, + AllowedPluginLicense, GeneratedScript, ScriptExecutor, ScriptToManifestInput, diff --git a/packages/plugins/src/manifest.ts b/packages/plugins/src/manifest.ts index 9d3bc93a2..aa4e964a0 100644 --- a/packages/plugins/src/manifest.ts +++ b/packages/plugins/src/manifest.ts @@ -28,6 +28,33 @@ import type { Platform, PluginPermissions } from './types' // ─── Manifest Types ──────────────────────────────────────────────────────── +/** + * How a plugin is monetized (exploration 0196). `free` is the default when + * `pricing` is absent. Paid plugins (`one-time`/`subscription`) are gated at + * install by a license check (see `PluginRegistry.install`'s `checkLicense`). + */ +export interface PluginPricing { + /** `free` — no license required. `one-time`/`subscription` — license-gated. */ + mode: 'free' | 'one-time' | 'subscription' + /** Price in integer minor units (e.g. cents). Omitted/0 for free. */ + amountMinor?: number + /** ISO-4217 currency code (e.g. `USD`). Required when `amountMinor` > 0. */ + currency?: string + /** + * Who runs checkout: `managed` = the xNet marketplace via Stripe Connect (the + * platform takes its fee); `byo` = the author hosts their own checkout and + * mints their own license (xNet takes 0%). Default `managed`. + */ + billing?: 'managed' | 'byo' + /** Free-trial length in days (subscriptions). */ + trialDays?: number +} + +/** True when a pricing descriptor denotes a paid plugin that needs a license. */ +export function isPaidPricing(pricing: PluginPricing | undefined): boolean { + return !!pricing && pricing.mode !== 'free' +} + /** * Plugin manifest - defines what a plugin provides and how it integrates */ @@ -54,6 +81,21 @@ export interface XNetExtension { */ dependencies?: Record + /** + * SPDX license id (exploration 0196). Paid plugins must declare a license the + * marketplace pre-approves — `FSL-1.1-MIT` / `FSL-1.1-Apache-2.0` (source- + * available, auto-opens after 2 years) or an OSI id (`MIT`, `Apache-2.0`, …). + * Defaults to `MIT` when absent. + */ + license?: string + /** How this plugin is monetized (exploration 0196). Absent = free. */ + pricing?: PluginPricing + /** + * The publisher's DID. Supersedes the bare `author` string for paid plugins — + * licenses, payouts, and provenance attach to this identity (exploration 0196). + */ + publisherDid?: string + /** Static contributions declared in manifest */ contributes?: PluginContributions @@ -161,6 +203,16 @@ export function validateManifest(manifest: unknown): XNetExtension { issues.push('author must be a string') } + if (m.license !== undefined && (typeof m.license !== 'string' || !m.license)) { + issues.push('license must be a non-empty SPDX id string') + } + + if (m.publisherDid !== undefined && typeof m.publisherDid !== 'string') { + issues.push('publisherDid must be a string') + } + + validatePricing(m.pricing, issues) + if (m.platforms !== undefined) { if (!Array.isArray(m.platforms)) { issues.push('platforms must be an array') @@ -195,6 +247,48 @@ export function validateManifest(manifest: unknown): XNetExtension { return manifest as XNetExtension } +const PRICING_MODES = ['free', 'one-time', 'subscription'] +const BILLING_KINDS = ['managed', 'byo'] + +/** Validate the optional `pricing` descriptor (exploration 0196). */ +function validatePricing(pricing: unknown, issues: string[]): void { + if (pricing === undefined) return + if (!pricing || typeof pricing !== 'object' || Array.isArray(pricing)) { + issues.push('pricing must be an object') + return + } + const p = pricing as Record + if (typeof p.mode !== 'string' || !PRICING_MODES.includes(p.mode)) { + issues.push(`pricing.mode must be one of: ${PRICING_MODES.join(', ')}`) + } + if (p.amountMinor !== undefined) { + if ( + typeof p.amountMinor !== 'number' || + !Number.isInteger(p.amountMinor) || + p.amountMinor < 0 + ) { + issues.push('pricing.amountMinor must be a non-negative integer (minor units)') + } else if (p.amountMinor > 0 && typeof p.currency !== 'string') { + issues.push('pricing.currency is required when amountMinor > 0') + } + } + if ( + p.currency !== undefined && + (typeof p.currency !== 'string' || !/^[A-Za-z]{3}$/.test(p.currency)) + ) { + issues.push('pricing.currency must be a 3-letter ISO-4217 code') + } + if ( + p.billing !== undefined && + (typeof p.billing !== 'string' || !BILLING_KINDS.includes(p.billing)) + ) { + issues.push(`pricing.billing must be one of: ${BILLING_KINDS.join(', ')}`) + } + if (p.trialDays !== undefined && (typeof p.trialDays !== 'number' || p.trialDays < 0)) { + issues.push('pricing.trialDays must be a non-negative number') + } +} + /** Validate the optional `dependencies` map (exploration 0192). */ function validateDependencies(dependencies: unknown, issues: string[]): void { if (dependencies === undefined) return diff --git a/packages/plugins/src/registry.ts b/packages/plugins/src/registry.ts index e6f9fadc0..651201c35 100644 --- a/packages/plugins/src/registry.ts +++ b/packages/plugins/src/registry.ts @@ -20,7 +20,7 @@ import { type InstallProvenance, type PluginTrustTier } from './ecosystem/provenance-trust' -import { validateManifest, PluginValidationError } from './manifest' +import { validateManifest, PluginValidationError, isPaidPricing } from './manifest' import { MiddlewareChain } from './middleware' import { PluginSchema } from './schemas/plugin' @@ -39,6 +39,18 @@ export interface RegisteredPlugin { trustTier?: PluginTrustTier } +/** + * Result of a paid-plugin license check (exploration 0196). The host wires this + * to `@xnetjs/licenses`' `checkLicenseFor`; the plugin package stays free of a + * hard dependency on the license verifier. + */ +export interface LicenseCheckResult { + /** `true` if the buyer holds a valid license for this plugin. */ + ok: boolean + /** Why it failed (`no-license`, `expired`, `bad-signature`, …) — surfaced to UI. */ + reason?: string +} + /** Options for {@link PluginRegistry.install} (all optional, back-compatible). */ export interface InstallOptions { /** Where the plugin came from. Drives trust tier + consent. Default `imported`. */ @@ -50,6 +62,12 @@ export interface InstallOptions { * plugin actually requests capabilities. Return `false` to abort the install. */ onConsent?: (decision: ConsentDecision) => boolean | Promise + /** + * Paid-license callback (exploration 0196). Called only when the manifest's + * `pricing` is non-free. Return `{ ok: false }` to block the install with a + * {@link LicenseRequiredError}. Absent ⇒ paid plugins are blocked (fail-closed). + */ + checkLicense?: (manifest: XNetExtension) => LicenseCheckResult | Promise } export class PluginError extends Error { @@ -59,6 +77,17 @@ export class PluginError extends Error { } } +/** Thrown when a paid plugin is installed without a valid license (0196). */ +export class LicenseRequiredError extends PluginError { + constructor( + public readonly pluginId: string, + public readonly reason: string + ) { + super(`Plugin '${pluginId}' requires a valid license (${reason})`) + this.name = 'LicenseRequiredError' + } +} + // ─── Plugin Registry ─────────────────────────────────────────────────────── /** @@ -132,6 +161,17 @@ export class PluginRegistry { } } + // 6.5. Paid-license gate (0196). Fail-closed: a priced plugin with no + // license provider wired in is blocked, never silently installed for free. + if (isPaidPricing(manifest.pricing)) { + const result = options.checkLicense + ? await options.checkLicense(manifest) + : { ok: false, reason: 'no-license-provider' } + if (!result.ok) { + throw new LicenseRequiredError(manifest.id, result.reason ?? 'no-license') + } + } + // 7. Store plugin metadata as Node await this.store.create({ schemaId: PluginSchema._schemaId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093bb32e8..6f746c13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1348,6 +1348,25 @@ importers: specifier: ^4.0.0 version: 4.0.18(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2) + packages/licenses: + dependencies: + '@xnetjs/crypto': + specifier: workspace:* + version: link:../crypto + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.30 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.18(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.2) + packages/maps: dependencies: '@xnetjs/data': @@ -1482,6 +1501,9 @@ importers: '@types/react': specifier: ^18.2.0 version: 18.3.27 + '@xnetjs/licenses': + specifier: workspace:* + version: link:../licenses jsdom: specifier: ^26.0.0 version: 26.1.0 diff --git a/scripts/check-plugin-licenses.mjs b/scripts/check-plugin-licenses.mjs new file mode 100644 index 000000000..f208677d3 --- /dev/null +++ b/scripts/check-plugin-licenses.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Enforce the paid-plugin license policy (exploration 0196). + * + * Every marketplace listing with non-free `pricing` must declare a license the + * marketplace pre-approves — FSL-1.1-MIT / FSL-1.1-Apache-2.0 (source-available, + * auto-opens after 2 years) or an OSI id. This scans every `marketplace/**\/ + * registry.json` (the publish target) plus any path passed as an argument. With + * no registry present yet it is a no-op forward guard. + * + * The allowed set MUST stay in sync with + * packages/plugins/src/ecosystem/license-policy.ts (ALLOWED_PLUGIN_LICENSES). + */ +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const ALLOWED = new Set(['FSL-1.1-MIT', 'FSL-1.1-Apache-2.0', 'MIT', 'Apache-2.0', 'AGPL-3.0-only']) + +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', 'coverage']) +const root = resolve(process.cwd()) + +/** Recursively collect `registry.json` files living under a `marketplace/` dir. */ +function findRegistries(dir, underMarketplace, out) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue + findRegistries(join(dir, e.name), underMarketplace || e.name === 'marketplace', out) + } else if (e.isFile() && e.name === 'registry.json' && underMarketplace) { + out.push(join(dir, e.name)) + } + } +} + +const files = [] +findRegistries(root, false, files) +for (const arg of process.argv.slice(2)) { + const p = resolve(arg) + if (existsSync(p) && statSync(p).isFile() && !files.includes(p)) files.push(p) +} + +if (files.length === 0) { + console.log('✓ plugin license policy: no marketplace registry found — nothing to check') + process.exit(0) +} + +const isPaid = (pricing) => !!pricing && pricing.mode && pricing.mode !== 'free' +let fail = 0 +let checked = 0 + +for (const file of files) { + let entries + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')) + entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed.plugins) ? parsed.plugins : [] + } catch (err) { + console.error(`✗ ${file}: not valid JSON (${err.message})`) + fail = 1 + continue + } + for (const entry of entries) { + if (!isPaid(entry?.pricing)) continue + checked++ + const id = entry.id ?? '(unknown id)' + if (typeof entry.license !== 'string' || !entry.license) { + console.error(`✗ ${id}: paid listing is missing a "license" field`) + fail = 1 + } else if (!ALLOWED.has(entry.license)) { + console.error( + `✗ ${id}: license "${entry.license}" is not marketplace-approved (allowed: ${[...ALLOWED].join(', ')})` + ) + fail = 1 + } + } +} + +if (fail === 0) { + console.log( + `✓ plugin license policy OK (${checked} paid listing(s) across ${files.length} registr(y/ies))` + ) +} +process.exit(fail) diff --git a/vitest.config.ts b/vitest.config.ts index ed31e25b1..1ab6d6f24 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,7 @@ const workspaceAliases = { '@xnetjs/identity': new URL('./packages/identity/src/index.ts', import.meta.url).pathname, '@xnetjs/labs': new URL('./packages/labs/src/index.ts', import.meta.url).pathname, '@xnetjs/ledger': new URL('./packages/ledger/src/index.ts', import.meta.url).pathname, + '@xnetjs/licenses': new URL('./packages/licenses/src/index.ts', import.meta.url).pathname, '@xnetjs/maps': new URL('./packages/maps/src/index.ts', import.meta.url).pathname, '@xnetjs/network': new URL('./packages/network/src/index.ts', import.meta.url).pathname, '@xnetjs/plugins/node': new URL('./packages/plugins/src/services/node.ts', import.meta.url) @@ -100,8 +101,8 @@ export default defineConfig({ pool: 'threads', isolate: false, include: [ - 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,trust,vectors}/src/**/*.test.ts', - 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,network,query,sqlite,storage,sync,telemetry,trust,vectors}/test/**/*.test.ts', + 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,licenses,network,query,sqlite,storage,sync,telemetry,trust,vectors}/src/**/*.test.ts', + 'packages/{abuse,billing,canvas-core,cli,cloud,crm,dictation,entitlements,comms,crypto,core,data,experiments,formula,history,identity,ledger,licenses,network,query,sqlite,storage,sync,telemetry,trust,vectors}/test/**/*.test.ts', // Control-plane app logic (xNet Cloud — managed-hosting explorations 0174/0175) 'apps/cloud/src/**/*.test.ts', // Social matching layer — pure connect modules only; the