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:
+56
-11
@@ -61,7 +61,8 @@ const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
|
||||
// --- types ---------------------------------------------------------------
|
||||
|
||||
export type Deployment = "cloud" | "self_hosted";
|
||||
export type Tier = "free" | "professional" | "self_hosted";
|
||||
export type Tier = "free" | "professional" | "enterprise";
|
||||
export type Term = "monthly" | "annual";
|
||||
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
|
||||
|
||||
/*
|
||||
@@ -103,8 +104,10 @@ export interface InstanceMember {
|
||||
|
||||
export interface Limits {
|
||||
max_servers: number;
|
||||
max_monitors: number;
|
||||
max_secret_groups: number;
|
||||
max_channels: number;
|
||||
audit_retention_days: number;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
@@ -157,16 +160,48 @@ export interface Subscription {
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
deployment: Deployment;
|
||||
tier: Tier;
|
||||
name: string;
|
||||
deployment: Deployment;
|
||||
limits: Limits;
|
||||
features: string[];
|
||||
paddle_product_id?: string;
|
||||
paddle_price_ids?: Record<string, string>;
|
||||
/* The allowance BEFORE anything is bought. Not the total — a metered
|
||||
* dimension adds to it. */
|
||||
base_limits: Limits;
|
||||
base_features: string[];
|
||||
support_level: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogueRow {
|
||||
kind: "base" | "limit" | "feature";
|
||||
deployment: Deployment;
|
||||
tier: Tier;
|
||||
limit_key?: string;
|
||||
feature_key?: string;
|
||||
/* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
|
||||
* inner map; both environments are stored so promotion is a config change
|
||||
* rather than a data migration. */
|
||||
price_ids?: Record<string, Partial<Record<Term, string>>>;
|
||||
}
|
||||
|
||||
export interface EntitlementConfig {
|
||||
servers: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface Entitlement {
|
||||
instance_id: string;
|
||||
account_id: string;
|
||||
deployment: Deployment;
|
||||
tier: Tier;
|
||||
term: Term;
|
||||
desired: EntitlementConfig;
|
||||
granted: EntitlementConfig;
|
||||
resolved_limits: Limits;
|
||||
scheduled_change_at?: string;
|
||||
granted_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerUser {
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
@@ -274,11 +309,21 @@ export const api = {
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
|
||||
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(plan),
|
||||
}),
|
||||
updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) =>
|
||||
put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan),
|
||||
catalogue: () => req<CatalogueRow[]>("/api/staff/catalogue"),
|
||||
updateCatalogue: (row: CatalogueRow) =>
|
||||
put<{ updated: boolean }>("/api/staff/catalogue", row),
|
||||
entitlement: (id: string) =>
|
||||
req<{ entitlement: Entitlement; pending: boolean }>(
|
||||
`/api/staff/instances/${id}/entitlement`,
|
||||
),
|
||||
setEntitlement: (
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean },
|
||||
) =>
|
||||
put<{ entitlement: Entitlement; pending: boolean }>(
|
||||
`/api/staff/instances/${id}/entitlement`, body),
|
||||
audit: (accountId?: string) =>
|
||||
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
|
||||
injectionHealth: () =>
|
||||
|
||||
Reference in New Issue
Block a user