From b9dd25ef683dfb4158b345a822d5734cfa161445 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:59:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=20v03=20user-billing?= =?UTF-8?q?=20R1=20parity=20=E5=9F=BA=E7=A1=80=20(#1179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/server.ts | 218 ++++++ internal/cloud/user-billing-client.ts | 45 ++ .../cloud/user-billing-integration.test.ts | 173 +++++ internal/userbilling/service.go | 733 +++++++++++++++++- 4 files changed, 1167 insertions(+), 2 deletions(-) diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 9a9441a1..f487b38d 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -324,6 +324,23 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) { + await handleAdminBillingUsersHttp(request, response, url, options); + return; + } + + const adminBillingUserMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)$/u); + if (adminBillingUserMatch && (request.method === "GET" || request.method === "PATCH")) { + await handleAdminBillingUserHttp(request, response, url, decodeURIComponent(adminBillingUserMatch[1]), options); + return; + } + + const adminBillingUserCreditsMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)\/credits\/adjust$/u); + if (adminBillingUserCreditsMatch && request.method === "POST") { + await handleAdminBillingCreditAdjustHttp(request, response, decodeURIComponent(adminBillingUserCreditsMatch[1]), options); + return; + } + const adminBillingUserStatusMatch = url.pathname.match(/^\/v1\/admin\/billing\/users\/([^/]+)\/status$/u); if (adminBillingUserStatusMatch && request.method === "PATCH") { await handleAdminBillingUserStatusHttp(request, response, decodeURIComponent(adminBillingUserStatusMatch[1]), options); @@ -382,7 +399,18 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/users/me/profile" && request.method === "PATCH") { + await handleUserBillingProfileHttp(request, response, options); + return; + } + + if (url.pathname === "/v1/users/me/password" && request.method === "POST") { + await handleUserBillingPasswordHttp(request, response, options); + return; + } + if (url.pathname === "/v1/api-keys" || url.pathname === "/v1/api-keys/default" || url.pathname.startsWith("/v1/api-keys/")) { + if (await maybeHandleUserBillingApiKeysHttp(request, response, url, options)) return; await options.accessController.handleUserRoute(request, response, url); return; } @@ -1039,6 +1067,96 @@ async function handleAdminBillingSummaryHttp(request, response, url, options) { }); } +async function requireAdminBillingClient(request, response, options, operation) { + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return null; + } + if (auth.actor?.role !== "admin") { + sendRestError(request, response, 403, "admin_required", "Only admin users can call user billing admin APIs", { + operation, + target: { type: "route", id: operation }, + result: "rejected" + }); + return null; + } + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!client?.configured) { + sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin API is not configured", { + operation, + target: { type: "service", id: "hwlab-user-billing" } + }); + return null; + } + return { auth, client }; +} + +async function handleAdminBillingUsersHttp(request, response, url, options) { + const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users`); + if (!context) return; + const { auth, client } = context; + let result; + if (request.method === "GET") { + if (typeof client.adminUsers !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin users API is not configured", { operation: "GET /v1/admin/billing/users", target: { type: "service", id: "hwlab-user-billing" } }); + } + result = await client.adminUsers({ + page: parsePositiveInteger(url.searchParams.get("page"), 1), + pageSize: Math.min(parsePositiveInteger(url.searchParams.get("pageSize") || url.searchParams.get("limit"), 50), 100), + search: url.searchParams.get("search") ?? "", + status: url.searchParams.get("status") ?? "", + role: url.searchParams.get("role") ?? "" + }); + } else { + if (typeof client.createAdminUser !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user create API is not configured", { operation: "POST /v1/admin/billing/users", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + result = await client.createAdminUser(parsed.value); + } + sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users`, route: "/v1/admin/billing/users", actor: adminActorSummary(auth.actor) }); +} + +async function handleAdminBillingUserHttp(request, response, url, userId, options) { + const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users/{userId}`); + if (!context) return; + const { auth, client } = context; + let result; + if (request.method === "GET") { + if (typeof client.adminUser !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user detail API is not configured", { operation: "GET /v1/admin/billing/users/{userId}", target: { type: "service", id: "hwlab-user-billing" } }); + } + result = await client.adminUser(userId); + } else { + if (typeof client.updateAdminUser !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin user update API is not configured", { operation: "PATCH /v1/admin/billing/users/{userId}", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + result = await client.updateAdminUser(userId, parsed.value); + } + sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users/{userId}`, route: "/v1/admin/billing/users/{userId}", actor: adminActorSummary(auth.actor) }); +} + +async function handleAdminBillingCreditAdjustHttp(request, response, userId, options) { + const context = await requireAdminBillingClient(request, response, options, "POST /v1/admin/billing/users/{userId}/credits/adjust"); + if (!context) return; + const { auth, client } = context; + if (typeof client.adjustAdminCredits !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin credit adjust API is not configured", { operation: "POST /v1/admin/billing/users/{userId}/credits/adjust", target: { type: "service", id: "hwlab-user-billing" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const result = await client.adjustAdminCredits({ + ...parsed.value, + userId, + metadata: { ...(parsed.value?.metadata && typeof parsed.value.metadata === "object" && !Array.isArray(parsed.value.metadata) ? parsed.value.metadata : {}), operator: adminActorSummary(auth.actor) } + }); + sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/admin/billing/users/{userId}/credits/adjust", route: "/v1/admin/billing/users/{userId}/credits/adjust", actor: adminActorSummary(auth.actor) }); +} + async function handleAdminBillingUserStatusHttp(request, response, userId, options) { const auth = await options.accessController.authenticate(request, { required: true }); if (!auth.ok) { @@ -1098,6 +1216,106 @@ async function handleAdminBillingUserStatusHttp(request, response, userId, optio }); } +async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) { + const token = bearerTokenFromRequest(request); + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!token || !client?.configured) return false; + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok || !String(auth.authMethod ?? "").startsWith("user-billing")) return false; + if (url.pathname === "/v1/api-keys/default") { + sendRestError(request, response, 404, "user_billing_default_key_unsupported", "user-billing API keys do not support legacy default-key regeneration", { + operation: `${request.method} /v1/api-keys/default`, + target: { type: "route", id: "/v1/api-keys/default" }, + result: "rejected" + }); + return true; + } + let result = null; + const path = url.pathname; + if (request.method === "GET" && path === "/v1/api-keys" && typeof client.listApiKeys === "function") { + result = await client.listApiKeys(token); + } else if (request.method === "POST" && path === "/v1/api-keys" && typeof client.createApiKey === "function") { + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) { + sendJson(response, 400, parsed.error); + return true; + } + result = await client.createApiKey(token, parsed.value); + } else { + const match = path.match(/^\/v1\/api-keys\/([^/]+)$/u); + if (match && request.method === "PATCH" && typeof client.updateApiKey === "function") { + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) { + sendJson(response, 400, parsed.error); + return true; + } + result = await client.updateApiKey(token, decodeURIComponent(match[1]), parsed.value); + } else if (match && request.method === "DELETE" && typeof client.revokeApiKey === "function") { + result = await client.revokeApiKey(token, decodeURIComponent(match[1])); + } + } + if (!result) return false; + sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/api-keys`, route: path, actor: adminActorSummary(auth.actor) }); + return true; +} + +async function handleUserBillingProfileHttp(request, response, options) { + const token = bearerTokenFromRequest(request); + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!token || !client?.configured || typeof client.updateProfile !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing profile API is not configured", { operation: "PATCH /v1/users/me/profile", target: { type: "service", id: "hwlab-user-billing" } }); + } + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!String(auth.authMethod ?? "").startsWith("user-billing")) { + return sendRestError(request, response, 403, "user_billing_auth_required", "profile update requires a user-billing bearer token", { operation: "PATCH /v1/users/me/profile", target: { type: "route", id: "/v1/users/me/profile" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const result = await client.updateProfile(token, parsed.value); + sendUserBillingProxyResult({ request, response, result, operation: "PATCH /v1/users/me/profile", route: "/v1/users/me/profile", actor: adminActorSummary(auth.actor) }); +} + +async function handleUserBillingPasswordHttp(request, response, options) { + const token = bearerTokenFromRequest(request); + const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; + if (!token || !client?.configured || typeof client.changePassword !== "function") { + return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing password API is not configured", { operation: "POST /v1/users/me/password", target: { type: "service", id: "hwlab-user-billing" } }); + } + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (auth.authMethod !== "user-billing-session") { + return sendRestError(request, response, 403, "user_billing_session_required", "password update requires a user-billing session token", { operation: "POST /v1/users/me/password", target: { type: "route", id: "/v1/users/me/password" } }); + } + const parsed = await readJsonObject(request, DEFAULT_BODY_LIMIT_BYTES); + if (!parsed.ok) return sendJson(response, 400, parsed.error); + const result = await client.changePassword(token, parsed.value); + sendUserBillingProxyResult({ request, response, result, operation: "POST /v1/users/me/password", route: "/v1/users/me/password", actor: adminActorSummary(auth.actor) }); +} + +function sendUserBillingProxyResult({ request, response, result, operation, route, actor = null }) { + if (!result?.ok) { + sendRestError(request, response, result?.status ?? 502, result?.error?.code ?? "user_billing_request_failed", result?.error?.message ?? "user-billing request failed", { + operation, + target: { type: "service", id: "hwlab-user-billing" }, + reason: result?.error?.code, + result: "failed" + }); + return; + } + sendJson(response, result.status ?? 200, { + ...(result.body ?? {}), + actor, + proxy: { + serviceId: CLOUD_API_SERVICE_ID, + route, + source: "hwlab-user-billing", + valuesRedacted: true + }, + valuesRedacted: true + }); +} + function adminActorSummary(actor) { return actor ? { id: String(actor.id ?? ""), diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index 9a6f73b3..ad315f76 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -57,6 +57,14 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } 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, @@ -73,10 +81,47 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch } const query = new URLSearchParams({ limit: String(limit) }); return requestJson(`/v1/billing/summary?${query.toString()}`, { 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 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 }); } diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts index 904363a2..c8ff606e 100644 --- a/internal/cloud/user-billing-integration.test.ts +++ b/internal/cloud/user-billing-integration.test.ts @@ -344,3 +344,176 @@ test("cloud api proxies admin user status updates for web admins", async () => { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); + +test("cloud api proxies R1 admin user management and credit adjustment to user-billing", async () => { + const calls = []; + const userBillingClient = { + configured: true, + async adminUsers(options = {}) { + calls.push({ op: "adminUsers", options }); + assert.equal(options.page, 2); + assert.equal(options.pageSize, 10); + assert.equal(options.search, "billing"); + assert.equal(options.status, "active"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-users-v1", users: [], count: 0, total: 0, pagination: { page: 2, pageSize: 10 } } }; + }, + async adminUser(userId) { + calls.push({ op: "adminUser", userId }); + assert.equal(userId, "usr_billing_admin_subject"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-user-v1", detail: { summary: { user: { id: userId, username: "billing-subject" } } } } }; + }, + async createAdminUser(body = {}) { + calls.push({ op: "createAdminUser", body }); + assert.equal(body.email, "new@hwlab.local"); + return { ok: true, status: 201, body: { contractVersion: "user-billing-admin-user-v1", created: true, user: { id: "usr_created", email: body.email } } }; + }, + async updateAdminUser(userId, body = {}) { + calls.push({ op: "updateAdminUser", userId, body }); + assert.equal(userId, "usr_billing_admin_subject"); + assert.equal(body.displayName, "Updated Subject"); + return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-user-v1", updated: true, user: { id: userId, displayName: body.displayName } } }; + }, + async adjustAdminCredits(body = {}) { + calls.push({ op: "adjustAdminCredits", body }); + assert.equal(body.userId, "usr_billing_admin_subject"); + assert.equal(body.deltaCredits, 25); + assert.equal(body.metadata.operator.username, "admin"); + return { ok: true, status: 200, body: { userId: body.userId, balance: 124 } }; + } + }; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127" + }, + userBillingClient + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "admin-password-1127" }) + }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + const adminHeaders = { "content-type": "application/json", cookie }; + + const list = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users?page=2&pageSize=10&search=billing&status=active`, { headers: { cookie } }); + assert.equal(list.status, 200); + assert.equal((await list.json()).proxy.source, "hwlab-user-billing"); + + const detail = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject`, { headers: { cookie } }); + assert.equal(detail.status, 200); + assert.equal((await detail.json()).detail.summary.user.id, "usr_billing_admin_subject"); + + const created = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users`, { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ email: "new@hwlab.local", username: "new-user", password: "new-password-1127" }) + }); + assert.equal(created.status, 201); + + const updated = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject`, { + method: "PATCH", + headers: adminHeaders, + body: JSON.stringify({ displayName: "Updated Subject" }) + }); + assert.equal(updated.status, 200); + + const adjusted = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject/credits/adjust`, { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ deltaCredits: 25, reason: "manual top-up" }) + }); + assert.equal(adjusted.status, 200); + assert.equal((await adjusted.json()).balance, 124); + assert.equal(JSON.stringify(calls).includes("admin-password-1127"), false); + assert.deepEqual(calls.map((call) => call.op), ["adminUsers", "adminUser", "createAdminUser", "updateAdminUser", "adjustAdminCredits"]); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + +test("cloud api proxies R1 user-billing API key and profile self-service routes", async () => { + const calls = []; + const bearer = "hwl_user_billing_session_secret"; + const userBillingClient = { + configured: true, + async introspect(token) { + calls.push({ op: "introspect", tokenPrefix: token.slice(0, 8) }); + assert.equal(token, bearer); + return { + ok: true, + status: 200, + body: { + active: true, + principal: { + userId: "usr_self_service", + email: "self@hwlab.local", + username: "self-service", + role: "user", + scopes: ["session"], + authType: "session" + } + } + }; + }, + async listApiKeys(token) { + calls.push({ op: "listApiKeys", tokenPrefix: token.slice(0, 8) }); + return { ok: true, status: 200, body: { contractVersion: "user-api-key-v1", keys: [{ id: "key_self", prefix: "hwl_self", status: "active" }], count: 1 } }; + }, + async createApiKey(token, body = {}) { + calls.push({ op: "createApiKey", tokenPrefix: token.slice(0, 8), body }); + assert.equal(body.name, "Self key"); + return { ok: true, status: 201, body: { contractVersion: "user-api-key-v1", apiKey: { id: "key_created", prefix: "hwl_created" }, secret: "hwl_created_secret", created: true } }; + }, + async updateApiKey(token, keyId, body = {}) { + calls.push({ op: "updateApiKey", tokenPrefix: token.slice(0, 8), keyId, body }); + assert.equal(keyId, "key_created"); + assert.equal(body.name, "Renamed key"); + return { ok: true, status: 200, body: { contractVersion: "user-api-key-v1", key: { id: keyId, name: body.name }, updated: true } }; + }, + async revokeApiKey(token, keyId) { + calls.push({ op: "revokeApiKey", tokenPrefix: token.slice(0, 8), keyId }); + assert.equal(keyId, "key_created"); + return { ok: true, status: 200, body: { contractVersion: "user-api-key-v1", key: { id: keyId, status: "revoked" }, revoked: true } }; + }, + async updateProfile(token, body = {}) { + calls.push({ op: "updateProfile", tokenPrefix: token.slice(0, 8), body }); + assert.equal(body.displayName, "Self Service"); + assert.equal("username" in body, false); + return { ok: true, status: 200, body: { contractVersion: "user-profile-v1", updated: true, user: { id: "usr_self_service", displayName: body.displayName } } }; + }, + async changePassword(token, body = {}) { + calls.push({ op: "changePassword", tokenPrefix: token.slice(0, 8), bodyKeys: Object.keys(body).sort() }); + assert.equal(body.currentPassword, "old-password-1127"); + return { ok: true, status: 200, body: { contractVersion: "user-profile-v1", changed: true } }; + } + }; + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, userBillingClient }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const headers = { authorization: `Bearer ${bearer}`, "content-type": "application/json" }; + const list = await fetch(`http://127.0.0.1:${port}/v1/api-keys`, { headers }); + assert.equal(list.status, 200); + assert.equal((await list.json()).proxy.source, "hwlab-user-billing"); + const create = await fetch(`http://127.0.0.1:${port}/v1/api-keys`, { method: "POST", headers, body: JSON.stringify({ name: "Self key" }) }); + assert.equal(create.status, 201); + const update = await fetch(`http://127.0.0.1:${port}/v1/api-keys/key_created`, { method: "PATCH", headers, body: JSON.stringify({ name: "Renamed key" }) }); + assert.equal(update.status, 200); + const revoke = await fetch(`http://127.0.0.1:${port}/v1/api-keys/key_created`, { method: "DELETE", headers, body: JSON.stringify({}) }); + assert.equal(revoke.status, 200); + const profile = await fetch(`http://127.0.0.1:${port}/v1/users/me/profile`, { method: "PATCH", headers, body: JSON.stringify({ displayName: "Self Service" }) }); + assert.equal(profile.status, 200); + const password = await fetch(`http://127.0.0.1:${port}/v1/users/me/password`, { method: "POST", headers, body: JSON.stringify({ currentPassword: "old-password-1127", newPassword: "new-password-1127" }) }); + assert.equal(password.status, 200); + assert.equal(JSON.stringify(calls).includes(bearer), false); + assert.deepEqual(calls.map((call) => call.op), ["introspect", "listApiKeys", "introspect", "createApiKey", "introspect", "updateApiKey", "introspect", "revokeApiKey", "introspect", "updateProfile", "introspect", "changePassword"]); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index 906da2b3..01ec3997 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -133,6 +133,33 @@ type apiKeyAggregate struct { LastUsedAt *time.Time `json:"lastUsedAt"` } +type apiKeySummary struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt"` + RevokedAt *time.Time `json:"revokedAt"` +} + +type adminUsersQuery struct { + Page int + PageSize int + Search string + Status string + Role string +} + +type adminUserDetail struct { + Summary adminBillingUserSummary `json:"summary"` + Ledger []ledgerSummaryRow `json:"ledger"` + APIKeys []apiKeySummary `json:"apiKeys"` + UsageBy []serviceUsageSummary `json:"usageByService"` + Reservations []reservationSummaryRow `json:"reservations"` +} + type reservationAdminSummary struct { ActiveCount int64 `json:"activeCount"` Active []reservationSummaryRow `json:"active"` @@ -249,13 +276,18 @@ func (s *Server) routes() { s.route(http.MethodPost, "/v1/auth/logout", s.handleLogout) s.route(http.MethodPost, "/v1/auth/refresh", s.handleRefresh) s.route(http.MethodGet, "/v1/me", s.handleMe) + s.route(http.MethodPatch, "/v1/me/profile", s.handleUpdateProfile) + s.route(http.MethodPost, "/v1/me/password", s.handleChangePassword) s.route(http.MethodGet, "/v1/billing/summary", s.handleBillingSummary) - s.route(http.MethodPost, "/v1/api-keys", s.handleCreateAPIKey) + s.mux.HandleFunc("/v1/api-keys", s.handleAPIKeys) + s.mux.HandleFunc("/v1/api-keys/", s.handleAPIKeyByID) s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect) s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight) s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord) s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary) s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust) + s.mux.HandleFunc("/internal/admin/users", s.handleAdminUsers) + s.mux.HandleFunc("/internal/admin/users/", s.handleAdminUserByID) s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus) } @@ -497,6 +529,100 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"principal": principal, "credits": map[string]any{"balance": balance, "reserved": reserved, "available": balance - reserved}}) } +func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req struct { + Username *string `json:"username"` + DisplayName *string `json:"displayName"` + } + if !decodeJSON(w, r, &req) { + return + } + updates := []string{} + args := []any{principal.UserID} + if req.Username != nil { + username := normalizeUsername(*req.Username) + if strings.TrimSpace(*req.Username) != "" && username == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_username", "username must be 3-64 characters and contain only lowercase letters, digits, '-' or '_'") + return + } + if username != "" && username != principal.Username { + args = append(args, username) + updates = append(updates, "username = $"+strconv.Itoa(len(args))) + } + } + if req.DisplayName != nil { + args = append(args, strings.TrimSpace(*req.DisplayName)) + updates = append(updates, "display_name = $"+strconv.Itoa(len(args))) + } + if len(updates) == 0 { + writeAPIError(w, http.StatusBadRequest, "no_profile_fields", "at least one profile field must be provided") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + var user User + query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at` + err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt) + if err != nil { + if isDuplicate(err) { + writeAPIError(w, http.StatusConflict, "username_exists", "username already exists") + return + } + writeAPIError(w, http.StatusInternalServerError, "profile_update_failed", "could not update profile") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-profile-v1", "updated": true, "user": user, "valuesRedacted": true}) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + if principal.AuthType != "session" { + writeAPIError(w, http.StatusForbidden, "session_required", "only session-authenticated users can change password") + return + } + var req struct { + CurrentPassword string `json:"currentPassword"` + Password string `json:"password"` + NewPassword string `json:"newPassword"` + } + if !decodeJSON(w, r, &req) { + return + } + newPassword := first(req.NewPassword, req.Password) + if req.CurrentPassword == "" || len(newPassword) < 10 { + writeAPIError(w, http.StatusBadRequest, "invalid_password_change", "currentPassword and a new password with at least 10 characters are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + var currentHash string + if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM hwlab_users WHERE id = $1 AND status = 'active'`, principal.UserID).Scan(¤tHash); err != nil { + writeAPIError(w, http.StatusUnauthorized, "invalid_subject", "active user was not found") + return + } + if !verifyPassword(currentHash, req.CurrentPassword) { + writeAPIError(w, http.StatusUnauthorized, "invalid_current_password", "current password is invalid") + return + } + passwordHash, err := hashPassword(newPassword) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password") + return + } + if _, err := s.db.ExecContext(ctx, `UPDATE hwlab_users SET password_hash = $2, updated_at = now() WHERE id = $1`, principal.UserID, passwordHash); err != nil { + writeAPIError(w, http.StatusInternalServerError, "password_update_failed", "could not update password") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-profile-v1", "changed": true, "valuesRedacted": true}) +} + func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) { principal, ok := s.requirePrincipal(w, r) if !ok { @@ -572,6 +698,18 @@ func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAPIKeys(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleListAPIKeys(w, r) + case http.MethodPost: + s.handleCreateAPIKey(w, r) + default: + w.Header().Set("Allow", "GET, POST") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { principal, ok := s.requirePrincipal(w, r) if !ok { @@ -600,7 +738,112 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { writeAPIError(w, http.StatusInternalServerError, "api_key_create_failed", "could not create API key") return } - writeJSON(w, http.StatusCreated, map[string]any{"id": keyID, "key": key, "prefix": prefix, "scopes": scopes}) + summary := apiKeySummary{ID: keyID, Name: name, Prefix: prefix, Scopes: scopes, Status: "active", CreatedAt: time.Now().UTC()} + writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-api-key-v1", "id": keyID, "key": key, "secret": key, "apiKey": summary, "prefix": prefix, "scopes": scopes, "created": true, "valuesRedacted": true}) +} + +func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + keys, err := s.apiKeysForUser(ctx, principal.UserID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "api_key_list_failed", "could not list API keys") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "keys": keys, "count": len(keys), "valuesRedacted": true}) +} + +func (s *Server) handleAPIKeyByID(w http.ResponseWriter, r *http.Request) { + principal, ok := s.requirePrincipal(w, r) + if !ok { + return + } + keyID := strings.TrimPrefix(r.URL.Path, "/v1/api-keys/") + keyID = strings.TrimSpace(keyID) + if keyID == "" || strings.Contains(keyID, "/") { + writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found") + return + } + switch r.Method { + case http.MethodPatch: + s.handleUpdateAPIKey(w, r, principal.UserID, keyID) + case http.MethodDelete: + s.handleRevokeAPIKey(w, r, principal.UserID, keyID) + default: + w.Header().Set("Allow", "PATCH, DELETE") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request, userID, keyID string) { + var req struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + } + if !decodeJSON(w, r, &req) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + updates := []string{} + args := []any{keyID, userID} + if strings.TrimSpace(req.Name) != "" { + args = append(args, strings.TrimSpace(req.Name)) + updates = append(updates, "name = $"+strconv.Itoa(len(args))) + } + if len(req.Scopes) > 0 { + scopesJSON, _ := json.Marshal(req.Scopes) + args = append(args, string(scopesJSON)) + updates = append(updates, "scopes_json = $"+strconv.Itoa(len(args))+"::jsonb") + } + status := strings.TrimSpace(strings.ToLower(req.Status)) + if status != "" { + if status != "revoked" { + writeAPIError(w, http.StatusBadRequest, "invalid_status", "only revoked status can be set through user API key update") + return + } + updates = append(updates, "status = 'revoked'", "revoked_at = now()") + } + if len(updates) == 0 { + key, err := s.apiKeyForUser(ctx, userID, keyID) + if err != nil { + writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "updated": false, "valuesRedacted": true}) + return + } + query := `UPDATE hwlab_api_keys SET ` + strings.Join(updates, ", ") + ` WHERE id = $1 AND user_id = $2 RETURNING id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at` + key, err := scanAPIKey(s.db.QueryRowContext(ctx, query, args...)) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found") + return + } + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "api_key_update_failed", "could not update API key") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "updated": true, "valuesRedacted": true}) +} + +func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request, userID, keyID string) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + key, err := scanAPIKey(s.db.QueryRowContext(ctx, `UPDATE hwlab_api_keys SET status = 'revoked', revoked_at = COALESCE(revoked_at, now()) WHERE id = $1 AND user_id = $2 RETURNING id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at`, keyID, userID)) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "api_key_not_found", "API key was not found") + return + } + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "api_key_revoke_failed", "could not revoke API key") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-api-key-v1", "key": key, "revoked": true, "valuesRedacted": true}) } func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) { @@ -693,6 +936,251 @@ func (s *Server) handleBillingRecord(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + switch r.Method { + case http.MethodGet: + s.handleAdminUserList(w, r) + case http.MethodPost: + s.handleAdminCreateUser(w, r) + default: + w.Header().Set("Allow", "GET, POST") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleAdminUserByID(w http.ResponseWriter, r *http.Request) { + if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { + return + } + userID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/internal/admin/users/")) + if userID == "" || strings.Contains(userID, "/") { + writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found") + return + } + switch r.Method { + case http.MethodGet: + s.handleAdminUserDetail(w, r, userID) + case http.MethodPatch: + s.handleAdminUpdateUser(w, r, userID) + default: + w.Header().Set("Allow", "GET, PATCH") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleAdminUserList(w http.ResponseWriter, r *http.Request) { + query := adminUsersQueryFromRequest(r) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + users, total, err := s.adminUsers(ctx, query) + if err != nil { + s.logDatabaseError("admin_users_list", err) + writeAPIError(w, http.StatusInternalServerError, "admin_users_failed", "could not load users") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "contractVersion": "user-billing-admin-users-v1", + "users": users, + "count": len(users), + "total": total, + "pagination": map[string]any{ + "page": query.Page, + "pageSize": query.PageSize, + "offset": (query.Page - 1) * query.PageSize, + }, + "filters": map[string]any{"search": query.Search, "status": query.Status, "role": query.Role}, + "state": map[string]any{ + "stateless": true, + "stateAuthority": s.config.StateAuthority, + "database": map[string]any{"authority": s.config.ExternalDatabaseLabel}, + "redis": map[string]any{"role": "cache-only"}, + }, + "valuesRedacted": true, + }) +} + +func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request, userID string) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + detail, err := s.adminUserDetail(ctx, userID) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found") + return + } + if err != nil { + s.logDatabaseError("admin_user_detail", err) + writeAPIError(w, http.StatusInternalServerError, "admin_user_detail_failed", "could not load user detail") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "detail": detail, "valuesRedacted": true}) +} + +func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role string `json:"role"` + Status string `json:"status"` + InitialCredits int64 `json:"initialCredits"` + } + if !decodeJSON(w, r, &req) { + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + username := normalizeUsername(req.Username) + role := normalizeRole(req.Role, "user") + status := normalizeUserStatus(req.Status, "active") + if email == "" || !strings.Contains(email, "@") || username == "" || len(req.Password) < 10 || role == "" || status == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_admin_user", "email, username, role, status and a password with at least 10 characters are required") + return + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "transaction_failed", "could not start transaction") + return + } + defer tx.Rollback() + user := User{ID: newID("usr"), Email: email, Username: username, DisplayName: strings.TrimSpace(req.DisplayName), Status: status, Role: role} + err = tx.QueryRowContext(ctx, `INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING email_verified, created_at`, user.ID, user.Email, user.Username, user.DisplayName, passwordHash, status, role).Scan(&user.EmailVerified, &user.CreatedAt) + if err != nil { + if isDuplicate(err) { + writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists") + return + } + writeAPIError(w, http.StatusInternalServerError, "user_create_failed", "could not create user") + return + } + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) VALUES ($1, $2, 0, 'default')`, user.ID, nonNegative(req.InitialCredits)) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "credit_account_failed", "could not create credit account") + return + } + if req.InitialCredits > 0 { + _, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, metadata) VALUES ($1, $2, $3, $3, 'admin_grant', 'admin_create_user_initial_credit', '{}'::jsonb)`, newID("led"), user.ID, req.InitialCredits) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger") + return + } + } + if err := tx.Commit(); err != nil { + writeAPIError(w, http.StatusInternalServerError, "transaction_commit_failed", "could not commit user creation") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-billing-admin-user-v1", "created": true, "user": user, "valuesRedacted": true}) +} + +func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, userID string) { + var req struct { + Email *string `json:"email"` + Username *string `json:"username"` + DisplayName *string `json:"displayName"` + Password *string `json:"password"` + Role *string `json:"role"` + Status *string `json:"status"` + } + if !decodeJSON(w, r, &req) { + return + } + updates := []string{} + args := []any{userID} + if req.Email != nil { + email := strings.ToLower(strings.TrimSpace(*req.Email)) + if email == "" || !strings.Contains(email, "@") { + writeAPIError(w, http.StatusBadRequest, "invalid_email", "email is invalid") + return + } + args = append(args, email) + updates = append(updates, "email = $"+strconv.Itoa(len(args))) + } + if req.Username != nil { + username := normalizeUsername(*req.Username) + if username == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_username", "username is invalid") + return + } + args = append(args, username) + updates = append(updates, "username = $"+strconv.Itoa(len(args))) + } + if req.DisplayName != nil { + args = append(args, strings.TrimSpace(*req.DisplayName)) + updates = append(updates, "display_name = $"+strconv.Itoa(len(args))) + } + if req.Role != nil { + role := normalizeRole(*req.Role, "") + if role == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_role", "role must be user or admin") + return + } + args = append(args, role) + updates = append(updates, "role = $"+strconv.Itoa(len(args))) + } + if req.Status != nil { + status := normalizeUserStatus(*req.Status, "") + if status == "" { + writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active, disabled or pending") + return + } + args = append(args, status) + updates = append(updates, "status = $"+strconv.Itoa(len(args))) + } + if req.Password != nil { + if len(*req.Password) < 10 { + writeAPIError(w, http.StatusBadRequest, "invalid_password", "password must be at least 10 characters") + return + } + passwordHash, err := hashPassword(*req.Password) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "password_hash_failed", "could not hash password") + return + } + args = append(args, passwordHash) + updates = append(updates, "password_hash = $"+strconv.Itoa(len(args))) + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if len(updates) == 0 { + detail, err := s.adminUserDetail(ctx, userID) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found") + return + } + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "admin_user_detail_failed", "could not load user detail") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": false, "detail": detail, "valuesRedacted": true}) + return + } + query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at` + var user User + err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found") + return + } + if err != nil { + if isDuplicate(err) { + writeAPIError(w, http.StatusConflict, "user_exists", "email or username already exists") + return + } + writeAPIError(w, http.StatusInternalServerError, "user_update_failed", "could not update user") + return + } + writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "user": user, "valuesRedacted": true}) +} + func (s *Server) handleAdminCreditAdjust(w http.ResponseWriter, r *http.Request) { if !s.internalAllowed(w, r) || !s.databaseAvailable(w) { return @@ -1337,6 +1825,247 @@ SELECT }, nil } +func (s *Server) apiKeysForUser(ctx context.Context, userID string) ([]apiKeySummary, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at FROM hwlab_api_keys WHERE user_id = $1 ORDER BY created_at DESC, id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + keys := []apiKeySummary{} + for rows.Next() { + key, err := scanAPIKey(rows) + if err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +func (s *Server) apiKeyForUser(ctx context.Context, userID, keyID string) (apiKeySummary, error) { + return scanAPIKey(s.db.QueryRowContext(ctx, `SELECT id, name, key_prefix, scopes_json::text, status, created_at, last_used_at, revoked_at FROM hwlab_api_keys WHERE user_id = $1 AND id = $2`, userID, keyID)) +} + +type apiKeyScanner interface { + Scan(dest ...any) error +} + +func scanAPIKey(scanner apiKeyScanner) (apiKeySummary, error) { + var key apiKeySummary + var scopesRaw string + var lastUsed sql.NullTime + var revoked sql.NullTime + if err := scanner.Scan(&key.ID, &key.Name, &key.Prefix, &scopesRaw, &key.Status, &key.CreatedAt, &lastUsed, &revoked); err != nil { + return key, err + } + _ = json.Unmarshal([]byte(scopesRaw), &key.Scopes) + if len(key.Scopes) == 0 { + key.Scopes = []string{"api"} + } + if lastUsed.Valid { + value := lastUsed.Time + key.LastUsedAt = &value + } + if revoked.Valid { + value := revoked.Time + key.RevokedAt = &value + } + return key, nil +} + +func adminUsersQueryFromRequest(r *http.Request) adminUsersQuery { + page := queryLimit(r, "page", 1, 100000) + pageSize := queryLimit(r, "pageSize", queryLimit(r, "limit", 50, 100), 100) + status := normalizeUserStatus(r.URL.Query().Get("status"), "") + role := normalizeRole(r.URL.Query().Get("role"), "") + return adminUsersQuery{ + Page: page, + PageSize: pageSize, + Search: strings.TrimSpace(r.URL.Query().Get("search")), + Status: status, + Role: role, + } +} + +func (s *Server) adminUsers(ctx context.Context, query adminUsersQuery) ([]adminBillingUserSummary, int64, error) { + where, args := adminUsersWhere(query) + var total int64 + countQuery := `SELECT COUNT(*) FROM hwlab_users u ` + where + if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + offset := (query.Page - 1) * query.PageSize + args = append(args, query.PageSize, offset) + limitParam := "$" + strconv.Itoa(len(args)-1) + offsetParam := "$" + strconv.Itoa(len(args)) + rows, err := s.db.QueryContext(ctx, ` +SELECT + u.id, + u.email, + u.username, + u.display_name, + u.status, + u.role, + u.email_verified, + u.created_at, + COALESCE(a.plan_id, 'default'), + COALESCE(a.balance_credits, 0), + COALESCE(a.reserved_credits, 0), + COALESCE(usage.total_credits, 0), + COALESCE(usage.total_quantity, 0), + COALESCE(usage.record_count, 0), + usage.last_used_at, + COALESCE(keys.active_count, 0), + COALESCE(keys.total_count, 0), + keys.last_key_used_at, + COALESCE(res.active_count, 0) +FROM hwlab_users u +LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id +LEFT JOIN ( + SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at + FROM hwlab_usage_records + GROUP BY user_id +) usage ON usage.user_id = u.id +LEFT JOIN ( + SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at + FROM hwlab_api_keys + GROUP BY user_id +) keys ON keys.user_id = u.id +LEFT JOIN ( + SELECT user_id, COUNT(*) AS active_count + FROM hwlab_billing_reservations + WHERE status = 'reserved' AND expires_at > now() + GROUP BY user_id +) res ON res.user_id = u.id +`+where+` +ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC +LIMIT `+limitParam+` OFFSET `+offsetParam, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + users, userIDs, err := scanAdminBillingUsers(rows) + if err != nil { + return nil, 0, err + } + reservations, err := s.activeReservationsForUsers(ctx, userIDs, 5) + if err != nil { + return nil, 0, err + } + for i := range users { + users[i].Reservations.Active = reservations[users[i].User.ID] + } + return users, total, nil +} + +func adminUsersWhere(query adminUsersQuery) (string, []any) { + clauses := []string{} + args := []any{} + if query.Status != "" { + args = append(args, query.Status) + clauses = append(clauses, "u.status = $"+strconv.Itoa(len(args))) + } + if query.Role != "" { + args = append(args, query.Role) + clauses = append(clauses, "u.role = $"+strconv.Itoa(len(args))) + } + if query.Search != "" { + args = append(args, "%"+strings.ToLower(query.Search)+"%") + param := "$" + strconv.Itoa(len(args)) + clauses = append(clauses, "(lower(u.id) LIKE "+param+" OR lower(u.email) LIKE "+param+" OR lower(u.username) LIKE "+param+" OR lower(u.display_name) LIKE "+param+")") + } + if len(clauses) == 0 { + return "", args + } + return "WHERE " + strings.Join(clauses, " AND "), args +} + +func (s *Server) adminUserDetail(ctx context.Context, userID string) (adminUserDetail, error) { + users, _, err := s.adminUsers(ctx, adminUsersQuery{Page: 1, PageSize: 1, Search: userID}) + if err != nil { + return adminUserDetail{}, err + } + var summary adminBillingUserSummary + found := false + for _, user := range users { + if user.User.ID == userID { + summary = user + found = true + break + } + } + if !found { + return adminUserDetail{}, sql.ErrNoRows + } + ledger, err := s.ledgerRows(ctx, userID, 50) + if err != nil { + return adminUserDetail{}, err + } + keys, err := s.apiKeysForUser(ctx, userID) + if err != nil { + return adminUserDetail{}, err + } + usageBy, err := s.usageByService(ctx, userID, 50) + if err != nil { + return adminUserDetail{}, err + } + reservations, err := s.activeReservations(ctx, userID, 50) + if err != nil { + return adminUserDetail{}, err + } + return adminUserDetail{Summary: summary, Ledger: ledger, APIKeys: keys, UsageBy: usageBy, Reservations: reservations}, nil +} + +func scanAdminBillingUsers(rows *sql.Rows) ([]adminBillingUserSummary, []string, error) { + users := []adminBillingUserSummary{} + userIDs := []string{} + for rows.Next() { + var item adminBillingUserSummary + var lastUsed sql.NullTime + var lastKeyUsed sql.NullTime + if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil { + return nil, nil, err + } + if lastUsed.Valid { + value := lastUsed.Time + item.Usage.LastUsedAt = &value + } + if lastKeyUsed.Valid { + value := lastKeyUsed.Time + item.APIKeys.LastUsedAt = &value + } + item.Credits.Available = item.Credits.Balance - item.Credits.Reserved + userIDs = append(userIDs, item.User.ID) + users = append(users, item) + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + return users, userIDs, nil +} + +func normalizeRole(value, fallback string) string { + role := strings.TrimSpace(strings.ToLower(value)) + if role == "" { + return fallback + } + if role == "user" || role == "admin" { + return role + } + return "" +} + +func normalizeUserStatus(value, fallback string) string { + status := strings.TrimSpace(strings.ToLower(value)) + if status == "" { + return fallback + } + if status == "active" || status == "disabled" || status == "pending" { + return status + } + return "" +} + func queryLimit(r *http.Request, name string, fallback, max int) int { value := strings.TrimSpace(r.URL.Query().Get(name)) if value == "" {