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>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/*
|
|
* The typed client for the licensing service.
|
|
*
|
|
* The browser calls admin directly, so every request carries credentials and
|
|
* every failure mode is one of three: the API is unreachable (NotConnected),
|
|
* the caller is not signed in (ApiError 401, which layouts redirect on), or the
|
|
* request was refused (ApiError with the backend's own message, which is
|
|
* customer-facing and should be shown verbatim).
|
|
*/
|
|
|
|
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
|
|
|
|
export class NotConnected extends Error {
|
|
constructor() {
|
|
super("not connected");
|
|
this.name = "NotConnected";
|
|
}
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|
if (!API_BASE) throw new NotConnected();
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${API_BASE}${path}`, {
|
|
...init,
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
|
});
|
|
} catch {
|
|
// Network-level failure, DNS, or a CORS preflight the browser refused.
|
|
throw new NotConnected();
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
|
|
const body = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
const post = <T,>(path: string, payload?: unknown) =>
|
|
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
|
|
|
|
const put = <T,>(path: string, payload?: unknown) =>
|
|
req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });
|
|
|
|
const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
|
|
|
|
// --- types ---------------------------------------------------------------
|
|
|
|
export type Deployment = "cloud" | "self_hosted";
|
|
export type Tier = "free" | "professional" | "enterprise";
|
|
export type Term = "monthly" | "annual";
|
|
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
|
|
|
|
/*
|
|
* Two role vocabularies, same three words. AccountRole governs the HQ account:
|
|
* who may invite, create instances and grant access. InstanceRole is the role a
|
|
* projected user holds INSIDE one instance. A person can be an account member
|
|
* and an instance owner at once — that is normal, not a mistake.
|
|
*/
|
|
export type AccountRole = "owner" | "admin" | "member";
|
|
export type InstanceRole = "owner" | "admin" | "member";
|
|
|
|
export interface Session {
|
|
kind: "staff" | "customer";
|
|
email: string;
|
|
account_id?: string;
|
|
account_role?: AccountRole;
|
|
}
|
|
|
|
export interface AccountUser {
|
|
user_id: string;
|
|
account_id: string;
|
|
email: string;
|
|
account_role: AccountRole;
|
|
verified_at?: string | null;
|
|
hq_sync_failed_at?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface InstanceMember {
|
|
member_id: string;
|
|
account_id: string;
|
|
instance_id: string;
|
|
customer_user_id: string;
|
|
control_user_id: string;
|
|
role: InstanceRole;
|
|
email: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Limits {
|
|
max_servers: number;
|
|
max_monitors: number;
|
|
max_secret_groups: number;
|
|
max_channels: number;
|
|
audit_retention_days: number;
|
|
}
|
|
|
|
export interface Account {
|
|
account_id: string;
|
|
name: string;
|
|
billing_email: string;
|
|
paddle_customer_id?: string;
|
|
status: "active" | "suspended";
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Instance {
|
|
instance_id: string;
|
|
account_id: string;
|
|
name: string;
|
|
slug?: string;
|
|
deployment: Deployment;
|
|
tier?: Tier;
|
|
status: InstanceStatus;
|
|
current_license?: string;
|
|
relink_count: number;
|
|
inject_failed_at?: string | null;
|
|
notices_sent?: string[];
|
|
created_at: string;
|
|
}
|
|
|
|
export interface License {
|
|
license_id: string;
|
|
instance_id: string;
|
|
account_id: string;
|
|
tier: Tier;
|
|
deployment: Deployment;
|
|
limits: Limits;
|
|
features: string[];
|
|
issued_at: string;
|
|
expires_at: string;
|
|
superseded_by?: string;
|
|
issued_by: string;
|
|
reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
|
|
}
|
|
|
|
export interface Subscription {
|
|
subscription_id: string;
|
|
account_id: string;
|
|
instance_id?: string;
|
|
tier: Tier;
|
|
term: string;
|
|
status: string;
|
|
current_period_end: string;
|
|
}
|
|
|
|
export interface Plan {
|
|
deployment: Deployment;
|
|
tier: Tier;
|
|
name: 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;
|
|
email: string;
|
|
verified_at?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AuditEntry {
|
|
actor: string;
|
|
action: string;
|
|
account_id?: string;
|
|
target?: string;
|
|
detail?: string;
|
|
ip?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AccountResponse {
|
|
account: Account;
|
|
instances: Instance[];
|
|
max_relinks: number;
|
|
}
|
|
|
|
export interface StaffAccountResponse {
|
|
account: Account;
|
|
instances: Instance[];
|
|
subscriptions: Subscription[];
|
|
users: CustomerUser[];
|
|
audit: AuditEntry[];
|
|
}
|
|
|
|
export type InjectionState = "current" | "stale" | "missing" | "none_issued";
|
|
|
|
export interface StaffInstanceResponse {
|
|
instance: Instance;
|
|
account: Account;
|
|
licenses: License[];
|
|
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
|
|
}
|
|
|
|
// --- calls ---------------------------------------------------------------
|
|
|
|
export const api = {
|
|
me: () => req<Session>("/auth/me"),
|
|
login: (email: string, password: string) => post<Session>("/auth/login", { email, password }),
|
|
staffLogin: (email: string, password: string) =>
|
|
post<Session>("/auth/staff/login", { email, password }),
|
|
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
|
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
|
post<{ pending: boolean }>("/auth/signup", payload),
|
|
verify: (token: string) =>
|
|
req<{ verified: boolean; needs_password?: boolean }>(
|
|
`/auth/verify?token=${encodeURIComponent(token)}`,
|
|
),
|
|
|
|
account: () => req<AccountResponse>("/api/account"),
|
|
link: (instance_id: string, name: string) =>
|
|
post<Instance>("/api/instances/link", { instance_id, name }),
|
|
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
|
|
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
|
|
relink: (id: string, instance_id: string) =>
|
|
post<License>(`/api/instances/${id}/relink`, { instance_id }),
|
|
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
|
|
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
|
|
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
|
|
|
|
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
|
invite: (email: string, role: AccountRole) =>
|
|
post<{ invited: boolean }>("/api/account/users", { email, role }),
|
|
setAccountRole: (userId: string, role: AccountRole) =>
|
|
put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
|
|
removeAccountUser: (userId: string) =>
|
|
del<{ deleted: boolean }>(`/api/account/users/${userId}`),
|
|
changePassword: (current_password: string, new_password: string) =>
|
|
put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
|
|
current_password,
|
|
new_password,
|
|
}),
|
|
acceptInvite: (token: string, password: string) =>
|
|
post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),
|
|
|
|
members: (instanceId: string) =>
|
|
req<InstanceMember[]>(`/api/instances/${instanceId}/members`),
|
|
grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
|
|
post<InstanceMember>(`/api/instances/${instanceId}/members`, { user_id, role }),
|
|
setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
|
|
put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
|
|
revokeMember: (instanceId: string, userId: string) =>
|
|
del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
|
|
|
|
staff: {
|
|
accounts: (q?: string) =>
|
|
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
|
account: (id: string) => req<StaffAccountResponse>(`/api/staff/accounts/${id}`),
|
|
instances: (params?: Record<string, string>) =>
|
|
req<Instance[]>(
|
|
`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`,
|
|
),
|
|
instance: (id: string) => req<StaffInstanceResponse>(`/api/staff/instances/${id}`),
|
|
issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
|
|
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
|
relink: (id: string, instance_id: string) =>
|
|
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
|
licenses: (params?: Record<string, string>) =>
|
|
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
|
plans: () => req<Plan[]>("/api/staff/plans"),
|
|
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: () =>
|
|
req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
|
|
subscriptions: (status?: string) =>
|
|
req<Subscription[]>(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
|
|
},
|
|
};
|