@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.61

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.
Files changed (143) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-dedup-request-id.test.ts +229 -0
  99. package/src/send-reply-agent-warning.test.ts +202 -0
  100. package/src/send-reply-alias-optional.test.ts +131 -0
  101. package/src/send-reply-attachments.test.ts +458 -0
  102. package/src/send_dedup.test.ts +54 -0
  103. package/src/send_dedup.ts +52 -6
  104. package/src/serve-error.test.ts +38 -0
  105. package/src/serve-error.ts +27 -0
  106. package/src/server.ts +3901 -0
  107. package/src/shared/external-schedule-contract.ts +60 -0
  108. package/src/side-thread-command-transport.test.ts +302 -0
  109. package/src/side-thread-command-transport.ts +318 -0
  110. package/src/side-thread-http-integration.test.ts +233 -0
  111. package/src/side-thread-http.ts +504 -0
  112. package/src/side-thread-production.test.ts +72 -0
  113. package/src/side-thread-production.ts +118 -0
  114. package/src/side-thread.test.ts +1067 -0
  115. package/src/side-thread.ts +2495 -0
  116. package/src/skillhub-http.test.ts +92 -0
  117. package/src/start-node.test.ts +109 -0
  118. package/src/stop-delete-node.test.ts +1164 -0
  119. package/src/task-auth-origin-http.test.ts +91 -0
  120. package/src/task-auth-origin.test.ts +24 -0
  121. package/src/task-auth-origin.ts +7 -0
  122. package/src/task-consumption.test.ts +482 -0
  123. package/src/task-diagnostic-http.test.ts +149 -0
  124. package/src/task-diagnostic.test.ts +115 -0
  125. package/src/task-diagnostic.ts +118 -0
  126. package/src/task-idempotency-mcp.test.ts +142 -0
  127. package/src/task-idempotency.test.ts +60 -0
  128. package/src/task-idempotency.ts +75 -0
  129. package/src/task-lifecycle-watcher.test.ts +204 -0
  130. package/src/task-lifecycle-watcher.ts +49 -0
  131. package/src/task-network-resolution-http.test.ts +141 -0
  132. package/src/tasks-pagination-http.test.ts +141 -0
  133. package/src/tools-list-json-schema.test.ts +66 -0
  134. package/src/tools.ts +3280 -206
  135. package/src/update-provider.test.ts +65 -0
  136. package/src/upload-early-reject-guard.test.ts +155 -0
  137. package/src/uploads-http.test.ts +97 -15
  138. package/src/uploads.test.ts +66 -0
  139. package/src/uploads.ts +130 -5
  140. package/src/user-inbox-by-agent-http.test.ts +161 -0
  141. package/src/user-inbox-netid-notnull.test.ts +122 -0
  142. package/src/user-inbox-read-http.test.ts +223 -0
  143. package/src/user-inbox-schema.test.ts +54 -0
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { db } from "./db.js";
3
+ import { parseStoredTags } from "./node-attrs-validate.js";
3
4
 
4
5
  // Regression test for SELECT * fragility on GET /api/nodes.
5
6
  //
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
41
42
  catch { /* malformed */ }
42
43
  }
43
44
  const { config_snapshot, ...rest } = r;
44
- return { ...rest, role };
45
+ return {
46
+ ...rest,
47
+ tags: parseStoredTags(r.tags),
48
+ attrs_revision: Number(r.attrs_revision ?? 0),
49
+ role,
50
+ };
45
51
  }
46
52
 
47
53
  describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
66
72
  `SELECT node_id, node_name, alias, runtime, model,
67
73
  config_path, channels, server, hostname,
68
74
  network_id, created_at, updated_at,
69
- config_snapshot
75
+ config_snapshot, display_name, team, tags, attrs_revision
70
76
  FROM nodes WHERE node_id = ?1`,
71
77
  TEST_NODE,
72
78
  );
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
85
91
  expect(row).not.toHaveProperty("config_snapshot");
86
92
  // role is EXTRACTED from config_snapshot JSON, not the raw blob
