diff --git a/.changeset/billing-plan-features.md b/.changeset/billing-plan-features.md new file mode 100644 index 0000000..dc4ad4d --- /dev/null +++ b/.changeset/billing-plan-features.md @@ -0,0 +1,9 @@ +--- +"@codelitdev/billing": minor +--- + +`createBilling` takes `planFeatures`, the feature keys each paid plan includes (for example `{ business: ["learner_sso"] }`), and the engine gains `hasFeature(billableEntityId, feature)`. In `cloud` mode it is true only when the entity's entitled paid plan (the same rules as `commercialState().activePaidPlan`) includes the feature. In `oss` mode it is always true. An unknown feature, no paid plan, or any error reading state is false. In `cloud` mode a `planFeatures` key with no offer throws `plan_features_invalid`. + +**Product changes for this release** + +- Declare `planFeatures` and gate features with `billing.hasFeature(...)` instead of keeping a separate plan-to-feature map. Plans do not inherit, so list a feature on every plan that includes it. diff --git a/.changeset/domain-verification.md b/.changeset/domain-verification.md new file mode 100644 index 0000000..60e42ea --- /dev/null +++ b/.changeset/domain-verification.md @@ -0,0 +1,10 @@ +--- +"@codelitdev/platform": minor +--- + +Adds custom-domain verification to the root export: `normalizeHostname`, `isApexHostname`, `issueOwnershipToken`, `createDnsResolver` and `createDomainVerification` (`ownershipRecord`, `verifyOwnership`, `verifyRouting`). It keeps the behaviour FrontLit and CourseLit each carry today: the TXT value is the bare token, tokens are 16 hex bytes, CNAME chains are followed with loop protection, and an apex may use CNAME flattening or A records that match a configured set. Lookups go through an injectable resolver that can pin trusted DNS servers and times out after 5 seconds by default. The package now depends on `tldts` for public-suffix handling. + +**Product changes for this release** + +- FrontLit (`domains/queries.ts`) and CourseLit (`school-hosts.ts`, `school-host.ts`) can replace their copies with this. Apex/`www` pairing and one-domain-per-school stay in the apps. +- `normalizeHostname` replaces CourseLit's `normalizeCustomHostname`. It also rejects public suffixes and IP literals by default, and `isApexHostname` now uses the public suffix list rather than counting labels. diff --git a/bun.lock b/bun.lock index 363ed43..7c6e262 100644 --- a/bun.lock +++ b/bun.lock @@ -80,7 +80,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.9.0", + "version": "0.10.0", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +106,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,7 +124,7 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", @@ -143,7 +143,7 @@ }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@better-auth/core": "1.7.22", "@better-auth/oauth-provider": "1.7.22", @@ -167,7 +167,7 @@ }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "pino": "^9.6.0", }, @@ -192,7 +192,10 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.9.0", + "version": "0.10.0", + "dependencies": { + "tldts": "7.4.19", + }, "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -202,7 +205,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/billing": ">=0.9.0", + "@codelitdev/billing": ">=0.10.0", "drizzle-orm": "^0.45.2", }, "optionalPeers": [ @@ -212,7 +215,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.9.0", + "version": "0.10.0", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -224,7 +227,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", @@ -1367,6 +1370,10 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.5.2", "", {}, "sha512-8W1topazDwhMKbm2I83IPa57Ow5VUxFgLbXv42+vSILnv8FY5OHvdEY/tS6WoWeMbxkHZQhra5cmeFHriKptSQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], diff --git a/packages/billing/README.md b/packages/billing/README.md index 9749c47..1f3e528 100644 --- a/packages/billing/README.md +++ b/packages/billing/README.md @@ -38,7 +38,7 @@ The engine is mechanical. It will not infer product policy from plan names. | Paid offers | `offers` + `requiredOfferKeys` at `createBilling`; exact set, no `"free"` in `planIds` | | Trial eligibility | `trialDays` on checkout (0 if ineligible). Claims, HMAC, school trials stay in your tables | | Upgrade vs downgrade | `startPlanChange({ effectiveAt, prorationMode })`. The package does not rank Pro/Business | -| What a plan unlocks | Your entitlements resolver. `commercialState()` is paid snapshot only | +| What a plan unlocks | `planFeatures` at `createBilling`, read with `hasFeature()`; anything finer is your entitlements resolver | | Entity lifecycle | `getBillableEntityBillingBlockers` / `getPayerBillingResponsibilities`; you choose the user-facing error | | Product side effects | `afterProjection` on the **engine transaction** (`store.getTransaction() ?? db`) | @@ -355,6 +355,26 @@ await db.transaction(async (tx) => { `commercialState` is the source of truth for paid access. It re-checks the clock, so a scheduled cancellation stops counting as paid once `paidThroughAt` passes, even before the provider's final event or `runDeadlineBatch`. Decide what a plan unlocks from it; do not copy paid status into product tables and re-derive it. +### Features on plans + +Declare which feature keys each paid plan includes with `planFeatures`, then gate with `hasFeature`: + +```ts +const billing = createBilling({ + // ... + planFeatures: { business: ["learner_sso"] }, +}); + +if (await billing.hasFeature(schoolId, "learner_sso")) { + // the school's entitled paid plan includes the feature +} +``` + +- Keys are plan ids from `offers`; in `cloud` mode a key with no offer throws `plan_features_invalid` at composition. Plans do not inherit: list a feature on every plan that includes it. +- `cloud` mode: true only when `commercialState(...).activePaidPlan` includes the feature, so grace and cancellation rules are exactly those of `activePaidPlan`. No paid plan, or an unknown feature, is false. +- `oss` mode: always true. `planFeatures` is ignored. +- Any error reading state returns false (fail closed). Like `commercialState`, it takes `{ transaction }` to read inside an open transaction. + Compose those with your trial, Free plan, quotas, and `canManageBilling`. `projectionVersion` is a cache key; equivalent webhook replays update freshness only and do not increment it. Before close / delete / owner demotion, call `getBillableEntityBillingBlockers` / `getPayerBillingResponsibilities` in the same transaction as the mutation. The package reports blockers; you decide the user-facing error. diff --git a/packages/billing/src/testing/plan-features.test.ts b/packages/billing/src/testing/plan-features.test.ts new file mode 100644 index 0000000..1795b34 --- /dev/null +++ b/packages/billing/src/testing/plan-features.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { frozenClock } from "../core/clock.js"; +import { MemoryBillingStore } from "../persistence/memory.js"; +import { MemoryAuthorizationPort } from "../ports/authorization.js"; +import { createBilling } from "../workflows/engine.js"; +import { entity, grant, payer, REFERENCE_OFFERS } from "./fixtures.js"; +import { createWorkflowHarness } from "./workflow-harness.js"; + +const planFeatures = { business: ["learner_sso"], pro: ["custom_domain"] }; + +async function subscribe( + harness: ReturnType, + offerKey: string, + entityId: string, +) { + const { billing, fake, authorization, now } = harness; + const workspace = entity(entityId); + const actor = payer(); + const token = grant("checkout", workspace.id, actor.id, now); + authorization.issue(token); + const checkout = await billing.startCheckout({ + grant: token, + entity: workspace, + payer: actor, + offerKey, + catalogRevision: 1, + returnUrl: "https://app.test/billing", + }); + const snapshot = await fake.simulatePayment( + checkout.attempt.providerCheckoutSessionId!, + ); + await billing.projectSnapshot(snapshot, { + checkoutAttemptId: checkout.attempt.attemptId, + }); + return workspace; +} + +describe("hasFeature", () => { + it("is true only for features of the entitled paid plan", async () => { + const harness = createWorkflowHarness(undefined, { planFeatures }); + const business = await subscribe(harness, "business_month", "ws_business"); + const pro = await subscribe(harness, "pro_month", "ws_pro"); + const { billing } = harness; + expect(await billing.hasFeature(business.id, "learner_sso")).toBe(true); + expect(await billing.hasFeature(business.id, "custom_domain")).toBe(false); + expect(await billing.hasFeature(pro.id, "learner_sso")).toBe(false); + expect(await billing.hasFeature(pro.id, "custom_domain")).toBe(true); + }); + + it("is false for unknown features, free entities and prototype keys", async () => { + const harness = createWorkflowHarness(undefined, { planFeatures }); + const business = await subscribe(harness, "business_month", "ws_business"); + const { billing } = harness; + expect(await billing.hasFeature(business.id, "nope")).toBe(false); + expect(await billing.hasFeature(business.id, "constructor")).toBe(false); + expect(await billing.hasFeature("ws_free", "learner_sso")).toBe(false); + }); + + it("is false when no planFeatures are declared", async () => { + const harness = createWorkflowHarness(); + const business = await subscribe(harness, "business_month", "ws_business"); + expect(await harness.billing.hasFeature(business.id, "learner_sso")).toBe(false); + }); + + it("follows the same entitlement semantics as activePaidPlan", async () => { + const harness = createWorkflowHarness(undefined, { planFeatures }); + const business = await subscribe(harness, "business_month", "ws_business"); + const { billing, store } = harness; + const subscription = store.subscriptions[0]!; + // Cancelled at period end and the period has passed: no entitlement. + subscription.cancelAtPeriodEnd = true; + subscription.paidThroughAt = new Date(harness.now.getTime() - 1); + expect((await billing.commercialState(business.id)).activePaidPlan).toBeNull(); + expect(await billing.hasFeature(business.id, "learner_sso")).toBe(false); + // Cancelled at period end but still inside the paid period: entitled. + subscription.paidThroughAt = new Date(harness.now.getTime() + 86_400_000); + expect((await billing.commercialState(business.id)).activePaidPlan).toBe( + "business", + ); + expect(await billing.hasFeature(business.id, "learner_sso")).toBe(true); + }); + + it("fails closed when state cannot be read", async () => { + const harness = createWorkflowHarness(undefined, { planFeatures }); + const business = await subscribe(harness, "business_month", "ws_business"); + harness.store.findPlanState = () => { + throw new Error("db down"); + }; + expect(await harness.billing.hasFeature(business.id, "learner_sso")).toBe(false); + }); + + it("is true for every feature in oss mode, even when reads fail", async () => { + const store = new MemoryBillingStore(); + store.findPlanState = () => { + throw new Error("db down"); + }; + const billing = createBilling({ + database: store, + providers: [], + clock: frozenClock(new Date("2026-01-01T00:00:00.000Z")), + authorization: new MemoryAuthorizationPort(), + mode: "oss", + checkoutProvider: "", + requestedRevision: null, + requiredOfferKeys: [], + }); + expect(await billing.hasFeature("any_entity", "learner_sso")).toBe(true); + expect(await billing.hasFeature("any_entity", "anything_else")).toBe(true); + }); + + it("rejects planFeatures naming a plan that has no offer", () => { + expect(() => + createWorkflowHarness(undefined, { planFeatures: { gold: ["x"] } }), + ).toThrow(/plan_features_invalid/); + expect(REFERENCE_OFFERS.some((offer) => offer.plan === "gold")).toBe(false); + }); +}); diff --git a/packages/billing/src/testing/workflow-harness.ts b/packages/billing/src/testing/workflow-harness.ts index c07cca0..569b15f 100644 --- a/packages/billing/src/testing/workflow-harness.ts +++ b/packages/billing/src/testing/workflow-harness.ts @@ -16,6 +16,7 @@ export function createBillingFrom( extras: { authorization?: MemoryAuthorizationPort; audit?: MemoryAuditHook; + planFeatures?: Record; } = {}, ) { return createBilling({ @@ -29,6 +30,7 @@ export function createBillingFrom( requestedRevision: offers[0]!.revision, requiredOfferKeys: [...COURSELIT_OFFER_KEYS], offers, + planFeatures: extras.planFeatures, returnUrlValidator: (url) => url.startsWith("https://app.test/"), }); } @@ -38,6 +40,7 @@ export function createWorkflowHarness( options: { seedLocalCatalog?: boolean; offers?: BillingOffer[]; + planFeatures?: Record; } = {}, ) { const clock = frozenClock(now); @@ -67,6 +70,7 @@ export function createWorkflowHarness( requestedRevision: offers[0]!.revision, requiredOfferKeys: [...COURSELIT_OFFER_KEYS], offers, + planFeatures: options.planFeatures, returnUrlValidator: (url) => url.startsWith("https://app.test/"), }); return { diff --git a/packages/billing/src/workflows/engine.ts b/packages/billing/src/workflows/engine.ts index 7168074..b228322 100644 --- a/packages/billing/src/workflows/engine.ts +++ b/packages/billing/src/workflows/engine.ts @@ -96,6 +96,12 @@ export type CreateBillingOptions = { requestedRevision: number | null; requiredOfferKeys: string[]; offers?: BillingOffer[]; + /** + * Feature keys each paid plan includes, e.g. `{ business: ["learner_sso"] }`. + * Cloud mode requires every key to be a plan of `offers`. Read with + * `hasFeature`; `oss` mode ignores it and grants every feature. + */ + planFeatures?: Record; webhookRetrieveCurrent?: boolean; /** How long work may sit before `runReconciliationBatch` treats it as stuck. */ reconciliation?: { @@ -161,6 +167,24 @@ export function createBilling(options: CreateBillingOptions) { ) { throw new BillingCompositionError("cloud_billing_configuration_incomplete"); } + if (options.planFeatures !== undefined) { + const plans = new Set((options.offers ?? []).map((offer) => offer.plan)); + for (const [plan, features] of Object.entries(options.planFeatures)) { + if ( + !Array.isArray(features) || + features.some((feature) => typeof feature !== "string" || feature === "") || + (options.mode === "cloud" && !plans.has(plan)) + ) { + throw new BillingCompositionError("plan_features_invalid"); + } + } + } + const planFeatures = new Map>( + Object.entries(options.planFeatures ?? {}).map(([plan, features]) => [ + plan, + new Set(features), + ]), + ); const requestedOffers = options.mode === "cloud" ? validateCatalog({ @@ -2519,6 +2543,26 @@ export function createBilling(options: CreateBillingOptions) { }; } + /** + * Whether the entity's currently entitled paid plan includes `feature`. + * Uses the same entitlement rules as `commercialState().activePaidPlan`. + * `oss` mode grants every feature; any read error answers false. + */ + async function hasFeature( + entityId: string, + feature: string, + readOptions: { transaction?: unknown } = {}, + ): Promise { + if (options.mode === "oss") return true; + try { + const { activePaidPlan } = await commercialState(entityId, readOptions); + if (!activePaidPlan) return false; + return planFeatures.get(activePaidPlan)?.has(feature) ?? false; + } catch { + return false; + } + } + /** Stops a subscription whose paid access has lapsed from granting entitlement. */ async function releaseLapsedEntitlement(subscriptionId: string, actorId: string) { await store.withTransaction(() => @@ -2846,6 +2890,7 @@ export function createBilling(options: CreateBillingOptions) { getBillableEntityBillingBlockers, getPayerBillingResponsibilities, commercialState, + hasFeature, publicCatalog, recordRequestedCatalog, verifyRequestedCatalog, diff --git a/packages/platform/README.md b/packages/platform/README.md index fbc7f77..3227342 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -5,11 +5,48 @@ safe errors, UUIDv7 / prefixed public IDs, health/readiness/shutdown, and HTTP/MCP mapping conventions. The package imports no product schema, reads no environment at import, and -performs no network I/O. Products implement authentication, tenant, and +performs no network I/O unless a product calls the DNS helpers below. Products implement authentication, tenant, and authorization adapters. See `docs/architecture.md` §6.4 and ADR 0001. +## Domain verification + +Custom-domain checks shared by every product (root export). No storage, no +environment reads, no product rules: apps keep their own apex/`www` pairing or +one-domain-per-tenant policy. + +```ts +import { + createDnsResolver, + createDomainVerification, + issueOwnershipToken, + normalizeHostname, +} from "@codelitdev/platform"; + +const hostname = normalizeHostname(userInput); // null when invalid +const token = issueOwnershipToken(); // 16 random bytes, hex +const domains = createDomainVerification({ + label: "_myproduct-verification", + resolver: createDnsResolver({ servers: ["1.1.1.1", "8.8.8.8"] }), +}); + +domains.ownershipRecord(hostname, token); // { type: "TXT", name, value: token } +await domains.verifyOwnership(hostname, token); +await domains.verifyRouting(hostname, { + cnameTarget: "edge.example.net", + aRecords: ["203.0.113.10"], // optional, apex only +}); +``` + +| Export | Use | +| --- | --- | +| `normalizeHostname(input, { allowIpLiteral?, allowPublicSuffix? })` | Lowercase, punycode, no trailing dot; null for ports, paths, credentials, bad labels, single-label names, IP literals and public suffixes (`com`, `co.uk`, `github.io`) unless allowed. | +| `isApexHostname(hostname)` | True when the host is its registrable domain (`example.co.uk` yes, `www.example.co.uk` no). Uses the public suffix list including its private section. | +| `issueOwnershipToken()` | 16 random bytes, hex. | +| `createDnsResolver({ servers?, timeoutMs? })` | `node:dns` resolver; `servers` pins trusted recursive resolvers, every lookup times out (default 5000 ms). Inject any `DnsResolver` in tests. | +| `createDomainVerification({ label, resolver? })` | `ownershipRecord`, `verifyOwnership` (TXT at `