Gate a feature
Restrict a route to subscribers who hold a capability, and reflect the gate in the UI.
Restrict a route to subscribers who hold a capability, and reflect the gate in the UI.
You want a feature that only certain plans can reach — enforced at the gateway
and surfaced honestly in the product UI. A capability is the unit that does this:
you declare a @Capability, bind a @Feature's routes to it, grant it on the
plans that should have it, and place a gated component on the frontend page.
CronCloud already gates its cron-job routes behind the managed-cron capability.
This recipe walks that wiring end to end and shows how to add a second, premium
gate.
A capability lists the features it unlocks via includesFeatures. A plan grants
the capability with capabilityGrant(...) in its grants. The gateway allows a
subscriber to reach a feature's routes only if their plan grants a capability
that includes that feature.
// product/product.config.ts
import {
Product,
Requests,
Resource,
Capability,
Feature,
Plan,
capabilityGrant,
} from "@farthershore/product";
@Product({
name: "croncloud",
origin: "https://api.example.com",
displayName: "CronCloud",
})
export default class CronCloud {
@Requests()
requests!: unknown;
@Resource("cron_jobs", { display: "Cron jobs", countSource: "action_inferred" })
cronJobs!: unknown;
// 1. Declare the capability and the features it unlocks.
@Capability("managed-cron", {
title: "Managed Cron Jobs",
includesFeatures: ["cron-jobs"],
})
managedCron!: unknown;
// 2. The feature's routes are gated by any capability that includes it.
@Feature("cron-jobs", {
description: "Cron job CRUD",
plans: ["starter", "pro"],
routes: {
"GET /v1/cron-jobs": {},
"POST /v1/cron-jobs": {},
"DELETE /v1/cron-jobs/{id}": {},
},
})
cronJobsFeature!: unknown;
// 3. Grant the capability on the plans that should reach the feature.
// The optional `limits` cap a counted resource (here: cron jobs).
@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;
}
A plan that does not grant managed-cron cannot call any cron-jobs route —
the gateway denies it before the request reaches your origin.
The SDK validates references at build time: a @Capability whose
includesFeatures names a feature you didn't declare fails the build, as does a
plan that grants a capability you didn't declare. Misspellings surface locally,
not in production.
To gate a subset behind a higher tier, declare a second capability, attach it to a second feature, and grant it only on Pro.
@Capability("priority-runs", { title: "Priority Execution" })
priorityRuns!: unknown;
@Feature("priority", {
description: "Jump-the-queue execution",
routes: { "POST /v1/cron-jobs/{id}/run-now": {} },
})
priorityFeature!: unknown;
Keep the two capabilities separate (don't fold priority into managed-cron's
includesFeatures), and grant the new one only on Pro:
// In @Plan("pro"):
grants: [
capabilityGrant("managed-cron", { limits: { cron_jobs: 100 } }),
capabilityGrant("priority-runs"),
],
Now Starter keeps cron CRUD but cannot call run-now; Pro can do both.
The product's @Frontend manifest declares pages and the components on them.
A page can require a capability, and a feature_panel component renders a gated
surface with a gateMode of hide, disable, or upsell. CronCloud upsells
the cron page to subscribers who lack managed-cron:
import { Frontend } from "@farthershore/product";
@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", // hide | disable | upsell
props: { feature: "cron-jobs" },
},
],
},
],
})
hide — the panel is absent for ungranted subscribers.disable — the panel renders read-only / non-interactive.upsell — the panel shows an upgrade prompt pointing at a plan that grants it.The nav item's capability hides the link the same way. The generated frontend/
app reads this manifest, so the UI gate and the gateway gate come from one source.
farthershore build --format json
Verify the gate end to end with a test persona that you assign to each plan:
# A Starter persona reaches cron CRUD but not run-now;
# a persona on a plan WITHOUT the grant is denied at the gateway.
farthershore persona bootstrap croncloud --env preview --plan starter --format json
managed-cron can call the cron-jobs routes.priority-runs, granted only on Pro, leaves Starter unable to call run-now.