@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.
@@ -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 function resolveCallerDaemonTokenBound(opts: {
236
- callerTokenIsNetwork: boolean;
237
- callerTokenId: string | null | undefined;
238
- enforceNetworkId: string | null | undefined;
239
- }): DaemonResolveResult {
240
- if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
- return { ok: false, error: "caller_not_a_daemon" };
242
- }
243
- if (!opts.enforceNetworkId) {
244
- return { ok: false, error: "caller_not_a_daemon" };
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
- opts.callerTokenId,
273
+ tokenId,
249
274
  );
250
- if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
- return { ok: false, error: "caller_not_a_daemon" };
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 ? rows[0] : undefined;
262
- if (!nodeRow) {
263
- return { ok: false, error: "caller_not_a_daemon" };
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
- // Legacy daemon tokens have no bound_node_id: retain their owner's authority.
266
- // A token name is a lookup hint, never proof of authority over that node.
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, error: "caller_not_a_daemon" };
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: nodeRow.node_id,
274
- daemonAlias: nodeRow.alias,
275
- networkId: nodeRow.network_id,
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 ambiguity fails closed", async () => {
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 the later owner's exact-bound token.
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")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
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")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
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,