@farthershore/product
Decorators and value types for authoring a product as a TypeScript class.
Decorators and value types for authoring a product as a TypeScript class.
@farthershore/product is the product-as-code SDK. You author a product as one
decorated @Product class in product/product.config.ts and export default
it; Farther Shore compiles it to deterministic Manifest IR, validates it, and
applies it through Core. There is no YAML — the class is the source of truth.
Pin it in lockstep with @farthershore/backend and
@farthershore/farthershore-js.
pnpm add @farthershore/product
Member decorators (@Meter, @Feature, @Plan, …) declare resources at
class-definition time; the @Product class decorator drains and finalizes them
into one product. Cross-references are by string key — a route's
reports: "tokens_used" names a @Meter key, a plan grant names a
@Capability key. The build is deterministic: no dates, randomness, network, or
process state.
CronCloud — a metered cron-jobs product — exercises most of the surface:
import {
Product, Frontend, Requests, Meter, Resource,
Capability, Feature, Plan, capabilityGrant,
} from "@farthershore/product";
@Product({
name: "croncloud",
origin: "https://api.example.com",
displayName: "CronCloud",
description: "Managed cron jobs",
billOn4xx: false,
})
@Frontend({
nav: [{ label: "Cron", path: "/cron", capability: "managed-cron" }],
pages: [
{
path: "/cron",
title: "Cron jobs",
requiresAuth: true,
capability: "managed-cron",
components: [
{ component: "usage_card", props: { meter: "requests" } },
{ component: "feature_panel", capability: "managed-cron",
gateMode: "upsell", props: { feature: "cron-jobs" } },
],
},
],
})
export default class CronCloud {
@Requests()
requests!: unknown;
@Meter("compute", { display: "Compute", unit: "ms" })
compute!: unknown;
@Resource("cron_jobs", { display: "Cron jobs", countSource: "action_inferred" })
cronJobs!: unknown;
@Capability("managed-cron", {
title: "Managed Cron Jobs",
includesFeatures: ["cron-jobs"],
})
managedCron!: unknown;
@Feature("cron-jobs", {
description: "Cron job CRUD",
plans: ["starter", "pro"],
actions: [
{ id: "cron-job.create", kind: "mutation", title: "Create cron job",
resource: { resource: "cron_jobs", effect: "create" } },
{ id: "cron-job.delete", kind: "mutation", title: "Delete cron job",
subject: { type: "cron_job", from: "path_param", name: "id" },
resource: { resource: "cron_jobs", effect: "delete" }, audit: "full" },
],
routes: {
"GET /v1/cron-jobs": {},
"POST /v1/cron-jobs": { action: "cron-job.create" },
"DELETE /v1/cron-jobs/{id}": { action: "cron-job.delete" },
},
})
cronJobsFeature!: unknown;
@Plan("starter", {
name: "Starter",
price: { amount: 2900, currency: "usd", interval: "month" },
grants: [capabilityGrant("managed-cron", { limits: { cron_jobs: 10 } })],
limits: { requests: { rate: 600, interval: "minute", enforcement: "enforce" } },
})
starter!: unknown;
@Plan("pro", {
name: "Pro",
price: { amount: 19900, currency: "usd", interval: "month" },
grants: [capabilityGrant("managed-cron", { limits: { cron_jobs: 100 } })],
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
})
pro!: unknown;
}
@Product and @Frontend / @Raw decorate the class (the latter two are
stackable); the rest decorate members.
| Decorator | Purpose |
|---|---|
@Product(options) | The class decorator. options.origin (the business-logic origin Farther Shore calls) is required; options.name defaults to the class name. |
@Surface(type, options?) | Declare a customer-facing surface: frontend | api | docs | widget | webhook | worker | agent | dashboard. |
@Requests(options?) | Declare the platform-managed successful-request meter; applies requests = 1 to every metered route. No backend code needed. |
@Meter(key, options?) | Declare a billable/enforceable dimension. { display, unit, estimate, routeDefault, aggregation }. A routeDefault is a reusable fixed cost on every metered route. |
@Resource(name, options?) | Declare a counted resource for resource-count constraints ({ display, countSource }). Capped per-plan via caps / capability limits. |
@Capability(key, options?) | Declare a capability bundle ({ title, includesFeatures }). Grant it on a plan via capabilityGrant(key, { limits }). |
@Feature(key, options?) | Declare gateway routes (keyed "METHOD /path"), costs/reports/estimates, actions, and the plans that grant the feature. |
@Plan(key, options?) | Declare pricing, limits, grants, trial, caps. See below. |
@Entitlement(key, options?) | Group capabilities, featureGates, limits, and meters into reusable access metadata. |
@Workflow(key, options?) | Declare a non-HTTP workflow ({ kind, trigger, capabilities, meters, estimates }) — e.g. an agent task or scheduled job. |
@Backend(id, options?) | Declare a first-class backend; bind routes via a route's backend. See @farthershore/backend. |
@Policy(name, options) | Declare a policy layer in code. |
@Frontend(options) | Declare the frontend nav + pages manifest (class decorator, stackable). |
@Raw(options) | Escape hatch to merge platform-schema JSON the typed SDK lacks sugar for: , , , , . |
capabilityGrant(key, { limits }) builds a capability grant (with optional
per-capability limits) for a plan's grants[] — the only grant value that isn't
a plain string.
Each @Feature route value (keyed "METHOD /path", insertion order preserved
because the gateway is first-match-wins) accepts:
| Field | Meaning |
|---|---|
cost | Fixed gateway-known costs, keyed by meter key ({ api_credits: 10 }). |
report / reports | Dynamic meter key(s) the upstream reports with @farthershore/backend. |
estimates | Per-route pre-request estimates for admission checks, keyed by meter. |
action | Bind a declared action to this route by id. |
backend | Bind this route to a declared backend by id. |
onStatusCodes | Override the successful-response range ("200-299,304" or [200, 201]). |
unmetered | true clears all inherited + explicit route metering. |
inheritDefaultMeters | false disables inherited defaults (e.g. @Requests) only. |
A meter cannot be both a fixed cost and a dynamic report — that throws at
class-definition time. So does a route key that isn't "METHOD /path", an
integer-like key, a NaN estimate, or an estimate for an undeclared/unbound meter.
@Meter("api_credits", { unit: "credit", routeDefault: 1 })
apiCredits!: unknown;
@Feature("exports", {
routes: {
"POST /v1/bulk-export": { cost: { api_credits: 10 } }, // defaults → 11
"GET /healthz": { unmetered: true },
"GET /status": { inheritDefaultMeters: false },
},
})
exports!: unknown;
@Plan options| Option | Type / meaning |
|---|---|
name | Display name (defaults to the member name). |
price | { amount, currency: "usd", interval: "month" | "year" } (cents, verbatim) or { free: true }. Omit for a $0 plan. |
limits | Per-dimension RateLimit or Count, declaration order kept. Rate limits → limits[]; counts → capability limits. |
caps | Resource caps → capability limits (alias for count-only limits): { cron_jobs: 10 }. |
meter | Per-dimension MeterOverage → plan meters[]. Mutually exclusive with meters. |
meters | Raw platform-native meters[] passthrough. |
grants | Credit grants and/or capabilityGrant(...) entries. |
trialDays, maxMonthlySpendCents, minMonthlySpendCents | Trial length; optional hard/soft monthly spend caps. |
capabilities, featureGates | Capability keys granted without limits; per-key feature gates. |
overageBehavior, selfServeEnabled, legacy, archive, raw | "block" | "allow_and_bill"; self-serve flag; legacy flag; archive policy; per-plan escape hatch. |
Publishing requires every plan to carry at least one rate-limit rule (run at
farthershore build). The minimal shape:
limits: { requests: { rate: 600, interval: "minute" } }.
The author-facing structured types the decorators consume. Money is integer-only and taken verbatim (no ×100).
| Type | Shape | Notes |
|---|---|---|
Price | { amount, currency: "usd", interval: "month" | "year" } | { free: true } | amount is integer cents. |
RateLimit | { rate, interval, enforcement? } | interval: second|minute|hour|day|week|month; enforcement: "enforce" | "track". |
Count | { count } | A counted-resource cap; lands in capability limits. |
MeterOverage | { micros, includedUnits? } | micros = per-unit rate in micro-dollars (not microcents). |
RouteCost | Record<string, number> | Fixed gateway-known costs, keyed by meter key. |
A @Product class is a snapshot of product state. Moving live subscribers onto
a new plan version is a transition — an OPERATE verb, not a declaration. Reprice
or change a plan and publish; existing subscribers are grandfathered by default.
To actively migrate them, run
farthershore plan migrate <product> <plan> --from <v> --to head --policy <policy>
(see the CLI reference).
productPatchplanrouteLayerpolicyLayercapabilityLayer