@sleep2agi/commhub-server 0.9.0-preview.111 → 0.9.0-preview.113
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/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth.ts +85 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +70 -26
- package/src/daemon-token-owner-http.test.ts +10 -5
- package/src/db.ts +15 -1
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-permissions.ts +45 -12
- package/src/requirement-due-reminders.ts +3 -3
- package/src/requirement-notify.ts +1 -1
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-errors.ts +4 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +2 -2
- package/src/requirements-migrate.ts +34 -0
- package/src/requirements-priority-lowest.test.ts +1 -1
- package/src/requirements-stats.ts +4 -1
- package/src/requirements.ts +62 -17
- package/src/rest-identity.ts +29 -4
- package/src/server.ts +67 -23
- package/src/tools.ts +55 -26
package/src/create-node.ts
CHANGED
|
@@ -232,47 +232,91 @@ export type DaemonResolveResult =
|
|
|
232
232
|
| { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
|
|
233
233
|
| { ok: false; error: string };
|
|
234
234
|
|
|
235
|
-
export
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
export type NodeCallerResolution =
|
|
236
|
+
| { ok: true; kind: "node"; nodeId: string; alias: string; networkId: string }
|
|
237
|
+
| { ok: true; kind: "unregistered"; alias: string; networkId: string }
|
|
238
|
+
| { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
|
|
239
|
+
|
|
240
|
+
type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
241
|
+
|
|
242
|
+
// nodes has no unique (network, alias). An unbound token that hits several
|
|
243
|
+
// rows is not rejected outright: the row owned by the token's issuer wins;
|
|
244
|
+
// if that is still not unique, the node_id on the latest session for the
|
|
245
|
+
// alias wins. Still not unique, then refuse.
|
|
246
|
+
function disambiguateUnboundAlias(
|
|
247
|
+
rows: NodeCallerRow[],
|
|
248
|
+
userId: string | null,
|
|
249
|
+
alias: string,
|
|
250
|
+
networkId: string,
|
|
251
|
+
): NodeCallerRow | undefined {
|
|
252
|
+
const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
|
|
253
|
+
if (owned.length === 1) return owned[0]; // board679-unbound-dup
|
|
254
|
+
const pool = owned.length > 1 ? owned : rows;
|
|
255
|
+
const recent = db.get<{ node_id: string | null }>(
|
|
256
|
+
`SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
|
|
257
|
+
alias,
|
|
258
|
+
networkId,
|
|
259
|
+
);
|
|
260
|
+
const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
|
|
261
|
+
return hit.length === 1 ? hit[0] : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// One lookup for every caller-identity entry. The token name is a hint.
|
|
265
|
+
// bound_node_id is the row: if that row is gone, refuse — do not look up by name.
|
|
266
|
+
// An unbound token with no row in its network stays the token name. First
|
|
267
|
+
// registration (report_status) still depends on that; sending and subscribing
|
|
268
|
+
// do not get a second exception.
|
|
269
|
+
export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
|
|
270
|
+
if (!tokenId) return { ok: false, reason: "not_a_node_token" };
|
|
246
271
|
const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
|
|
247
272
|
`SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
248
|
-
|
|
273
|
+
tokenId,
|
|
249
274
|
);
|
|
250
|
-
if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
|
|
251
|
-
return { ok: false,
|
|
252
|
-
}
|
|
253
|
-
if (tokRow.network_id !== opts.enforceNetworkId) {
|
|
254
|
-
return { ok: false, error: "caller_not_a_daemon" };
|
|
275
|
+
if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:") || !tokRow.network_id) {
|
|
276
|
+
return { ok: false, reason: "not_a_node_token" };
|
|
255
277
|
}
|
|
256
278
|
const tokenAlias = tokRow.name.slice(5);
|
|
257
279
|
type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
258
280
|
const rows = tokRow.bound_node_id
|
|
259
281
|
? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
|
|
260
282
|
: db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
|
|
261
|
-
const nodeRow = rows.length === 1
|
|
262
|
-
|
|
263
|
-
|
|
283
|
+
const nodeRow = rows.length === 1
|
|
284
|
+
? rows[0]
|
|
285
|
+
: (!tokRow.bound_node_id && rows.length > 1
|
|
286
|
+
? disambiguateUnboundAlias(rows, tokRow.user_id, tokenAlias, tokRow.network_id)
|
|
287
|
+
: undefined);
|
|
288
|
+
// board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
|
|
289
|
+
if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
|
|
290
|
+
if (!nodeRow && rows.length === 0) {
|
|
291
|
+
return { ok: true, kind: "unregistered", alias: tokenAlias, networkId: tokRow.network_id };
|
|
264
292
|
}
|
|
265
|
-
|
|
266
|
-
//
|
|
293
|
+
if (!nodeRow) return { ok: false, reason: "ambiguous" };
|
|
294
|
+
// Legacy unbound tokens keep their owner's authority. A token name is only a hint.
|
|
267
295
|
const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
|
|
268
296
|
const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
|
|
269
297
|
const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
|
|
270
|
-
if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false,
|
|
298
|
+
if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, reason: "not_owner" };
|
|
299
|
+
return { ok: true, kind: "node", nodeId: nodeRow.node_id, alias: nodeRow.alias, networkId: nodeRow.network_id };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function resolveCallerDaemonTokenBound(opts: {
|
|
303
|
+
callerTokenIsNetwork: boolean;
|
|
304
|
+
callerTokenId: string | null | undefined;
|
|
305
|
+
enforceNetworkId: string | null | undefined;
|
|
306
|
+
}): DaemonResolveResult {
|
|
307
|
+
if (!opts.callerTokenIsNetwork || !opts.callerTokenId || !opts.enforceNetworkId) {
|
|
308
|
+
return { ok: false, error: "caller_not_a_daemon" };
|
|
309
|
+
}
|
|
310
|
+
const resolved = resolveNodeCaller(opts.callerTokenId);
|
|
311
|
+
// No row yet is not a daemon. First registration stays on report_status.
|
|
312
|
+
if (!resolved.ok || resolved.kind !== "node" || resolved.networkId !== opts.enforceNetworkId) {
|
|
313
|
+
return { ok: false, error: "caller_not_a_daemon" };
|
|
314
|
+
}
|
|
271
315
|
return {
|
|
272
316
|
ok: true,
|
|
273
|
-
daemonNodeId:
|
|
274
|
-
daemonAlias:
|
|
275
|
-
networkId:
|
|
317
|
+
daemonNodeId: resolved.nodeId,
|
|
318
|
+
daemonAlias: resolved.alias,
|
|
319
|
+
networkId: resolved.networkId,
|
|
276
320
|
};
|
|
277
321
|
}
|
|
278
322
|
|
|
@@ -116,7 +116,7 @@ test("legacy owner and exact bound identity remain supported", async () => {
|
|
|
116
116
|
const denied = await fetch(`${base}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${revoked}` } });
|
|
117
117
|
expect(denied.status).toBe(401);
|
|
118
118
|
expect(await call(legacy, "ack_adopt_request", { request_id: requestId, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
|
|
119
|
-
expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted" });
|
|
119
|
+
expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted", binding_request_id: requestId });
|
|
120
120
|
const stop = await call(owner.token, "stop_node", { node_id: child, network_id: owner.network_id });
|
|
121
121
|
expect(stop.ok).toBe(true);
|
|
122
122
|
expect((await call(legacy, "get_stop_request", { request_id: stop.request_id })).ok).toBe(true);
|
|
@@ -169,9 +169,10 @@ test("fresh ID cannot mint another node alias", async () => {
|
|
|
169
169
|
expect((await call(legacy, "list_my_children")).ok).toBe(true);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
test("duplicate legacy aliases use bound IDs and unbound
|
|
172
|
+
test("duplicate legacy aliases use bound IDs and an unbound token follows its owner", async () => {
|
|
173
173
|
// Historical duplicate rows: the member's row was inserted first. Neither
|
|
174
|
-
// lookup order nor alias can redirect
|
|
174
|
+
// lookup order nor alias can redirect an exact-bound token. An unbound
|
|
175
|
+
// token resolves to the row its issuer owns.
|
|
175
176
|
const { hashToken, generateNetworkToken } = await import("./db.js");
|
|
176
177
|
const tokens: string[] = [];
|
|
177
178
|
for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
|
|
@@ -188,9 +189,13 @@ test("duplicate legacy aliases use bound IDs and unbound ambiguity fails closed"
|
|
|
188
189
|
expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
|
|
189
190
|
}
|
|
190
191
|
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
|
|
191
|
-
expect(await call(tokens[1], "list_my_children")).
|
|
192
|
+
expect((await call(tokens[1], "list_my_children")).ok).toBe(true);
|
|
193
|
+
expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(true);
|
|
194
|
+
expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(false);
|
|
192
195
|
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
|
|
193
|
-
expect(await call(tokens[0], "list_my_children")).
|
|
196
|
+
expect((await call(tokens[0], "list_my_children")).ok).toBe(true);
|
|
197
|
+
expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(true);
|
|
198
|
+
expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(false);
|
|
194
199
|
expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
|
|
195
200
|
});
|
|
196
201
|
|
package/src/db.ts
CHANGED
|
@@ -646,12 +646,26 @@ for (const ddl of [
|
|
|
646
646
|
// 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
|
|
647
647
|
"ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
|
|
648
648
|
"ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
|
|
649
|
+
// #711 不绑网络的用户令牌分两类:'login' = 浏览器 / app 的登录会话;'cli' = anet 命令行登录、
|
|
650
|
+
// POST /api/auth/tokens 建的具名脚本令牌。只用于展示和 keep_cli_tokens 那条显式保留路径;节点 / 网络令牌(network_id 非空)不填。
|
|
651
|
+
"ALTER TABLE api_tokens ADD COLUMN kind TEXT",
|
|
649
652
|
]) {
|
|
650
653
|
try { db.exec(ddl); }
|
|
651
654
|
catch (e: any) {
|
|
652
655
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
// #711 回填 kind:只碰 kind 还是 NULL 的非网络令牌(幂等;回滚到旧 Hub 期间新签的行,下次启动照样补上)。
|
|
659
|
+
// 能明确认出是 anet 命令行的才算 'cli':scope='full'(POST /api/auth/tokens 建的具名令牌),
|
|
660
|
+
// 或 client_label 去掉首尾空格后以「anet 」开头(anet 自报的「anet <版本> · <主机> · <子命令>」)。其余一律 'login'。
|
|
661
|
+
// 用 SUBSTR + = 而不是 LIKE:SQLite 的 LIKE 不分大小写、PG 分,两边结果会不一样;= 两边都区分大小写,
|
|
662
|
+
// 与运行时 isAnetClientLabel(auth.ts)同一条规则。
|
|
663
|
+
db.run(
|
|
664
|
+
`UPDATE api_tokens SET kind = CASE
|
|
665
|
+
WHEN scope = 'full' OR SUBSTR(TRIM(client_label), 1, 5) = 'anet ' THEN 'cli'
|
|
666
|
+
ELSE 'login' END
|
|
667
|
+
WHERE network_id IS NULL AND kind IS NULL`,
|
|
668
|
+
);
|
|
655
669
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
|
|
656
670
|
catch (e: any) { /* index may already exist */ void e; }
|
|
657
671
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
|
|
@@ -969,7 +983,7 @@ db.exec(`
|
|
|
969
983
|
requirement_id TEXT PRIMARY KEY,
|
|
970
984
|
network_id TEXT NOT NULL,
|
|
971
985
|
title TEXT NOT NULL,
|
|
972
|
-
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
|
|
986
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned')),
|
|
973
987
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
|
|
974
988
|
due_on TEXT,
|
|
975
989
|
assignee TEXT,
|