@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26

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/src/tools.ts CHANGED
@@ -4,6 +4,7 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, ge
4
4
  import { pushEvent, pushNetworkObserverEvent } from "./push.js";
5
5
  import { assertNodeActive } from "./lifecycle-guard.js";
6
6
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
7
+ import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
7
8
  import {
8
9
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
9
10
  validateName as validateChildName,
@@ -30,7 +31,7 @@ import {
30
31
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
31
32
  VaultError,
32
33
  } from "./vault.js";
33
- import { stripHostLocalPathsForCrossHostSafe } from "./uploads.js";
34
+ import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
34
35
  import {
35
36
  validateBaseUrl as _validateBaseUrl,
36
37
  SUPPORTED_VENDORS,
@@ -97,28 +98,54 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
97
98
  }],
98
99
  };
99
100
  };
100
- // If enforceNetworkId is set, override any client-supplied network_id
101
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
101
+ // MCP-side auth context in the shape network-scope.ts helpers take.
102
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
103
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
104
+
105
+ // If enforceNetworkId is set (ntok_), override any client-supplied
106
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
107
+ // the user's single membership via the SAME singleNetworkId used by REST
108
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
109
+ // design, so without this fallback single-network nodes could read
110
+ // everything but write nothing. Multi-network stays null (ambiguous) and
111
+ // canWrite rejects with network_id_required.
112
+ const getNetworkId = (clientNetId?: string | null) => {
113
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
114
+ if (explicit !== null || !enforceUserId) return explicit;
115
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
116
+ };
102
117
 
103
- // Check write access. For ntok_ the network is enforced by the token.
104
- // For utok_ (no enforced network) we accept the network_id supplied in the
105
- // request and verify the user has a write role on it.
118
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
119
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
120
+ // bypass today; keep behavior identical to before the extraction.
106
121
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
107
- if (!enforceUserId) return true; // legacy global token mode, allow
108
122
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
109
- if (!netId) return false; // no network resolvable
110
- const role = getUserNetworkRole(enforceUserId, netId);
111
- return !!role && role !== "viewer"; // owner/admin/member can write
123
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
112
124
  };
113
125
 
126
+ // #517: name the REAL cause. The old catch-all blamed permissions for
127
+ // what was actually an unresolvable network, sending operators down the
128
+ // wrong debugging path (roles/membership) for hours.
114
129
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
115
130
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
116
- const message = !netId
117
- ? "network_id required (utok current_network is null; pass explicit network_id from /api/auth/me networks[0].network_id)"
118
- : action === "send_task"
131
+ let error: string;
132
+ let message: string;
133
+ if (!netId) {
134
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
135
+ error = "network_id_required";
136
+ message = memberships.length === 0
137
+ ? "user token has no network memberships; join or create a network first"
138
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
139
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
140
+ error = "access_denied";
141
+ message = "access denied to requested network (not a member)";
142
+ } else {
143
+ error = "permission_denied";
144
+ message = action === "send_task"
119
145
  ? "Viewer role cannot send tasks"
120
146
  : "Viewer role cannot write to this network";
121
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
147
+ }
148
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
122
149
  };
123
150
 
124
151
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
@@ -130,13 +157,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
130
157
 
131
158
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
132
159
 
133
- const getReadableNetworkIds = (): string[] => {
134
- if (!enforceUserId) return [];
135
- return db.all<{ network_id: string }>(
136
- "SELECT network_id FROM network_members WHERE user_id = ?1",
137
- enforceUserId
138
- ).map((row) => row.network_id);
139
- };
160
+ // Delegates to the shared membership query (network-scope.ts) — was a
161
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
162
+ const getReadableNetworkIds = (): string[] =>
163
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
140
164
 
141
165
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
142
166
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
@@ -690,9 +714,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
690
714
  alias: z.string().min(1).max(200).describe("Session alias"),
691
715
  message_id: z.string().min(1).max(200),
692
716
  response: z.string().max(10000).optional(),
717
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
693
718
  },
