/** * @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 * - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one * - 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 * - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions * - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others * (Auth) — Settings → Account: the account's own profile, password and logins * - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form * 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" }, displayName: { type: "string", nullable: true }, email: { type: "string", nullable: true }, createdAt: { type: "string", nullable: true }, }, }, MyAccount: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["local", "server"] }, user: { $ref: "#/components/schemas/AuthUser" }, workspaces: { type: "integer" }, sessions: { type: "integer" }, logins: { type: "integer", description: "Unexpired logins of the account" }, lastLoginAt: { type: "string", nullable: true }, }, }, 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 accountBody = { "application/json": { schema: { $ref: "#/components/schemas/MyAccount" } }, }; const prefsBody = { "application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } }, }; const instrBody = { "application/json": { schema: { type: "object", required: ["instructions", "max", "tools"], properties: { instructions: { type: "string" }, max: { type: "integer" }, tools: { type: "array", items: { type: "string" }, description: "AI tool ids whose new panes receive the instructions (claude, codex)", }, }, }, }, }; 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/instructions": { get: { tags: ["Auth"], summary: "The account's own agent instructions", description: "Put before the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns.", responses: { 200: { description: "Instructions", content: instrBody }, 401: err("EAUTH") }, }, put: { tags: ["Auth"], summary: "Replace the account's agent instructions (blank or null clears)", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { instructions: { type: "string", nullable: true, maxLength: 16000 } }, }, }, }, }, responses: { 200: { description: "Saved", content: instrBody }, 400: err("EBADFIELD — not a string"), 413: err("ETOOBIG — longer than 16 000 characters"), 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/me/account": { get: { tags: ["Auth"], operationId: "getMyAccount", summary: 'Settings → Account: the signed-in account (local mode: {mode: "local"})', responses: { 200: { description: "Account", content: accountBody }, 401: err("EAUTH"), }, }, patch: { tags: ["Auth"], operationId: "updateMyAccount", summary: 'The account\'s own display name and email (""/null clears). Login cookie only, audited', requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { displayName: { type: "string", nullable: true, maxLength: 100 }, email: { type: "string", nullable: true, maxLength: 254 }, }, }, }, }, }, responses: { 200: { description: "Updated", content: accountBody }, 400: err("EBADFIELD / EBADEMAIL"), 401: err("EAUTH — no login cookie (a pane token is refused)"), 403: err("ELOCALMODE"), }, }, }, "/api/me/password": { post: { tags: ["Auth"], operationId: "changeMyPassword", summary: "Change the account's own password: the current one required (throttled); this login stays, every other ends", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["currentPassword", "newPassword"], properties: { currentPassword: { type: "string" }, newPassword: { type: "string", minLength: 1 }, }, }, }, }, }, responses: { 200: { description: "Changed ({ok: true})" }, 400: err("EBADPASSWORD"), 401: err("EAUTH — no login cookie (a pane token is refused)"), 403: err("EREAUTH (current password wrong) / ELOCALMODE"), 429: err("ETOOMANY — 10 wrong passwords in 15 minutes"), }, }, }, "/api/me/sign-out-others": { post: { tags: ["Auth"], operationId: "signOutMyOtherLogins", summary: "End every login of the account but this one; answers {ended}", responses: { 200: { description: "Ended" }, 401: err("EAUTH — no login cookie (a pane token is refused)"), 403: err("ELOCALMODE"), }, }, }, "/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: 1 }, 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}": { get: { tags: ["Auth"], operationId: "getServerUser", summary: "One account with its logins and the workspaces it owns (admin/analyst)", parameters: [idParam], responses: { 200: { description: "Account" }, 403: err("EADMIN — a developer"), 404: err("ENOUSER"), }, }, patch: { tags: ["Auth"], operationId: "updateServerUser", summary: "Admin (Edit user): display name, email, role, disabled, a new password, workspaces given or taken back, sign out everywhere — every field validated before any is written. Never your own role or disable (ESELF)", parameters: [idParam], requestBody: changeBody({ displayName: { type: "string", nullable: true, maxLength: 100 }, email: { type: "string", nullable: true, maxLength: 254 }, role: { type: "string", enum: ["admin", "analyst", "developer"] }, disabled: { type: "boolean" }, password: { type: "string", minLength: 1 }, addWorkspaces: { type: "array", items: { type: "integer" } }, removeWorkspaces: { type: "array", items: { type: "integer" } }, signOut: { type: "boolean", enum: [true] }, }), responses: { ...changeResponses, 400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"), 409: err("ESELF / ENOTOWNED / EOWNERFOLDER"), }, }, }, "/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: 1 } }, ["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 }, reader_display: { type: "string", nullable: true }, owner_display: { 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" }, }, }, }, }, }, }, }, }, }, }, post: { tags: ["Auth"], operationId: "addPoolAccount", summary: "Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["tool", "currentPassword"], properties: { tool: { type: "string", enum: ["claude", "codex"] }, name: { type: "string", description: "Defaults to the profile's name" }, profile: { type: "string", description: "Claude: a saved switch-user profile, moved into the pool", }, credentials: { type: "string", description: "Claude: the text of a .credentials.json", }, token: { type: "string", description: "Claude: a setup-token; Codex: an API key or auth.json text", }, currentPassword: { type: "string" }, }, }, }, }, }, responses: { 201: { description: "`{account: {name, addedAt, …}}` — no token" }, 400: err("EBADTOOL, EBADNAME, EBADTOKEN"), 403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"), 409: err( "EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile" ), 502: err("EUPSTREAM — Anthropic could not be reached"), 429: err("ETOOMANY — too many wrong passwords"), }, }, }, "/api/accounts/profiles": { get: { tags: ["Auth"], operationId: "listPoolProfiles", summary: "Saved switch-user profiles an admin may move into the Claude pool (no token)", responses: { 200: { description: "`{profiles: [{name, label, email, subscriptionType, active}]}`" }, }, }, }, "/api/accounts/{name}": { delete: { tags: ["Auth"], operationId: "removePoolAccount", summary: "Admin: remove an account from the pool, with their own password again", parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["tool", "currentPassword"], properties: { tool: { type: "string", enum: ["claude", "codex"] }, currentPassword: { type: "string" }, }, }, }, }, }, responses: { 200: { description: "`{ok: true}`" }, 403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"), 404: err("ENOACCOUNT"), 429: err("ETOOMANY — too many wrong passwords"), }, }, }, "/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 };