feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s

Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 09:37:39 +01:00
co-authored by Claude Opus 5
parent 3fc726da9e
commit c4e6ad5485
35 changed files with 2241 additions and 222 deletions
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="space-y-6">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<section key={g} className="space-y-2">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
{tier}
</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
<thead>
<tr className="border-b border-rule text-left text-ink-3">
<th className="py-2 pr-3 font-normal">Component</th>
{ENVS.map((env) =>
terms.map((t) => (
<th
key={`${env}-${t}`}
className="py-2 pr-3 font-normal"
>
{env} / {t}
</th>
)),
)}
<th className="py-2 font-normal" />
</tr>
</thead>
<tbody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<tr
key={k}
className="border-b border-rule/60"
>
<td className="py-2 pr-3 text-ink">
{componentLabel(r)}
</td>
{ENVS.map((env) =>
terms.map((t) => (
<td
key={`${env}-${t}`}
className="py-2 pr-3"
>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
/>
</td>
)),
)}
<td className="py-2">
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
>
Save
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
@@ -8,13 +8,11 @@ import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
deployment,
}: {
instanceId: string;
deployment: string;
}) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
@@ -47,7 +45,7 @@ export function IssuePanel({
>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label className="grid gap-1.5">
@@ -1,12 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
@@ -71,11 +73,112 @@ export default function StaffInstancePage() {
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
<IssuePanel instanceId={data.instance.instance_id} />
</section>
<EntitlementSection
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
</div>
);
}
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
const qc = useQueryClient();
const { data: plans = [] } = useQuery({
queryKey: ["staff", "plans"],
queryFn: api.staff.plans,
});
const { data: catalogue = [] } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const { data } = useQuery({
queryKey: ["staff", "entitlement", instanceId],
queryFn: () => api.staff.entitlement(instanceId),
retry: false,
});
const ent = data?.entitlement;
const [draft, setDraft] = useState<PlanChoice | null>(null);
const choice: PlanChoice =
draft ??
(ent
? {
tier: ent.tier,
term: ent.term,
servers: ent.desired.servers,
features: ent.desired.features ?? [],
}
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) =>
api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
},
});
return (
<section className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3">
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
<p className="text-[0.78rem] text-ink-3">
What this instance is allowed. A licence is signed from{" "}
<em>granted</em>, never from <em>desired</em>.
</p>
</header>
{ent && data?.pending && (
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
Pending change currently granted {ent.granted.servers} servers,
configured for {ent.desired.servers}
{ent.scheduled_change_at
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
: ""}
.
</p>
)}
<PlanConfigurator
deployment={deployment}
value={choice}
plans={plans}
catalogue={catalogue}
onChange={setDraft}
disabled={save.isPending}
/>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(false)}
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
>
Save as configured
</button>
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(true)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
Save and grant
</button>
</div>
<p className="mt-2 text-[0.72rem] text-ink-3">
Granting takes effect on the next licence issued. It does not issue
one.
</p>
{save.error && (
<p className="mt-2 text-[0.82rem] text-expired">
{String((save.error as Error).message)}
</p>
)}
</section>
);
}
+1
View File
@@ -8,6 +8,7 @@ const LINKS: NavLink[] = [
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/audit", label: "Audit" },
];
@@ -35,7 +35,8 @@ export default function LicensesPage() {
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
<option value="self_hosted">Self-Hosted (legacy)</option>
</select>
<select
value={reason}
+147 -85
View File
@@ -2,12 +2,115 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Plan } from "@/lib/api";
import { Button } from "@/components/Button";
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({
plan,
onSave,
saving,
}: {
plan: Plan;
onSave: (next: Plan) => void;
saving: boolean;
}) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
{f.label}
</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
1 is unlimited
</span>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
Support level
</span>
<select
value={draft.support_level}
onChange={(e) =>
setDraft({ ...draft, support_level: e.target.value })
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input
type="checkbox"
checked={draft.active}
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
/>
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">
Changes apply to licences issued from now on. Existing licences
snapshotted their plan and are unaffected.
</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
@@ -16,30 +119,27 @@ export default function PlansPage() {
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const save = useMutation({
mutationFn: (p: Plan) =>
api.staff.updatePlan(p.tier, {
name: p.name,
limits: p.limits,
features: p.features,
paddle_product_id: p.paddle_product_id,
paddle_price_ids: p.paddle_price_ids,
active: p.active,
}),
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.tier === draft?.tier);
const original = plans.data?.find(
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
);
return (
<div className="grid gap-6">
<PageHeader
title="Plans"
subtitle="The authoritative tier table. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
@@ -48,84 +148,46 @@ export default function PlansPage() {
next={draft}
issuedCount={
(licenses.data ?? []).filter(
(l) => l.tier === draft.tier,
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
).length
}
onConfirm={() => save.mutate(draft)}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
}}
onCancel={() => setDraft(null)}
/>
)}
<div className="grid gap-4 lg:grid-cols-3">
{(plans.data ?? []).map((p) => (
<section
key={p.tier}
className="grid gap-3 rounded border border-rule bg-panel p-5"
>
<h2 className="text-xl">{p.name}</h2>
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
<div className="flex justify-between gap-2">
<dt>servers</dt>
<dd>{limitLabel(p.limits.max_servers)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>secret groups</dt>
<dd>
{limitLabel(p.limits.max_secret_groups)}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt>channels</dt>
<dd>{limitLabel(p.limits.max_channels)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>features</dt>
<dd>{p.features?.join(", ") || "none"}</dd>
</div>
</dl>
{/* Guard rail two: deployment is shown, never edited. */}
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
<span aria-hidden="true">🔒</span>
<span>
Deployment is fixed at{" "}
<b className="font-mono">{p.deployment}</b>.
Moving a tier between cloud and self-hosted is a
code change, not a form field.
</span>
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
limits: { ...p.limits, max_servers: 7 },
})
}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="space-y-3">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<article
key={`${p.deployment}/${p.tier}`}
className="rounded-lg border border-rule bg-panel p-4"
>
Cap servers at 7
</Button>
<Button
type="button"
variant="line"
onClick={() =>
setDraft({
...p,
features: p.features.filter(
(f) => f !== "oidc",
),
})
}
>
Remove OIDC
</Button>
</div>
</section>
))}
</div>
<header className="mb-3 flex items-baseline justify-between gap-3">
<h3 className="text-[0.9rem] font-medium text-ink">
{p.name}
</h3>
<span className="font-mono text-[0.75rem] text-ink-3">
{p.deployment}/{p.tier}
</span>
</header>
<AllowanceForm
plan={p}
saving={saving === `${p.deployment}/${p.tier}`}
onSave={(next: Plan) => setDraft(next)}
/>
</article>
))}
</section>
))}
</div>
);
}