cursedbelt-core 2.13.1 → 2.13.2

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.
@@ -33,7 +33,7 @@ export async function convertIfOnlyGETRequests(url, init = { method: 'GET' }, fo
33
33
  if (!METHOD_TO_ABBREV[upper]) {
34
34
  throw new Error(`ctgr: "${method}" is not a tunnelable method`);
35
35
  }
36
- const body = form ? await formDataToBody(form) : bodyFromInit(init.body);
36
+ const body = form ? await formDataToBody(form) : bodyFromInit(init.body, upper, url);
37
37
  const getInit = toGetInit(init);
38
38
  const { chunks } = await encodeRequest(upper, url, body);
39
39
  return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
@@ -62,10 +62,26 @@ async function formDataToBody(formData) {
62
62
  }
63
63
  return { __files: files, ...fields };
64
64
  }
65
- /** The request body as a JS value: a JSON string is parsed; anything else passes through. */
66
- function bodyFromInit(body) {
67
- if (typeof body !== 'string')
68
- return body ?? undefined;
65
+ /**
66
+ * The request body as a JS value: a JSON string is parsed, a plain string rides as itself.
67
+ *
68
+ * 🔴 Anything else is REFUSED. The tunnel carries a JSON value or a form's files; a `File`, a
69
+ * `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-serialised as `{}` —
70
+ * desk's import preview posted a 3.4 MB workbook and the server described "a JSON file" with `{}`
71
+ * as its sample (forge task 3443). A loud error at the call is the bug; a silent `{}` is data loss.
72
+ * Send an upload as `FormData`, which is carried whole.
73
+ */
74
+ function bodyFromInit(body, method, url) {
75
+ if (body === null || body === undefined)
76
+ return undefined;
77
+ if (typeof body !== 'string') {
78
+ // A plain object or array a caller cast to `BodyInit` serialises as itself — carried as before.
79
+ const proto = Object.getPrototypeOf(body);
80
+ if (Array.isArray(body) || proto === Object.prototype || proto === null)
81
+ return body;
82
+ const shape = Object.prototype.toString.call(body).slice(8, -1);
83
+ throw new Error(`ctgr: a ${shape} body cannot ride the GET-only tunnel (${method} ${url}) — send it as FormData or a JSON string`);
84
+ }
69
85
  try {
70
86
  return JSON.parse(body);
71
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.13.1",
3
+ "version": "2.13.2",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -57,7 +57,7 @@ export async function convertIfOnlyGETRequests(
57
57
  throw new Error(`ctgr: "${method}" is not a tunnelable method`);
58
58
  }
59
59
 
60
- const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body);
60
+ const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body, upper, url);
61
61
  const getInit = toGetInit(init);
62
62
  const { chunks } = await encodeRequest(upper, url, body);
63
63
  return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
@@ -89,9 +89,26 @@ async function formDataToBody(formData: FormData): Promise<Record<string, unknow
89
89
  return { __files: files, ...fields };
90
90
  }
91
91
 
92
- /** The request body as a JS value: a JSON string is parsed; anything else passes through. */
93
- function bodyFromInit(body: BodyInit | null | undefined): unknown {
94
- if (typeof body !== 'string') return body ?? undefined;
92
+ /**
93
+ * The request body as a JS value: a JSON string is parsed, a plain string rides as itself.
94
+ *
95
+ * 🔴 Anything else is REFUSED. The tunnel carries a JSON value or a form's files; a `File`, a
96
+ * `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-serialised as `{}` —
97
+ * desk's import preview posted a 3.4 MB workbook and the server described "a JSON file" with `{}`
98
+ * as its sample (forge task 3443). A loud error at the call is the bug; a silent `{}` is data loss.
99
+ * Send an upload as `FormData`, which is carried whole.
100
+ */
101
+ function bodyFromInit(body: BodyInit | null | undefined, method: string, url: string): unknown {
102
+ if (body === null || body === undefined) return undefined;
103
+ if (typeof body !== 'string') {
104
+ // A plain object or array a caller cast to `BodyInit` serialises as itself — carried as before.
105
+ const proto = Object.getPrototypeOf(body);
106
+ if (Array.isArray(body) || proto === Object.prototype || proto === null) return body;
107
+ const shape = Object.prototype.toString.call(body).slice(8, -1);
108
+ throw new Error(
109
+ `ctgr: a ${shape} body cannot ride the GET-only tunnel (${method} ${url}) — send it as FormData or a JSON string`,
110
+ );
111
+ }
95
112
  try {
96
113
  return JSON.parse(body);
97
114
  } catch {
@@ -132,4 +132,36 @@ describe('convertIfOnlyGETRequests', () => {
132
132
  expect(decoded.files?.[0]).toMatchObject({ originalname: 'p.bin', mimetype: 'image/png' });
133
133
  expect(decoded.body).toEqual({ name: 'pic' });
134
134
  });
135
+
136
+ // forge task 3443: desk posted a workbook as a raw `File`, and it arrived as `{}`.
137
+ test('mode ON: a body the tunnel cannot carry is refused, never sent as {}', async () => {
138
+ setCtgrMode(true);
139
+ const bodies: BodyInit[] = [
140
+ new File([new Uint8Array([0x50, 0x4b, 3, 4])], 'budget.xlsx'),
141
+ new Blob(['x']),
142
+ new Uint8Array([1, 2, 3]),
143
+ new Uint8Array([1, 2, 3]).buffer,
144
+ new URLSearchParams({ a: '1' }),
145
+ ];
146
+ for (const body of bodies) {
147
+ await expect(convertIfOnlyGETRequests('/api/v1/files/preview', { method: 'POST', body })).rejects.toThrow(
148
+ /cannot ride the GET-only tunnel \(POST \/api\/v1\/files\/preview\) — send it as FormData/,
149
+ );
150
+ }
151
+ });
152
+
153
+ test('mode ON: a plain object a caller cast to BodyInit still rides as JSON', async () => {
154
+ setCtgrMode(true);
155
+ const body = { name: 'Budget' } as unknown as BodyInit;
156
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files', { method: 'POST', body });
157
+ const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
158
+ if (decoded === 'partial') throw new Error('unreachable');
159
+ expect(decoded.body).toEqual({ name: 'Budget' });
160
+ });
161
+
162
+ test('mode OFF: any body passes through untouched — only the tunnel is particular', async () => {
163
+ const body = new Blob(['x']);
164
+ const { requests } = await convertIfOnlyGETRequests('/api/v1/files/preview', { method: 'POST', body });
165
+ expect(requests[0].init.body).toBe(body);
166
+ });
135
167
  });