Features & routes
@Feature bundles gateway routes, the meters and actions they report, and the plans that grant them.
@Feature bundles gateway routes, the meters and actions they report, and the plans that grant them.
A @Feature is a bundle of gateway routes plus the metering and actions those routes carry.
Routes are a record keyed by "METHOD /path", in declaration order — order matters because
the gateway matcher is first-match-wins. Cross-references (which meter a
route reports, which plans grant the feature) are by string key.
import { Product, Resource, Capability, Feature, Plan } from "@farthershore/product";
@Product({ name: "croncloud", origin: "https://api.example.com" })
export default class CronCloud {
@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", limits: { requests: { rate: 600, interval: "minute" } } })
starter!: unknown;
}
| Option | Type | Meaning |
|---|---|---|
routes | Record<"METHOD /path", entry> | The gateway routes, in declaration order. |
description | string | Human description of the feature. |
plans | string[] | Plan keys that grant this feature directly. |
actions | Array<{ id } & action> | Action metadata bound to routes by id. |
policies | string[] | Policy keys applied to the feature. |
backend | string | Default backend id for all the feature's routes (per-route backend overrides). |
mutationClass | "runtime" | "contractual" | Mutation classification. |
cacheProfile | "long" | "short" | "blocking" | Cache behavior. |
upstreamOrigin | string | null | Per-feature origin override (default: the product origin). |
Each route key is "METHOD /path", where METHOD is one of
GET POST PUT PATCH DELETE HEAD OPTIONS *. The shape is enforced at decoration time — a bare
key, a missing or unknown method, or an integer-like key throws a ManifestBuilderError
immediately:
@Feature("f", { routes: { "no-slash": {} } }) // throws: must be "METHOD /path"
@Feature("f", { routes: { "0": {} } }) // throws: integer-like route key
* is a valid wildcard method ("* /catch": {}). Path parameters use braces:
"DELETE /v1/cron-jobs/{id}".
Each route entry is an object. An empty {} means "inherit the product defaults" — for a
product with @Requests, that is requests = 1. The fields:
| Field | Type | Meaning |
|---|---|---|
cost | Record<meterKey, number> | Fixed gateway-known cost, added on top of inherited defaults. |
reports | string | string[] | Dynamic meter key(s) your backend reports after the request. |
report | string | Sugar for a single reports entry. |
estimates | Record<meterKey, number> | Route-specific pre-request estimate for reported meters. |
action | string | Bind a declared action to this route by id. |
backend | string | Bind this route to a declared backend by id. |
onStatusCodes | string | number[] | Override the default successful-response range. |
unmetered | boolean | Clear all inherited and explicit metering for this route. |
inheritDefaultMeters | boolean | false disables inherited product defaults for this route only. |
@Meter("api_credits", { unit: "credit", routeDefault: 2 })
credits!: unknown;
@Meter("tokens_used", { unit: "token", estimate: 500 })
tokens!: unknown;
@Feature("runs", {
routes: {
"POST /v1/runs": {
cost: { api_credits: 10 }, // 2 (routeDefault) + 10 = 12
reports: "tokens_used", // upstream reports actual tokens
estimates: { tokens_used: 750 }, // admission estimate for this route
},
"GET /healthz": { unmetered: true }, // never metered
"GET /status": { inheritDefaultMeters: false }, // no inherited requests/credits
},
})
runs!: unknown;
POST /v1/runs compiles to:
{ "defaults": { "api_credits": 12, "requests": 1 },
"reports": ["tokens_used"],
"estimates": { "tokens_used": 750 } }
GET /healthz carries unmetered: true and no metering; GET /status carries
inheritDefaultMeters: false and no metering.
A meter cannot be both a fixed cost and a dynamic reports on the same route — that
conflict throws at decoration time (meter "…" cannot be both a fixed route cost and a dynamic report). A route estimates entry must also be listed in reports, and its meter
must be declared, or the build fails.
By default only successful responses are metered. Override the range per route with a gateway-parseable string range or an explicit list:
"POST /v1/runs": { cost: { api_credits: 1 }, onStatusCodes: "200-299,304" },
"POST /v1/import": { onStatusCodes: [200, 201, 202] },
A non-parseable value like "2xx" throws. (Whether 4xx counts toward request metering at
the product level is the @Product({ billOn4xx }) switch — see
the @Product class.)
actions declare typed operations bound to routes by id. An action's resource effect
(create / delete) feeds an action_inferred resource count; subject
identifies the target (e.g. from a path param); audit controls audit logging.
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: {
"POST /v1/cron-jobs": { action: "cron-job.create" },
"DELETE /v1/cron-jobs/{id}": { action: "cron-job.delete" },
},
Action ids must be unique across all features; a duplicate id throws at decoration time. The
create action then increments the cron_jobs count and the delete action decrements it, so a
plan capping cron_jobs (via capability grants) is enforced without
any backend counting code.
Routes are HTTP. For non-HTTP product workflows (scheduled jobs, agent tasks, async jobs),
use the @Workflow member instead — it declares a trigger, the capabilities/meters it
consumes, and estimates, and compiles to product.workflows, separate from routes:
@Workflow("run_agent", {
title: "Run agent",
kind: "agent_task",
trigger: { type: "api", path: "/v1/agent/runs" },
capabilities: ["agent_access"],
meters: ["workflow_runs"],
estimates: { workflow_runs: 1 },
})
runAgent!: unknown;