@sleep2agi/commhub-server 0.9.0-preview.25 → 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/server.ts CHANGED
@@ -5,6 +5,7 @@ import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
7
7
  import { assertNodeActive } from "./lifecycle-guard.js";
8
+ import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
8
9
  import { validateAvatarUrl } from "./avatar-validate.js";
9
10
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
10
11
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
@@ -216,7 +217,55 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
216
217
  return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
217
218
  }
218
219
 
219
- // #495 — shared authorization gate for /api/files/:file_id downloads.
220
+ // #503 — the credential kinds that authorization decisions branch on.
221
+ // Resolved once per request at the handler, so the authz helpers stay
222
+ // pure functions of (principal, entry) and every credential kind is a
223
+ // directly constructible test input instead of a hand-assembled Request.
224
+ export type Principal =
225
+ | { kind: "anonymous" }
226
+ | { kind: "dev-open-anon" }
227
+ | { kind: "legacy-master" }
228
+ | { kind: "utok"; userId: string; username: string; isAdmin: false }
229
+ | { kind: "ntok"; userId: string; boundNetworkId: string }
230
+ | { kind: "admin-utok"; userId: string; username: string };
231
+
232
+ export function resolvePrincipal(req: Request): Principal {
233
+ const authCtx = resolveRequestAuth(req);
234
+ if (!authCtx) {
235
+ if (isLegacyAuthToken(req)) return { kind: "legacy-master" };
236
+ if (DEV_OPEN) return { kind: "dev-open-anon" };
237
+ return { kind: "anonymous" };
238
+ }
239
+ const token = requestToken(req);
240
+ const resolved = token ? resolveToken(token) : null;
241
+ const isAdmin = !!resolved && resolved.user.role === "admin";
242
+ // Bound network wins over admin classification: a ntok_ issued BY an
243
+ // admin is still a ntok_, and its bound scope defines its identity.
244
+ // Inverting this order would silently re-grant every production agent
245
+ // node cross-network read, since production node tokens resolve to an
246
+ // admin user (see the 生产影响 note on authorizeFileDownload).
247
+ if (authCtx.networkId) return { kind: "ntok", userId: authCtx.userId, boundNetworkId: authCtx.networkId };
248
+ if (isAdmin) return { kind: "admin-utok", userId: authCtx.userId, username: authCtx.username };
249
+ return { kind: "utok", userId: authCtx.userId, username: authCtx.username, isAdmin: false };
250
+ }
251
+
252
+ // #503 — coerce the on-disk (untrusted, hand-editable) index entry into
253
+ // the shape the authz helper reasons about. Every `typeof` narrowing for
254
+ // these two fields lives here and nowhere else, so the authz rules can be
255
+ // read as rules rather than as string-checks.
256
+ export function normalizeEntry(
257
+ entry: { owner_id?: unknown; network_id?: unknown },
258
+ ): { ownerId: string | null; networkId: string | null } {
259
+ const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
260
+ ? entry.owner_id
261
+ : null;
262
+ const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
263
+ ? entry.network_id
264
+ : null;
265
+ return { ownerId, networkId };
266
+ }
267
+
268
+ // #495/#503 — shared authorization gate for /api/files/:file_id downloads.
220
269
  // Called from the GET handler and any future HEAD/Range/dashboard-proxy
221
270
  // entry so the allow-list can't drift between methods (通信龙 clause 2:
222
271
  // "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
@@ -225,90 +274,85 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
225
274
  // caller emits 404 on false — never 403; 403 would leak that the
226
275
  // file_id exists and enables enumeration.
227
276
  //
228
- // Allow-list (staged P0 hotfix; same-network non-owner is NOT included
229
- // here — see follow-up #503):
277
+ // Allow-list — network scope enforced by #503:
230
278
  // - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
231
279
  // - admin utok_ → allow (mirrors requireAdminAuth).
232
- // - Owner match: caller's resolved user_id === entry.owner_id.
233
- // - null owner_id + DEV_OPEN → allow. DEV_OPEN uploads carry
234
- // owner_id=null (authCtx=null in DEV_OPEN mode); rejecting them
235
- // would break the intended local-dev anonymous flow. Encoded as
236
- // an explicit env-gated branch so if DEV_OPEN semantics change,
237
- // the code fails loudly instead of trusting a stale note.
280
+ // - entry.network_id present → caller must belong to THAT network:
281
+ // ntok_ must be bound to it; utok_ must hold any role in it
282
+ // (viewer included — viewer is read-only, and a viewer who can see
283
+ // a task but not open its attachment is a broken product).
284
+ // - entry.network_id absent (legacy / unattributed) → the pre-#503
285
+ // rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
238
286
  // - Everything else → deny (404). New uploads in production always
