Files
AgentHub/server/openapi-extra/auth.js
T
nntrivi2001 0f17efe3b4 feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
2026-10-28 10:41:53 +07:00

1221 lines
43 KiB
JavaScript

/**
* @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, Codex and Cursor account pools and
* the pool groups, no keys or tokens
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
* - POST /api/accounts/groups, PATCH/DELETE /api/accounts/groups/{id} (Auth) —
* pool groups: a team's members run only on the group's accounts
* - 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/PUT /api/me/pool-accounts (Auth) — the account's own pick of a pool account per tool
* - 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/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
* 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ĩ <vinnt@smartgift.vn>
*/
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 = {
AccountPin: {
type: "object",
required: ["userId", "username", "account"],
properties: {
userId: { type: "integer" },
username: { type: "string" },
account: { type: "string", description: "The pool account's name" },
pinnedAt: { type: "string" },
},
},
PoolGroup: {
type: "object",
required: ["id", "name", "users", "accounts"],
properties: {
id: { type: "integer" },
name: { type: "string" },
createdAt: { type: "string" },
users: {
type: "array",
items: {
type: "object",
properties: { userId: { type: "integer" }, username: { type: "string" } },
},
},
accounts: {
type: "object",
description: "Per tool (claude, codex, cursor), the pool account names it may use",
additionalProperties: { type: "array", items: { type: "string" } },
},
},
},
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 },
avatarAt: {
type: "string",
nullable: true,
description: "When the avatar was last set (null = none)",
},
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/pool-accounts": {
get: {
tags: ["Auth"],
summary: "The account pool as this account may use it (server mode)",
description:
"Per tool with at least one account the account's pool group allows: those accounts (never a token), an admin's pin and the account's own choice. Local mode: {tools: []}.",
responses: {
200: {
description: "Tools",
content: {
"application/json": {
schema: {
type: "object",
required: ["tools"],
properties: {
tools: {
type: "array",
items: {
type: "object",
required: ["tool", "pin", "choice", "accounts"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
pin: { type: "string", nullable: true },
choice: { type: "string", nullable: true },
accounts: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
kind: { type: "string" },
email: { type: "string", nullable: true },
expired: { type: "boolean" },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
},
},
},
},
},
},
},
},
},
},
},
401: err("EAUTH"),
},
},
put: {
tags: ["Auth"],
summary: "Run this account's new sessions of a tool on one pool account (null = automatic)",
description:
"Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
account: { type: "string", nullable: true },
},
},
},
},
},
responses: {
200: {
description: "Chosen",
content: {
"application/json": {
schema: {
type: "object",
properties: {
tool: { type: "string" },
choice: { type: "string", nullable: true },
switched: {
type: "string",
nullable: true,
enum: ["switched", "kept", "own-login", "expired", "next-session"],
},
},
},
},
},
},
400: err("EBADTOOL"),
401: err("EAUTH"),
403: err("ELOCALMODE"),
404: err("ENOACCOUNT — not in the pool, or not one the account's group may use"),
409: err("ELOCKED — an admin pinned this account's sessions of the tool"),
},
},
},
"/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); the new one must pass every rule (8+ characters, lower, upper, digit, symbol, not the username) and differ from the current one; this login stays, every other ends; clears mustChangePassword",
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 / EWEAKPASSWORD (error.rules names the failed rules)"),
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/me/avatar": {
get: {
tags: ["Auth"],
operationId: "getMyAvatar",
summary: "The account's own picture (nosniff, sandbox CSP), or a 302 to its avatar link",
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
302: { description: "Redirect to the account's avatar link" },
401: err("EAUTH"),
404: err("ENOAVATAR"),
},
},
put: {
tags: ["Auth"],
operationId: "setMyAvatar",
summary:
"Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
requestBody: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
responses: {
200: { description: "Set", content: accountBody },
400: err("EBADIMAGE"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
413: err("ETOOBIG"),
},
},
delete: {
tags: ["Auth"],
operationId: "removeMyAvatar",
summary: "Remove the account's picture",
responses: {
200: { description: "Removed", content: accountBody },
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" },
today: {
type: "object",
description: "Spent since the viewer's local midnight (`tz_offset`).",
properties: { cost: { type: "number" }, tokens: { 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/tmux": {
get: {
tags: ["Auth"],
operationId: "listMemberTmux",
summary:
"Each member's own tmux server (one Linux user per account): its version beside the installed one, sessions, agents working (admin/analyst)",
responses: {
200: {
description:
"{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]} — available false without S3",
},
403: err("EADMIN — a developer"),
},
},
},
"/api/users/{id}/tmux-restart": {
post: {
tags: ["Auth"],
operationId: "restartMemberTmux",
summary:
"End one member's tmux server — every pane of theirs closes; the next start runs the installed tmux (admin, own password again, audited)",
parameters: [idParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: { description: "{user}" },
403: err("EREAUTH (own password wrong) / EADMIN / ELOCALMODE"),
404: err("ENOUSER"),
409: err(
"ETMUXSTUCK — the server did not stop (the message names the pid for root) / ENOS3"
),
},
},
},
"/api/users/tmux-restart-idle": {
post: {
tags: ["Auth"],
operationId: "restartIdleMemberTmux",
summary:
"Restart every member server older than the installed tmux while no agent of theirs works (admin, own password again, audited per member)",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: {
description:
"{restarted: [username], skipped: [username — an agent working], failed: [{username, message}]}",
},
403: err("EREAUTH / EADMIN / ELOCALMODE"),
},
},
},
"/api/me/avatar/url": {
post: {
tags: ["Auth"],
operationId: "setMyAvatarFromUrl",
summary:
"Set the account's picture to an https link, stored and never downloaded — the avatar GETs answer 302 to it (no userinfo, no localhost/private IP literal, at most 2048 chars). Login cookie only, audited",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["url"],
properties: { url: { type: "string", format: "uri" } },
},
},
},
},
responses: {
200: { description: "Set", content: accountBody },
400: err("EBADURL / EPRIVATEURL"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/users/{id}/avatar": {
get: {
tags: ["Auth"],
operationId: "getUserAvatar",
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
302: { description: "Redirect to that account's avatar link" },
403: err("EADMIN — a developer"),
404: err("ENOAVATAR"),
},
},
},
"/api/users/{id}/details": {
get: {
tags: ["Auth"],
operationId: "getServerUserDetails",
summary:
"One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account's quota beside this user's own 5 h / 7 d share. Never a token",
parameters: [
idParam,
{
name: "tz_offset",
in: "query",
schema: { type: "integer" },
description: "The browser's getTimezoneOffset(), for the daily buckets",
},
],
responses: {
200: { description: "Account details" },
403: err("EADMIN — a developer"),
404: err("ENOUSER"),
},
},
},
"/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"),
},
},
delete: {
tags: ["Auth"],
operationId: "deleteServerUser",
summary:
"Admin (Delete user): the account for good — its logins, panes, workspaces' records and prefs, and under S3 its Linux account and whole home, removed FIRST so a refusal leaves every row. Session history and the audit log stay. Never yourself (ESELF) or the last enabled admin (ELASTADMIN)",
parameters: [idParam],
requestBody: changeBody({}),
responses: {
...changeResponses,
200: { description: "Deleted — the account as it was", content: userBody },
409: err("ESELF — yourself / ELASTADMIN — the last enabled admin"),
502: err("EPROVISION — the Linux account or home could not be removed; nothing deleted"),
},
},
},
"/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, Codex and Cursor account pools as the balancer sees them, and the pool groups (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" },
tokens5h: { type: "integer" },
tokens7d: { type: "integer" },
quota: { type: "object", nullable: true },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
},
},
},
pins: {
type: "object",
description:
"Per tool, the users an admin pinned to one account (empty in local mode)",
properties: {
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
cursor: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
},
},
groups: {
type: "array",
description:
"Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group's accounts, a user in no group on the accounts no group lists",
items: { $ref: "#/components/schemas/PoolGroup" },
},
cursor: {
type: "array",
description:
"The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend",
items: {
type: "object",
properties: {
name: { type: "string" },
kind: { type: "string", enum: ["apikey"] },
addedAt: { type: "string" },
livePanes: { type: "integer" },
},
},
},
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" },
tokens5h: { type: "integer" },
tokens7d: { type: "integer" },
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), Codex (saved profile, API key / auth.json text) or Cursor (API key) 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", "cursor"] },
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; Cursor: a Cursor API key",
},
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/pins": {
put: {
tags: ["Auth"],
operationId: "pinPoolAccount",
summary:
"Admin: switch one user's sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "userId", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
userId: { type: "integer" },
account: { type: "string", nullable: true },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description:
"`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null",
},
400: err("EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"),
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}/users": {
put: {
tags: ["Auth"],
operationId: "setPoolAccountUsers",
summary:
"Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user's home at once",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "userIds", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
userIds: { type: "array", items: { type: "integer" } },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description:
"`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session",
},
400: err("EBADTOOL, EBADUSER"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/groups": {
post: {
tags: ["Auth"],
operationId: "createPoolGroup",
summary: "Admin: a new, empty pool group, with their own password again",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "currentPassword"],
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
},
},
},
},
responses: {
201: { description: "`{group: {id, name}}`" },
400: err("EBADNAME"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
409: err("EEXISTS — a group has that name"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/groups/{id}": {
patch: {
tags: ["Auth"],
operationId: "updatePoolGroup",
summary:
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: {
name: { type: "string" },
userIds: { type: "array", items: { type: "integer" } },
accounts: {
type: "object",
description: "`{tool: [account names]}` for claude, codex, cursor",
additionalProperties: { type: "array", items: { type: "string" } },
},
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description: "`{group}`",
content: {
"application/json": {
schema: {
type: "object",
properties: { group: { $ref: "#/components/schemas/PoolGroup" } },
},
},
},
},
400: err("EBADNAME, EBADUSER, EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
409: err("EEXISTS — a group has that name"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
delete: {
tags: ["Auth"],
operationId: "deletePoolGroup",
summary:
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword"],
properties: { currentPassword: { type: "string" } },
},
},
},
},
responses: {
200: { description: "`{ok: true}`" },
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOGROUP"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/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", "cursor"] },
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 };