feat: 补齐 v03 user-billing R1 parity 基础 (#1179)
This commit is contained in:
@@ -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 ?? ""),
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user