feat(pool): pin a user to one Claude/Codex pool account

An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
This commit is contained in:
2026-10-19 10:05:19 +07:00
parent aa8ac6cad9
commit 9c98638ec0
20 changed files with 776 additions and 39 deletions
+34 -1
View File
@@ -9,7 +9,8 @@
* same account; removing the account takes it out of those homes; and the
* Users-page route moves a saved switch-user profile in — the one this
* machine's own Claude Code is logged into with its LIVE login, the host's
* home then being one more placement the server refreshes.
* home then being one more placement the server refreshes. A pin of the
* owner to another login waits while a live pane holds the first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -188,6 +189,38 @@ describe("a pool login", () => {
assert.equal(pool.__read("team"), null);
});
it("follows the owner's pin, but never over another login a live pane holds", () => {
const { db } = require("../db");
const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pool.addLoginAccount("alpha", { oauth: oauth("a"), account: ACCOUNT });
pool.addLoginAccount("beta", {
oauth: oauth("b"),
account: { accountUuid: "uuid-beta", emailAddress: "beta@example.com" },
});
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "unpinned: the balancer's pick");
pins.setPin(miaId, "claude", "beta");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(pool.forSpawn(acct, null).name, "alpha", "another owner is not pinned");
// A pane of mia's runs on alpha: beta waits until it ends.
pool.forSpawn(acct).place();
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
const lane = db
.prepare("INSERT INTO lanes (cwd, owner_id) VALUES (?, ?)")
.run(path.join(HOME, "proj"), miaId).lastInsertRowid;
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
assert.equal(pool.forSpawn(acct, miaId).name, "alpha");
db.prepare("UPDATE dashboard_runs SET status = 'completed' WHERE id = 'pin-run'").run();
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
pool.removeAccount("beta");
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins");
db.prepare("DELETE FROM dashboard_runs WHERE id = 'pin-run'").run();
db.prepare("DELETE FROM lanes WHERE id = ?").run(lane);
});
it("refuses a second copy of one Claude account", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
@@ -4,7 +4,8 @@
* admin may add or remove an account, each change needs the admin's OWN
* password again (EREAUTH), the token only goes in — no answer (the add, the
* list) ever carries it — the audit log names the account, not its secret, and
* in local mode the routes refuse.
* in local mode the routes refuse. An admin pins a user to one account
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -144,3 +145,75 @@ describe("the account pool from the Users page", () => {
assert.ok(claudePool.pickAccount(), "team-1 is still there");
});
});
describe("pinning a user to one account", () => {
const pins = require("../lib/account-pins");
const pin = (cookie, body) =>
call("PUT", "/api/accounts/pins", {
cookie,
body: { tool: "claude", currentPassword: BOSS_PASS, ...body },
});
it("lets an admin pin and unpin, audited, and new panes follow the pin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
claudePool.addAccount("team-z", `sk-ant-oat01-${"z".repeat(40)}`);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1", "the balancer's pick");
const r = await pin(boss, { userId: miaId, account: "team-z" });
assert.equal(r.status, 200);
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" });
assert.equal(claudePool.pickAccount(null, miaId).name, "team-z");
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves");
const audit = db.prepare("SELECT owner_id, path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.equal(audit.owner_id, miaId);
assert.match(audit.path, /pin mia to claude account team-z/);
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.pins.claude.map((p) => [p.username, p.account]),
[["mia", "team-z"]]
);
assert.deepEqual(list.body.pins.codex, []);
const off = await pin(boss, { userId: miaId, account: null });
assert.equal(off.body.pin.account, null);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1");
claudePool.removeAccount("team-z");
});
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
assert.equal(
(await pin(boss, { userId: miaId, account: "nope" })).body.error.code,
"ENOACCOUNT"
);
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
assert.equal(
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
"EREAUTH"
);
const ana = await login("ana", "analyst-pass-1");
const w = await call("PUT", "/api/accounts/pins", {
cookie: ana,
body: { tool: "claude", userId: miaId, account: "team-1", currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
assert.equal(pins.pinFor(miaId, "claude"), null);
});
it("is ignored in local mode and dropped with its user", async () => {
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pins.setPin(miaId, "claude", "team-1");
delete process.env.AGENTHUB_MODE;
try {
assert.equal(pins.pinFor(miaId, "claude"), null);
} finally {
process.env.AGENTHUB_MODE = "server";
}
assert.equal(pins.pinFor(miaId, "claude"), "team-1");
db.prepare("DELETE FROM users WHERE id = ?").run(miaId);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM account_pins WHERE user_id = ?").get(miaId).n,
0
);
});
});
+12
View File
@@ -1873,6 +1873,18 @@ for (const col of ["display_name", "email", "avatar_at"]) {
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
}
}
// An admin's pin of one server-mode user to one pool account per tool
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
// The account is named, not referenced — the pool lives in files.
db.exec(`
CREATE TABLE IF NOT EXISTS account_pins (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
account TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, tool)
);
`);
// An existing database made before auto_vacuum: the setting is stored only by
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
+91
View File
@@ -0,0 +1,91 @@
/**
* @file account-pins.js
* @description Account pins: an admin's choice that one server-mode user's
* new sessions of a tool run on one named pool account instead of the one the
* balancer would pick (claude-pool.js / codex-pool.js `pick`). One pin per
* user and tool (`account_pins`); the user is the workspace's OWNER
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
* pool still bounds: an account a pane may not use (expired, or a login the
* pane's home cannot take) falls back to the balancer, and a login never
* replaces another one a live pane in the same home runs on.
*
* Server mode only: in local mode there are no users and nothing is pinned.
* Removing the account drops its pins (`dropAccount`); deleting the user drops
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const TOOLS = ["claude", "codex"];
const err = (code, message) => Object.assign(new Error(message), { code });
const db = () => require("../db").db;
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
function validTool(tool) {
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
return tool;
}
/** The account name `ownerId`'s new `tool` sessions are pinned to, or null. */
function pinFor(ownerId, tool) {
if (!require("./auth").isServerMode() || !Number.isInteger(ownerId)) return null;
try {
return (
db()
.prepare("SELECT account FROM account_pins WHERE user_id = ? AND tool = ?")
.get(ownerId, tool)?.account ?? null
);
} catch {
return null; // a spawn never fails over a pin
}
}
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
function listPins(tool) {
return db()
.prepare(
`SELECT p.user_id AS userId, u.username, p.account, p.pinned_at AS pinnedAt
FROM account_pins p JOIN users u ON u.id = p.user_id
WHERE p.tool = ? ORDER BY u.username`
)
.all(validTool(tool));
}
/**
* Pin `userId`'s new `tool` sessions to `account`, or unpin them (`account`
* null). Returns `{userId, username, account}`.
*/
function setPin(userId, tool, account) {
validTool(tool);
const user = Number.isInteger(userId)
? db().prepare("SELECT id, username FROM users WHERE id = ?").get(userId)
: null;
if (!user) throw err("ENOUSER", `no user ${userId}`);
if (account == null || account === "") {
db().prepare("DELETE FROM account_pins WHERE user_id = ? AND tool = ?").run(user.id, tool);
return { userId: user.id, username: user.username, account: null };
}
const name = String(account);
if (!poolOf(tool).hasAccount(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
}
db()
.prepare(
`INSERT INTO account_pins (user_id, tool, account) VALUES (?, ?, ?)
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
pinned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
)
.run(user.id, tool, name);
return { userId: user.id, username: user.username, account: name };
}
/** A removed account takes its pins with it. */
function dropAccount(tool, account) {
try {
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
} catch {
/* no database (a bare CLI test): nothing pinned */
}
}
module.exports = { TOOLS, pinFor, listPins, setPin, dropAccount };
+1
View File
@@ -367,6 +367,7 @@ function deleteUser(username) {
} catch {
/* no personal profiles */
}
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length };
}
+25 -8
View File
@@ -18,6 +18,8 @@
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* An admin may pin a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
@@ -193,6 +195,7 @@ function removeAccount(name) {
}
}
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("claude", n);
quotaCache.delete(n);
}
@@ -434,10 +437,15 @@ function liveInHome(name, member) {
});
}
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
function pick(member) {
/**
* The full record a new Claude pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
let live = null;
if (!member) {
// A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk:
@@ -453,10 +461,15 @@ function pick(member) {
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
if (live) return live;
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || null;
}
}
// A pinned token goes in by env and wins outright; a pinned login waits
// while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "claude");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live;
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
@@ -489,8 +502,8 @@ function pick(member) {
* background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is.
*/
function pickAccount(member = null) {
const a = pick(member);
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
return a.kind === "login"
? { name: a.name, kind: "login" }
@@ -502,8 +515,8 @@ function pickAccount(member = null) {
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
* before the pane starts and writes a login into the member's home.
*/
function forSpawn(member = null) {
const a = pick(member);
function forSpawn(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
@@ -524,8 +537,12 @@ function forSpawn(member = null) {
};
}
/** Is `name` an account in the pool (any state)? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
BLOCK_PERCENT,
hasAccount,
REFRESH_AHEAD_MS,
addAccount,
addLoginAccount,
+24 -8
View File
@@ -29,6 +29,7 @@
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
* rollouts of the sessions that account's runs started (no network), each
* from the CODEX_HOME of the account its workspace runs as.
* A user an admin pinned to one account (account-pins.js) skips the ranking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -139,6 +140,7 @@ function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("codex", n);
quotaCache = null;
}
@@ -332,10 +334,15 @@ function syncBack() {
// ── Picking ────────────────────────────────────────────────────────────────
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
function pick(member) {
/**
* The full record a new Codex pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts();
if (usable.length === 0) return null;
let live = null;
if (!member) {
// A host pane's CODEX_HOME holds the host's own login.
usable = usable.filter((a) => a.kind === "apikey");
@@ -348,10 +355,15 @@ function pick(member) {
} else {
// One auth.json per home: while a pane of theirs runs on a pool
// login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
if (live) return live;
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
}
}
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
// waits while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "codex");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
if (live) return live;
if (usable.length === 0) return null;
const ranked = usable
.map((a) => {
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
}
/** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null) {
const a = pick(member);
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
return a ? { name: a.name, kind: a.kind } : null;
}
@@ -405,8 +417,8 @@ function pickAccount(member = null) {
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
* (a member's launch file); `place()` runs just before the pane starts.
*/
function forSpawn(member = null) {
const a = pick(member);
function forSpawn(member = null, ownerId = null) {
const a = pick(member, ownerId);
if (!a) return null;
if (a.kind === "apikey") {
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
@@ -439,8 +451,12 @@ function forSpawn(member = null) {
};
}
/** Is `name` an account in the pool? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = {
BLOCK_PERCENT,
hasAccount,
addAccount,
removeAccount,
poolStatus,
+4 -3
View File
@@ -461,19 +461,20 @@ function spawnRun(args) {
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// account, if the admin added any (claude-pool.js) — or the one the
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
// A real login (kind `login`) is written into a MEMBER's home by place();
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
const poolAccount =
entry.launcher === "claude" && !proxyProfile
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
// Codex likewise, from its own pool (codex-pool.js): an API key through
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
const codexPool =
entry.launcher === "codex" && !proxyProfile
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null;
const paneCred = require("./auth").newPaneToken();
if (forkSession === true) {
+52
View File
@@ -5,6 +5,7 @@
* - 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
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
* - 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
@@ -30,6 +31,16 @@ const tags = [
];
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" },
},
},
UserPrefs: {
type: "object",
required: ["prefs", "onboardedAt"],
@@ -633,6 +644,15 @@ const paths = {
},
},
},
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" } },
},
},
codex: {
type: "array",
description:
@@ -706,6 +726,38 @@ const paths = {
},
},
},
"/api/accounts/pins": {
put: {
tags: ["Auth"],
operationId: "pinPoolAccount",
summary:
"Admin: pin one user's new sessions of a tool to one pool account (`account: null` unpins), with their own password again",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "userId", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
userId: { type: "integer" },
account: { type: "string", nullable: true },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: { description: "`{pin: {userId, username, account}}` — `account` null when unpinned" },
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"],
+23 -1
View File
@@ -16,6 +16,9 @@
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
* one is an OpenAI API key or the text of an `auth.json` (`token`).
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
* user's new sessions of a tool to one pool account (account-pins.js); `GET /`
* lists the pins under `pins`.
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
* they need the admin's own password again (`currentPassword`), are audited
* (the account NAME only), and refuse in local mode. The secret only goes IN:
@@ -29,6 +32,7 @@ const codexPool = require("../lib/codex-pool");
const auth = require("../lib/auth");
const { recordAccountChange } = require("../lib/audit");
const profiles = require("../lib/cc-profiles");
const pins = require("../lib/account-pins");
const router = Router();
@@ -39,6 +43,9 @@ router.get("/", async (req, res) => {
res.json({
accounts: pool.poolStatus(),
codex: codexPool.poolStatus(),
pins: auth.isServerMode()
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
: { claude: [], codex: [] },
blockPercent: pool.BLOCK_PERCENT,
});
} catch (e) {
@@ -72,6 +79,7 @@ const STATUS = {
EBADNAME: 400,
EBADTOKEN: 400,
ENOACCOUNT: 404,
ENOUSER: 404,
EEXISTS: 409,
ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent
@@ -93,7 +101,7 @@ function poolChange(handler) {
}
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
recordAccountChange(req.user.id, null, req.method, out.action);
recordAccountChange(req.user.id, out.target ?? null, req.method, out.action);
res.status(out.status || 200).json(out.json);
} catch (e) {
const status = STATUS[e.code];
@@ -203,6 +211,20 @@ router.post(
})
);
router.put(
"/pins",
poolChange((_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
return {
json: { pin },
target: pin.userId,
action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
: `pool: unpin ${pin.username} from ${body.tool}`,
};
})
);
router.delete(
"/:name",
poolChange((req, body) => {