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