cursedbelt-server 4.7.0 β 4.9.0
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/dist/server/storage/binaryStore.js +6 -1
- package/dist/server/storage/files/chunkedUpload.d.ts +12 -1
- package/dist/server/storage/files/chunkedUpload.js +6 -1
- package/dist/server/storage/types.d.ts +26 -0
- package/package.json +1 -1
- package/src/server/storage/binaryStore.spec.ts +20 -0
- package/src/server/storage/binaryStore.ts +6 -1
- package/src/server/storage/files/chunkedUpload.spec.ts +15 -1
- package/src/server/storage/files/chunkedUpload.ts +18 -2
- package/src/server/storage/types.ts +26 -0
|
@@ -454,7 +454,12 @@ export function createBinaryStore(cfg) {
|
|
|
454
454
|
const titleQs = title ? `&title=${encodeURIComponent(title)}` : '';
|
|
455
455
|
const immutableQs = opts?.immutable ? '&immutable=1' : '';
|
|
456
456
|
for (let ci = 0; ci < total; ci++) {
|
|
457
|
-
|
|
457
|
+
// π΄ `sz` is the SOURCE length, and it is what makes `tc` above not load-bearing: bs
|
|
458
|
+
// checks it before and after assembly and answers 422 rather than indexing a short
|
|
459
|
+
// object (see `FileTokenClaims.sz` in `./types`). `total` is derived from `blob.size`
|
|
460
|
+
// two lines up, so the number is already in hand β declaring it costs nothing, and
|
|
461
|
+
// NOT declaring it is what left 579 of 579 fleet assembles unprovable on 2026-09-18.
|
|
462
|
+
const token = await signToken({ k: appKey(key), sid, ci, tc: total, sz: blob.size }, UPLOAD_TOKEN_TTL_SECONDS);
|
|
458
463
|
const slice = new Uint8Array(await blob
|
|
459
464
|
.slice(ci * chunkBytes, Math.min((ci + 1) * chunkBytes, blob.size))
|
|
460
465
|
.arrayBuffer());
|
|
@@ -35,7 +35,7 @@ export interface ChunkedUploadService {
|
|
|
35
35
|
/**
|
|
36
36
|
* Mint the direct-to-backend PUT URL for one chunk. The app supplies this so the module stays
|
|
37
37
|
* backend-agnostic. For binary-server: `${base}/upload-chunk/${app}/${key}?token=` with a token
|
|
38
|
-
* carrying { k, uid, sid, ci, tc }.
|
|
38
|
+
* carrying { k, uid, sid, ci, tc, sz }.
|
|
39
39
|
*/
|
|
40
40
|
export type MintChunkUpload = (args: {
|
|
41
41
|
key: string;
|
|
@@ -43,6 +43,17 @@ export type MintChunkUpload = (args: {
|
|
|
43
43
|
ci: number;
|
|
44
44
|
tc: number;
|
|
45
45
|
owner: string;
|
|
46
|
+
/**
|
|
47
|
+
* The SOURCE byte length this session was opened with β pass it straight into the token's `sz`
|
|
48
|
+
* claim (`FileTokenClaims.sz`), which is the only end-to-end integrity check the upload path
|
|
49
|
+
* has: binary-server compares it to the assembled object and answers 422 instead of indexing a
|
|
50
|
+
* short one.
|
|
51
|
+
*
|
|
52
|
+
* π΄ `undefined` when the caller could not state a positive length, and then the claim must be
|
|
53
|
+
* OMITTED rather than sent as `0` β a declared zero refuses every assemble. That is the same
|
|
54
|
+
* posture the claim itself has: optional, so that declaring it could never take an upload down.
|
|
55
|
+
*/
|
|
56
|
+
sz?: number;
|
|
46
57
|
}) => Promise<string>;
|
|
47
58
|
export declare function createChunkedUploadService(cfg: {
|
|
48
59
|
catalogue: FileCatalogue;
|
|
@@ -39,7 +39,12 @@ export function createChunkedUploadService(cfg) {
|
|
|
39
39
|
tags: req.tags,
|
|
40
40
|
createdAt: now(),
|
|
41
41
|
});
|
|
42
|
-
|
|
42
|
+
// π΄ The source length rides along, because `chunkCount` above is derived from it and a
|
|
43
|
+
// count is not a length: a session that splits the file wrongly stores whatever it sent,
|
|
44
|
+
// faithfully, with a 201. Omitted rather than zeroed when the caller stated no positive
|
|
45
|
+
// size β `sz: 0` would refuse every assemble. See `MintChunkUpload.sz`.
|
|
46
|
+
const declaredSize = Number.isFinite(req.size) && req.size > 0 ? req.size : undefined;
|
|
47
|
+
const chunkUploadUrls = await Promise.all(Array.from({ length: chunkCount }, (_, ci) => cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner, sz: declaredSize })));
|
|
43
48
|
return { fileId, sid, key, chunkSize, chunkCount, chunkUploadUrls };
|
|
44
49
|
},
|
|
45
50
|
async complete(sid, confirmed) {
|
|
@@ -69,6 +69,32 @@ export type FileTokenClaims = {
|
|
|
69
69
|
ci?: number;
|
|
70
70
|
/** Total chunk count. */
|
|
71
71
|
tc?: number;
|
|
72
|
+
/**
|
|
73
|
+
* The SOURCE file's exact byte length, declared by the minting client when the chunk session
|
|
74
|
+
* is created. binary-server checks it before and after assembly and answers 422 on a
|
|
75
|
+
* mismatch, indexing nothing.
|
|
76
|
+
*
|
|
77
|
+
* π΄ This is the only END-TO-END integrity claim the upload path has; everything else in it
|
|
78
|
+
* is an internal consistency check. `tc` comes from the client, the assemble fires when `tc`
|
|
79
|
+
* parts have been counted, and the checksum is computed over whatever was assembled β so a
|
|
80
|
+
* session that believes a 6-minute video is one 16 MiB part gets exactly that stored,
|
|
81
|
+
* faithfully, with a 201. That is what happened to `collections/file/u_fd668e6a-β¦` on
|
|
82
|
+
* 2026-08-13: nothing anywhere compared the finished object to the file it came from.
|
|
83
|
+
*
|
|
84
|
+
* With `sz`, `tc` stops being load-bearing. The part size is the client's choice by design
|
|
85
|
+
* (a browser picks its own slice), so `tc` and the chunk size are one degree of freedom and
|
|
86
|
+
* the server cannot derive either; declaring the length costs a client nothing, because
|
|
87
|
+
* every mint site already has the number in hand. A wrong `tc` can then only cost a REFUSED
|
|
88
|
+
* upload, never a short object indexed `active`.
|
|
89
|
+
*
|
|
90
|
+
* Optional, and it stays optional until a tenant is measured at zero undeclared assembles:
|
|
91
|
+
* an upload the owner cannot complete is worse than the hole. An absent `sz` is recorded on
|
|
92
|
+
* the assemble event (`resp_meta.declared` is `null`), so which tenants still do not declare
|
|
93
|
+
* is a query rather than an audit. `apps/binary-server/src/tokens.ts` is the mirror this
|
|
94
|
+
* shape is verified against, and `declaredSizeOf` in `apps/binary-server/src/server.ts` is
|
|
95
|
+
* what reads it.
|
|
96
|
+
*/
|
|
97
|
+
sz?: number;
|
|
72
98
|
/** Download filename β Content-Disposition. */
|
|
73
99
|
fn?: string;
|
|
74
100
|
dl?: 'inline' | 'attachment';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -283,6 +283,26 @@ describe('putLarge', () => {
|
|
|
283
283
|
expect(claims.every((p) => p.k === 'roms/video/x')).toBe(true);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
it('π΄ declares the SOURCE byte length as `sz` on every chunk, whatever the split', async () => {
|
|
287
|
+
// The claim that makes `tc` above not load-bearing. binary-server checks `sz` before and
|
|
288
|
+
// after assembly and answers 422 rather than indexing a short object β but only for a
|
|
289
|
+
// client that declares it, and on 2026-09-18 that was 0 of 579 fleet assembles. So: the
|
|
290
|
+
// number is the SOURCE's, it is identical on every part, and it does not move when the
|
|
291
|
+
// client picks a different slice size (`tc` and the chunk size are the client's one
|
|
292
|
+
// degree of freedom; the length is not).
|
|
293
|
+
const store = makeStore();
|
|
294
|
+
reply = () => ok(null, { status: 201 });
|
|
295
|
+
|
|
296
|
+
await store.putLarge('video/x', new Uint8Array(10), 'video/mp4', { chunkBytes: 4 });
|
|
297
|
+
expect(calls.map((c) => tokenOf(c.url).payload.sz)).toEqual([10, 10, 10]);
|
|
298
|
+
|
|
299
|
+
calls = [];
|
|
300
|
+
await store.putLarge('video/y', new Uint8Array(10), 'video/mp4', { chunkBytes: 7 });
|
|
301
|
+
const claims = calls.map((c) => tokenOf(c.url).payload);
|
|
302
|
+
expect(claims.map((p) => p.tc)).toEqual([2, 2]); // a different splitβ¦
|
|
303
|
+
expect(claims.map((p) => p.sz)).toEqual([10, 10]); // β¦and the same declared length
|
|
304
|
+
});
|
|
305
|
+
|
|
286
306
|
it('π΄ sends the real BYTES of each slice, not a lazy view', async () => {
|
|
287
307
|
// The 2026-08-13 hang: a Bun.file partial view handed to fetch never sends, and
|
|
288
308
|
// 42 files / 35 GB failed 42/42 for a day with nothing in binary-server's log.
|
|
@@ -741,8 +741,13 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
741
741
|
const titleQs = title ? `&title=${encodeURIComponent(title)}` : '';
|
|
742
742
|
const immutableQs = opts?.immutable ? '&immutable=1' : '';
|
|
743
743
|
for (let ci = 0; ci < total; ci++) {
|
|
744
|
+
// π΄ `sz` is the SOURCE length, and it is what makes `tc` above not load-bearing: bs
|
|
745
|
+
// checks it before and after assembly and answers 422 rather than indexing a short
|
|
746
|
+
// object (see `FileTokenClaims.sz` in `./types`). `total` is derived from `blob.size`
|
|
747
|
+
// two lines up, so the number is already in hand β declaring it costs nothing, and
|
|
748
|
+
// NOT declaring it is what left 579 of 579 fleet assembles unprovable on 2026-09-18.
|
|
744
749
|
const token = await signToken(
|
|
745
|
-
{ k: appKey(key), sid, ci, tc: total },
|
|
750
|
+
{ k: appKey(key), sid, ci, tc: total, sz: blob.size },
|
|
746
751
|
UPLOAD_TOKEN_TTL_SECONDS,
|
|
747
752
|
);
|
|
748
753
|
const slice = new Uint8Array(
|
|
@@ -39,7 +39,7 @@ describe('chunked upload', () => {
|
|
|
39
39
|
now: () => ++clock,
|
|
40
40
|
});
|
|
41
41
|
const ledger = createStorageOpsLedger(db, () => ++clock);
|
|
42
|
-
const minted: Array<{ key: string; sid: string; ci: number; tc: number }> = [];
|
|
42
|
+
const minted: Array<{ key: string; sid: string; ci: number; tc: number; sz?: number }> = [];
|
|
43
43
|
const svc = createChunkedUploadService({
|
|
44
44
|
catalogue,
|
|
45
45
|
ledger,
|
|
@@ -67,6 +67,10 @@ describe('chunked upload', () => {
|
|
|
67
67
|
expect(init.chunkUploadUrls).toHaveLength(3);
|
|
68
68
|
expect(minted.map((m) => m.ci)).toEqual([0, 1, 2]);
|
|
69
69
|
expect(minted.every((m) => m.tc === 3 && m.sid === init.sid)).toBe(true);
|
|
70
|
+
// π΄ The SOURCE length, on every chunk. `tc` is derived from it and a count is not a length:
|
|
71
|
+
// without this the minter has nothing to put in the token's `sz` claim, and binary-server's
|
|
72
|
+
// only end-to-end check of the finished object against its source stays inert.
|
|
73
|
+
expect(minted.map((m) => m.sz)).toEqual([20 * 1024 * 1024, 20 * 1024 * 1024, 20 * 1024 * 1024]);
|
|
70
74
|
// The mint targets exactly the key the catalogue will look for on complete.
|
|
71
75
|
expect(init.key).toBe(catalogueUploadKey('u1', init.fileId, 'image/png'));
|
|
72
76
|
|
|
@@ -78,6 +82,16 @@ describe('chunked upload', () => {
|
|
|
78
82
|
expect(svc.pending()).toBe(1);
|
|
79
83
|
});
|
|
80
84
|
|
|
85
|
+
test('π΄ a size the caller could not state is OMITTED, never declared as zero', async () => {
|
|
86
|
+
// `sz: 0` would compare zero to the assembled length and refuse every assemble β so an
|
|
87
|
+
// unknown size degrades to exactly today's behaviour (no claim, recorded undeclared) rather
|
|
88
|
+
// than to a 422. `chunkCount` still clamps to one chunk, which is the old `req.size || 1`.
|
|
89
|
+
const { svc, minted } = build();
|
|
90
|
+
const init = await svc.init(CTX, { name: 'unknown.bin', mime: 'application/octet-stream', size: 0 });
|
|
91
|
+
expect(init.chunkCount).toBe(1);
|
|
92
|
+
expect(minted.map((m) => m.sz)).toEqual([undefined]);
|
|
93
|
+
});
|
|
94
|
+
|
|
81
95
|
test('complete registers the catalogue row over the assembled bytes + marks the op done', async () => {
|
|
82
96
|
const { catalogue, ledger, svc } = build();
|
|
83
97
|
const init = await svc.init(CTX, {
|
|
@@ -64,7 +64,7 @@ export interface ChunkedUploadService {
|
|
|
64
64
|
/**
|
|
65
65
|
* Mint the direct-to-backend PUT URL for one chunk. The app supplies this so the module stays
|
|
66
66
|
* backend-agnostic. For binary-server: `${base}/upload-chunk/${app}/${key}?token=` with a token
|
|
67
|
-
* carrying { k, uid, sid, ci, tc }.
|
|
67
|
+
* carrying { k, uid, sid, ci, tc, sz }.
|
|
68
68
|
*/
|
|
69
69
|
export type MintChunkUpload = (args: {
|
|
70
70
|
key: string;
|
|
@@ -72,6 +72,17 @@ export type MintChunkUpload = (args: {
|
|
|
72
72
|
ci: number;
|
|
73
73
|
tc: number;
|
|
74
74
|
owner: string;
|
|
75
|
+
/**
|
|
76
|
+
* The SOURCE byte length this session was opened with β pass it straight into the token's `sz`
|
|
77
|
+
* claim (`FileTokenClaims.sz`), which is the only end-to-end integrity check the upload path
|
|
78
|
+
* has: binary-server compares it to the assembled object and answers 422 instead of indexing a
|
|
79
|
+
* short one.
|
|
80
|
+
*
|
|
81
|
+
* π΄ `undefined` when the caller could not state a positive length, and then the claim must be
|
|
82
|
+
* OMITTED rather than sent as `0` β a declared zero refuses every assemble. That is the same
|
|
83
|
+
* posture the claim itself has: optional, so that declaring it could never take an upload down.
|
|
84
|
+
*/
|
|
85
|
+
sz?: number;
|
|
75
86
|
}) => Promise<string>;
|
|
76
87
|
|
|
77
88
|
export function createChunkedUploadService(cfg: {
|
|
@@ -110,9 +121,14 @@ export function createChunkedUploadService(cfg: {
|
|
|
110
121
|
tags: req.tags,
|
|
111
122
|
createdAt: now(),
|
|
112
123
|
});
|
|
124
|
+
// π΄ The source length rides along, because `chunkCount` above is derived from it and a
|
|
125
|
+
// count is not a length: a session that splits the file wrongly stores whatever it sent,
|
|
126
|
+
// faithfully, with a 201. Omitted rather than zeroed when the caller stated no positive
|
|
127
|
+
// size β `sz: 0` would refuse every assemble. See `MintChunkUpload.sz`.
|
|
128
|
+
const declaredSize = Number.isFinite(req.size) && req.size > 0 ? req.size : undefined;
|
|
113
129
|
const chunkUploadUrls = await Promise.all(
|
|
114
130
|
Array.from({ length: chunkCount }, (_, ci) =>
|
|
115
|
-
cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner }),
|
|
131
|
+
cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner, sz: declaredSize }),
|
|
116
132
|
),
|
|
117
133
|
);
|
|
118
134
|
return { fileId, sid, key, chunkSize, chunkCount, chunkUploadUrls };
|
|
@@ -93,6 +93,32 @@ export type FileTokenClaims = {
|
|
|
93
93
|
ci?: number;
|
|
94
94
|
/** Total chunk count. */
|
|
95
95
|
tc?: number;
|
|
96
|
+
/**
|
|
97
|
+
* The SOURCE file's exact byte length, declared by the minting client when the chunk session
|
|
98
|
+
* is created. binary-server checks it before and after assembly and answers 422 on a
|
|
99
|
+
* mismatch, indexing nothing.
|
|
100
|
+
*
|
|
101
|
+
* π΄ This is the only END-TO-END integrity claim the upload path has; everything else in it
|
|
102
|
+
* is an internal consistency check. `tc` comes from the client, the assemble fires when `tc`
|
|
103
|
+
* parts have been counted, and the checksum is computed over whatever was assembled β so a
|
|
104
|
+
* session that believes a 6-minute video is one 16 MiB part gets exactly that stored,
|
|
105
|
+
* faithfully, with a 201. That is what happened to `collections/file/u_fd668e6a-β¦` on
|
|
106
|
+
* 2026-08-13: nothing anywhere compared the finished object to the file it came from.
|
|
107
|
+
*
|
|
108
|
+
* With `sz`, `tc` stops being load-bearing. The part size is the client's choice by design
|
|
109
|
+
* (a browser picks its own slice), so `tc` and the chunk size are one degree of freedom and
|
|
110
|
+
* the server cannot derive either; declaring the length costs a client nothing, because
|
|
111
|
+
* every mint site already has the number in hand. A wrong `tc` can then only cost a REFUSED
|
|
112
|
+
* upload, never a short object indexed `active`.
|
|
113
|
+
*
|
|
114
|
+
* Optional, and it stays optional until a tenant is measured at zero undeclared assembles:
|
|
115
|
+
* an upload the owner cannot complete is worse than the hole. An absent `sz` is recorded on
|
|
116
|
+
* the assemble event (`resp_meta.declared` is `null`), so which tenants still do not declare
|
|
117
|
+
* is a query rather than an audit. `apps/binary-server/src/tokens.ts` is the mirror this
|
|
118
|
+
* shape is verified against, and `declaredSizeOf` in `apps/binary-server/src/server.ts` is
|
|
119
|
+
* what reads it.
|
|
120
|
+
*/
|
|
121
|
+
sz?: number;
|
|
96
122
|
/** Download filename β Content-Disposition. */
|
|
97
123
|
fn?: string;
|
|
98
124
|
dl?: 'inline' | 'attachment';
|