feat: 补齐 v03 user-billing R1 parity 基础 (#1179)

This commit is contained in:
Lyon
2026-06-14 09:59:48 +08:00
committed by GitHub
parent 06021bb685
commit b9dd25ef68
4 changed files with 1167 additions and 2 deletions
+218
View File
@@ -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 ?? ""),
+45
View File
@@ -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 });
}
@@ -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())));
}
});
+731 -2
View File
@@ -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(&currentHash); 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 == "" {