87
93
  expect(row.role).toBe("host_supervisor");
88
- // Belt: full key set is exactly the 13 contract fields
94
+ // Belt: the key set is exactly the dashboard-facing contract.
95
+ // NOTE: this file MIRRORS the handler's SQL rather than calling it, so
96
+ // it can drift — it already lagged behind `lifecycle_state` /
97
+ // `avatar_url`, which the handler selects but this mirror does not.
98
+ // Treat a diff here as "re-read the handler", not as proof of the wire
99
+ // shape; node-attrs.test.ts asserts the real HTTP response.
89
100
  expect(Object.keys(row).sort()).toEqual([
90
- "alias", "channels", "config_path", "created_at", "hostname",
91
- "model", "network_id", "node_id", "node_name", "role", "runtime",
92
- "server", "updated_at",
101
+ "alias", "attrs_revision", "channels", "config_path", "created_at",
102
+ "display_name", "hostname", "model", "network_id", "node_id",
103
+ "node_name", "role", "runtime", "server", "tags", "team",
104
+ "updated_at",
93
105
  ]);
94
106
  });
95
107
 
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
102
114
  );
103
115
  let row = mapRow(db.get<Record<string, unknown>>(
104
116
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
105
- server, hostname, network_id, created_at, updated_at, config_snapshot
117
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
118
+ display_name, team, tags, attrs_revision
106
119
  FROM nodes WHERE node_id = ?1`,
107
120
  TEST_NODE,
108
121
  )!);
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
113
126
  db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
114
127
  row = mapRow(db.get<Record<string, unknown>>(
115
128
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
116
- server, hostname, network_id, created_at, updated_at, config_snapshot
129
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
130
+ display_name, team, tags, attrs_revision
117
131
  FROM nodes WHERE node_id = ?1`,
118
132
  TEST_NODE,
119
133
  )!);
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
124
138
  [JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
125
139
  row = mapRow(db.get<Record<string, unknown>>(
126
140
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
127
- server, hostname, network_id, created_at, updated_at, config_snapshot
141
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
142
+ display_name, team, tags, attrs_revision
128
143
  FROM nodes WHERE node_id = ?1`,
129
144
  TEST_NODE,
130
145
  )!);
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
141
156
  );
142
157
  const rows = db.all<Record<string, unknown>>(
143
158
  `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
144
- server, hostname, network_id, created_at, updated_at, config_snapshot
159
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
160
+ display_name, team, tags, attrs_revision
145
161
  FROM nodes WHERE network_id = ?1`,
146
162
  TEST_NETWORK,
147
163
  ).map(mapRow);
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
10
10
 
11
11
  // First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
12
12
  // To test full-strength rules, seed a dummy admin first.
13
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
14
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
15
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
16
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
13
17
  beforeAll(() => {
14
18
  // Idempotent: if DB file already has users (from a previous run on the
15
19
  // same temp path), this errors with "username already taken" → that's
16
20
  // fine, the goal is just "ensure at least one user exists so subsequent
17
21
  // registers hit the strict path".
18
22
  register("_seed_admin", "BootstrapPw1", undefined, "seed");
19
- });
23
+ }, 30_000);
20
24
 
21
25
  describe("register — username rules", () => {
22
26
  it("rejects empty username", () => {
package/src/auth.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
6
7
 
7
8
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
8
9
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -87,11 +88,32 @@ export function register(username: string, password: string, email?: string, dis
87
88
  [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
88
89
  );
89
90
 
90
- // Auto-create default network + add as owner member
91
+ // Auto-create the user's own network + add as owner member.
92
+ //
93
+ // Name it after the user rather than the literal "default". Every
94
+ // registration used to mint a network called "default", so a hub with N
95
+ // users showed N identical "default" rows in the Dashboard sidebar with
96
+ // no way to tell them apart (23 of them on the live hub, 22 empty).
97
+ // Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
98
+ // a network as:
99
+ // me.user.default_network_id
100
+ // || networks.find(n => n.network_name === "default")
101
+ // || networks[0]
102
+ // Checked against the live hub: GET /api/auth/me returns
103
+ // {user_id, username, display_name, email, role} — there is no
104
+ // default_network_id field, and nothing in server/ ever writes one. So the
105
+ // first term is always undefined and the name lookup is the operative path,
106
+ // not a fallback.
107
+ //
108
+ // After this change that lookup misses for new users and they land on
109
+ // networks[0]. That still resolves to this network because listNetworks()
110
+ // is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
111
+ // user's oldest. Reordering that query would silently point those three
112
+ // commands at the wrong network.
91
113
  const networkId = generateId("net");
92
114
  db.run(
93
115
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
94
- [networkId, "default", userId, "Auto-created default network"]
116
+ [networkId, username, userId, `Auto-created network for ${username}`]
95
117
  );
96
118
  db.run(
97
119
  "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
@@ -191,18 +213,67 @@ export function login(username: string, password: string): AuthResult {
191
213
  };
192
214
  }
193
215
 
194
- /** Create a network-scoped token (ntok_) for a specific node */
195
- export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
216
+ /**
217
+ * Create a network-scoped token (ntok_) for a specific node.
218
+ *
219
+ * RFC-036: callers that provide nodeId atomically establish the immutable
220
+ * node-owner binding before the plaintext token is returned. The 3-argument
221
+ * legacy form remains available for old clients, but its token has no bound
222
+ * node id and therefore cannot consume external-schedule edit intents.
223
+ */
224
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
196
225
  // Verify user is a member of this network with write access
197
226
  const role = getUserNetworkRole(userId, networkId);
198
227
  if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
228
+ if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
229
+ if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
230
+ return { ok: false, error: "invalid_node_id" };
231
+ }
199
232
  const token = generateNetworkToken();
200
233
  const tokenId = generateId("tok");
201
- db.run(
202
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
203
- [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
204
- );
205
- return { ok: true, token };
234
+ try {
235
+ db.transaction(() => {
236
+ if (nodeId) {
237
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
238
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
239
+ nodeId,
240
+ );
241
+ if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
242
+ // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
243
+ // refresh must not turn knowledge of a legacy node_id into ownership.
244
+ // They remain read-only until a separately authorized migration.
245
+ if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
246
+ if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
247
+ db.run(
248
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
249
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
250
+ ON CONFLICT(node_id) DO UPDATE SET
251
+ node_name = COALESCE(nodes.node_name, ?2),
252
+ alias = COALESCE(nodes.alias, ?2),
253
+ network_id = COALESCE(nodes.network_id, ?3),
254
+ owner_user_id = COALESCE(nodes.owner_user_id, ?4),
255
+ updated_at = datetime('now')`,
256
+ [nodeId, nodeName, networkId, userId],
257
+ );
258
+ if (!existing) {
259
+ db.run(
260
+ `INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
261
+ VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
262
+ [userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
263
+ );
264
+ }
265
+ }
266
+ db.run(
267
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
268
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
269
+ );
270
+ });
271
+ } catch (error: any) {
272
+ const code = String(error?.message || "node_token_create_failed");
273
+ if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
274
+ throw error;
275
+ }
276
+ return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
206
277
  }
207
278
 
208
279
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
@@ -235,7 +306,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
235
306
 
236
307
  export function getUserNetworks(userId: string) {
237
308
  return db.all<any>(
238
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
309
+ `SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
239
310
  userId);
240
311
  }
241
312
 
@@ -280,7 +351,7 @@ export function listTokens(userId: string) {
280
351
  }
281
352
 
282
353
  export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
283
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
354
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
284
355
  if (!net) return { ok: false, error: "network not found" };
285
356
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
286
357
  const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
@@ -290,13 +361,18 @@ export function renameNetwork(userId: string, networkId: string, newName: string
290
361
  }
291
362
 
292
363
  export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
293
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
364
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
294
365
  if (!net) return { ok: false, error: "network not found" };
295
366
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
296
367
  // Check if any sessions/tasks still reference this network
297
368
  const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
298
369
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
299
370
  db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
371
+ // PR #519 codex catch: leaving membership rows behind made deleted
372
+ // networks count toward getUserNetworkIds — a stale row could flip a
373
+ // single-network user to "ambiguous" or auto-resolve writes INTO the
374
+ // deleted network. Remove memberships with the network.
375
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
300
376
  return { ok: true };
301
377
  }
302
378
 
@@ -444,7 +520,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
444
520
  }
