/** * @file OpenAPI 3.0 fragment for server mode's login routes (server/routes/auth.js): * - GET /api/auth/me (Auth) * - POST /api/auth/login (Auth) * - POST /api/auth/logout (Auth) * - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens * - GET /api/users (Auth) — every account's usage (admin/analyst) * - GET /api/users/audit (Auth) — staff reads of developers' sessions * - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences * Exports `{ tags, schemas, paths }`, combined into the spec by * server/openapi-extra.js. See docs/SERVER-MODE.md. * @author Nguyễn Ngọc Trí Vĩ */ const tags = [ { name: "Auth", description: 'Server mode\'s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.', }, ]; const schemas = { UserPrefs: { type: "object", required: ["prefs", "onboardedAt"], properties: { prefs: { type: "object", additionalProperties: { type: "string" } }, onboardedAt: { type: "string", nullable: true }, }, }, AuthUser: { type: "object", required: ["id", "username", "role"], properties: { id: { type: "integer" }, username: { type: "string" }, role: { type: "string", enum: ["admin", "analyst", "developer"] }, disabled: { type: "boolean" }, }, }, AuthMe: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["local", "server"] }, user: { description: "Server mode only: the logged-in user, or null.", nullable: true, allOf: [{ $ref: "#/components/schemas/AuthUser" }], }, }, }, }; const err = (description) => ({ description, content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, }); // The Users page's account changes: every body carries the admin's own // password again (`currentPassword`). const userBody = { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/AuthUser" } } }, }, }; const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } }; const changeBody = (properties, required = []) => ({ required: true, content: { "application/json": { schema: { type: "object", required: [...required, "currentPassword"], properties: { ...properties, currentPassword: { type: "string" } }, }, }, }, }); const changeResponses = { 200: { description: "Changed", content: userBody }, 400: err("EBADPASSWORD / EBADROLE / EBADFIELD"), 403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"), 404: err("ENOUSER / ENOLANE"), 409: err("ESELF — your own role or disable"), 429: err("ETOOMANY — 10 wrong passwords in 15 minutes"), }; const prefsBody = { "application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } }, }; const paths = { "/api/me/prefs": { get: { tags: ["Auth"], summary: "The signed-in account's own UI preferences", description: "Local mode is the one account `local`; server mode one row per account. Keys are the browser's localStorage names (docs/API.md).", responses: { 200: { description: "Preferences", content: prefsBody }, 401: err("EAUTH") }, }, put: { tags: ["Auth"], summary: "Merge the account's preferences (null deletes a key)", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["prefs"], properties: { prefs: { type: "object", additionalProperties: { type: "string", nullable: true, maxLength: 8192 }, }, }, }, }, }, }, responses: { 200: { description: "Saved", content: prefsBody }, 400: err("EBADPREFS — an unknown key or a value that is not a short string"), 401: err("EAUTH"), }, }, }, "/api/me/onboarded": { post: { tags: ["Auth"], summary: "The account went through (or skipped) the first-run setup", responses: { 200: { description: "Marked", content: prefsBody }, 401: err("EAUTH") }, }, }, "/api/users": { get: { tags: ["Auth"], operationId: "listServerUsers", summary: "Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a developer)", responses: { 200: { description: "Accounts", content: { "application/json": { schema: { type: "object", properties: { users: { type: "array", items: { allOf: [ { $ref: "#/components/schemas/AuthUser" }, { type: "object", properties: { workspaces: { type: "integer" }, sessions: { type: "integer" }, last7d: { type: "object", properties: { cost: { type: "number" }, tokens: { type: "integer" } }, }, last30d: { type: "object", properties: { cost: { type: "number" }, tokens: { type: "integer" } }, }, }, }, ], }, }, }, }, }, }, }, 403: err("EADMIN — a developer"), }, }, post: { tags: ["Auth"], operationId: "createServerUser", summary: "Admin: add an account. Needs the admin's own password again (currentPassword); audited", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["username", "password", "currentPassword"], properties: { username: { type: "string" }, password: { type: "string", minLength: 8 }, role: { type: "string", enum: ["admin", "analyst", "developer"], default: "developer", }, currentPassword: { type: "string" }, }, }, }, }, }, responses: { 201: { description: "Created", content: userBody }, 400: err("EBADUSER / EBADPASSWORD / EBADROLE"), 403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"), 409: err("EEXISTS"), 429: err("ETOOMANY — 10 wrong passwords in 15 minutes"), }, }, }, "/api/users/{id}": { patch: { tags: ["Auth"], operationId: "updateServerUser", summary: "Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)", parameters: [idParam], requestBody: changeBody({ role: { type: "string", enum: ["admin", "analyst", "developer"] }, disabled: { type: "boolean" }, }), responses: changeResponses, }, }, "/api/users/{id}/password": { post: { tags: ["Auth"], operationId: "resetServerUserPassword", summary: "Admin: a new password for that user; every login of theirs ends", parameters: [idParam], requestBody: changeBody({ password: { type: "string", minLength: 8 } }, ["password"]), responses: changeResponses, }, }, "/api/users/{id}/workspaces": { post: { tags: ["Auth"], operationId: "assignServerUserWorkspace", summary: "Admin: make that user the owner of a workspace (lane)", parameters: [idParam], requestBody: changeBody({ laneId: { type: "integer" } }, ["laneId"]), responses: changeResponses, }, }, "/api/users/audit": { get: { tags: ["Auth"], operationId: "listAuditLog", summary: "Admin/analyst reads of sessions developers own, newest first", parameters: [{ name: "limit", in: "query", schema: { type: "integer", maximum: 1000 } }], responses: { 200: { description: "Entries", content: { "application/json": { schema: { type: "object", properties: { entries: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, at: { type: "string" }, method: { type: "string" }, path: { type: "string" }, session_id: { type: "string", nullable: true }, reader: { type: "string", nullable: true }, owner: { type: "string", nullable: true }, }, }, }, }, }, }, }, }, 403: err("EADMIN — a developer"), }, }, }, "/api/accounts": { get: { tags: ["Auth"], operationId: "listPoolAccounts", summary: "The Claude and Codex account pools as the balancer sees them (never a key or token)", parameters: [ { name: "force", in: "query", schema: { type: "string", enum: ["1"] }, description: "Re-read every account's quota first", }, ], responses: { 200: { description: "Accounts with live panes, recent spend and quota", content: { "application/json": { schema: { type: "object", properties: { blockPercent: { type: "number" }, accounts: { type: "array", items: { type: "object", properties: { name: { type: "string" }, addedAt: { type: "string" }, expiresAt: { type: "string" }, expired: { type: "boolean" }, livePanes: { type: "integer" }, spend5h: { type: "number" }, spend7d: { type: "number" }, quota: { type: "object", nullable: true }, quotaPercent: { type: "number", nullable: true }, full: { type: "boolean" }, }, }, }, codex: { type: "array", description: "The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry", items: { type: "object", properties: { name: { type: "string" }, kind: { type: "string", enum: ["apikey", "chatgpt"] }, addedAt: { type: "string" }, livePanes: { type: "integer" }, spend5h: { type: "number" }, spend7d: { type: "number" }, quota: { type: "object", nullable: true }, quotaPercent: { type: "number", nullable: true }, full: { type: "boolean" }, }, }, }, }, }, }, }, }, }, }, }, "/api/auth/me": { get: { tags: ["Auth"], operationId: "authMe", summary: "The dashboard's mode and, in server mode, who is logged in", responses: { 200: { description: "Mode and user", content: { "application/json": { schema: { $ref: "#/components/schemas/AuthMe" } } }, }, }, }, }, "/api/auth/login": { post: { tags: ["Auth"], operationId: "authLogin", summary: "Log in; sets the httpOnly, SameSite=Strict cookie agenthub_sid", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["username", "password"], properties: { username: { type: "string" }, password: { type: "string" } }, }, }, }, }, responses: { 200: { description: "Logged in", content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/AuthUser" } }, }, }, }, }, 401: err("EBADLOGIN — wrong username or password, or a disabled user"), 403: err("EBADORIGIN — a cross-origin request"), 404: err("ENOTSERVER — the dashboard is in local mode"), 429: err("ETOOMANY — too many failed logins from this address (15 minutes)"), }, }, }, "/api/auth/logout": { post: { tags: ["Auth"], operationId: "authLogout", summary: "End this login and clear its cookie", responses: { 200: { description: "Logged out", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" } } }, }, }, }, }, }, }, }; module.exports = { tags, schemas, paths };