@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +30 -0
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +79 -1
- package/src/requirements-mcp.test.ts +3 -0
- package/src/requirements.ts +103 -33
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-tasks-http.test.ts +2 -1
- package/src/scheduled-tasks.ts +12 -0
- package/src/server.ts +345 -41
- package/src/tools.ts +98 -31
package/src/tools.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
|
|
|
8
8
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
9
9
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
10
10
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
-
import { canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId } from "./network-scope.js";
|
|
11
|
+
import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
12
|
+
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
13
|
+
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
12
14
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
13
15
|
import {
|
|
14
16
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
@@ -129,7 +131,42 @@ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }
|
|
|
129
131
|
return { ok: true, node_id: id };
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[], _userId: string): void {
|
|
135
|
+
const denied = (tool: string) => ({
|
|
136
|
+
content: [{
|
|
137
|
+
type: "text" as const,
|
|
138
|
+
text: JSON.stringify({
|
|
139
|
+
ok: false,
|
|
140
|
+
error: "agent_access_restricted",
|
|
141
|
+
message: `${tool} is not available to restricted members in this network; pass network_id of a network where you are not restricted, or ask a network admin for access`,
|
|
142
|
+
}),
|
|
143
|
+
}],
|
|
144
|
+
});
|
|
145
|
+
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
146
|
+
if (!RESTRICTED_MEMBER_TOOLS.has(name)) {
|
|
147
|
+
const args = callArgs[0];
|
|
148
|
+
const raw = args && typeof args === "object" ? (args as Record<string, unknown>).network_id : undefined;
|
|
149
|
+
const net = typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
|
150
|
+
if (!net || restrictedNets.includes(net)) return denied(name);
|
|
151
|
+
}
|
|
152
|
+
return handler(...callArgs);
|
|
153
|
+
};
|
|
154
|
+
const anyServer = server as any;
|
|
155
|
+
for (const method of ["tool", "registerTool"] as const) {
|
|
156
|
+
const original = anyServer[method].bind(server);
|
|
157
|
+
anyServer[method] = (...regArgs: any[]) => {
|
|
158
|
+
const last = regArgs.length - 1;
|
|
159
|
+
if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
|
|
160
|
+
return original(...regArgs);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
166
|
+
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
167
|
+
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
168
|
+
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
169
|
+
if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
|
|
133
170
|
// Default from_session for outbound tools — extracted from the calling
|
|
134
171
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
135
172
|
// an agent's send_task call always claimed from='hub' and peer agents
|
|
@@ -176,6 +213,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
176
213
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
177
214
|
return canRestWriteNetwork(mcpAuthCtx, netId, false);
|
|
178
215
|
};
|
|
216
|
+
// 人类侧写入(私信):不看 Agent 授权,受限成员也可以。
|
|
217
|
+
const canWriteHuman = (effectiveNetworkId?: string | null): boolean =>
|
|
218
|
+
canRestWriteNetworkAsHuman(mcpAuthCtx, enforceNetworkId ?? effectiveNetworkId ?? null, false);
|
|
219
|
+
// 给 Agent 发任务 / 消息的准入。不受限:原判据。受限网络里:目标必须是授权且 can_message 的 Agent,
|
|
220
|
+
// 发件人固定为自己的用户名;目标不存在与没授权返回同一个错误,不留 alias 探测差异。
|
|
221
|
+
const agentSendDenied = (effectiveNetworkId: string | null, alias: string, clientFrom: string | undefined, action: "send_task" | "write", meta?: unknown) => {
|
|
222
|
+
if (!effectiveNetworkId || !restrictedNets.includes(effectiveNetworkId)) {
|
|
223
|
+
return canWrite(effectiveNetworkId) ? null : writeDeniedReply(effectiveNetworkId, action);
|
|
224
|
+
}
|
|
225
|
+
if (!canWriteHuman(effectiveNetworkId)) return writeDeniedReply(effectiveNetworkId, action);
|
|
226
|
+
const requestedFrom = clientFrom?.trim();
|
|
227
|
+
if (requestedFrom && requestedFrom !== callerAlias) {
|
|
228
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
|
|
229
|
+
}
|
|
230
|
+
const targetAlias = resolveCanonicalAlias(effectiveNetworkId, alias).alias;
|
|
231
|
+
const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, effectiveNetworkId);
|
|
232
|
+
if (!session || !canMessageAgent(enforceUserId!, effectiveNetworkId, { alias: targetAlias, nodeId: session.node_id })) {
|
|
233
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }) }] };
|
|
234
|
+
}
|
|
235
|
+
const rawAttachments = meta && typeof meta === "object" ? (meta as { attachments?: unknown }).attachments : undefined;
|
|
236
|
+
if (restrictedMemberAttachmentsDenied(enforceUserId!, callerAlias ?? "", effectiveNetworkId, rawAttachments)) {
|
|
237
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "attachment_not_accessible" }) }] };
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
179
241
|
|
|
180
242
|
// #517: name the REAL cause. The old catch-all blamed permissions for
|
|
181
243
|
// what was actually an unresolvable network, sending operators down the
|
|
@@ -379,24 +441,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
379
441
|
}
|
|
380
442
|
};
|
|
381
443
|
|
|
382
|
-
type ReadScope =
|
|
444
|
+
type ReadScope = RestNetworkScope;
|
|
383
445
|
|
|
384
446
|
// Delegates to the shared membership query (network-scope.ts) — was a
|
|
385
447
|
// byte-for-byte duplicate of getUserNetworkIds before #517.
|
|
386
448
|
const getReadableNetworkIds = (): string[] =>
|
|
387
449
|
enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
388
450
|
|
|
451
|
+
// 受限网络挂在 scope.agentRestriction 上:addReadScope(= addNetworkScope)对它们 fail-closed,
|
|
452
|
+
// 逐条审过的工具改用 addAgentNetworkScope / addOwnTrafficScope 按授权放行。
|
|
453
|
+
const withRestriction = (scope: ReadScope): ReadScope => {
|
|
454
|
+
if (!restrictedNets.length || !enforceUserId) return scope;
|
|
455
|
+
const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
|
|
456
|
+
const restricted = restrictedNets.filter((id) => inScope.includes(id));
|
|
457
|
+
return restricted.length ? { ...scope, agentRestriction: { userId: enforceUserId, username: callerAlias ?? "", networkIds: restricted } } : scope;
|
|
458
|
+
};
|
|
389
459
|
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
390
460
|
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
391
461
|
if (enforceNetworkId) {
|
|
392
462
|
const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
|
|
393
|
-
return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
|
|
463
|
+
return role ? { networkId: enforceNetworkId, networkIds: null } : { networkId: null, networkIds: [], denied: "not a member of token network" };
|
|
394
464
|
}
|
|
395
465
|
if (clientNetId) {
|
|
396
466
|
const role = getUserNetworkRole(enforceUserId, clientNetId);
|
|
397
|
-
return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
|
|
467
|
+
return role ? withRestriction({ networkId: clientNetId, networkIds: null }) : { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
398
468
|
}
|
|
399
|
-
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
469
|
+
return withRestriction({ networkId: null, networkIds: getReadableNetworkIds() });
|
|
400
470
|
};
|
|
401
471
|
|
|
402
472
|
// RFC-027 §2.3 race-free invariant — assertNodeActive lives in
|
|
@@ -407,20 +477,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
407
477
|
// Dispatch (POST /api/task + /api/broadcast). Per
|
|
408
478
|
// per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
|
|
409
479
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
params.push(scope.networkId);
|
|
414
|
-
return sql;
|
|
415
|
-
}
|
|
416
|
-
if (scope.networkIds) {
|
|
417
|
-
if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
|
|
418
|
-
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
419
|
-
sql += ` AND ${column} IN (${placeholders})`;
|
|
420
|
-
params.push(...scope.networkIds);
|
|
421
|
-
}
|
|
422
|
-
return sql;
|
|
423
|
-
};
|
|
480
|
+
// 默认读作用域:对受限网络 fail-closed(见 network-scope.ts addNetworkScope)。
|
|
481
|
+
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string =>
|
|
482
|
+
addNetworkScope(sql, params, scope, column);
|
|
424
483
|
|
|
425
484
|
type DeliveryTarget =
|
|
426
485
|
| { state: "online"; alias: string; session: any }
|
|
@@ -1433,7 +1492,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1433
1492
|
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
1434
1493
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
1435
1494
|
const params: any[] = [];
|
|
1436
|
-
sql =
|
|
1495
|
+
sql = addAgentNetworkScope(sql, params, readScope, { alias: "alias", nodeId: "node_id" });
|
|
1437
1496
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
1438
1497
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1439
1498
|
const aliasFilter = parseAliasFilter(filter_alias);
|
|
@@ -1454,7 +1513,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1454
1513
|
// under existing callers.
|
|
1455
1514
|
const summaryParams: any[] = [];
|
|
1456
1515
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
1457
|
-
summarySql =
|
|
1516
|
+
summarySql = addAgentNetworkScope(summarySql, summaryParams, readScope, { alias: "alias", nodeId: "node_id" });
|
|
1458
1517
|
summarySql += " GROUP BY status";
|
|
1459
1518
|
const summary = db.all(summarySql, ...summaryParams);
|
|
1460
1519
|
|
|
@@ -1488,7 +1547,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1488
1547
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
1489
1548
|
const sessionParams: any[] = [alias];
|
|
1490
1549
|
let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
|
|
1491
|
-
sessionSql =
|
|
1550
|
+
sessionSql = addAgentNetworkScope(sessionSql, sessionParams, readScope, { alias: "alias", nodeId: "node_id" });
|
|
1492
1551
|
const session = db.get(sessionSql, ...sessionParams);
|
|
1493
1552
|
|
|
1494
1553
|
const pendingParams: any[] = [alias];
|
|
@@ -1543,8 +1602,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1543
1602
|
// existence + ownership of foreign parents via the difference in
|
|
1544
1603
|
// error codes. Run canWrite first so a viewer ALWAYS gets the
|
|
1545
1604
|
// same `permission_denied` regardless of parent state.
|
|
1546
|
-
|
|
1547
|
-
|
|
1605
|
+
{
|
|
1606
|
+
const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "send_task", meta);
|
|
1607
|
+
if (sendDenied) return sendDenied;
|
|
1548
1608
|
}
|
|
1549
1609
|
|
|
1550
1610
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
@@ -1784,7 +1844,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1784
1844
|
},
|
|
1785
1845
|
async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1786
1846
|
const effectiveNetId = getNetworkId(netId);
|
|
1787
|
-
|
|
1847
|
+
{ const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "write"); if (sendDenied) return sendDenied; }
|
|
1788
1848
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
1789
1849
|
const targetAlias = canonical.alias;
|
|
1790
1850
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
@@ -2292,7 +2352,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2292
2352
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
2293
2353
|
const params: any[] = [task_id];
|
|
2294
2354
|
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
2295
|
-
sql =
|
|
2355
|
+
sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2296
2356
|
const task = db.get<any>(sql, ...params);
|
|
2297
2357
|
return {
|
|
2298
2358
|
content: [{
|
|
@@ -2361,7 +2421,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2361
2421
|
}
|
|
2362
2422
|
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
|
|
2363
2423
|
const params: any[] = [];
|
|
2364
|
-
sql =
|
|
2424
|
+
sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2365
2425
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
2366
2426
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
2367
2427
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
@@ -2377,7 +2437,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2377
2437
|
// Stats
|
|
2378
2438
|
const statsParams: any[] = [];
|
|
2379
2439
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
2380
|
-
statsSql =
|
|
2440
|
+
statsSql = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2381
2441
|
statsSql += " GROUP BY status";
|
|
2382
2442
|
const stats = db.all(statsSql, ...statsParams);
|
|
2383
2443
|
|
|
@@ -2511,7 +2571,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2511
2571
|
if (fromMismatch) return fromMismatch;
|
|
2512
2572
|
const from_session = defaultFrom(_fromIn);
|
|
2513
2573
|
const effectiveNetId = getNetworkId(netId);
|
|
2514
|
-
|
|
2574
|
+
// 人类 ↔ 人类私信:受限成员也能发(canWriteHuman 不看 Agent 授权),但发件人固定为自己的用户名。
|
|
2575
|
+
if (!canWriteHuman(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2576
|
+
if (effectiveNetId && restrictedNets.includes(effectiveNetId) && _fromIn?.trim() && _fromIn.trim() !== callerAlias) {
|
|
2577
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
|
|
2578
|
+
}
|
|
2515
2579
|
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2516
2580
|
|
|
2517
2581
|
if (!to_user_id && !to_username) {
|
|
@@ -2592,8 +2656,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2592
2656
|
// OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
|
|
2593
2657
|
db.run(
|
|
2594
2658
|
`INSERT INTO user_inbox
|
|
2595
|
-
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
2596
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
2659
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id)
|
|
2660
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
|
2597
2661
|
ON CONFLICT(message_id) DO NOTHING`,
|
|
2598
2662
|
[
|
|
2599
2663
|
messageId,
|
|
@@ -2610,6 +2674,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2610
2674
|
// 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
|
|
2611
2675
|
// 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
|
|
2612
2676
|
meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
|
|
2677
|
+
// 多用户私信:只有人(用户令牌)发的才记发信人 user_id;节点令牌发的是 Agent 消息。
|
|
2678
|
+
!callerTokenIsNetwork && enforceUserId ? enforceUserId : null,
|
|
2613
2679
|
],
|
|
2614
2680
|
);
|
|
2615
2681
|
});
|
|
@@ -5697,6 +5763,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5697
5763
|
const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
|
|
5698
5764
|
const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
|
|
5699
5765
|
const reqFields = {
|
|
5766
|
+
tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
|
|
5700
5767
|
name: z.string().min(1).max(80).optional(),
|
|
5701
5768
|
priority: z.enum(["high", "normal", "low"]).optional(),
|
|
5702
5769
|
column: z.enum(["pool", "doing", "done"]).optional(),
|