cursedbelt-core 2.13.0 → 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.
@@ -15,7 +15,10 @@ export interface ParsedHotkey extends HotkeyMods {
15
15
  * returns `false` when there's no `navigator`.
16
16
  */
17
17
  export declare function isApplePlatform(): boolean;
18
- /** Split a `'+'`-separated binding into its required modifiers + final key. */
18
+ /**
19
+ * Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
20
+ * key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
21
+ */
19
22
  export declare function parseHotkey(binding: string, isApple?: boolean): ParsedHotkey;
20
23
  /** Does a keyboard event satisfy `binding` — exact modifier set + the key? */
21
24
  export declare function matchHotkey(e: KeyboardEvent, binding: string, isApple?: boolean): boolean;
@@ -21,14 +21,18 @@ export function isApplePlatform() {
21
21
  const probe = `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`;
22
22
  return /mac|iphone|ipad|ipod/i.test(probe);
23
23
  }
24
- /** Split a `'+'`-separated binding into its required modifiers + final key. */
24
+ /**
25
+ * Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
26
+ * key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
27
+ */
25
28
  export function parseHotkey(binding, isApple = isApplePlatform()) {
26
- const parts = binding
29
+ const split = binding
27
30
  .toLowerCase()
28
31
  .split('+')
29
- .map((p) => p.trim())
30
- .filter(Boolean);
31
- const key = parts.pop() ?? '';
32
+ .map((p) => p.trim());
33
+ const plusKey = split.length > 1 && split[split.length - 1] === '';
34
+ const parts = split.filter(Boolean);
35
+ const key = plusKey ? '+' : (parts.pop() ?? '');
32
36
  const tokens = new Set(parts);
33
37
  const mod = tokens.has('$mod');
34
38
  return {
@@ -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.0",
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.",
@@ -35,14 +35,18 @@ export function isApplePlatform(): boolean {
35
35
  return /mac|iphone|ipad|ipod/i.test(probe);
36
36
  }
37
37
 
38
- /** Split a `'+'`-separated binding into its required modifiers + final key. */
38
+ /**
39
+ * Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
40
+ * key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
41
+ */
39
42
  export function parseHotkey(binding: string, isApple = isApplePlatform()): ParsedHotkey {
40
- const parts = binding
43
+ const split = binding
41
44
  .toLowerCase()
42
45
  .split('+')
43
- .map((p) => p.trim())
44
- .filter(Boolean);
45
- const key = parts.pop() ?? '';
46
+ .map((p) => p.trim());
47
+ const plusKey = split.length > 1 && split[split.length - 1] === '';
48
+ const parts = split.filter(Boolean);
49
+ const key = plusKey ? '+' : (parts.pop() ?? '');
46
50
  const tokens = new Set(parts);
47
51
  const mod = tokens.has('$mod');
48
52
  return {
@@ -133,6 +133,14 @@ describe('formatHotkey', () => {
133
133
  test('multiple modifiers order Apple-style (⌃⌥⇧⌘)', () => {
134
134
  expect(formatHotkey('$mod+shift+p', true)).toBe('⇧⌘P');
135
135
  });
136
+ test('`+` is a key too — a bare `+` or `shift++` draws a + cap, never an empty one', () => {
137
+ expect(formatHotkey('+', true)).toBe('+');
138
+ expect(formatHotkey('+', false)).toBe('+');
139
+ expect(formatHotkey('$mod++', true)).toBe('⌘+');
140
+ expect(formatHotkey('$mod++', false)).toBe('Ctrl +');
141
+ expect(parseHotkey('shift++')).toMatchObject({ shift: true, key: '+' });
142
+ expect(matchHotkey(kd({ key: '+' }), '+', true)).toBe(true);
143
+ });
136
144
  });
137
145
 
138
146
  // ── ShortcutRegistry ──────────────────────────────────────────────────────────
@@ -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
  });