@doist/todoist-sdk 10.3.1 → 10.3.3
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/cjs/clients/upload-client.js +80 -8
- package/dist/cjs/todoist-api.js +7 -2
- package/dist/cjs/transport/http-dispatcher.js +11 -1
- package/dist/esm/clients/upload-client.js +80 -8
- package/dist/esm/todoist-api.js +7 -2
- package/dist/esm/transport/http-dispatcher.js +11 -1
- package/dist/types/todoist-api.d.ts +7 -2
- package/package.json +1 -1
|
@@ -7,6 +7,70 @@ const file_response_1 = require("../utils/file-response");
|
|
|
7
7
|
const multipart_upload_1 = require("../utils/multipart-upload");
|
|
8
8
|
const validators_1 = require("../utils/validators");
|
|
9
9
|
const base_client_1 = require("./base-client");
|
|
10
|
+
/**
|
|
11
|
+
* First-party origin that serves attachment downloads behind Bearer auth.
|
|
12
|
+
* It redirects to the CDN; on the cross-origin hop the runtime drops the
|
|
13
|
+
* Authorization header, so the token never reaches the CDN.
|
|
14
|
+
*/
|
|
15
|
+
const AUTHENTICATED_ATTACHMENT_HOST = 'files.todoist.com';
|
|
16
|
+
/**
|
|
17
|
+
* CDN origins that serve attachments via pre-signed URLs. These are
|
|
18
|
+
* third-party infrastructure and must never receive the auth token.
|
|
19
|
+
*/
|
|
20
|
+
const CDN_ATTACHMENT_HOSTS = new Set(['todoist.b-cdn.net', 'd1ysz50cxb9zwl.cloudfront.net']);
|
|
21
|
+
const MAX_ATTACHMENT_REDIRECTS = 5;
|
|
22
|
+
/**
|
|
23
|
+
* Builds the request headers for an attachment fetch, attaching the Bearer
|
|
24
|
+
* token only when the URL is on the first-party authenticated host.
|
|
25
|
+
*/
|
|
26
|
+
function buildAttachmentAuthHeaders(url, authToken) {
|
|
27
|
+
return new URL(url).hostname === AUTHENTICATED_ATTACHMENT_HOST
|
|
28
|
+
? { Authorization: `Bearer ${authToken}` }
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetches an attachment through a caller-supplied `customFetch` while
|
|
33
|
+
* guaranteeing the auth token is never sent across origins.
|
|
34
|
+
*
|
|
35
|
+
* A `customFetch` is an arbitrary implementation that may not honour the Fetch
|
|
36
|
+
* standard's rule of stripping `Authorization` on cross-origin redirects (older
|
|
37
|
+
* `node-fetch`, for example, does not). Because `files.todoist.com` redirects
|
|
38
|
+
* downloads to the CDN, a naive implementation could forward the token there.
|
|
39
|
+
* We therefore drive redirects with `redirect: 'manual'` and follow them
|
|
40
|
+
* ourselves, dropping the auth header whenever the origin changes.
|
|
41
|
+
*
|
|
42
|
+
* Implementations that hide the redirect behind an opaque response (status `0`,
|
|
43
|
+
* e.g. undici and browsers) can't be followed manually; for those we re-issue
|
|
44
|
+
* with normal redirect following and rely on the Fetch standard's cross-origin
|
|
45
|
+
* auth stripping, which those runtimes implement.
|
|
46
|
+
*/
|
|
47
|
+
async function customFetchFollowingRedirects(customFetch, startUrl, authToken) {
|
|
48
|
+
let currentUrl = startUrl;
|
|
49
|
+
let headers = buildAttachmentAuthHeaders(currentUrl, authToken);
|
|
50
|
+
for (let redirects = 0; redirects < MAX_ATTACHMENT_REDIRECTS; redirects++) {
|
|
51
|
+
const response = await customFetch(currentUrl, {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers,
|
|
54
|
+
redirect: 'manual',
|
|
55
|
+
});
|
|
56
|
+
const location = response.headers['location'];
|
|
57
|
+
const isReadableRedirect = response.status >= 300 && response.status < 400 && Boolean(location);
|
|
58
|
+
if (!isReadableRedirect) {
|
|
59
|
+
// Opaque redirect (the impl hid the Location, e.g. undici): re-issue
|
|
60
|
+
// with normal following and trust the runtime to strip auth cross-origin.
|
|
61
|
+
if (response.status === 0) {
|
|
62
|
+
return customFetch(currentUrl, { method: 'GET', headers });
|
|
63
|
+
}
|
|
64
|
+
return response;
|
|
65
|
+
}
|
|
66
|
+
const nextUrl = new URL(location, currentUrl);
|
|
67
|
+
if (nextUrl.origin !== new URL(currentUrl).origin) {
|
|
68
|
+
headers = {};
|
|
69
|
+
}
|
|
70
|
+
currentUrl = nextUrl.toString();
|
|
71
|
+
}
|
|
72
|
+
throw new Error('Too many redirects while fetching attachment');
|
|
73
|
+
}
|
|
10
74
|
/**
|
|
11
75
|
* Internal sub-client handling upload + attachment endpoints
|
|
12
76
|
* (file upload, upload delete, attachment view).
|
|
@@ -57,22 +121,30 @@ class UploadClient extends base_client_1.BaseClient {
|
|
|
57
121
|
}
|
|
58
122
|
fileUrl = commentOrUrl.fileAttachment.fileUrl;
|
|
59
123
|
}
|
|
60
|
-
//
|
|
124
|
+
// Only allow known attachment origins, and only attach the auth token to the
|
|
125
|
+
// first-party authenticated host. This prevents the token leaking to other
|
|
126
|
+
// todoist.com hosts (e.g. api.todoist.com) or to third-party CDN infrastructure.
|
|
61
127
|
const urlHostname = new URL(fileUrl).hostname;
|
|
62
|
-
|
|
63
|
-
|
|
128
|
+
const isAuthenticatedHost = urlHostname === AUTHENTICATED_ATTACHMENT_HOST;
|
|
129
|
+
if (!isAuthenticatedHost && !CDN_ATTACHMENT_HOSTS.has(urlHostname)) {
|
|
130
|
+
throw new Error('Attachment URLs must be on a known Todoist attachment host');
|
|
64
131
|
}
|
|
65
|
-
const fetchOptions = {
|
|
66
|
-
method: 'GET',
|
|
67
|
-
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
68
|
-
};
|
|
69
132
|
if (this.customFetch) {
|
|
70
|
-
|
|
133
|
+
// customFetch may not strip auth on cross-origin redirects, so we
|
|
134
|
+
// follow the files.todoist.com -> CDN hop ourselves to avoid leaking
|
|
135
|
+
// the token. See customFetchFollowingRedirects for details.
|
|
136
|
+
const response = await customFetchFollowingRedirects(this.customFetch, fileUrl, this.authToken);
|
|
71
137
|
if (!response.ok) {
|
|
72
138
|
throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
|
|
73
139
|
}
|
|
74
140
|
return (0, file_response_1.wrapAsFileResponse)(response, 'viewAttachment');
|
|
75
141
|
}
|
|
142
|
+
// Built-in fetch (undici) strips Authorization on cross-origin redirects
|
|
143
|
+
// per the Fetch standard, so the files.todoist.com -> CDN hop is safe here.
|
|
144
|
+
const fetchOptions = {
|
|
145
|
+
method: 'GET',
|
|
146
|
+
headers: isAuthenticatedHost ? { Authorization: `Bearer ${this.authToken}` } : {},
|
|
147
|
+
};
|
|
76
148
|
const response = await fetch(fileUrl, fetchOptions);
|
|
77
149
|
if (!response.ok) {
|
|
78
150
|
throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
|
package/dist/cjs/todoist-api.js
CHANGED
|
@@ -862,7 +862,7 @@ class TodoistApi {
|
|
|
862
862
|
* @example
|
|
863
863
|
* ```typescript
|
|
864
864
|
* await api.deleteUpload({
|
|
865
|
-
* fileUrl: 'https://
|
|
865
|
+
* fileUrl: 'https://files.todoist.com/...'
|
|
866
866
|
* })
|
|
867
867
|
* ```
|
|
868
868
|
*/
|
|
@@ -876,9 +876,14 @@ class TodoistApi {
|
|
|
876
876
|
* or a direct file URL string. Returns the raw Response object so the caller
|
|
877
877
|
* can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
|
|
878
878
|
*
|
|
879
|
+
* Only known Todoist attachment hosts are allowed: the authenticated
|
|
880
|
+
* `files.todoist.com` origin (which receives the Bearer token) and the
|
|
881
|
+
* attachment CDN origins (fetched without the token). Other URLs are rejected.
|
|
882
|
+
*
|
|
879
883
|
* @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
|
|
880
884
|
* @returns The raw fetch Response for the file content.
|
|
881
|
-
* @throws Error if a Comment is provided without a file attachment or file URL
|
|
885
|
+
* @throws Error if a Comment is provided without a file attachment or file URL,
|
|
886
|
+
* or if the URL is not on a known Todoist attachment host.
|
|
882
887
|
*
|
|
883
888
|
* @example
|
|
884
889
|
* ```typescript
|
|
@@ -68,6 +68,16 @@ async function createDefaultDispatcher() {
|
|
|
68
68
|
// undici into their bundle. `getDefaultDispatcher` already short-circuits
|
|
69
69
|
// outside Node, so this branch only runs when undici is safe to load.
|
|
70
70
|
const { EnvHttpProxyAgent, interceptors } = await Promise.resolve().then(() => __importStar(require('undici')));
|
|
71
|
+
const agent = new EnvHttpProxyAgent(KEEP_ALIVE_OPTIONS);
|
|
72
|
+
// Some runtimes report `process.versions.node` (so `isNodeEnvironment()`
|
|
73
|
+
// passes) but ship only a partial undici: `interceptors.decompress` is
|
|
74
|
+
// absent and dispatchers have no `.compose`. Bun is the common case. There
|
|
75
|
+
// the proxy agent alone is enough — Bun's `fetch` decompresses
|
|
76
|
+
// gzip/deflate/br/zstd natively — so skip the interceptor instead of
|
|
77
|
+
// crashing on the missing API.
|
|
78
|
+
if (typeof interceptors.decompress !== 'function') {
|
|
79
|
+
return agent;
|
|
80
|
+
}
|
|
71
81
|
// Compose the response-decompression interceptor so gzip/deflate/br/zstd
|
|
72
82
|
// bodies are decoded before consumers parse them. Required on Node 24+:
|
|
73
83
|
// attaching any custom dispatcher to the global `fetch` strips the
|
|
@@ -75,7 +85,7 @@ async function createDefaultDispatcher() {
|
|
|
75
85
|
// so callers receive raw gzipped bytes and `JSON.parse` fails.
|
|
76
86
|
// See https://github.com/Doist/todoist-cli/issues/318.
|
|
77
87
|
const decompress = suppressExperimentalWarningsSync(() => interceptors.decompress());
|
|
78
|
-
return
|
|
88
|
+
return agent.compose(decompress);
|
|
79
89
|
}
|
|
80
90
|
// undici emits an `ExperimentalWarning` the first time `interceptors.decompress()`
|
|
81
91
|
// runs. The interceptor is stable for our gzipped-JSON-over-HTTPS use case;
|
|
@@ -4,6 +4,70 @@ import { wrapAsFileResponse } from '../utils/file-response.js';
|
|
|
4
4
|
import { uploadMultipartFile } from '../utils/multipart-upload.js';
|
|
5
5
|
import { validateAttachment } from '../utils/validators.js';
|
|
6
6
|
import { BaseClient } from './base-client.js';
|
|
7
|
+
/**
|
|
8
|
+
* First-party origin that serves attachment downloads behind Bearer auth.
|
|
9
|
+
* It redirects to the CDN; on the cross-origin hop the runtime drops the
|
|
10
|
+
* Authorization header, so the token never reaches the CDN.
|
|
11
|
+
*/
|
|
12
|
+
const AUTHENTICATED_ATTACHMENT_HOST = 'files.todoist.com';
|
|
13
|
+
/**
|
|
14
|
+
* CDN origins that serve attachments via pre-signed URLs. These are
|
|
15
|
+
* third-party infrastructure and must never receive the auth token.
|
|
16
|
+
*/
|
|
17
|
+
const CDN_ATTACHMENT_HOSTS = new Set(['todoist.b-cdn.net', 'd1ysz50cxb9zwl.cloudfront.net']);
|
|
18
|
+
const MAX_ATTACHMENT_REDIRECTS = 5;
|
|
19
|
+
/**
|
|
20
|
+
* Builds the request headers for an attachment fetch, attaching the Bearer
|
|
21
|
+
* token only when the URL is on the first-party authenticated host.
|
|
22
|
+
*/
|
|
23
|
+
function buildAttachmentAuthHeaders(url, authToken) {
|
|
24
|
+
return new URL(url).hostname === AUTHENTICATED_ATTACHMENT_HOST
|
|
25
|
+
? { Authorization: `Bearer ${authToken}` }
|
|
26
|
+
: {};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fetches an attachment through a caller-supplied `customFetch` while
|
|
30
|
+
* guaranteeing the auth token is never sent across origins.
|
|
31
|
+
*
|
|
32
|
+
* A `customFetch` is an arbitrary implementation that may not honour the Fetch
|
|
33
|
+
* standard's rule of stripping `Authorization` on cross-origin redirects (older
|
|
34
|
+
* `node-fetch`, for example, does not). Because `files.todoist.com` redirects
|
|
35
|
+
* downloads to the CDN, a naive implementation could forward the token there.
|
|
36
|
+
* We therefore drive redirects with `redirect: 'manual'` and follow them
|
|
37
|
+
* ourselves, dropping the auth header whenever the origin changes.
|
|
38
|
+
*
|
|
39
|
+
* Implementations that hide the redirect behind an opaque response (status `0`,
|
|
40
|
+
* e.g. undici and browsers) can't be followed manually; for those we re-issue
|
|
41
|
+
* with normal redirect following and rely on the Fetch standard's cross-origin
|
|
42
|
+
* auth stripping, which those runtimes implement.
|
|
43
|
+
*/
|
|
44
|
+
async function customFetchFollowingRedirects(customFetch, startUrl, authToken) {
|
|
45
|
+
let currentUrl = startUrl;
|
|
46
|
+
let headers = buildAttachmentAuthHeaders(currentUrl, authToken);
|
|
47
|
+
for (let redirects = 0; redirects < MAX_ATTACHMENT_REDIRECTS; redirects++) {
|
|
48
|
+
const response = await customFetch(currentUrl, {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
headers,
|
|
51
|
+
redirect: 'manual',
|
|
52
|
+
});
|
|
53
|
+
const location = response.headers['location'];
|
|
54
|
+
const isReadableRedirect = response.status >= 300 && response.status < 400 && Boolean(location);
|
|
55
|
+
if (!isReadableRedirect) {
|
|
56
|
+
// Opaque redirect (the impl hid the Location, e.g. undici): re-issue
|
|
57
|
+
// with normal following and trust the runtime to strip auth cross-origin.
|
|
58
|
+
if (response.status === 0) {
|
|
59
|
+
return customFetch(currentUrl, { method: 'GET', headers });
|
|
60
|
+
}
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
const nextUrl = new URL(location, currentUrl);
|
|
64
|
+
if (nextUrl.origin !== new URL(currentUrl).origin) {
|
|
65
|
+
headers = {};
|
|
66
|
+
}
|
|
67
|
+
currentUrl = nextUrl.toString();
|
|
68
|
+
}
|
|
69
|
+
throw new Error('Too many redirects while fetching attachment');
|
|
70
|
+
}
|
|
7
71
|
/**
|
|
8
72
|
* Internal sub-client handling upload + attachment endpoints
|
|
9
73
|
* (file upload, upload delete, attachment view).
|
|
@@ -54,22 +118,30 @@ export class UploadClient extends BaseClient {
|
|
|
54
118
|
}
|
|
55
119
|
fileUrl = commentOrUrl.fileAttachment.fileUrl;
|
|
56
120
|
}
|
|
57
|
-
//
|
|
121
|
+
// Only allow known attachment origins, and only attach the auth token to the
|
|
122
|
+
// first-party authenticated host. This prevents the token leaking to other
|
|
123
|
+
// todoist.com hosts (e.g. api.todoist.com) or to third-party CDN infrastructure.
|
|
58
124
|
const urlHostname = new URL(fileUrl).hostname;
|
|
59
|
-
|
|
60
|
-
|
|
125
|
+
const isAuthenticatedHost = urlHostname === AUTHENTICATED_ATTACHMENT_HOST;
|
|
126
|
+
if (!isAuthenticatedHost && !CDN_ATTACHMENT_HOSTS.has(urlHostname)) {
|
|
127
|
+
throw new Error('Attachment URLs must be on a known Todoist attachment host');
|
|
61
128
|
}
|
|
62
|
-
const fetchOptions = {
|
|
63
|
-
method: 'GET',
|
|
64
|
-
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
65
|
-
};
|
|
66
129
|
if (this.customFetch) {
|
|
67
|
-
|
|
130
|
+
// customFetch may not strip auth on cross-origin redirects, so we
|
|
131
|
+
// follow the files.todoist.com -> CDN hop ourselves to avoid leaking
|
|
132
|
+
// the token. See customFetchFollowingRedirects for details.
|
|
133
|
+
const response = await customFetchFollowingRedirects(this.customFetch, fileUrl, this.authToken);
|
|
68
134
|
if (!response.ok) {
|
|
69
135
|
throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
|
|
70
136
|
}
|
|
71
137
|
return wrapAsFileResponse(response, 'viewAttachment');
|
|
72
138
|
}
|
|
139
|
+
// Built-in fetch (undici) strips Authorization on cross-origin redirects
|
|
140
|
+
// per the Fetch standard, so the files.todoist.com -> CDN hop is safe here.
|
|
141
|
+
const fetchOptions = {
|
|
142
|
+
method: 'GET',
|
|
143
|
+
headers: isAuthenticatedHost ? { Authorization: `Bearer ${this.authToken}` } : {},
|
|
144
|
+
};
|
|
73
145
|
const response = await fetch(fileUrl, fetchOptions);
|
|
74
146
|
if (!response.ok) {
|
|
75
147
|
throw new Error(`Failed to fetch attachment: ${response.status} ${response.statusText}`);
|
package/dist/esm/todoist-api.js
CHANGED
|
@@ -859,7 +859,7 @@ export class TodoistApi {
|
|
|
859
859
|
* @example
|
|
860
860
|
* ```typescript
|
|
861
861
|
* await api.deleteUpload({
|
|
862
|
-
* fileUrl: 'https://
|
|
862
|
+
* fileUrl: 'https://files.todoist.com/...'
|
|
863
863
|
* })
|
|
864
864
|
* ```
|
|
865
865
|
*/
|
|
@@ -873,9 +873,14 @@ export class TodoistApi {
|
|
|
873
873
|
* or a direct file URL string. Returns the raw Response object so the caller
|
|
874
874
|
* can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
|
|
875
875
|
*
|
|
876
|
+
* Only known Todoist attachment hosts are allowed: the authenticated
|
|
877
|
+
* `files.todoist.com` origin (which receives the Bearer token) and the
|
|
878
|
+
* attachment CDN origins (fetched without the token). Other URLs are rejected.
|
|
879
|
+
*
|
|
876
880
|
* @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
|
|
877
881
|
* @returns The raw fetch Response for the file content.
|
|
878
|
-
* @throws Error if a Comment is provided without a file attachment or file URL
|
|
882
|
+
* @throws Error if a Comment is provided without a file attachment or file URL,
|
|
883
|
+
* or if the URL is not on a known Todoist attachment host.
|
|
879
884
|
*
|
|
880
885
|
* @example
|
|
881
886
|
* ```typescript
|
|
@@ -30,6 +30,16 @@ async function createDefaultDispatcher() {
|
|
|
30
30
|
// undici into their bundle. `getDefaultDispatcher` already short-circuits
|
|
31
31
|
// outside Node, so this branch only runs when undici is safe to load.
|
|
32
32
|
const { EnvHttpProxyAgent, interceptors } = await import('undici');
|
|
33
|
+
const agent = new EnvHttpProxyAgent(KEEP_ALIVE_OPTIONS);
|
|
34
|
+
// Some runtimes report `process.versions.node` (so `isNodeEnvironment()`
|
|
35
|
+
// passes) but ship only a partial undici: `interceptors.decompress` is
|
|
36
|
+
// absent and dispatchers have no `.compose`. Bun is the common case. There
|
|
37
|
+
// the proxy agent alone is enough — Bun's `fetch` decompresses
|
|
38
|
+
// gzip/deflate/br/zstd natively — so skip the interceptor instead of
|
|
39
|
+
// crashing on the missing API.
|
|
40
|
+
if (typeof interceptors.decompress !== 'function') {
|
|
41
|
+
return agent;
|
|
42
|
+
}
|
|
33
43
|
// Compose the response-decompression interceptor so gzip/deflate/br/zstd
|
|
34
44
|
// bodies are decoded before consumers parse them. Required on Node 24+:
|
|
35
45
|
// attaching any custom dispatcher to the global `fetch` strips the
|
|
@@ -37,7 +47,7 @@ async function createDefaultDispatcher() {
|
|
|
37
47
|
// so callers receive raw gzipped bytes and `JSON.parse` fails.
|
|
38
48
|
// See https://github.com/Doist/todoist-cli/issues/318.
|
|
39
49
|
const decompress = suppressExperimentalWarningsSync(() => interceptors.decompress());
|
|
40
|
-
return
|
|
50
|
+
return agent.compose(decompress);
|
|
41
51
|
}
|
|
42
52
|
// undici emits an `ExperimentalWarning` the first time `interceptors.decompress()`
|
|
43
53
|
// runs. The interceptor is stable for our gzipped-JSON-over-HTTPS use case;
|
|
@@ -722,7 +722,7 @@ export declare class TodoistApi {
|
|
|
722
722
|
* @example
|
|
723
723
|
* ```typescript
|
|
724
724
|
* await api.deleteUpload({
|
|
725
|
-
* fileUrl: 'https://
|
|
725
|
+
* fileUrl: 'https://files.todoist.com/...'
|
|
726
726
|
* })
|
|
727
727
|
* ```
|
|
728
728
|
*/
|
|
@@ -734,9 +734,14 @@ export declare class TodoistApi {
|
|
|
734
734
|
* or a direct file URL string. Returns the raw Response object so the caller
|
|
735
735
|
* can read the body in the appropriate format (.arrayBuffer(), .text(), etc.).
|
|
736
736
|
*
|
|
737
|
+
* Only known Todoist attachment hosts are allowed: the authenticated
|
|
738
|
+
* `files.todoist.com` origin (which receives the Bearer token) and the
|
|
739
|
+
* attachment CDN origins (fetched without the token). Other URLs are rejected.
|
|
740
|
+
*
|
|
737
741
|
* @param commentOrUrl - A Comment object with a file attachment, or a file URL string.
|
|
738
742
|
* @returns The raw fetch Response for the file content.
|
|
739
|
-
* @throws Error if a Comment is provided without a file attachment or file URL
|
|
743
|
+
* @throws Error if a Comment is provided without a file attachment or file URL,
|
|
744
|
+
* or if the URL is not on a known Todoist attachment host.
|
|
740
745
|
*
|
|
741
746
|
* @example
|
|
742
747
|
* ```typescript
|
package/package.json
CHANGED