694
- async ({ alias, message_id, response }) => {
695
- const effectiveNetId = getNetworkId(null);
719
+ async ({ alias, message_id, response, network_id: netId }) => {
720
+ const effectiveNetId = getNetworkId(netId);
696
721
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
697
722
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
698
723
  const ackParams: any[] = [message_id, alias];
@@ -1004,9 +1029,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1004
1029
  alias: z.string().min(1).max(200).describe("Target session alias"),
1005
1030
  message: z.string().min(1).max(10000).describe("Message content"),
1006
1031
  from_session: z.string().max(200).optional(),
1032
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1007
1033
  },
1008
- async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1009
- const effectiveNetId = getNetworkId(null);
1034
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1035
+ const effectiveNetId = getNetworkId(netId);
1010
1036
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1011
1037
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
1012
1038
  const targetAlias = canonical.alias;
@@ -1065,11 +1091,48 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1065
1091
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
1066
1092
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
1067
1093
  from_session: z.string().max(200).optional(),
1094
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1095
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1096
+ // otherwise silently strips unknown fields, so a caller passing
1097
+ // `attachments` before this schema entry existed would see `ok:true`
1098
+ // and never learn the attachments were dropped. Validated by
1099
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1100
+ attachments: z.any().optional().describe("Optional attachment array; parity with send_task's meta.attachments. Each item: {type:'file', file_id, name?, mime?, size?}. Persisted into tasks.meta_json + inbox.meta_json."),
1101
+ // #507 — optional structured metadata (parity with send_task L837). If
1102
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1103
+ // top-level wins (same rule as REST /api/task L2101).
1104
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1068
1105
  },
1069
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1070
- const effectiveNetId = getNetworkId(null);
1106
+ async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn, network_id: netId, attachments, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1107
+ const effectiveNetId = getNetworkId(netId);
1071
1108
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1072
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
1109
+
1110
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1111
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1112
+ // type field) with an explicit error the caller can act on. Empty /
1113
+ // absent attachments return { ok: true, attachments: [] } — the
1114
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1115
+ // pinned by tests that assert byte-identical response shape
1116
+ // before/after this validation runs.
1117
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1118
+ if (!attachmentsResult.ok) {
1119
+ return {
1120
+ content: [{
1121
+ type: "text" as const,
1122
+ text: JSON.stringify({
1123
+ ok: false,
1124
+ error: "bad_attachments",
1125
+ message: attachmentsResult.error,
1126
+ }),
1127
+ }],
1128
+ };
1129
+ }
1130
+ const mergedMeta = attachmentsResult.attachments.length
1131
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1132
+ : meta;
1133
+ const metaJson = normalizeMetaJson(mergedMeta);
1134
+
1135
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}`);
1073
1136
  const id = uuidv4();
1074
1137
  let taskBefore: { status: string } | null = null;
1075
1138
  if (in_reply_to) {
@@ -1114,17 +1177,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1114
1177
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1115
1178
  }
1116
1179
  const replyLogged = db.transaction(() => {
1180
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
1181
+ // Prior to this the attachments field on a send_reply call was
1182
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
1183
+ // meta.
1117
1184
  db.run(
1118
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1119
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1120
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
1185
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1186
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1187
+ [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1121
1188
  );
1122
1189
 
1123
1190
  // 更新 tasks 表
1124
1191
  if (in_reply_to) {
1125
- const updateParams: any[] = [replyStatus, text, in_reply_to];
1126
- let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
1127
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
1192
+ // #507 — persist meta_json onto the tasks row too, so
1193
+ // dashboard's task view sees the reply's attachments alongside
1194
+ // the reply text (parity with send_task L957/959 which writes
1195
+ // meta_json into both inbox and tasks). The COALESCE guards
1196
+ // against clobbering pre-existing meta_json when this reply
1197
+ // brings no attachments (metaJson === null → keep the existing
1198
+ // task meta_json unchanged). Reverse-(e) invariant.
1199
+ const updateParams: any[] = [replyStatus, text, metaJson, in_reply_to];
1200
+ let updateSql = `UPDATE tasks
1201
+ SET status = ?1,
1202
+ result = ?2,
1203
+ completed_at = datetime('now'),
1204
+ meta_json = COALESCE(?3, meta_json)
1205
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
1128
1206
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1129
1207
  const result = db.run(updateSql, updateParams);