239
287
  // carry a truthy owner_id (requireAuth blocks the null-owner-
240
288
  // producing path when DEV_OPEN=off and no legacy master token is
241
289
  // configured), so denying null-owner in production closes the
242
290
  // enumeration vector for legacy blobs without breaking new uploads.
243
- export function authorizeFileDownload(req: Request, entry: { owner_id?: unknown }): boolean {
244
- const token = requestToken(req);
245
- const authCtx = resolveRequestAuth(req);
246
- const resolved = token ? resolveToken(token) : null;
247
- const isLegacyMasterCaller = !authCtx && isLegacyAuthToken(req);
248
- const isAdminCaller = !!resolved && resolved.user.role === "admin";
249
- const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
250
- ? entry.owner_id
251
- : null;
252
- const isOwnerCaller = !!authCtx && !!ownerId && authCtx.userId === ownerId;
253
- if (isLegacyMasterCaller || isAdminCaller || isOwnerCaller) return true;
254
- if (ownerId === null && DEV_OPEN) return true;
255
- return false;
256
- }
257
-
258
- type RestNetworkScope = {
259
- networkId: string | null;
260
- networkIds: string[] | null;
261
- denied?: string;
262
- };
263
-
264
- function getUserNetworkIds(userId: string): string[] {
265
- return db.all<{ network_id: string }>(
266
- "SELECT network_id FROM network_members WHERE user_id = ?1",
267
- userId
268
- ).map((row) => row.network_id);
269
- }
270
-
271
- function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
272
- const requested = url.searchParams.get("network_id");
273
-
274
- // Legacy global token or open dev mode keeps the old global behavior.
275
- if (!authCtx) return { networkId: requested || null, networkIds: null };
276
-
277
- // Network tokens are forcibly scoped to their bound network.
278
- if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
279
-
280
- // System admins may intentionally inspect all networks.
281
- if (isAdmin) return { networkId: requested || null, networkIds: null };
282
-
283
- if (requested) {
284
- const role = getUserNetworkRole(authCtx.userId, requested);
285
- if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
286
- return { networkId: requested, networkIds: null };
291
+ //
292
+ // 生产影响: 新的 ntok 归属文件走 network gate, admin 签发的 ntok 也走 gate
293
+ // (kind='ntok' 分类优先绑定). 98 历史文件无 network_id 走老文件兼容分支
294
+ // (owner/admin 可读, 存量行为不变).
295
+ //
296
+ // 行为变更: admin 的节点令牌 (ntok_) 失去它今天拥有的跨网络读权. 生产今天
297
+ // 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
298
+ export function authorizeFileDownload(
299
+ principal: Principal,
300
+ entry: { ownerId: string | null; networkId: string | null },
301
+ ): boolean {
302
+ if (principal.kind === "legacy-master") return true;
303
+ if (principal.kind === "admin-utok") return true;
304
+
305
+ if (entry.networkId !== null) {
306
+ if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
307
+ if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
308
+ return !!getUserNetworkRole(principal.userId, entry.networkId);
287
309
  }
288
310
 
289
- return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
311
+ if (principal.kind === "ntok" || principal.kind === "utok") {
312
+ if (entry.ownerId !== null && principal.userId === entry.ownerId) return true;
313
+ }
314
+ // Kept env-gated rather than narrowed to kind==='dev-open-anon': the
315
+ // pre-#503 rule allowed ANY caller to read a null-owner entry while
316
+ // DEV_OPEN is on, and #503 is scoped to adding network scope, not to
317
+ // tightening the local-dev carve-out.
318
+ if (entry.ownerId === null && DEV_OPEN) return true;
319
+ return false;
290
320
  }
291
321
 
292
- function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
293
- if (scope.networkId) {
294
- sql += ` AND ${column} = ?${params.length + 1}`;
295
- params.push(scope.networkId);
296
- } else if (scope.networkIds) {
297
- if (scope.networkIds.length === 0) {
298
- sql += " AND 1=0";
322
+ type ByteRange =
323
+ | { ok: true; start: number; end: number; length: number }
324
+ | { ok: false };
325
+
326
+ function parseSingleByteRange(rangeHeader: string | null, size: number): ByteRange | null {
327
+ if (rangeHeader === null) return null;
328
+ if (!Number.isSafeInteger(size) || size < 0) return { ok: false };
329
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
330
+ if (!match) return { ok: false };
331
+ const [, rawStart, rawEnd] = match;
332
+ if (rawStart === "" && rawEnd === "") return { ok: false };
333
+
334
+ let start: number;
335
+ let end: number;
336
+ if (rawStart === "") {
337
+ const suffixLength = Number(rawEnd);
338
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { ok: false };
339
+ if (size === 0) return { ok: false };
340
+ start = Math.max(0, size - suffixLength);
341
+ end = size - 1;
342
+ } else {
343
+ start = Number(rawStart);
344
+ if (!Number.isSafeInteger(start) || start < 0) return { ok: false };
345
+ if (start >= size) return { ok: false };
346
+ if (rawEnd === "") {
347
+ end = size - 1;
299
348
  } else {
300
- const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
301
- sql += ` AND ${column} IN (${placeholders})`;
302
- params.push(...scope.networkIds);
349
+ end = Number(rawEnd);
350
+ if (!Number.isSafeInteger(end) || end < start) return { ok: false };
351
+ end = Math.min(end, size - 1);
303
352
  }
304
353
  }
305
- return sql;
306
- }
307
354
 
308
- function singleNetworkId(scope: RestNetworkScope): string | null {
309
- if (scope.networkId) return scope.networkId;
310
- if (scope.networkIds?.length === 1) return scope.networkIds[0];
311
- return null;
355
+ return { ok: true, start, end, length: end - start + 1 };
312
356
  }
313
357
 
314
358
  function sqliteTime(date: Date): string {
@@ -409,13 +453,6 @@ function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
409
453
  }));
410
454
  }
