Purchase flow: name -> placeholder -> configure via the shipped PlanConfigurator -> Paddle overlay with custom_data -> paste install UUID to link and issue. lineItemsFor mirrors the Go catalogue.LineItems/billable exactly (base included in exactly one place). ManageBillingButton opens the hosted portal. Paddle token and env are baked into the build, never fetched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
14 KiB
TypeScript
394 lines
14 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 CheckoutOptions {
|
|
plans: Plan[];
|
|
catalogue: CatalogueRow[];
|
|
env: "sandbox" | "production";
|
|
}
|
|
|
|
/*
|
|
* lineItemsFor builds the Paddle checkout items for a configuration, client-side
|
|
* from the catalogue already fetched. It mirrors the Go catalogue.LineItems and
|
|
* its billable() exactly: base is quantity 1; the per-server unit's quantity is
|
|
* servers MINUS the plan's base allowance (never charge for the base — the one
|
|
* subtraction, kept here to match the server); a feature contributes an item
|
|
* only when its row has a price in this environment/term.
|
|
*/
|
|
export function lineItemsFor(
|
|
opts: CheckoutOptions,
|
|
choice: { tier: Tier; term: Term; servers: number; features: string[] },
|
|
deployment: Deployment,
|
|
): { priceId: string; quantity: number }[] {
|
|
const env = opts.env;
|
|
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
|
|
if (!plan) return [];
|
|
const rows = opts.catalogue.filter(
|
|
(r) => r.deployment === deployment && r.tier === choice.tier,
|
|
);
|
|
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
|
|
const base = plan.base_limits.max_servers;
|
|
const items: { priceId: string; quantity: number }[] = [];
|
|
for (const r of rows) {
|
|
const id = priceOf(r);
|
|
if (r.kind === "base") {
|
|
if (id) items.push({ priceId: id, quantity: 1 });
|
|
} else if (r.kind === "limit" && r.limit_key === "max_servers") {
|
|
// -1 base is unlimited: nothing metered. Otherwise charge servers over base.
|
|
const qty = base === -1 ? 0 : choice.servers - base;
|
|
if (qty > 0 && id) items.push({ priceId: id, quantity: qty });
|
|
} else if (r.kind === "feature" && r.feature_key) {
|
|
if (choice.features.includes(r.feature_key) && id) {
|
|
items.push({ priceId: id, quantity: 1 });
|
|
}
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
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"),
|
|
|
|
entitlement: (id: string) =>
|
|
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
|
|
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
|
createSelfHosted: (name: string) =>
|
|
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
|
updateEntitlement: (
|
|
id: string,
|
|
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
|
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
|
|
claimLink: (placeholderId: string, instance_id: string) =>
|
|
post<{ instance_id: string; warning?: string }>(
|
|
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
|
|
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
|
|
|
|
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}` : ""}`),
|
|
},
|
|
};
|