1130
1208
  if (result.changes === 0) {
@@ -1202,6 +1280,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1202
1280
  ? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
1203
1281
  : undefined;
1204
1282
 
1283
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
1284
+ // of the echo is to prove attachments actually landed in storage, not
1285
+ // to reflect the in-memory variable we tried to write. Reading
1286
+ // `attachmentsResult.attachments` here would still show `ok:true` +
1287
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
1288
+ // terminal between the pre-check and the transaction). SELECT from
1289
+ // inbox.meta_json (always written, uses the id we just generated) so
1290
+ // the echo means "these attachments are on disk now". Absent field
1291
+ // when the caller sent no attachments — reverse-(e) invariant: a
1292
+ // no-attachment call returns the exact same response shape as before
1293
+ // this PR. Persisted-attachments field is only added when the caller
1294
+ // actually asked for attachments.
1295
+ let attachmentsSaved: unknown[] | null = null;
1296
+ if (attachmentsResult.attachments.length > 0) {
1297
+ const persistedRow = db.get<{ meta_json: string | null }>(
1298
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
1299
+ );
1300
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
1301
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
1302
+ ? (persistedMeta as any).attachments
1303
+ : [];
1304
+ }
1305
+
1205
1306
  return {
1206
1307
  content: [{
1207
1308
  type: "text" as const,
@@ -1210,6 +1311,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1210
1311
  message_id: id,
1211
1312
  session_status: session?.status ?? "unknown",
1212
1313
  ...(warning ? { warning } : {}),
1314
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
1213
1315
  }),
1214
1316
  }],
1215
1317
  };
@@ -1223,9 +1325,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1223
1325
  {
1224
1326
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1225
1327
  from_session: z.string().max(200).optional(),
1328
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1226
1329
  },
1227
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1228
- const effectiveNetId = getNetworkId(null);
1330
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1331
+ const effectiveNetId = getNetworkId(netId);
1229
1332
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1230
1333
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1231
1334
  const updateParams: any[] = [task_id];
@@ -1249,9 +1352,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1249
1352
  {
1250
1353
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1251
1354
  from_session: z.string().max(200).optional(),
1355
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1252
1356
  },
1253
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1254
- const effectiveNetId = getNetworkId(null);
1357
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1358
+ const effectiveNetId = getNetworkId(netId);
1255
1359
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1256
1360
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1257
1361
  // Find the original task
@@ -1368,9 +1472,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1368
1472
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1369
1473
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1370
1474
  from_session: z.string().max(200).optional(),
1475
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1371
1476
  },
1372
- async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1373
- const effectiveNetId = getNetworkId(null);
1477
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1478
+ const effectiveNetId = getNetworkId(netId);
1374
1479
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1375
1480
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1376
1481
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
@@ -1400,9 +1505,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1400
1505
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1401
1506
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1402
1507
  from_session: z.string().max(200).optional(),
1508
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1403
1509
  },
1404
- async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1405
- const effectiveNetId = getNetworkId(null);
1510
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1511
+ const effectiveNetId = getNetworkId(netId);
1406
1512
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1407
1513
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1408
1514
  const taskParams: any[] = [task_id];