445
521
 
446
522
  export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
447
- const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
523
+ const invite = db.get<any>(
524
+ "SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
525
+ inviteCode,
526
+ );
448
527
  if (!invite) return { ok: false, error: "invalid invite code" };
449
528
  if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
450
529
  if (invite.expires_at) {
@@ -469,7 +548,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
469
548
  /** Get all networks a user is a member of (replaces owner-only query) */
470
549
  export function getUserAllNetworks(userId: string) {
471
550
  return db.all<any>(
472
- `SELECT n.*, nm.role as member_role
551
+ `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role
473
552
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
474
553
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
475
554
  userId);
@@ -0,0 +1,163 @@
1
+ // #462 — unit tests for the avatar_url XSS boundary (avatar-validate.ts).
2
+ //
3
+ // The validator's whole job is to keep hostile values out of a dashboard
4
+ // <img src>; every rejection case here is a concrete injection shape.
5
+ // Live-gate check: replace validateAvatarUrl with `return { ok: true,
6
+ // value: String(raw) }` and the javascript:/data:/control-char tests go
7
+ // red.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { MAX_AVATAR_URL_LENGTH, validateAvatarUrl } from "./avatar-validate";
11
+
12
+ describe("#462 validateAvatarUrl", () => {
13
+ test("accepts plain https URL", () => {
14
+ const r = validateAvatarUrl("https://cdn.example.com/a/b.png");
15
+ expect(r).toEqual({ ok: true, value: "https://cdn.example.com/a/b.png" });
16
+ });
17
+
18
+ test("accepts http URL and trims surrounding whitespace", () => {
19
+ const r = validateAvatarUrl(" http://example.com/x.jpg ");
20
+ expect(r).toEqual({ ok: true, value: "http://example.com/x.jpg" });
21
+ });
22
+
23
+ test("accepts URL with query string", () => {
24
+ const r = validateAvatarUrl("https://example.com/img?size=64&v=2");
25
+ expect(r.ok).toBe(true);
26
+ });
27
+
28
+ test("null / undefined / empty / whitespace-only → clear (value null)", () => {
29
+ expect(validateAvatarUrl(null)).toEqual({ ok: true, value: null });
30
+ expect(validateAvatarUrl(undefined)).toEqual({ ok: true, value: null });
31
+ expect(validateAvatarUrl("")).toEqual({ ok: true, value: null });
32
+ expect(validateAvatarUrl(" ")).toEqual({ ok: true, value: null });
33
+ });
34
+
35
+ test("rejects non-string types", () => {
36
+ expect(validateAvatarUrl(42).ok).toBe(false);
37
+ expect(validateAvatarUrl({ url: "https://x" }).ok).toBe(false);
38
+ expect(validateAvatarUrl(["https://x"]).ok).toBe(false);
39
+ expect(validateAvatarUrl(true).ok).toBe(false);
40
+ });
41
+
42
+ test("rejects javascript: pseudo-protocol", () => {
43
+ expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
44
+ expect(validateAvatarUrl("JavaScript:alert(1)").ok).toBe(false);
45
+ expect(validateAvatarUrl("JAVASCRIPT:alert(document.cookie)").ok).toBe(false);
46
+ });
47
+
48
+ test("rejects javascript: smuggled via embedded tab/newline (URL() would strip them)", () => {
49
+ expect(validateAvatarUrl("java\tscript:alert(1)").ok).toBe(false);
50
+ expect(validateAvatarUrl("java\nscript:alert(1)").ok).toBe(false);
51
+ expect(validateAvatarUrl("java\rscript:alert(1)").ok).toBe(false);
52
+ });
53
+
54
+ test("rejects data: / vbscript: / file: / blob: and other non-http protocols", () => {
55
+ expect(validateAvatarUrl("data:text/html,<script>alert(1)</script>").ok).toBe(false);
56
+ expect(validateAvatarUrl("data:image/png;base64,iVBORw0KGgo=").ok).toBe(false);
57
+ expect(validateAvatarUrl("vbscript:msgbox(1)").ok).toBe(false);
58
+ expect(validateAvatarUrl("file:///etc/passwd").ok).toBe(false);
59
+ expect(validateAvatarUrl("blob:https://example.com/uuid").ok).toBe(false);
60
+ expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
61
+ });
62
+
63
+ test("rejects protocol-relative / non-URL / schemeless strings", () => {
64
+ // SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
65
+ // (/avatars/<name>.<ext>) are now ACCEPTED — that case moved to the
66
+ // dedicated relative-branch suite below. Everything here stays rejected.
67
+ expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
68
+ expect(validateAvatarUrl("not a url").ok).toBe(false);
69
+ expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
70
+ });
71
+
72
+ test("rejects embedded whitespace and control characters", () => {
73
+ expect(validateAvatarUrl("https://example.com/a b.png").ok).toBe(false);
74
+ expect(validateAvatarUrl("https://example.com/a\u0000.png").ok).toBe(false);
75
+ expect(validateAvatarUrl("https://example.com/a\u009f.png").ok).toBe(false);
76
+ });
77
+
78
+ test("normalizes to parsed.href: markup-hostile chars come out percent-encoded (finding 3)", () => {
79
+ const r1 = validateAvatarUrl('https://example.com/a"b.png');
80
+ expect(r1).toEqual({ ok: true, value: "https://example.com/a%22b.png" });
81
+ const r2 = validateAvatarUrl("https://example.com/</script><script>.png");
82
+ expect(r2.ok).toBe(true);
83
+ if (r2.ok) {
84
+ expect(r2.value).not.toContain("<");
85
+ expect(r2.value).not.toContain(">");
86
+ }
87
+ const r3 = validateAvatarUrl("https://example.com/a`b.png");
88
+ expect(r3.ok).toBe(true);
89
+ if (r3.ok) expect(r3.value).not.toContain("`");
90
+ });
91
+
92
+ test("rejects values whose href still carries attribute-breaking chars (single quote)", () => {
93
+ // WHATWG URL leaves ' un-encoded in the PATH component — href alone
94
+ // doesn't neutralize it there, so the validator must refuse.
95
+ expect(validateAvatarUrl("https://example.com/a'b.png").ok).toBe(false);
96
+ // In the QUERY component ' IS percent-encoded by href → accepted,
97
+ // but the stored value must carry no raw quote.
98
+ const q = validateAvatarUrl("https://example.com/x.png?a='1'");
99
+ expect(q.ok).toBe(true);
100
+ if (q.ok) expect(q.value).not.toContain("'");
101
+ });
102
+
103
+ test("rejects URLs with embedded credentials (userinfo)", () => {
104
+ expect(validateAvatarUrl("https://user:pass@example.com/a.png").ok).toBe(false);
105
+ expect(validateAvatarUrl("https://user@example.com/a.png").ok).toBe(false);
106
+ expect(validateAvatarUrl("http://:secret@example.com/a.png").ok).toBe(false);
107
+ });
108
+
109
+ test("enforces the length cap", () => {
110
+ const base = "https://example.com/";
111
+ const ok = base + "a".repeat(MAX_AVATAR_URL_LENGTH - base.length);
112
+ expect(validateAvatarUrl(ok).ok).toBe(true);
113
+ expect(validateAvatarUrl(ok + "a").ok).toBe(false);
114
+ });
115
+ });
116
+
117
+ // ── Same-origin relative branch (通信龙 裁定: relax to portable pool paths;
118
+ // absolute URLs would weld one hostname into the DB across multi-origin
119
+ // entry points). Each direction is its OWN test so any single guard
120
+ // being deleted turns its OWN assertion red (no merged catch-all).
121
+ describe("validateAvatarUrl — same-origin relative pool paths", () => {
122
+ test("accepts a pool path: /avatars/avatar-09.webp", () => {
123
+ const r = validateAvatarUrl("/avatars/avatar-09.webp");
124
+ expect(r).toEqual({ ok: true, value: "/avatars/avatar-09.webp" });
125
+ });
126
+
127
+ test("accepts png and svg pool files", () => {
128
+ expect(validateAvatarUrl("/avatars/intern_avatar.png")).toEqual({ ok: true, value: "/avatars/intern_avatar.png" });
129
+ expect(validateAvatarUrl("/avatars/team-logo.svg")).toEqual({ ok: true, value: "/avatars/team-logo.svg" });
130
+ });
131
+
132
+ test("rejects protocol-relative //host (classic same-origin bypass)", () => {
133
+ const r = validateAvatarUrl("//evil.com/x.png");
134
+ expect(r.ok).toBe(false);
135
+ if (!r.ok) expect(r.reason).toContain("protocol-relative");
136
+ });
137
+
138
+ test("rejects javascript: scheme (still, after the relax)", () => {
139
+ expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
140
+ });
141
+
142
+ test("rejects non-/avatars/ absolute paths (/etc/passwd)", () => {
143
+ expect(validateAvatarUrl("/etc/passwd").ok).toBe(false);
144
+ });
145
+
146
+ test("rejects non-whitelisted extensions under /avatars/", () => {
147
+ expect(validateAvatarUrl("/avatars/payload.html").ok).toBe(false);
148
+ });
149
+
150
+ test("rejects nested/traversal paths (charset forbids further slashes)", () => {
151
+ expect(validateAvatarUrl("/avatars/../secrets.webp").ok).toBe(false);
152
+ expect(validateAvatarUrl("/avatars/sub/x.webp").ok).toBe(false);
153
+ });
154
+
155
+ test("rejects percent-encoding inside the filename (no encoded surprises)", () => {
156
+ expect(validateAvatarUrl("/avatars/a%2f..%2fx.webp").ok).toBe(false);
157
+ });
158
+
159
+ test("absolute http(s) branch is UNTOUCHED by the relax (positive control)", () => {
160
+ const r = validateAvatarUrl("https://example.com/pic.png");
161
+ expect(r.ok).toBe(true);
162
+ });
163
+ });
@@ -0,0 +1,103 @@
1
+ // #462 — validator for the user-set custom avatar URL (nodes.avatar_url).
2
+ //
3
+ // The value ends up in an <img src> on every dashboard client, so this is
4
+ // an XSS trust boundary: protocol MUST be http/https (rejects javascript:,
5
+ // data:, vbscript:, file:, blob:, …), no whitespace/control characters,
6
+ // bounded length. Pure function, unit-tested in avatar-validate.test.ts.
7
+
8
+ export const MAX_AVATAR_URL_LENGTH = 2048;
9
+
10
+ export type AvatarValidation =
11
+ | { ok: true; value: string | null }
12
+ | { ok: false; reason: string };
13
+
14
+ /**
15
+ * Normalize + validate an untrusted avatar_url patch value.
16
+ *
17
+ * null / undefined / "" / whitespace-only → { ok, value: null } (clear)
18
+ * same-origin pool path /avatars/<name>.<ext> → { ok, value: trimmed }
19
+ * valid absolute http(s) URL → { ok, value: normalized }
20
+ * anything else → { ok: false, reason }
21
+ *
22
+ * Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is
23
+ * reached through several origins (localhost dev, public domains) — an
24
+ * absolute URL would weld ONE hostname into the DB and break the image
25
+ * for every other entry point; a same-origin relative path is portable.
26
+ * 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" —
27
+ * "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL
28
+ * the browser resolves to evil.com. Hence: single leading slash with the
29
+ * SECOND char not "/", then an exact value-set match (the /avatars/ pool
30
+ * prefix + extension whitelist), not a shape match.
31
+ */
32
+
33
+ // Exact allowed set for same-origin values: the dashboard's designed pool
34
+ // under /avatars/. Filename charset excludes "/" (no traversal, no nested
35
+ // paths) and "%" (no encoded surprises); extensions mirror the actual
36
+ // pool contents. Widen ONLY by extending this list deliberately.
37
+ const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/;
38
+ export function validateAvatarUrl(raw: unknown): AvatarValidation {
39
+ if (raw === null || raw === undefined) return { ok: true, value: null };
40
+ if (typeof raw !== "string") {
41
+ return { ok: false, reason: "avatar_url must be a string or null" };
42
+ }
43
+ const trimmed = raw.trim();
44
+ if (trimmed.length === 0) return { ok: true, value: null };
45
+ if (trimmed.length > MAX_AVATAR_URL_LENGTH) {
46
+ return { ok: false, reason: `avatar_url must be ≤ ${MAX_AVATAR_URL_LENGTH} chars` };
47
+ }
48
+ // Reject embedded whitespace + C0/C1 control chars outright. URL()
49
+ // silently strips some of these (e.g. tabs/newlines), which would let
50
+ // "java\tscript:" style payloads normalize into a hostile protocol —
51
+ // so this check must run BEFORE parsing, on the raw trimmed string.
52
+ if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
53
+ return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
54
+ }
55
+ // Same-origin relative branch (runs AFTER the whitespace/control gate
56
+ // above — that check protects this branch too). Order of the two tests
57
+ // matters for the reason string, not for safety: the exact-set regex
58
+ // alone already rejects "//…" (second char is "/", first segment must
59
+ // literally be "avatars"), the explicit double-slash check just names
60
+ // the classic bypass in its own words.
61
+ if (trimmed.startsWith("/")) {
62
+ if (trimmed.startsWith("//")) {
63
+ return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" };
64
+ }
65
+ if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) {
66
+ return { ok: false, reason: "relative avatar_url must match /avatars/<name>.(webp|png|svg)" };
67
+ }
68
+ return { ok: true, value: trimmed };
69
+ }
70
+ // Absolute http(s) branch — unchanged from #462 (do not touch while
71
+ // relaxing: 通信龙 裁定 condition 3).
72
+ let parsed: URL;
73
+ try {
74
+ parsed = new URL(trimmed);
75
+ } catch {
76
+ return { ok: false, reason: "avatar_url must be an absolute URL" };
77
+ }
78
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
79
+ return { ok: false, reason: "avatar_url protocol must be http or https" };
80
+ }
81
+ // Embedded credentials (https://user:pass@host/…) would be persisted,
82
+ // leaked to the avatar host on every render, and can spoof the visible
83
+ // origin in UIs. Reject outright rather than silently stripping — the
84
+ // caller should know their input carried secrets (通信龙 review round-2
85
+ // follow-up).
86
+ if (parsed.username || parsed.password) {
87
+ return { ok: false, reason: "avatar_url must not contain credentials (userinfo)" };
88
+ }
89
+ // Persist the NORMALIZED href, not the raw input: href percent-encodes
90
+ // markup-hostile characters (" < > ` → %22 %3C %3E %60), so what lands
91
+ // in the DB is already safe to interpolate into an <img src> attribute.
92
+ // The dashboard renderer lives in a different package this repo cannot
93
+ // audit — the server must not bet on it escaping (审查修复 per 通信龙
94
+ // #461/#462 review, finding 3).
95
+ const normalized = parsed.href;
96
+ // Belt-and-braces: reject anything href does NOT encode that could
97
+ // still break out of an attribute context (WHATWG URL leaves ' and \
98
+ // untouched in some positions).
99
+ if (/["'<>\\`]/.test(normalized)) {
100
+ return { ok: false, reason: "avatar_url contains characters not allowed in a URL" };
101
+ }
102
+ return { ok: true, value: normalized };
103
+ }