@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +933 -36
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -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
|
+
});
|
package/src/uploads-http.test.ts
CHANGED
|
@@ -7,27 +7,32 @@
|
|
|
7
7
|
// this file already exercises the same code path). The hub binds to
|
|
8
8
|
// an ephemeral port so multiple test runs don't collide.
|
|
9
9
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import "./require-explicit-test-db.js";
|
|
10
11
|
import { mkdtempSync, rmSync, existsSync } from "fs";
|
|
11
12
|
import { join } from "path";
|
|
12
13
|
import { tmpdir } from "os";
|
|
13
14
|
import { register, login } from "./auth.js";
|
|
14
15
|
|
|
15
|
-
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
|
|
16
16
|
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #438 corrective: private bootServer({ port: 0 }) instance — the OS
|
|
18
|
+
// assigns the port and BASE is derived from the ACTUAL bound port, so
|
|
19
|
+
// this suite is immune to both the parent-side random-port TOCTOU and
|
|
20
|
+
// the aggregate module-cache no-op (import no longer boots anything).
|
|
21
|
+
let BASE = "";
|
|
19
22
|
|
|
20
23
|
let server: any;
|
|
21
24
|
let userToken = "";
|
|
22
25
|
let userNetworkId = "";
|
|
23
26
|
|
|
27
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
28
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
29
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
30
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
24
31
|
beforeAll(async () => {
|
|
25
32
|
// Bootstrap a real admin + token on the temp DB so the server can
|
|
26
33
|
// resolve it via requireAuth (we don't want to short-circuit auth —
|
|
27
34
|
// we want to validate the production code path).
|
|
28
|
-
process.env.COMMHUB_DB = SERVER_DB;
|
|
29
35
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
30
|
-
process.env.PORT = String(PORT);
|
|
31
36
|
process.env.HOST = "127.0.0.1";
|
|
32
37
|
|
|
33
38
|
// Use a unique username + strong password each run so we don't trip
|
|
@@ -50,31 +55,40 @@ beforeAll(async () => {
|
|
|
50
55
|
}
|
|
51
56
|
expect(userToken).toBeTruthy();
|
|
52
57
|
|
|
53
|
-
//
|
|
54
|
-
// the port
|
|
55
|
-
await import("./
|
|
58
|
+
// Importing is side-effect-free now; boot a private instance and
|
|
59
|
+
// read the real port back.
|
|
60
|
+
const { bootServer } = await import("./server.js");
|
|
61
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
62
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
56
63
|
// Tiny settle so the listener is ready.
|
|
57
64
|
await new Promise((r) => setTimeout(r, 100));
|
|
58
|
-
});
|
|
65
|
+
}, 30_000);
|
|
59
66
|
|
|
60
67
|
afterAll(() => {
|
|
61
|
-
|
|
62
|
-
// time so we can't easily stop it — process exit handles that.
|
|
68
|
+
try { server?.stop?.(true); } catch {}
|
|
63
69
|
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
64
|
-
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
function authHeaders(): Record<string, string> {
|
|
68
73
|
return { "Authorization": `Bearer ${userToken}` };
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
// #503 — the bootstrap user is the first account in this suite's fresh
|
|
77
|
+
// DB, so it is a system admin, and admins must name the target network
|
|
78
|
+
// explicitly (attribution is never guessed). uploadNetworkId() keeps
|
|
79
|
+
// that detail in one place.
|
|
80
|
+
function uploadNetworkId(): string {
|
|
81
|
+
if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
|
|
82
|
+
return `?network_id=${encodeURIComponent(userNetworkId)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
|
|
72
86
|
const form = new FormData();
|
|
73
87
|
form.append("file", new Blob([content], { type: mime }), filename);
|
|
74
88
|
const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
|
|
75
89
|
// Note: fetch sets Content-Length automatically; only override when
|
|
76
90
|
// testing the missing-header path.
|
|
77
|
-
const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
|
|
91
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
|
|
78
92
|
return res;
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -134,7 +148,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
134
148
|
controller.close();
|
|
135
149
|
},
|
|
136
150
|
});
|
|
137
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
151
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
138
152
|
method: "POST",
|
|
139
153
|
body: stream,
|
|
140
154
|
headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
|
|
@@ -150,7 +164,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
150
164
|
// this body" which is the security property we care about; the
|
|
151
165
|
// exact code depends on whether Bun's multipart parser bails before
|
|
152
166
|
// or after our Content-Type pre-check.
|
|
153
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
167
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
154
168
|
method: "POST",
|
|
155
169
|
body: JSON.stringify({ nope: 1 }),
|
|
156
170
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
@@ -161,12 +175,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
161
175
|
|
|
162
176
|
describe("GET /api/files/:file_id — download safety", () => {
|
|
163
177
|
let fileId = "";
|
|
178
|
+
let rangeFileId = "";
|
|
179
|
+
const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
|
|
164
180
|
|
|
165
181
|
beforeAll(async () => {
|
|
166
182
|
const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
|
|
167
183
|
const res = await uploadFile(content, "greeting.txt", "text/plain");
|
|
168
184
|
const body = await res.json() as any;
|
|
169
185
|
fileId = body.file_id;
|
|
186
|
+
|
|
187
|
+
const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
|
|
188
|
+
const rangeBody = await rangeRes.json() as any;
|
|
189
|
+
rangeFileId = rangeBody.file_id;
|
|
170
190
|
});
|
|
171
191
|
|
|
172
192
|
test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
|
|
@@ -175,10 +195,72 @@ describe("GET /api/files/:file_id — download safety", () => {
|
|
|
175
195
|
expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
|
|
176
196
|
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
177
197
|
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
198
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
178
199
|
const text = await res.text();
|
|
179
200
|
expect(text).toBe("hello-world-from-#221-integration-test");
|
|
180
201
|
});
|
|
181
202
|
|
|
203
|
+
test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
|
|
204
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
205
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
206
|
+
});
|
|
207
|
+
expect(res.status).toBe(206);
|
|
208
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
209
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
210
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
211
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
212
|
+
expect(body.length).toBe(6);
|
|
213
|
+
expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
|
|
217
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
218
|
+
headers: { ...authHeaders(), Range: "bytes=60-" },
|
|
219
|
+
});
|
|
220
|
+
expect(res.status).toBe(206);
|
|
221
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
|
|
222
|
+
expect(res.headers.get("Content-Length")).toBe("4");
|
|
223
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
224
|
+
expect(body.length).toBe(4);
|
|
225
|
+
expect([...body]).toEqual([...rangeContent.slice(60)]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("#514 Range bytes=-N returns the exact suffix", async () => {
|
|
229
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
230
|
+
headers: { ...authHeaders(), Range: "bytes=-7" },
|
|
231
|
+
});
|
|
232
|
+
expect(res.status).toBe(206);
|
|
233
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
|
|
234
|
+
expect(res.headers.get("Content-Length")).toBe("7");
|
|
235
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
236
|
+
expect(body.length).toBe(7);
|
|
237
|
+
expect([...body]).toEqual([...rangeContent.slice(57)]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
|
|
241
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
242
|
+
headers: { ...authHeaders(), Range: "bytes=64-70" },
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(416);
|
|
245
|
+
expect(res.headers.get("Content-Range")).toBe("bytes */64");
|
|
246
|
+
expect(res.headers.get("Content-Length")).toBe("0");
|
|
247
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
248
|
+
expect(body.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
|
|
252
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
253
|
+
method: "HEAD",
|
|
254
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
255
|
+
});
|
|
256
|
+
expect(res.status).toBe(206);
|
|
257
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
258
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
259
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
260
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
261
|
+
expect(body.length).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
182
264
|
test("anonymous download is 401", async () => {
|
|
183
265
|
const res = await fetch(`${BASE}/api/files/${fileId}`);
|
|
184
266
|
expect(res.status).toBe(401);
|
package/src/uploads.ts
CHANGED
|
@@ -53,6 +53,17 @@ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
|
|
|
53
53
|
// traversal.
|
|
54
54
|
export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
|
|
55
55
|
|
|
56
|
+
// Stored extension tokens have one invariant at every path boundary:
|
|
57
|
+
// either no extension, or a leading dot followed by 1-16 ASCII
|
|
58
|
+
// alphanumeric characters. Keep this separate from sanitizeExt(), which
|
|
59
|
+
// parses a client filename and therefore deliberately has different
|
|
60
|
+
// capture/anchoring semantics.
|
|
61
|
+
const EXT_TOKEN_REGEX = /^\.[A-Za-z0-9]{1,16}$/;
|
|
62
|
+
|
|
63
|
+
function isValidExtToken(ext: unknown): ext is string {
|
|
64
|
+
return typeof ext === "string" && (ext === "" || EXT_TOKEN_REGEX.test(ext));
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export type GeneratedFile = {
|
|
57
68
|
fileId: string;
|
|
58
69
|
ext: string;
|
|
@@ -94,14 +105,51 @@ export function sanitizeExt(filename: string | undefined | null): string {
|
|
|
94
105
|
return match ? "." + match[1].toLowerCase() : "";
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
// YYYY-MM-DD bucket string regex — matches what getDateBucket produces
|
|
109
|
+
// and what an index entry's `date_bucket` field is validated against.
|
|
110
|
+
const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
|
|
114
|
+
* AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
|
|
115
|
+
* "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
|
|
116
|
+
* enough because the regex accepts any digit combination in each
|
|
117
|
+
* position.
|
|
118
|
+
*
|
|
119
|
+
* Used as a pre-check by the download handler before it calls into
|
|
120
|
+
* pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
|
|
121
|
+
* rejected at the boundary), NOT a 500 (uncaught throw from deep in
|
|
122
|
+
* the read path). Distinguishing these matters: 500 says "server
|
|
123
|
+
* failed"; 404 says "we know your request is bad, refused early".
|
|
124
|
+
*/
|
|
125
|
+
export function isValidCalendarBucket(bucket: unknown): bucket is string {
|
|
126
|
+
if (typeof bucket !== "string") return false;
|
|
127
|
+
if (!DATE_BUCKET_REGEX.test(bucket)) return false;
|
|
128
|
+
// Round-trip via Date to reject non-calendar shapes like "2026-02-30"
|
|
129
|
+
// or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
|
|
130
|
+
// "2026-03-02") — if the ISO round-trip doesn't equal the input, the
|
|
131
|
+
// caller supplied a non-calendar bucket.
|
|
132
|
+
const [yStr, mStr, dStr] = bucket.split("-");
|
|
133
|
+
const y = Number(yStr), m = Number(mStr), d = Number(dStr);
|
|
134
|
+
if (m < 1 || m > 12) return false;
|
|
135
|
+
if (d < 1 || d > 31) return false;
|
|
136
|
+
const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
137
|
+
return roundtrip === bucket;
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
/**
|
|
98
|
-
* Build the
|
|
99
|
-
*
|
|
100
|
-
*
|
|
141
|
+
* Build the storage path for a NEW upload. Computes today's date bucket
|
|
142
|
+
* from the runtime clock (or `opts.now` for tests). Use ONLY for creating
|
|
143
|
+
* a fresh blob; for reading an existing blob whose bucket is already
|
|
144
|
+
* recorded in the index, use pathForExistingBlob() instead.
|
|
145
|
+
*
|
|
146
|
+
* The file_id and ext are validated against strict regexes; any
|
|
147
|
+
* path-separator or `..` segment from a malicious client gets rejected
|
|
148
|
+
* at the regex layer before any I/O.
|
|
101
149
|
*/
|
|
102
150
|
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
103
151
|
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
104
|
-
if (
|
|
152
|
+
if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
105
153
|
const dateBucket = getDateBucket(opts.now);
|
|
106
154
|
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
107
155
|
const relativePath = join(dateBucket, fileId + ext);
|
|
@@ -109,6 +157,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
|
|
|
109
157
|
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
110
158
|
}
|
|
111
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Build the storage path for an EXISTING blob, using the caller-supplied
|
|
162
|
+
* `dateBucket` (typically read from the index entry). Does NOT compute
|
|
163
|
+
* a date from the runtime clock — that is the write-side concern.
|
|
164
|
+
*
|
|
165
|
+
* This split exists because sharing one date-computing function between
|
|
166
|
+
* write and read semantics caused #509: yesterday's file downloaded
|
|
167
|
+
* today would look under today's bucket → blob_missing → 404, even
|
|
168
|
+
* though the blob was still sitting in yesterday's directory.
|
|
169
|
+
*
|
|
170
|
+
* The file_id, ext and dateBucket are validated against strict regexes
|
|
171
|
+
* before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
|
|
172
|
+
* here just as file_id path-escape attempts are.
|
|
173
|
+
*/
|
|
174
|
+
export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
|
|
175
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
176
|
+
if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
177
|
+
if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
|
|
178
|
+
throw new Error(`date_bucket "${dateBucket}" is invalid`);
|
|
179
|
+
}
|
|
180
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
181
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
182
|
+
const absolutePath = join(root, relativePath);
|
|
183
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
184
|
+
}
|
|
185
|
+
|
|
112
186
|
export type UploadIndexEntry = {
|
|
113
187
|
file_id: string;
|
|
114
188
|
date_bucket: string; // YYYY-MM-DD
|
|
@@ -117,9 +191,24 @@ export type UploadIndexEntry = {
|
|
|
117
191
|
mime: string; // best-effort mime from the multipart Content-Type
|
|
118
192
|
size: number; // byte length of the binary
|
|
119
193
|
owner?: string; // upload token id / username, for traceability
|
|
194
|
+
// Written by the upload handler as `authCtx?.userId ?? null`, so `null`
|
|
195
|
+
// is a real on-disk value — the type must say so rather than pretend
|
|
196
|
+
// the field is `string | undefined`.
|
|
197
|
+
owner_id?: string | null;
|
|
198
|
+
// #503 — network the upload is attributed to. The key is OMITTED (not
|
|
199
|
+
// written as null) when there is no attribution, so "absent" and
|
|
200
|
+
// "unattributed" are the same on-disk shape and legacy entries written
|
|
201
|
+
// before #503 are indistinguishable from new unattributed ones.
|
|
202
|
+
network_id?: string;
|
|
203
|
+
// 私信文件:上传时声明 `?purpose=dm`(App 的私信输入框这么传)。第一次用途就是私信 ——
|
|
204
|
+
// 只有上传者、收到/发出过带它的私信的人和管理员能下载(server.ts authorizeFileDownload)。
|
|
205
|
+
// 不是私信的文件不写这个键;以后被转进私信也不会补写,原来谁能看还是谁能看。
|
|
206
|
+
scope?: typeof DM_FILE_SCOPE;
|
|
120
207
|
uploaded_at: string; // ISO 8601
|
|
121
208
|
};
|
|
122
209
|
|
|
210
|
+
export const DM_FILE_SCOPE = "dm";
|
|
211
|
+
|
|
123
212
|
/** Path to the index entry file for a given file_id. Lives in
|
|
124
213
|
* `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
|
|
125
214
|
* an O(1) lookup without scanning every date bucket. */
|
|
@@ -135,10 +224,18 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
|
|
|
135
224
|
export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
|
|
136
225
|
if (!entry || typeof entry !== "object") return false;
|
|
137
226
|
const e: any = entry;
|
|
227
|
+
// #503 — if the network_id key is present it must be a non-empty string.
|
|
228
|
+
// An absent key is valid (legacy entries + unattributed uploads); that
|
|
229
|
+
// keeps this in sync with the writer, which omits the key entirely
|
|
230
|
+
// rather than writing null. owner_id is deliberately NOT constrained
|
|
231
|
+
// here — existing entries legitimately carry owner_id=null.
|
|
232
|
+
if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
|
|
233
|
+
// 未知的 scope 值不当成「没有 scope」放行(那等于把私信文件退回全网可见):整条判坏,下载 404。
|
|
234
|
+
if ("scope" in e && e.scope !== DM_FILE_SCOPE) return false;
|
|
138
235
|
return (
|
|
139
236
|
typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
|
|
140
237
|
typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
|
|
141
|
-
|
|
238
|
+
isValidExtToken(e.ext) &&
|
|
142
239
|
typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
|
|
143
240
|
);
|
|
144
241
|
}
|
|
@@ -247,7 +344,7 @@ export type TaskAttachment = {
|
|
|
247
344
|
* returns `{file_id, path}` where `path` is a hub-machine-local
|
|
248
345
|
* absolute path. Senders sometimes echo `path` back into meta.attachments.
|
|
249
346
|
* When the receiving agent runs on a different host (Vincent 2026-06-30
|
|
250
|
-
*
|
|
347
|
+
* a partner-team host case), that path string is meaningless. agent-node's
|
|
251
348
|
* fetch-attachment.ts will fetch via `file_id` regardless, but leaving
|
|
252
349
|
* the stale `path` in the JSON misleads any tooling that reads it.
|
|
253
350
|
*
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { register, login, createNetworkTokenForNode, addNetworkMember } from "./auth.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
|
|
5
|
+
// One sequential HTTP scenario: fail a prerequisite before running the next layer.
|
|
6
|
+
// Docker supplies an isolated DB; never import this against a production DB.
|
|
7
|
+
test("human avatar: auth → save/read/login → member visibility → isolation/rejection → clear", async () => {
|
|
8
|
+
const a = register("avatar_alice", "AvatarPassw0rd!");
|
|
9
|
+
const b = register("avatar_bob", "AvatarPassw0rd!");
|
|
10
|
+
const stranger = register("avatar_stranger", "AvatarPassw0rd!");
|
|
11
|
+
expect(a.ok && b.ok && stranger.ok).toBe(true);
|
|
12
|
+
expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
|
|
13
|
+
const node = createNetworkTokenForNode(a.user!.user_id, a.network_id!, "avatar_alice", "avatar_node");
|
|
14
|
+
expect(node.ok).toBe(true);
|
|
15
|
+
const { bootServer } = await import("./server.js");
|
|
16
|
+
const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
17
|
+
const base = `http://127.0.0.1:${hub.port}`;
|
|
18
|
+
const call = async (token: string, method: string, path: string, body?: unknown) => {
|
|
19
|
+
const res = await fetch(base + path, { method,
|
|
20
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
21
|
+
body: body === undefined ? undefined : JSON.stringify(body) });
|
|
22
|
+
return { status: res.status, body: await res.json() as any };
|
|
23
|
+
};
|
|
24
|
+
const put = (token: string, body: unknown) => call(token, "PUT", "/api/auth/me/avatar", body);
|
|
25
|
+
const me = (token: string) => call(token, "GET", "/api/auth/me");
|
|
26
|
+
try {
|
|
27
|
+
expect((await me(a.token!)).status).toBe(200);
|
|
28
|
+
expect((await me(a.token!)).body.user.avatar_url).toBeNull();
|
|
29
|
+
expect((await put("invalid", { avatar_url: null })).status).toBe(401);
|
|
30
|
+
expect((await put(node.token!, { avatar_url: null })).status).toBe(403);
|
|
31
|
+
|
|
32
|
+
const avatar = "/avatars/avatar-03.webp";
|
|
33
|
+
const saved = await put(a.token!, { avatar_url: avatar });
|
|
34
|
+
expect(saved.status).toBe(200);
|
|
35
|
+
expect(saved.body).toEqual({ ok: true, user_id: a.user!.user_id, avatar_url: avatar });
|
|
36
|
+
expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
|
|
37
|
+
const secondDevice = login("avatar_alice", "AvatarPassw0rd!");
|
|
38
|
+
expect(secondDevice.ok).toBe(true);
|
|
39
|
+
expect(secondDevice.user!.avatar_url).toBe(avatar);
|
|
40
|
+
expect((await me(secondDevice.token!)).body.user.avatar_url).toBe(avatar);
|
|
41
|
+
// An ordinary profile edit must preserve and return the saved avatar.
|
|
42
|
+
const profile = await call(a.token!, "PUT", "/api/auth/me", { display_name: "Alice" });
|
|
43
|
+
expect(profile.status).toBe(200);
|
|
44
|
+
expect(profile.body.user.avatar_url).toBe(avatar);
|
|
45
|
+
const people = await call(b.token!, "GET", `/api/networks/${a.network_id}/humans`);
|
|
46
|
+
expect(people.status).toBe(200);
|
|
47
|
+
expect(people.body.humans.find((u: any) => u.user_id === a.user!.user_id).avatar_url).toBe(avatar);
|
|
48
|
+
expect((await call(stranger.token!, "GET", `/api/networks/${a.network_id}/humans`)).status).toBe(403);
|
|
49
|
+
expect((await me(b.token!)).body.user.avatar_url).toBeNull();
|
|
50
|
+
|
|
51
|
+
for (const body of [null, [], {}, { avatar_url: avatar, user_id: b.user!.user_id },
|
|
52
|
+
...[123, {}, "javascript:alert(1)", "file:///tmp/photo.png", "data:image/png;base64,AAAA",
|
|
53
|
+
"//evil.example/a.png", "/avatars/../x.png", "https://user:secret@example.com/x.png", "x".repeat(3000)]
|
|
54
|
+
.map((avatar_url) => ({ avatar_url }))]) {
|
|
55
|
+
expect((await put(a.token!, body)).status).toBe(400);
|
|
56
|
+
expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
|
|
57
|
+
}
|
|
58
|
+
const malformed = await fetch(base + "/api/auth/me/avatar", { method: "PUT",
|
|
59
|
+
headers: { Authorization: `Bearer ${a.token}`, "Content-Type": "application/json" }, body: "{" });
|
|
60
|
+
expect(malformed.status).toBe(400);
|
|
61
|
+
expect((await put(node.token!, { avatar_url: "/avatars/avatar-09.webp" })).status).toBe(403);
|
|
62
|
+
expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
|
|
63
|
+
expect((await me(b.token!)).body.user.avatar_url).toBeNull();
|
|
64
|
+
expect(db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", "avatar_node")?.avatar_url ?? null).toBeNull();
|
|
65
|
+
|
|
66
|
+
const remote = "https://example.invalid/photo.png";
|
|
67
|
+
expect((await put(a.token!, { avatar_url: remote })).status).toBe(200);
|
|
68
|
+
expect((await me(a.token!)).body.user.avatar_url).toBe(remote);
|
|
69
|
+
expect((await put(a.token!, { avatar_url: null })).status).toBe(200);
|
|
70
|
+
expect((await me(secondDevice.token!)).body.user.avatar_url).toBeNull();
|
|
71
|
+
expect((await put(a.token!, { avatar_url: avatar })).status).toBe(200);
|
|
72
|
+
expect((await put(a.token!, { avatar_url: " " })).body.avatar_url).toBeNull();
|
|
73
|
+
} finally { hub.stop(true); }
|
|
74
|
+
}, 30_000);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
|
|
4
|
+
test("existing users survive additive avatar migration and next process preserves it", async () => {
|
|
5
|
+
const path = process.env.COMMHUB_DB!;
|
|
6
|
+
expect(typeof path).toBe("string");
|
|
7
|
+
const old = new Database(path);
|
|
8
|
+
old.exec(`CREATE TABLE users (
|
|
9
|
+
user_id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
|
|
10
|
+
email TEXT, display_name TEXT, role TEXT DEFAULT 'user',
|
|
11
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
12
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
13
|
+
); INSERT INTO users (user_id, username, password_hash) VALUES ('old_user', 'old_user', 'test-only-hash');`);
|
|
14
|
+
old.close();
|
|
15
|
+
const { db } = await import("./db.js");
|
|
16
|
+
expect(db.get<any>("SELECT avatar_url, password_hash FROM users WHERE user_id = ?1", "old_user"))
|
|
17
|
+
.toEqual({ avatar_url: null, password_hash: "test-only-hash" });
|
|
18
|
+
db.run("UPDATE users SET avatar_url = ?1 WHERE user_id = ?2", ["/avatars/avatar-03.webp", "old_user"]);
|
|
19
|
+
const child = Bun.spawn([process.execPath, "-e", `
|
|
20
|
+
const { db } = await import('./src/db.ts');
|
|
21
|
+
const row = db.get('SELECT avatar_url, password_hash FROM users WHERE user_id = ?1', 'old_user');
|
|
22
|
+
if (row.avatar_url !== '/avatars/avatar-03.webp' || row.password_hash !== 'test-only-hash') process.exit(1);
|
|
23
|
+
`], { cwd: new URL("..", import.meta.url).pathname, env: process.env, stdout: "pipe", stderr: "pipe" });
|
|
24
|
+
expect(await child.exited).toBe(0);
|
|
25
|
+
});
|