@@ -0,0 +1,155 @@
1
+ // #503 F3=A static guard: every 4xx return in the /api/upload handler
2
+ // that fires BEFORE `req.formData()` drains the request body MUST be
3
+ // wrapped in `earlyReject` (which sets `Connection: close`). Otherwise
4
+ // the client's still-inbound body poisons the keepalive pool and the
5
+ // next pooled request stalls.
6
+ //
7
+ // Why a STATIC guard (not a runtime one): mutation M9 in
8
+ // docs/tests/p-503-file-network-scope/mutation-matrix.txt showed that
9
+ // under Bun's fetch client in this test setup, removing
10
+ // `Connection: close` does NOT turn any dynamic test red — the pool
11
+ // poisoning is real HTTP-level behavior but the client under test does
12
+ // not reliably reproduce it. Without a guard, deleting the helper or
13
+ // forgetting to wrap a NEW 15th pre-drain return goes silent. This
14
+ // test judges the SOURCE FILE, per lead 7a88ce29 and Constraint 3-1
15
+ // pattern (source-based, not runner-output-based).
16
+ //
17
+ // PRIOR-ART GOTCHA (lead 7a88ce29 踩过): parse boundaries MUST exclude
18
+ // comments. `req.formData()` appears in a doc comment near the top of
19
+ // the handler too; if the boundary matches on that, the pre-drain
20
+ // range collapses to empty and this guard silently becomes vacuously
21
+ // true. Same trap family as "范围为空的全称断言恒真". So this test
22
+ // has an explicit precondition: the parsed pre-drain return count must
23
+ // be ≥ 10 (currently 14). If the count drops to 0, the boundary parser
24
+ // broke, not the code.
25
+
26
+ import { describe, expect, test } from "bun:test";
27
+ import { readFileSync } from "fs";
28
+ import { join } from "path";
29
+
30
+ const SOURCE_PATH = join(import.meta.dirname, "server.ts");
31
+ const RAW = readFileSync(SOURCE_PATH, "utf-8");
32
+ const LINES = RAW.split("\n");
33
+
34
+ // Strip line-comments so we don't match on comment-mentioned tokens
35
+ // like the "req.formData()" doc references at the top of the handler.
36
+ // (Block comments /* */ are rare inside this handler; if you add one
37
+ // that mentions the boundary, extend this helper.)
38
+ //
39
+ // KNOWN LIMITATION (lead 50208c1b): this naive strip cuts on ANY `//`,
40
+ // including one inside a string literal (e.g. `"https://…"`). If a
41
+ // pre-drain line ever contains such a literal, the strip could snip
42
+ // off the return statement after it and produce a false NEGATIVE (miss
43
+ // a genuine unwrapped return). Currently no pre-drain line in the
44
+ // /api/upload handler holds this shape; the precondition floor of 10
45
+ // catches only "range collapsed to empty", not "silently minus one".
46
+ // If someone adds a URL-string literal in this range in the future,
47
+ // upgrade to a real tokenizer — not because block-comment mentions of
48
+ // req.formData() need it, but because the string-literal-with-`//`
49
+ // case is the actual gap.
50
+ function stripLineComment(s: string): string {
51
+ const idx = s.indexOf("//");
52
+ return idx >= 0 ? s.slice(0, idx) : s;
53
+ }
54
+ const CODE = LINES.map(stripLineComment);
55
+
56
+ // Find the /api/upload handler start (guard against a repo rename).
57
+ const handlerStartIdx = CODE.findIndex((line) =>
58
+ line.includes('url.pathname === "/api/upload"') &&
59
+ line.includes('req.method === "POST"'),
60
+ );
61
+
62
+ // Find the true body-drain boundary: `await req.formData()` INSIDE the
63
+ // handler. Search from handler start forward for the first NON-COMMENT
64
+ // occurrence (stripLineComment above already ate any `// ... formData ...`
65
+ // mention).
66
+ let drainIdx = -1;
67
+ if (handlerStartIdx >= 0) {
68
+ for (let i = handlerStartIdx + 1; i < CODE.length; i++) {
69
+ if (CODE[i].includes("req.formData()")) {
70
+ drainIdx = i;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Collect every `return` statement in the pre-drain range. Classify:
77
+ // earlyReject — the correct wrapping (sets Connection: close)
78
+ // withCors — bare wrapping without Connection: close (regression)
79
+ // other — produces a Response some OTHER way (e.g.
80
+ // `return Response.json(...)` / `return new Response(...)`);
81
+ // this shape ALSO skips earlyReject and MUST fail the guard.
82
+ // Detected via /Response\b/ so `return wrapped;` /
83
+ // `return;` / `return blob;` (helper body, non-response
84
+ // returns) are correctly skipped, but any new Response-
85
+ // producing path (Response.json / new Response / etc.) is
86
+ // caught. Lead 50208c1b: earlier version used implicit-skip
87
+ // which made the third assertion恒真 — it counted only what
88
+ // it recognised, so "unrecognised = 0" was vacuous.
89
+ // Order matters: earlyReject → withCors → other. A line
90
+ // like `return earlyReject(new Response(…))` contains
91
+ // both `earlyReject` AND `Response`, and must classify as
92
+ // earlyReject (already-wrapped), not as other.
93
+ type ReturnHit = { lineNumber: number; text: string; kind: "earlyReject" | "withCors" | "other" };
94
+ const preDrainReturns: ReturnHit[] = [];
95
+ if (handlerStartIdx >= 0 && drainIdx > handlerStartIdx) {
96
+ for (let i = handlerStartIdx; i < drainIdx; i++) {
97
+ const code = CODE[i];
98
+ if (!/\breturn\b/.test(code)) continue;
99
+ if (/\breturn earlyReject\b/.test(code)) {
100
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "earlyReject" });
101
+ } else if (/\breturn withCors\b/.test(code)) {
102
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "withCors" });
103
+ } else if (/Response\b/.test(code)) {
104
+ // `return new Response(...)` / `return Response.json(...)` / etc.
105
+ // — produces a Response but skips the earlyReject wrapper.
106
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "other" });
107
+ }
108
+ // Bare `return wrapped;`, `return;`, `return blob;` are non-response
109
+ // returns and correctly skipped (no `Response` token).
110
+ }
111
+ }
112
+
113
+ describe("#503 F3=A static guard: /api/upload pre-body-drain returns use earlyReject", () => {
114
+ test("precondition: scanner located handler + drain boundary + non-trivial return count (defends against parse collapse)", () => {
115
+ expect(handlerStartIdx).toBeGreaterThanOrEqual(0);
116
+ expect(drainIdx).toBeGreaterThan(handlerStartIdx);
117
+ // If boundary parsing collapses (someone renames req.formData(),
118
+ // or a comment-strip bug lets a doc reference win), this range
119
+ // becomes 0 and the second test would vacuously pass. Anchor
120
+ // the range: at time of writing (85e5c140 + #503) the count is
121
+ // 14; lead 7a88ce29 confirmed 14/14. Floor at 10 to leave room
122
+ // for genuine deletions while still catching a full collapse.
123
+ expect(preDrainReturns.length).toBeGreaterThanOrEqual(10);
124
+ });
125
+
126
+ test("every pre-drain return is wrapped in earlyReject (not bare withCors)", () => {
127
+ const bareWithCors = preDrainReturns.filter((r) => r.kind === "withCors");
128
+ if (bareWithCors.length > 0) {
129
+ const detail = bareWithCors
130
+ .map((r) => ` L${r.lineNumber}: ${r.text}`)
131
+ .join("\n");
132
+ throw new Error(
133
+ `#503 F3=A regression: ${bareWithCors.length} pre-body-drain return(s) in ` +
134
+ `/api/upload use bare withCors() instead of earlyReject():\n${detail}\n\n` +
135
+ `Every early-reject in this range MUST set Connection: close. Otherwise ` +
136
+ `the client's in-flight request body poisons the keepalive pool and the ` +
137
+ `next pooled request stalls. Wrap with the earlyReject() helper defined ` +
138
+ `at the top of the upload handler.`,
139
+ );
140
+ }
141
+ expect(bareWithCors.length).toBe(0);
142
+ });
143
+
144
+ test("no pre-drain return produces a Response without going through earlyReject (catches new response shapes)", () => {
145
+ // Catches `return Response.json(...)` / `return new Response(...)` /
146
+ // any other pre-drain Response-producing path that skips the wrapper.
147
+ // Lead 50208c1b: previous version was 恒真 because unrecognised
148
+ // shapes were silently dropped from `preDrainReturns` — the filter
149
+ // then counted "recognised = recognised" and never failed. Now the
150
+ // collector classifies unrecognised Response-producers as "other";
151
+ // this assertion reports them by line, not just count.
152
+ const unknown = preDrainReturns.filter((r) => r.kind === "other");
153
+ expect(unknown.map((r) => `L${r.lineNumber}: ${r.text}`)).toEqual([]);
154
+ });
155
+ });
@@ -71,13 +71,22 @@ function authHeaders(): Record<string, string> {
71
71
  return { "Authorization": `Bearer ${userToken}` };
72
72
  }
