const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_LOGIN_RETRY_ATTEMPTS = 2; const DEFAULT_LOGIN_RETRY_DELAY_MS = 250; const NON_RETRYABLE_USER_BILLING_ERROR_CODES = new Set(["user_billing_not_configured"]); export function createUserBillingClient({ env = process.env, fetchImpl = fetch } = {}) { const baseUrl = normalizeBaseUrl(firstText( env.HWLAB_USER_BILLING_URL, env.HWLAB_USER_BILLING_BASE_URL, env.HWLAB_USER_BILLING_INTERNAL_URL )); const internalToken = text(env.HWLAB_USER_BILLING_INTERNAL_TOKEN); const timeoutMs = positiveInteger(env.HWLAB_USER_BILLING_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); const loginRetryAttempts = nonNegativeInteger(env.HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS, DEFAULT_LOGIN_RETRY_ATTEMPTS); const loginRetryDelayMs = positiveInteger(env.HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS, DEFAULT_LOGIN_RETRY_DELAY_MS); async function requestJson(path, { method = "GET", body = null, bearerToken = "", traceparent = "" } = {}) { return requestJsonWithRetry(path, { method, body, bearerToken, traceparent }); } async function requestJsonWithRetry(path, { method = "GET", body = null, bearerToken = "", traceparent = "", retry = null } = {}) { const retryAttempts = nonNegativeInteger(retry?.attempts, 0); const retryDelayMs = positiveInteger(retry?.delayMs, 0); let retryCount = 0; let transientObserved = false; for (;;) { const result = await requestJsonOnce(path, { method, body, bearerToken, traceparent }); const retryable = typeof retry?.shouldRetry === "function" ? retry.shouldRetry(result) : false; if (!retryable || retryCount >= retryAttempts) { if (retryCount > 0 || transientObserved || retryable) { return { ...result, retryable, retryCount, transientObserved: transientObserved || retryable, valuesRedacted: true }; } return result; } retryCount += 1; transientObserved = true; if (retryDelayMs > 0) await sleep(retryDelayMs * retryCount); } } async function requestJsonOnce(path, { method = "GET", body = null, bearerToken = "", traceparent = "" } = {}) { if (!baseUrl) { return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } }; } const headers = { accept: "application/json" }; if (body !== null) headers["content-type"] = "application/json"; const token = text(bearerToken); if (token) headers.authorization = `Bearer ${token}`; const normalizedTraceparent = normalizeTraceparent(traceparent); if (normalizedTraceparent) headers.traceparent = normalizedTraceparent; if (internalToken) headers["x-hwlab-internal-token"] = internalToken; try { const init = { method, headers, signal: AbortSignal.timeout(timeoutMs), ...(body !== null ? { body: JSON.stringify(body) } : {}) }; const response = await fetchImpl(`${baseUrl}${path}`, { ...init }); const raw = await response.text(); const payload = parseJson(raw, null); if (!response.ok) { return { ok: false, status: response.status, body: payload, error: normalizeUserBillingError(payload, response.status) }; } return { ok: true, status: response.status, body: payload ?? {} }; } catch (error) { return { ok: false, status: 503, error: { code: "user_billing_unreachable", message: error instanceof Error ? error.message : String(error) } }; } } async function requestText(path, { method = "GET" } = {}) { if (!baseUrl) { return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } }; } const headers = { accept: "text/csv" }; if (internalToken) headers["x-hwlab-internal-token"] = internalToken; try { const response = await fetchImpl(`${baseUrl}${path}`, { method, headers, signal: AbortSignal.timeout(timeoutMs) }); const body = await response.text(); if (!response.ok) { return { ok: false, status: response.status, body, error: { code: "user_billing_request_failed", message: `user-billing request failed with HTTP ${response.status}` } }; } return { ok: true, status: response.status, body, contentType: response.headers?.get?.("content-type") ?? "text/csv" }; } catch (error) { return { ok: false, status: 503, error: { code: "user_billing_unreachable", message: error instanceof Error ? error.message : String(error) } }; } } async function post(path, body = {}) { return requestJson(path, { method: "POST", body }); } async function publicPost(path, body = {}) { return requestJson(path, { method: "POST", body }); } async function patch(path, body = {}, options = {}) { return requestJson(path, { method: "PATCH", body, ...options }); } async function deleteRequest(path, options = {}) { return requestJson(path, { method: "DELETE", body: {}, ...options }); } return { configured: Boolean(baseUrl), baseUrl, async register(body = {}) { return publicPost("/v1/auth/register", body); }, async login(body = {}, options = {}) { return requestJsonWithRetry("/v1/auth/login", { method: "POST", body, traceparent: options.traceparent, retry: { attempts: loginRetryAttempts, delayMs: loginRetryDelayMs, shouldRetry: isRetryableUserBillingResult } }); }, async logout(token) { return requestJson("/v1/auth/logout", { method: "POST", body: {}, bearerToken: token }); }, async introspect(token) { return post("/internal/auth/introspect", { token }); }, async billingPreflight(body) { return post("/internal/billing/preflight", body); }, async billingRecord(body) { return post("/internal/billing/record", body); }, async billingRelease(body) { return post("/internal/billing/release", body); }, async billingSummary(token, { limit = 20 } = {}) { const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token }); }, async redeem(token, body = {}) { return requestJson("/v1/redeem", { method: "POST", body, bearerToken: token }); }, async subscriptionSummary(token) { return requestJson("/v1/subscription/summary", { method: "GET", bearerToken: token }); }, async paymentSummary(token) { return requestJson("/v1/payments/summary", { method: "GET", bearerToken: token }); }, async updateProfile(token, body = {}) { return patch("/v1/me/profile", body, { bearerToken: token }); }, async changePassword(token, body = {}) { return requestJson("/v1/me/password", { method: "POST", body, bearerToken: token }); }, async listApiKeys(token) { return requestJson("/v1/api-keys", { method: "GET", bearerToken: token }); }, async createApiKey(token, body = {}) { return requestJson("/v1/api-keys", { method: "POST", body, bearerToken: token }); }, async updateApiKey(token, keyId, body = {}) { return patch(`/v1/api-keys/${encodeURIComponent(text(keyId))}`, body, { bearerToken: token }); }, async revokeApiKey(token, keyId) { return deleteRequest(`/v1/api-keys/${encodeURIComponent(text(keyId))}`, { bearerToken: token }); }, async adminBillingSummary({ limit = 50 } = {}) { const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" }); }, async adminBillingPlans() { return requestJson("/internal/admin/billing/plans", { method: "GET" }); }, async adminUsage(params = {}) { return requestJson(adminAuditPath("/internal/admin/usage", params), { method: "GET" }); }, async adminUsageExport(params = {}) { return requestText(adminAuditPath("/internal/admin/usage/export", params), { method: "GET" }); }, async adminLedger(params = {}) { return requestJson(adminAuditPath("/internal/admin/ledger", params), { method: "GET" }); }, async adminLedgerExport(params = {}) { return requestText(adminAuditPath("/internal/admin/ledger/export", params), { method: "GET" }); }, async adminRedeemCodes(params = {}) { return requestJson(adminRedeemPath("/internal/admin/redeem-codes", params), { method: "GET" }); }, async createAdminRedeemCode(body = {}) { return post("/internal/admin/redeem-codes", body); }, async updateAdminRedeemCode(codeId, body = {}) { return patch(`/internal/admin/redeem-codes/${encodeURIComponent(text(codeId))}`, body); }, async adminRedeemRedemptions(params = {}) { return requestJson(adminRedeemPath("/internal/admin/redeem-redemptions", params), { method: "GET" }); }, async adminUsers({ page = 1, pageSize = 50, search = "", status = "", role = "" } = {}) { const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (text(search)) query.set("search", text(search)); if (text(status)) query.set("status", text(status)); if (text(role)) query.set("role", text(role)); return requestJson(`/internal/admin/users?${query.toString()}`, { method: "GET" }); }, async adminUser(userId) { return requestJson(`/internal/admin/users/${encodeURIComponent(text(userId))}`, { method: "GET" }); }, async createAdminUser(body = {}) { return post("/internal/admin/users", body); }, async updateAdminUser(userId, body = {}) { return patch(`/internal/admin/users/${encodeURIComponent(text(userId))}`, body); }, async adjustAdminCredits(body = {}) { return post("/internal/admin/credits/adjust", body); }, async updateAdminUserStatus(userId, status) { return post("/internal/admin/users/status", { userId, status }); } }; } function adminAuditPath(path, params = {}) { const query = new URLSearchParams(); for (const key of ["page", "pageSize", "limit", "userId", "search", "serviceId", "resourceType", "apiKeyId", "status", "kind", "source", "idempotencyKey", "from", "to"]) { const value = text(params?.[key]); if (value) query.set(key, value); } const suffix = query.toString(); return suffix ? `${path}?${suffix}` : path; } function adminRedeemPath(path, params = {}) { const query = new URLSearchParams(); for (const key of ["limit", "status", "userId", "codeId"]) { const value = text(params?.[key]); if (value) query.set(key, value); } const suffix = query.toString(); return suffix ? `${path}?${suffix}` : path; } function normalizeBaseUrl(value) { const input = text(value); if (!input) return ""; try { const url = new URL(input); url.pathname = url.pathname.replace(/\/+$/u, ""); url.search = ""; url.hash = ""; return url.toString().replace(/\/+$/u, ""); } catch { return ""; } } function normalizeUserBillingError(payload, status) { const error = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.error : null; return { code: text(error?.code) || (status === 402 ? "insufficient_credits" : "user_billing_request_failed"), message: text(error?.message) || `user-billing request failed with HTTP ${status}` }; } function positiveInteger(value, fallback) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function nonNegativeInteger(value, fallback) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } function isRetryableUserBillingResult(result) { if (!result || result.ok) return false; const code = text(result.error?.code); if (NON_RETRYABLE_USER_BILLING_ERROR_CODES.has(code)) return false; if (code === "user_billing_unreachable") return true; const status = Number(result.status ?? 0); if ([500, 502, 503, 504].includes(status)) return true; return false; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))); } function firstText(...values) { for (const value of values) { const current = text(value); if (current) return current; } return ""; } function text(value) { return String(value ?? "").trim(); } function normalizeTraceparent(value) { const textValue = text(value).toLowerCase(); return /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/u.test(textValue) ? textValue : ""; } function parseJson(value, fallback) { if (!value) return fallback; try { return JSON.parse(String(value)); } catch { return fallback; } }