feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s
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:
@@ -22,29 +22,32 @@ export function ConfirmPlanChange({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
if (plan.limits.max_servers !== next.limits.max_servers)
|
||||
const fields = [
|
||||
"max_servers",
|
||||
"max_monitors",
|
||||
"max_secret_groups",
|
||||
"max_channels",
|
||||
"audit_retention_days",
|
||||
] as const;
|
||||
for (const f of fields) {
|
||||
if (plan.base_limits[f] !== next.base_limits[f])
|
||||
rows.push({
|
||||
field: f,
|
||||
was: limitLabel(plan.base_limits[f]),
|
||||
now: limitLabel(next.base_limits[f]),
|
||||
});
|
||||
}
|
||||
if (plan.support_level !== next.support_level)
|
||||
rows.push({
|
||||
field: "max_servers",
|
||||
was: limitLabel(plan.limits.max_servers),
|
||||
now: limitLabel(next.limits.max_servers),
|
||||
field: "support_level",
|
||||
was: plan.support_level || "none",
|
||||
now: next.support_level || "none",
|
||||
});
|
||||
if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
|
||||
rows.push({
|
||||
field: "max_secret_groups",
|
||||
was: limitLabel(plan.limits.max_secret_groups),
|
||||
now: limitLabel(next.limits.max_secret_groups),
|
||||
});
|
||||
if (plan.limits.max_channels !== next.limits.max_channels)
|
||||
rows.push({
|
||||
field: "max_channels",
|
||||
was: limitLabel(plan.limits.max_channels),
|
||||
now: limitLabel(next.limits.max_channels),
|
||||
});
|
||||
if (plan.features.join(",") !== next.features.join(","))
|
||||
if (plan.base_features.join(",") !== next.base_features.join(","))
|
||||
rows.push({
|
||||
field: "features",
|
||||
was: plan.features.join(", ") || "none",
|
||||
now: next.features.join(", ") || "none",
|
||||
was: plan.base_features.join(", ") || "none",
|
||||
now: next.base_features.join(", ") || "none",
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
term: Term;
|
||||
servers: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/* Self-hosted sells annual only. The reason is in shared/license: an offline
|
||||
* licence cannot be revoked, so the term length IS the revocation window. */
|
||||
function termsFor(deployment: Deployment): Term[] {
|
||||
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
|
||||
}
|
||||
|
||||
/*
|
||||
* PlanConfigurator is the whole of "what is this instance allowed", driven
|
||||
* entirely by the plans and catalogue it is handed.
|
||||
*
|
||||
* A feature appears because a catalogue row offers it, and shows a price because
|
||||
* that row has one. Nothing here is hardcoded per tier, which is what lets a new
|
||||
* paid add-on ship as a staff edit rather than a frontend release.
|
||||
*
|
||||
* It saves nothing and knows nothing about who is using it. Staff mount it to
|
||||
* set an entitlement; the customer purchase flow mounts the same component and
|
||||
* hands it a checkout.
|
||||
*/
|
||||
export default function PlanConfigurator({
|
||||
deployment,
|
||||
value,
|
||||
plans,
|
||||
catalogue,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
deployment: Deployment;
|
||||
value: PlanChoice;
|
||||
plans: Plan[];
|
||||
catalogue: CatalogueRow[];
|
||||
onChange: (next: PlanChoice) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const available = useMemo(
|
||||
() => plans.filter((p) => p.deployment === deployment && p.active),
|
||||
[plans, deployment],
|
||||
);
|
||||
const plan = available.find((p) => p.tier === value.tier);
|
||||
const rows = useMemo(
|
||||
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
|
||||
[catalogue, deployment, value.tier],
|
||||
);
|
||||
const featureRows = rows.filter((r) => r.kind === "feature");
|
||||
const base = plan?.base_limits.max_servers ?? 0;
|
||||
const extra = Math.max(0, value.servers - base);
|
||||
|
||||
const priceOf = (r: CatalogueRow) =>
|
||||
r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((p) => (
|
||||
<button
|
||||
key={p.tier}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
tier: p.tier,
|
||||
/* Moving tier moves the floor, so clamp up
|
||||
* rather than leaving an invalid count the
|
||||
* backend would refuse. */
|
||||
servers: Math.max(value.servers, p.base_limits.max_servers),
|
||||
})
|
||||
}
|
||||
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
|
||||
p.tier === value.tier
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Term</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{termsFor(deployment).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ ...value, term: t })}
|
||||
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
|
||||
t === value.term
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{t === "monthly" ? "Monthly" : "Annual"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{deployment === "self_hosted" && (
|
||||
<p className="text-[0.72rem] text-ink-3">
|
||||
Self-hosted is annual only.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
|
||||
<input
|
||||
type="number"
|
||||
min={base}
|
||||
value={value.servers}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, servers: Number(e.target.value) })
|
||||
}
|
||||
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="ml-2 text-[0.78rem] text-ink-3">
|
||||
{base} included{extra > 0 ? `, ${extra} extra` : ""}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{featureRows.length > 0 && (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Features</legend>
|
||||
{featureRows.map((r) => {
|
||||
const key = r.feature_key!;
|
||||
const on = value.features.includes(key);
|
||||
const priced = priceOf(r) !== "";
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
features: e.target.checked
|
||||
? [...value.features, key]
|
||||
: value.features.filter((f) => f !== key),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user