/* * 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(path: string, init?: RequestInit): Promise { 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 = (path: string, payload?: unknown) => req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined }); const put = (path: string, payload?: unknown) => req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined }); const del = (path: string) => req(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>>; } 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("/auth/me"), login: (email: string, password: string) => post("/auth/login", { email, password }), staffLogin: (email: string, password: string) => post("/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("/api/account"), link: (instance_id: string, name: string) => post("/api/instances/link", { instance_id, name }), createInstance: (name: string) => post("/api/instances", { name }), renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}), relink: (id: string, instance_id: string) => post(`/api/instances/${id}/relink`, { instance_id }), license: (id: string) => req(`/api/instances/${id}/license`), licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`, subscriptions: () => req("/api/subscriptions"), entitlement: (id: string) => req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`), checkoutOptions: () => req("/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("/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(`/api/instances/${instanceId}/members`), grantMember: (instanceId: string, user_id: string, role: InstanceRole) => post(`/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(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`), account: (id: string) => req(`/api/staff/accounts/${id}`), instances: (params?: Record) => req( `/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`, ), instance: (id: string) => req(`/api/staff/instances/${id}`), issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) => post(`/api/staff/instances/${id}/issue`, payload), relink: (id: string, instance_id: string) => post(`/api/staff/instances/${id}/relink`, { instance_id }), licenses: (params?: Record) => req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`), plans: () => req("/api/staff/plans"), updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) => put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan), catalogue: () => req("/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(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`), injectionHealth: () => req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"), subscriptions: (status?: string) => req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`), }, };