411
455
 
412
- function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
413
- if (!authCtx) return true; // legacy global token or open dev mode
414
- if (isAdmin) return true;
415
- if (!networkId) return false;
416
- const role = getUserNetworkRole(authCtx.userId, networkId);
417
- return !!role && role !== "viewer";
418
- }
419
456
 
420
457
  type RestDeliveryTarget =
421
458
  | { state: "online"; alias: string; session: any }
@@ -1305,7 +1342,7 @@ return Bun.serve({
1305
1342
  // Token-bound networkId takes precedence (ntok_ → forced), then query param
1306
1343
  const restAuth = resolveRequestAuth(req);
1307
1344
  const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
1308
- const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
1345
+ const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
1309
1346
  if (restScope.denied) {
1310
1347
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
1311
1348
  }
@@ -1602,8 +1639,22 @@ return Bun.serve({
1602
1639
  // X-Content-Type-Options: nosniff so uploaded files can never be
1603
1640
  // executed or served as HTML.
1604
1641
  if (url.pathname === "/api/upload" && req.method === "POST") {
1642
+ // #503 Finding 3 = Option A (lead 40be9845): every 4xx returned
1643
+ // BEFORE `req.formData()` drains the request body MUST include
1644
+ // `Connection: close`. Otherwise the client's still-inbound bytes
1645
+ // land on a keepalive connection the server already answered on,
1646
+ // poisoning the next pooled request. Bug is pre-existing (411/
1647
+ // 413/415/429/401 all have this shape) — the #503 authz block
1648
+ // just made it visible under aggregate `bun test` with a 12MiB
1649
+ // upload immediately followed by another request. Per-file green
1650
+ // hides this class of bug.
1651
+ const earlyReject = (res: Response): Response => {
1652
+ const wrapped = withCors(req, res);
1653
+ wrapped.headers.set("Connection", "close");
1654
+ return wrapped;
1655
+ };
1605
1656
  const authErr = requireAuth(req);
1606
- if (authErr) return withCors(req, authErr);
1657
+ if (authErr) return earlyReject(authErr);
1607
1658
 
1608
1659
  // Rate-limit key prefers the token id (so 60/h is per-token, not
1609
1660
  // per-IP); falls back to IP for legacy or anonymous-but-dev-open.
@@ -1617,7 +1668,7 @@ return Bun.serve({
1617
1668
  "X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
1618
1669
  };
1619
1670
  if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
1620
- return withCors(req, new Response(
1671
+ return earlyReject(new Response(
1621
1672
  JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
1622
1673
  { status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
1623
1674
  ));
@@ -1631,25 +1682,132 @@ return Bun.serve({
1631
1682
  // or lied Content-Length header)
1632
1683
  const contentLength = req.headers.get("Content-Length");
1633
1684
  if (!contentLength) {
1634
- return withCors(req, Response.json(
1685
+ return earlyReject(Response.json(
1635
1686
  { ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
1636
1687
  { status: 411 },
1637
1688
  ));
1638
1689
  }
1639
1690
  const declaredBytes = Number(contentLength);
1640
1691
  if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
1641
- return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
1692
+ return earlyReject(Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
1642
1693
  }
1643
1694
  if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
1644
- return withCors(req, Response.json(
1695
+ return earlyReject(Response.json(
1645
1696
  { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
1646
1697
  { status: 413 },
1647
1698
  ));
1648
1699
  }
1649
1700
 
1701
+ // #503 — decide which network this upload is attributed to, then
1702
+ // check the caller may write there. Attribution and authorization
1703
+ // are two questions; neither substitutes for the other. Placed
1704
+ // after the in-memory rate limiter (above) and before the body is
1705
+ // read, so an unauthorized caller never gets a DB query per byte
1706
+ // uploaded and never gets their multipart envelope parsed.
1707
+ const principal = resolvePrincipal(req);
1708
+ const requestedNetId = url.searchParams.get("network_id");
1709
+ let uploadNetId: string | null = null;
1710
+
1711
+ switch (principal.kind) {
1712
+ case "anonymous":
1713
+ // Unreachable: requireAuth above already 401s. Kept so the
1714
+ // switch stays exhaustive and a future auth change fails loud.
1715
+ return earlyReject(Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
1716
+ case "dev-open-anon":
1717
+ // Local dev only. Deliberately does NOT claim a real network,
1718
+ // even if one was requested — an unauthenticated caller must
1719
+ // not be able to file blobs into a tenant's network.
1720
+ uploadNetId = null;
1721
+ break;
1722
+ case "legacy-master":
1723
+ // Unreachable today: requireAuth 401s master tokens on every
1724
+ // non-GET /api/ request (RFC-001 made them read-only). Kept
1725
+ // fail-closed so relaxing that rule cannot silently start
1726
+ // producing unattributed blobs.
1727
+ if (!requestedNetId) {
1728
+ return earlyReject(Response.json({
1729
+ ok: false, error: "network_id_required",
1730
+ message: "legacy master uploads must specify a network_id query param",
1731
+ }, { status: 400 }));
1732
+ }
1733
+ uploadNetId = requestedNetId;
1734
+ break;
1735
+ case "ntok":
1736
+ if (requestedNetId && requestedNetId !== principal.boundNetworkId) {
1737
+ return earlyReject(Response.json({
1738
+ ok: false, error: "network_id_conflict",
1739
+ message: "network_id query param conflicts with the token-bound network",
1740
+ }, { status: 400 }));
1741
+ }
1742
+ uploadNetId = principal.boundNetworkId;
1743
+ break;
1744
+ case "admin-utok": {
1745
+ // #503 Finding 2 = Option F (lead 40be9845): admin follows the same
1746
+ // "auto-derive when unambiguous / require param when ambiguous" rule
1747
+ // as utok_. Rule 4's REASON (no unowned files) is preserved — the
1748
+ // derived network is a real membership. Strict on genuine ambiguity:
1749
+ // 0 or ≥2 memberships → 400. Preserves prod upload contract (in
1750
+ // prod admin is in exactly 1 network; auto-derive succeeds).
1751
+ if (requestedNetId) {
1752
+ // Admin bypasses membership; network existence validated below.
1753
+ uploadNetId = requestedNetId;
1754
+ break;
1755
+ }
1756
+ const adminNetworks = getUserNetworkIds(principal.userId);
1757
+ if (adminNetworks.length === 1) {
1758
+ uploadNetId = adminNetworks[0];
1759
+ } else {
1760
+ return earlyReject(Response.json({
1761
+ ok: false, error: "network_id_required",
1762
+ message: "admin has 0 or ≥2 network memberships; network_id query param required",
1763
+ }, { status: 400 }));
1764
+ }
1765
+ break;
1766
+ }
1767
+ case "utok": {
1768
+ if (requestedNetId) {
1769
+ // A non-member asking for someone else's network never gets
1770
+ // here: the REST scope guard (`restScope.denied`) already
1771
+ // 403'd, with the same body whether the network exists or
1772
+ // not. Re-checking membership here would be a second, drifting
1773
+ // copy of a decision this codebase makes in one place.
1774
+ uploadNetId = requestedNetId;
1775
+ break;
1776
+ }
1777
+ uploadNetId = singleNetworkId(resolveRestNetworkScope(url.searchParams.get("network_id"), authCtx, false));
1778
+ if (!uploadNetId) {
1779
+ return earlyReject(Response.json({
1780
+ ok: false, error: "network_id_required",
1781
+ message: "network_id query param required for a multi-network user token; 'first network' is not assumed",
1782
+ }, { status: 400 }));
1783
+ }
1784
+ break;
1785
+ }
1786
+ }
1787
+
1788
+ if (uploadNetId !== null) {
1789
+ const networkRow = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", uploadNetId);
1790
+ if (!networkRow) {
1791
+ if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
1792
+ return earlyReject(Response.json({
1793
+ ok: false, error: "unknown_network",
1794
+ message: "network_id does not exist",
1795
+ }, { status: 400 }));
1796
+ }
1797
+ // Byte-identical to the non-member 404 above: distinguishing
1798
+ // the two would hand an unprivileged caller a network-existence
1799
+ // oracle.
1800
+ return earlyReject(Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1801
+ }
1802
+ }
1803
+
1804
+ if (!canRestWriteNetwork(authCtx, uploadNetId, principal.kind === "admin-utok")) {
1805
+ return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1806
+ }
1807
+
1650
1808
  const contentType = req.headers.get("Content-Type") ?? "";
1651
1809
  if (!/^multipart\/form-data/i.test(contentType)) {
1652
- return withCors(req, Response.json(
1810
+ return earlyReject(Response.json(
1653
1811
  { ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
1654
1812
  { status: 415 },
1655
1813
  ));
@@ -1721,6 +1879,8 @@ return Bun.serve({
1721
1879
  size: file.size,
1722
1880
  owner: authCtx?.username ?? null,
1723
1881
  owner_id: authCtx?.userId ?? null,
1882
+ // Key omitted (not null) when unattributed — see UploadIndexEntry.
1883
+ ...(uploadNetId ? { network_id: uploadNetId } : {}),
1724
1884
  uploaded_at: new Date().toISOString(),
1725
1885
  }, null, 2));
1726
1886
  }
@@ -1791,7 +1951,7 @@ return Bun.serve({
1791
1951
  // "no such file". Both branches return the same 404 shape
1792
1952
  // for BOTH GET and HEAD (HEAD honours body-omission but the
1793
1953
  // status code and headers are the authoritative signal).
1794
- if (!authorizeFileDownload(req, entry)) {
1954
+ if (!authorizeFileDownload(resolvePrincipal(req), normalizeEntry(entry))) {
1795
1955
  return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1796
1956
  }
1797
1957
 
@@ -1847,7 +2007,31 @@ return Bun.serve({
1847
2007
  "Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
1848
2008
  "X-Content-Type-Options": "nosniff",
1849
2009
  "Cache-Control": "private, no-store",
2010
+ "Accept-Ranges": "bytes",
1850
2011
  };
2012
+ const range = parseSingleByteRange(req.headers.get("Range"), entry.size);
2013
+ if (range && !range.ok) {
2014
+ return new Response(null, {
2015
+ status: 416,
2016
+ headers: {
2017
+ ...responseHeaders,
2018
+ "Content-Length": "0",
2019
+ "Content-Range": `bytes */${entry.size}`,
2020
+ },
2021
+ });
2022
+ }
2023
+ if (range?.ok) {
2024
+ const headers = {
2025
+ ...responseHeaders,
2026
+ "Content-Length": String(range.length),
2027
+ "Content-Range": `bytes ${range.start}-${range.end}/${entry.size}`,
2028
+ };
2029
+ if (req.method === "HEAD") {
2030
+ return new Response(null, { status: 206, headers });
2031
+ }
2032
+ const blob = Bun.file(storage.absolutePath).slice(range.start, range.end + 1);
2033
+ return new Response(blob, { status: 206, headers });
2034
+ }
1851
2035
  // HEAD: return the same status and headers as GET, but no body,
1852
2036
  // per RFC 9110 §9.3.2. This ensures HEAD passes through the same
1853
2037
  // authorization helper — a cross-owner HEAD is 404 (same as GET),