@doist/comms-sdk 2.0.0 → 2.0.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.
@@ -143,7 +143,7 @@ class ConversationsClient extends base_client_1.BaseClient {
143
143
  * @returns The updated conversation object.
144
144
  */
145
145
  archiveConversation(id) {
146
- return this.simple('GET', 'archive', { id }, this.conversationSchema);
146
+ return this.simple('POST', 'archive', { id }, this.conversationSchema);
147
147
  }
148
148
  /**
149
149
  * Unarchives a conversation.
@@ -152,7 +152,7 @@ class ConversationsClient extends base_client_1.BaseClient {
152
152
  * @returns The updated conversation object.
153
153
  */
154
154
  unarchiveConversation(id) {
155
- return this.simple('GET', 'unarchive', { id }, this.conversationSchema);
155
+ return this.simple('POST', 'unarchive', { id }, this.conversationSchema);
156
156
  }
157
157
  /**
158
158
  * Adds a user to a conversation.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getContentTypeFromFileName = getContentTypeFromFileName;
4
4
  exports.uploadMultipartFile = uploadMultipartFile;
5
+ const uuid_1 = require("uuid");
5
6
  const fetch_with_retry_1 = require("../transport/fetch-with-retry");
6
7
  /**
7
8
  * Determine a content-type from a filename extension. Falls back to
@@ -71,8 +72,7 @@ const MIME_BY_EXTENSION = {
71
72
  };
72
73
  /**
73
74
  * Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
74
- * content type, so uploads use the cross-platform global `FormData`/`Blob` body that
75
- * `undici` and browsers both accept natively.
75
+ * content type, ready to be encoded into the multipart body.
76
76
  */
77
77
  function toBlob(file, fileName, contentType) {
78
78
  if (file instanceof Blob) {
@@ -97,15 +97,72 @@ function toBlob(file, fileName, contentType) {
97
97
  }
98
98
  throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
99
99
  }
100
+ /**
101
+ * Escapes a value for use inside a quoted `Content-Disposition` parameter, where a raw
102
+ * quote would end the value early and a raw CR or LF would end the header. A lone CR
103
+ * needs escaping too, not just CRLF pairs.
104
+ *
105
+ * This is the same escaping the platform's own `FormData` encoder applies, so servers
106
+ * see exactly what they would from any browser.
107
+ */
108
+ function escapeDispositionValue(value) {
109
+ return value.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
110
+ }
111
+ /**
112
+ * Rejects a media type that could not be safely written into a part header.
113
+ *
114
+ * `contentType` is public caller input and reaches the header verbatim. A value
115
+ * carrying a CR or LF would end the header early and let the caller forge one
116
+ * of its own — `Blob` normalises the `type` it stores, but the supplied value
117
+ * is what gets interpolated. Reject rather than silently rewrite, so a caller
118
+ * passing something unusable finds out instead of having it altered.
119
+ */
120
+ function assertHeaderSafeContentType(contentType) {
121
+ if (!/^[\x20-\x7e]*$/.test(contentType)) {
122
+ throw new Error('contentType must contain only printable ASCII characters, without CR or LF');
123
+ }
124
+ }
125
+ /**
126
+ * Encodes a `multipart/form-data` body as a `Blob`, together with the `Content-Type`
127
+ * that describes it.
128
+ *
129
+ * Deliberately avoids `FormData`. A `FormData` body is only encoded by the `fetch` that
130
+ * owns that `FormData` class — undici brands its own and checks it with a plain
131
+ * `instanceof` — and the SDK dispatches through undici's own `fetch` to keep the request
132
+ * client on the same undici as the dispatcher. A global `FormData` handed to it is
133
+ * stringified to the literal `"[object FormData]"`, so the upload silently carries no
134
+ * file. `Blob` has no such brand and works with whichever `fetch` sends the request,
135
+ * including a caller-supplied `customFetch`.
136
+ */
137
+ function buildMultipartBody(args) {
138
+ const { blob, fileName, contentType, fields } = args;
139
+ assertHeaderSafeContentType(contentType);
140
+ const boundary = `----comms-sdk-${(0, uuid_1.v4)()}`;
141
+ const parts = [
142
+ `--${boundary}\r\n` +
143
+ `Content-Disposition: form-data; name="file"; filename="${escapeDispositionValue(fileName)}"\r\n` +
144
+ `Content-Type: ${contentType}\r\n\r\n`,
145
+ blob,
146
+ '\r\n',
147
+ ];
148
+ for (const [key, value] of Object.entries(fields)) {
149
+ if (value !== undefined && value !== null) {
150
+ parts.push(`--${boundary}\r\n` +
151
+ `Content-Disposition: form-data; name="${escapeDispositionValue(key)}"\r\n\r\n` +
152
+ `${String(value)}\r\n`);
153
+ }
154
+ }
155
+ parts.push(`--${boundary}--\r\n`);
156
+ return { body: new Blob(parts), contentType: `multipart/form-data; boundary=${boundary}` };
157
+ }
100
158
  /**
101
159
  * Upload a file using `multipart/form-data`.
102
160
  *
103
- * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
104
- * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
161
+ * Encodes the request body as a `Blob` so it works unchanged in the browser and in
162
+ * Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
105
163
  * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
106
164
  * `additionalFields` are merged in and override the derived values. Authentication uses
107
- * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
108
- * intentionally left unset so the runtime adds the correct multipart boundary.
165
+ * `Authorization: Bearer`, matching every other Comms SDK client.
109
166
  *
110
167
  * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
111
168
  * the returned shape with the appropriate schema.
@@ -119,15 +176,17 @@ async function uploadMultipartFile(args) {
119
176
  underlying_type: resolvedType,
120
177
  ...additionalFields,
121
178
  };
122
- const form = new FormData();
123
- form.append('file', blob, resolvedFileName);
124
- for (const [key, value] of Object.entries(fields)) {
125
- if (value !== undefined && value !== null) {
126
- form.append(key, String(value));
127
- }
128
- }
179
+ const multipart = buildMultipartBody({
180
+ blob,
181
+ fileName: resolvedFileName,
182
+ contentType: resolvedType,
183
+ fields,
184
+ });
129
185
  const headers = {
130
186
  Authorization: `Bearer ${authToken}`,
187
+ // The boundary is ours, so this has to be declared explicitly — `fetch`
188
+ // only fills it in for a `FormData` body.
189
+ 'Content-Type': multipart.contentType,
131
190
  };
132
191
  if (requestId) {
133
192
  headers['X-Request-Id'] = requestId;
@@ -136,8 +195,7 @@ async function uploadMultipartFile(args) {
136
195
  const response = await (0, fetch_with_retry_1.fetchWithRetry)(url, {
137
196
  method: 'POST',
138
197
  headers,
139
- body: form,
140
- // Don't set Content-Type — the runtime adds the multipart boundary.
198
+ body: multipart.body,
141
199
  timeout: 30000,
142
200
  }, maxRetries, customFetch);
143
201
  return response.data;
@@ -140,7 +140,7 @@ export class ConversationsClient extends BaseClient {
140
140
  * @returns The updated conversation object.
141
141
  */
142
142
  archiveConversation(id) {
143
- return this.simple('GET', 'archive', { id }, this.conversationSchema);
143
+ return this.simple('POST', 'archive', { id }, this.conversationSchema);
144
144
  }
145
145
  /**
146
146
  * Unarchives a conversation.
@@ -149,7 +149,7 @@ export class ConversationsClient extends BaseClient {
149
149
  * @returns The updated conversation object.
150
150
  */
151
151
  unarchiveConversation(id) {
152
- return this.simple('GET', 'unarchive', { id }, this.conversationSchema);
152
+ return this.simple('POST', 'unarchive', { id }, this.conversationSchema);
153
153
  }
154
154
  /**
155
155
  * Adds a user to a conversation.
@@ -1,3 +1,4 @@
1
+ import { v4 as uuid } from 'uuid';
1
2
  import { fetchWithRetry } from '../transport/fetch-with-retry.js';
2
3
  /**
3
4
  * Determine a content-type from a filename extension. Falls back to
@@ -67,8 +68,7 @@ const MIME_BY_EXTENSION = {
67
68
  };
68
69
  /**
69
70
  * Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
70
- * content type, so uploads use the cross-platform global `FormData`/`Blob` body that
71
- * `undici` and browsers both accept natively.
71
+ * content type, ready to be encoded into the multipart body.
72
72
  */
73
73
  function toBlob(file, fileName, contentType) {
74
74
  if (file instanceof Blob) {
@@ -93,15 +93,72 @@ function toBlob(file, fileName, contentType) {
93
93
  }
94
94
  throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
95
95
  }
96
+ /**
97
+ * Escapes a value for use inside a quoted `Content-Disposition` parameter, where a raw
98
+ * quote would end the value early and a raw CR or LF would end the header. A lone CR
99
+ * needs escaping too, not just CRLF pairs.
100
+ *
101
+ * This is the same escaping the platform's own `FormData` encoder applies, so servers
102
+ * see exactly what they would from any browser.
103
+ */
104
+ function escapeDispositionValue(value) {
105
+ return value.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
106
+ }
107
+ /**
108
+ * Rejects a media type that could not be safely written into a part header.
109
+ *
110
+ * `contentType` is public caller input and reaches the header verbatim. A value
111
+ * carrying a CR or LF would end the header early and let the caller forge one
112
+ * of its own — `Blob` normalises the `type` it stores, but the supplied value
113
+ * is what gets interpolated. Reject rather than silently rewrite, so a caller
114
+ * passing something unusable finds out instead of having it altered.
115
+ */
116
+ function assertHeaderSafeContentType(contentType) {
117
+ if (!/^[\x20-\x7e]*$/.test(contentType)) {
118
+ throw new Error('contentType must contain only printable ASCII characters, without CR or LF');
119
+ }
120
+ }
121
+ /**
122
+ * Encodes a `multipart/form-data` body as a `Blob`, together with the `Content-Type`
123
+ * that describes it.
124
+ *
125
+ * Deliberately avoids `FormData`. A `FormData` body is only encoded by the `fetch` that
126
+ * owns that `FormData` class — undici brands its own and checks it with a plain
127
+ * `instanceof` — and the SDK dispatches through undici's own `fetch` to keep the request
128
+ * client on the same undici as the dispatcher. A global `FormData` handed to it is
129
+ * stringified to the literal `"[object FormData]"`, so the upload silently carries no
130
+ * file. `Blob` has no such brand and works with whichever `fetch` sends the request,
131
+ * including a caller-supplied `customFetch`.
132
+ */
133
+ function buildMultipartBody(args) {
134
+ const { blob, fileName, contentType, fields } = args;
135
+ assertHeaderSafeContentType(contentType);
136
+ const boundary = `----comms-sdk-${uuid()}`;
137
+ const parts = [
138
+ `--${boundary}\r\n` +
139
+ `Content-Disposition: form-data; name="file"; filename="${escapeDispositionValue(fileName)}"\r\n` +
140
+ `Content-Type: ${contentType}\r\n\r\n`,
141
+ blob,
142
+ '\r\n',
143
+ ];
144
+ for (const [key, value] of Object.entries(fields)) {
145
+ if (value !== undefined && value !== null) {
146
+ parts.push(`--${boundary}\r\n` +
147
+ `Content-Disposition: form-data; name="${escapeDispositionValue(key)}"\r\n\r\n` +
148
+ `${String(value)}\r\n`);
149
+ }
150
+ }
151
+ parts.push(`--${boundary}--\r\n`);
152
+ return { body: new Blob(parts), contentType: `multipart/form-data; boundary=${boundary}` };
153
+ }
96
154
  /**
97
155
  * Upload a file using `multipart/form-data`.
98
156
  *
99
- * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
100
- * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
157
+ * Encodes the request body as a `Blob` so it works unchanged in the browser and in
158
+ * Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
101
159
  * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
102
160
  * `additionalFields` are merged in and override the derived values. Authentication uses
103
- * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
104
- * intentionally left unset so the runtime adds the correct multipart boundary.
161
+ * `Authorization: Bearer`, matching every other Comms SDK client.
105
162
  *
106
163
  * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
107
164
  * the returned shape with the appropriate schema.
@@ -115,15 +172,17 @@ export async function uploadMultipartFile(args) {
115
172
  underlying_type: resolvedType,
116
173
  ...additionalFields,
117
174
  };
118
- const form = new FormData();
119
- form.append('file', blob, resolvedFileName);
120
- for (const [key, value] of Object.entries(fields)) {
121
- if (value !== undefined && value !== null) {
122
- form.append(key, String(value));
123
- }
124
- }
175
+ const multipart = buildMultipartBody({
176
+ blob,
177
+ fileName: resolvedFileName,
178
+ contentType: resolvedType,
179
+ fields,
180
+ });
125
181
  const headers = {
126
182
  Authorization: `Bearer ${authToken}`,
183
+ // The boundary is ours, so this has to be declared explicitly — `fetch`
184
+ // only fills it in for a `FormData` body.
185
+ 'Content-Type': multipart.contentType,
127
186
  };
128
187
  if (requestId) {
129
188
  headers['X-Request-Id'] = requestId;
@@ -132,8 +191,7 @@ export async function uploadMultipartFile(args) {
132
191
  const response = await fetchWithRetry(url, {
133
192
  method: 'POST',
134
193
  headers,
135
- body: form,
136
- // Don't set Content-Type — the runtime adds the multipart boundary.
194
+ body: multipart.body,
137
195
  timeout: 30000,
138
196
  }, maxRetries, customFetch);
139
197
  return response.data;
@@ -44,12 +44,11 @@ export declare function getContentTypeFromFileName(fileName: string): string;
44
44
  /**
45
45
  * Upload a file using `multipart/form-data`.
46
46
  *
47
- * Builds the request body with the global `FormData`/`Blob` so it works unchanged in the
48
- * browser and in Node.js (via `undici`). The `file` part is sent alongside `file_name`,
47
+ * Encodes the request body as a `Blob` so it works unchanged in the browser and in
48
+ * Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
49
49
  * `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
50
50
  * `additionalFields` are merged in and override the derived values. Authentication uses
51
- * `Authorization: Bearer`, matching every other Comms SDK client, and `Content-Type` is
52
- * intentionally left unset so the runtime adds the correct multipart boundary.
51
+ * `Authorization: Bearer`, matching every other Comms SDK client.
53
52
  *
54
53
  * The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
55
54
  * the returned shape with the appropriate schema.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/comms-sdk",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "A TypeScript wrapper for the Comms REST API.",
5
5
  "author": "Doist developers",
6
6
  "homepage": "https://doist.github.io/comms-sdk-typescript/",