@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/README.md +9 -6
- package/package.json +1 -1
- package/src/auth.ts +5 -0
- package/src/avatar-validate.test.ts +52 -2
- package/src/avatar-validate.ts +35 -1
- package/src/ext-validation-regression.test.ts +138 -0
- package/src/file-download-authz.test.ts +50 -16
- 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 +731 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-scope.ts +81 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +298 -89
- package/src/tools.ts +148 -42
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +80 -3
- package/src/uploads.ts +81 -3
package/src/uploads.ts
CHANGED
|
@@ -94,10 +94,47 @@ export function sanitizeExt(filename: string | undefined | null): string {
|
|
|
94
94
|
return match ? "." + match[1].toLowerCase() : "";
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// YYYY-MM-DD bucket string regex — matches what getDateBucket produces
|
|
98
|
+
// and what an index entry's `date_bucket` field is validated against.
|
|
99
|
+
const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
|
|
103
|
+
* AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
|
|
104
|
+
* "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
|
|
105
|
+
* enough because the regex accepts any digit combination in each
|
|
106
|
+
* position.
|
|
107
|
+
*
|
|
108
|
+
* Used as a pre-check by the download handler before it calls into
|
|
109
|
+
* pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
|
|
110
|
+
* rejected at the boundary), NOT a 500 (uncaught throw from deep in
|
|
111
|
+
* the read path). Distinguishing these matters: 500 says "server
|
|
112
|
+
* failed"; 404 says "we know your request is bad, refused early".
|
|
113
|
+
*/
|
|
114
|
+
export function isValidCalendarBucket(bucket: unknown): bucket is string {
|
|
115
|
+
if (typeof bucket !== "string") return false;
|
|
116
|
+
if (!DATE_BUCKET_REGEX.test(bucket)) return false;
|
|
117
|
+
// Round-trip via Date to reject non-calendar shapes like "2026-02-30"
|
|
118
|
+
// or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
|
|
119
|
+
// "2026-03-02") — if the ISO round-trip doesn't equal the input, the
|
|
120
|
+
// caller supplied a non-calendar bucket.
|
|
121
|
+
const [yStr, mStr, dStr] = bucket.split("-");
|
|
122
|
+
const y = Number(yStr), m = Number(mStr), d = Number(dStr);
|
|
123
|
+
if (m < 1 || m > 12) return false;
|
|
124
|
+
if (d < 1 || d > 31) return false;
|
|
125
|
+
const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
126
|
+
return roundtrip === bucket;
|
|
127
|
+
}
|
|
128
|
+
|
|
97
129
|
/**
|
|
98
|
-
* Build the
|
|
99
|
-
*
|
|
100
|
-
*
|
|
130
|
+
* Build the storage path for a NEW upload. Computes today's date bucket
|
|
131
|
+
* from the runtime clock (or `opts.now` for tests). Use ONLY for creating
|
|
132
|
+
* a fresh blob; for reading an existing blob whose bucket is already
|
|
133
|
+
* recorded in the index, use pathForExistingBlob() instead.
|
|
134
|
+
*
|
|
135
|
+
* The file_id and ext are validated against strict regexes; any
|
|
136
|
+
* path-separator or `..` segment from a malicious client gets rejected
|
|
137
|
+
* at the regex layer before any I/O.
|
|
101
138
|
*/
|
|
102
139
|
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
103
140
|
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
@@ -109,6 +146,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
|
|
|
109
146
|
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
110
147
|
}
|
|
111
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Build the storage path for an EXISTING blob, using the caller-supplied
|
|
151
|
+
* `dateBucket` (typically read from the index entry). Does NOT compute
|
|
152
|
+
* a date from the runtime clock — that is the write-side concern.
|
|
153
|
+
*
|
|
154
|
+
* This split exists because sharing one date-computing function between
|
|
155
|
+
* write and read semantics caused #509: yesterday's file downloaded
|
|
156
|
+
* today would look under today's bucket → blob_missing → 404, even
|
|
157
|
+
* though the blob was still sitting in yesterday's directory.
|
|
158
|
+
*
|
|
159
|
+
* The file_id, ext and dateBucket are validated against strict regexes
|
|
160
|
+
* before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
|
|
161
|
+
* here just as file_id path-escape attempts are.
|
|
162
|
+
*/
|
|
163
|
+
export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
|
|
164
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
165
|
+
if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
166
|
+
if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
|
|
167
|
+
throw new Error(`date_bucket "${dateBucket}" is invalid`);
|
|
168
|
+
}
|
|
169
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
170
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
171
|
+
const absolutePath = join(root, relativePath);
|
|
172
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
173
|
+
}
|
|
174
|
+
|
|
112
175
|
export type UploadIndexEntry = {
|
|
113
176
|
file_id: string;
|
|
114
177
|
date_bucket: string; // YYYY-MM-DD
|
|
@@ -117,6 +180,15 @@ export type UploadIndexEntry = {
|
|
|
117
180
|
mime: string; // best-effort mime from the multipart Content-Type
|
|
118
181
|
size: number; // byte length of the binary
|
|
119
182
|
owner?: string; // upload token id / username, for traceability
|
|
183
|
+
// Written by the upload handler as `authCtx?.userId ?? null`, so `null`
|
|
184
|
+
// is a real on-disk value — the type must say so rather than pretend
|
|
185
|
+
// the field is `string | undefined`.
|
|
186
|
+
owner_id?: string | null;
|
|
187
|
+
// #503 — network the upload is attributed to. The key is OMITTED (not
|
|
188
|
+
// written as null) when there is no attribution, so "absent" and
|
|
189
|
+
// "unattributed" are the same on-disk shape and legacy entries written
|
|
190
|
+
// before #503 are indistinguishable from new unattributed ones.
|
|
191
|
+
network_id?: string;
|
|
120
192
|
uploaded_at: string; // ISO 8601
|
|
121
193
|
};
|
|
122
194
|
|
|
@@ -135,6 +207,12 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
|
|
|
135
207
|
export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
|
|
136
208
|
if (!entry || typeof entry !== "object") return false;
|
|
137
209
|
const e: any = entry;
|
|
210
|
+
// #503 — if the network_id key is present it must be a non-empty string.
|
|
211
|
+
// An absent key is valid (legacy entries + unattributed uploads); that
|
|
212
|
+
// keeps this in sync with the writer, which omits the key entirely
|
|
213
|
+
// rather than writing null. owner_id is deliberately NOT constrained
|
|
214
|
+
// here — existing entries legitimately carry owner_id=null.
|
|
215
|
+
if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
|
|
138
216
|
return (
|
|
139
217
|
typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
|
|
140
218
|
typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
|