73
73
 
74
+ // #503 — the bootstrap user is the first account in this suite's fresh
75
+ // DB, so it is a system admin, and admins must name the target network
76
+ // explicitly (attribution is never guessed). uploadNetworkId() keeps
77
+ // that detail in one place.
78
+ function uploadNetworkId(): string {
79
+ if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
80
+ return `?network_id=${encodeURIComponent(userNetworkId)}`;
81
+ }
82
+
74
83
  async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
75
84
  const form = new FormData();
76
85
  form.append("file", new Blob([content], { type: mime }), filename);
77
86
  const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
78
87
  // Note: fetch sets Content-Length automatically; only override when
79
88
  // testing the missing-header path.
80
- const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
89
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
81
90
  return res;
82
91
  }
83
92
 
@@ -137,7 +146,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
137
146
  controller.close();
138
147
  },
139
148
  });
140
- const res = await fetch(`${BASE}/api/upload`, {
149
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
141
150
  method: "POST",
142
151
  body: stream,
143
152
  headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
@@ -153,7 +162,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
153
162
  // this body" which is the security property we care about; the
154
163
  // exact code depends on whether Bun's multipart parser bails before
155
164
  // or after our Content-Type pre-check.
156
- const res = await fetch(`${BASE}/api/upload`, {
165
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
157
166
  method: "POST",
158
167
  body: JSON.stringify({ nope: 1 }),
159
168
  headers: { ...authHeaders(), "Content-Type": "application/json" },
@@ -164,12 +173,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
164
173
 
165
174
  describe("GET /api/files/:file_id — download safety", () => {
166
175
  let fileId = "";
176
+ let rangeFileId = "";
177
+ const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
167
178
 
168
179
  beforeAll(async () => {
169
180
  const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
170
181
  const res = await uploadFile(content, "greeting.txt", "text/plain");
171
182
  const body = await res.json() as any;
172
183
  fileId = body.file_id;
184
+
185
+ const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
186
+ const rangeBody = await rangeRes.json() as any;
187
+ rangeFileId = rangeBody.file_id;
173
188
  });
174
189
 
175
190
  test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
@@ -178,10 +193,72 @@ describe("GET /api/files/:file_id — download safety", () => {
178
193
  expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
179
194
  expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
180
195
  expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
196
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
181
197
  const text = await res.text();
182
198
  expect(text).toBe("hello-world-from-#221-integration-test");
183
199
  });
184
200
 
201
+ test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
202
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
203
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
204
+ });
205
+ expect(res.status).toBe(206);
206
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
207
+ expect(res.headers.get("Content-Length")).toBe("6");
208
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
209
+ const body = new Uint8Array(await res.arrayBuffer());
210
+ expect(body.length).toBe(6);
211
+ expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
212
+ });
213
+
214
+ test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
215
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
216
+ headers: { ...authHeaders(), Range: "bytes=60-" },
217
+ });
218
+ expect(res.status).toBe(206);
219
+ expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
220
+ expect(res.headers.get("Content-Length")).toBe("4");
221
+ const body = new Uint8Array(await res.arrayBuffer());
222
+ expect(body.length).toBe(4);
223
+ expect([...body]).toEqual([...rangeContent.slice(60)]);
224
+ });
225
+
226
+ test("#514 Range bytes=-N returns the exact suffix", async () => {
227
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
228
+ headers: { ...authHeaders(), Range: "bytes=-7" },
229
+ });
230
+ expect(res.status).toBe(206);
231
+ expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
232
+ expect(res.headers.get("Content-Length")).toBe("7");
233
+ const body = new Uint8Array(await res.arrayBuffer());
234
+ expect(body.length).toBe(7);
235
+ expect([...body]).toEqual([...rangeContent.slice(57)]);
236
+ });
237
+
238
+ test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
239
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
240
+ headers: { ...authHeaders(), Range: "bytes=64-70" },
241
+ });
242
+ expect(res.status).toBe(416);
243
+ expect(res.headers.get("Content-Range")).toBe("bytes */64");
244
+ expect(res.headers.get("Content-Length")).toBe("0");
245
+ const body = new Uint8Array(await res.arrayBuffer());
246
+ expect(body.length).toBe(0);
247
+ });
248
+
249
+ test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
250
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
251
+ method: "HEAD",
252
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
253
+ });
254
+ expect(res.status).toBe(206);
255
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
256
+ expect(res.headers.get("Content-Length")).toBe("6");
257
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
258
+ const body = new Uint8Array(await res.arrayBuffer());
259
+ expect(body.length).toBe(0);
260
+ });
261
+
185
262
  test("anonymous download is 401", async () => {
186
263
  const res = await fetch(`${BASE}/api/files/${fileId}`);
187
264
  expect(res.status).toBe(401);