@doist/comms-sdk 2.0.1 → 3.0.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.
Files changed (48) hide show
  1. package/dist/esm/utils/multipart-upload.js +73 -15
  2. package/dist/esm/utils/url-helpers.js +104 -16
  3. package/dist/types/utils/multipart-upload.d.ts +3 -4
  4. package/dist/types/utils/url-helpers.d.ts +41 -0
  5. package/package.json +5 -10
  6. package/dist/cjs/authentication.js +0 -256
  7. package/dist/cjs/clients/add-comment-helper.js +0 -43
  8. package/dist/cjs/clients/attachments-client.js +0 -71
  9. package/dist/cjs/clients/base-client.js +0 -33
  10. package/dist/cjs/clients/channels-client.js +0 -211
  11. package/dist/cjs/clients/comments-client.js +0 -175
  12. package/dist/cjs/clients/conversation-messages-client.js +0 -169
  13. package/dist/cjs/clients/conversations-client.js +0 -283
  14. package/dist/cjs/clients/groups-client.js +0 -172
  15. package/dist/cjs/clients/hooks-client.js +0 -52
  16. package/dist/cjs/clients/inbox-client.js +0 -184
  17. package/dist/cjs/clients/notify-audience.js +0 -45
  18. package/dist/cjs/clients/reactions-client.js +0 -97
  19. package/dist/cjs/clients/search-client.js +0 -138
  20. package/dist/cjs/clients/threads-client.js +0 -364
  21. package/dist/cjs/clients/users-client.js +0 -326
  22. package/dist/cjs/clients/workspace-users-client.js +0 -252
  23. package/dist/cjs/clients/workspaces-client.js +0 -174
  24. package/dist/cjs/comms-api.js +0 -70
  25. package/dist/cjs/consts/endpoints.js +0 -34
  26. package/dist/cjs/index.js +0 -53
  27. package/dist/cjs/migration.js +0 -96
  28. package/dist/cjs/package.json +0 -1
  29. package/dist/cjs/testUtils/msw-handlers.js +0 -51
  30. package/dist/cjs/testUtils/msw-setup.js +0 -35
  31. package/dist/cjs/testUtils/obsidian-fetch-adapter.js +0 -53
  32. package/dist/cjs/testUtils/test-defaults.js +0 -106
  33. package/dist/cjs/transport/fetch-with-retry.js +0 -146
  34. package/dist/cjs/transport/http-client.js +0 -56
  35. package/dist/cjs/transport/http-dispatcher.js +0 -190
  36. package/dist/cjs/types/api-version.js +0 -8
  37. package/dist/cjs/types/entities.js +0 -496
  38. package/dist/cjs/types/enums.js +0 -37
  39. package/dist/cjs/types/errors.js +0 -12
  40. package/dist/cjs/types/http.js +0 -4
  41. package/dist/cjs/types/index.js +0 -22
  42. package/dist/cjs/types/requests.js +0 -183
  43. package/dist/cjs/utils/case-conversion.js +0 -54
  44. package/dist/cjs/utils/index.js +0 -19
  45. package/dist/cjs/utils/multipart-upload.js +0 -144
  46. package/dist/cjs/utils/timestamp-conversion.js +0 -58
  47. package/dist/cjs/utils/url-helpers.js +0 -133
  48. package/dist/cjs/utils/uuidv7.js +0 -182
@@ -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;
@@ -1,7 +1,34 @@
1
1
  /**
2
2
  * Helper functions for creating Comms permalinks (`https://comms.todoist.com/...`).
3
+ *
4
+ * Paths come from `@doist/sdk-kmp`, the shared implementation of Comms link
5
+ * routes, so they stay in step with the other Comms clients. The path builders
6
+ * are used rather than the URL builders so that `getFullCommsURL` can keep
7
+ * serving an arbitrary base URL.
3
8
  */
9
+ import { commsChannelPath, commsConversationPath, commsInboxThreadCommentPath, commsInboxThreadPath, commsMessagePath, commsSavedThreadCommentPath, commsSavedThreadPath, commsThreadCommentPath, commsThreadPath, parseCommsLink, } from '@doist/sdk-kmp/comms';
4
10
  const COMMS_BASE_URL = 'https://comms.todoist.com';
11
+ /**
12
+ * Builds a path, falling back to a locally built path when the link builder
13
+ * rejects one of the given ids. These helpers run while parsing API responses,
14
+ * so building a path must never fail the parse.
15
+ *
16
+ * Every error is caught, not just id rejection. The link builders expose no
17
+ * error type to narrow on, and a path is never important enough to fail a
18
+ * parse over, so any failure degrades to the locally built path.
19
+ *
20
+ * @param build Builds the path.
21
+ * @param fallback Builds the path without the shared link builder.
22
+ * @returns The built path, or the locally built path if an id was rejected.
23
+ */
24
+ function buildPath(build, fallback) {
25
+ try {
26
+ return build();
27
+ }
28
+ catch {
29
+ return fallback();
30
+ }
31
+ }
5
32
  /**
6
33
  * Builds a relative Comms URL based on the provided parameters
7
34
  * @param params - URL parameters including workspace, channel, conversation, thread, etc.
@@ -12,32 +39,33 @@ const COMMS_BASE_URL = 'https://comms.todoist.com';
12
39
  */
13
40
  export function getCommsURL(params) {
14
41
  const { workspaceId, channelId, conversationId, threadId, commentId, messageId, userId } = params;
15
- let url = `/${workspaceId}/`;
42
+ const workspace = String(workspaceId);
16
43
  if (channelId) {
17
- url += `ch/${channelId}/`;
44
+ if (threadId && commentId) {
45
+ return buildPath(() => commsThreadCommentPath(workspace, channelId, threadId, commentId), () => `/${workspace}/ch/${channelId}/t/${threadId}/c/${commentId}`);
46
+ }
18
47
  if (threadId) {
19
- url += `t/${threadId}/`;
20
- if (commentId) {
21
- url += `c/${commentId}`;
22
- }
48
+ return buildPath(() => commsThreadPath(workspace, channelId, threadId), () => `/${workspace}/ch/${channelId}/t/${threadId}/`);
23
49
  }
50
+ return buildPath(() => commsChannelPath(workspace, channelId), () => `/${workspace}/ch/${channelId}/`);
24
51
  }
25
- else if (threadId) {
26
- url += `inbox/t/${threadId}/`;
52
+ if (threadId) {
27
53
  if (commentId) {
28
- url += `c/${commentId}`;
54
+ return buildPath(() => commsInboxThreadCommentPath(workspace, threadId, commentId), () => `/${workspace}/inbox/t/${threadId}/c/${commentId}`);
29
55
  }
56
+ return buildPath(() => commsInboxThreadPath(workspace, threadId), () => `/${workspace}/inbox/t/${threadId}/`);
30
57
  }
31
- else if (conversationId) {
32
- url += `msg/${conversationId}/`;
58
+ if (conversationId) {
33
59
  if (messageId) {
34
- url += `m/${messageId}`;
60
+ return buildPath(() => commsMessagePath(workspace, conversationId, messageId), () => `/${workspace}/msg/${conversationId}/m/${messageId}`);
35
61
  }
62
+ return buildPath(() => commsConversationPath(workspace, conversationId), () => `/${workspace}/msg/${conversationId}/`);
36
63
  }
37
- else if (userId) {
38
- url += `people/u/${userId}`;
64
+ // The people view and the bare workspace root have no shared route.
65
+ if (userId) {
66
+ return `/${workspace}/people/u/${userId}`;
39
67
  }
40
- return url;
68
+ return `/${workspace}/`;
41
69
  }
42
70
  /**
43
71
  * Builds a full Comms URL (with protocol and hostname) based on the provided parameters
@@ -92,7 +120,13 @@ export function getSavedThreadsRootURL(workspaceId) {
92
120
  }
93
121
  /** Returns the URL for a saved thread. */
94
122
  export function getSavedThreadURL(params) {
95
- return `/${params.workspaceId}/saved/t/${params.threadId}`;
123
+ const workspace = String(params.workspaceId);
124
+ return buildPath(() => commsSavedThreadPath(workspace, params.threadId), () => `/${workspace}/saved/t/${params.threadId}`);
125
+ }
126
+ /** Returns the URL for a comment on a saved thread. */
127
+ export function getSavedThreadCommentURL(params) {
128
+ const workspace = String(params.workspaceId);
129
+ return buildPath(() => commsSavedThreadCommentPath(workspace, params.threadId, params.commentId), () => `/${workspace}/saved/t/${params.threadId}/c/${params.commentId}`);
96
130
  }
97
131
  /** Returns the URL for the search root. */
98
132
  export function getSearchRootURL(workspaceId) {
@@ -112,3 +146,57 @@ export function getSettingsURL(params) {
112
146
  export function getTeamMembersRootURL(workspaceId) {
113
147
  return `/${workspaceId}/people/u`;
114
148
  }
149
+ /** The kinds of Comms entity a URL can point at. */
150
+ export const COMMS_LINK_TYPES = [
151
+ 'channel',
152
+ 'thread',
153
+ 'thread_comment',
154
+ 'inbox_thread',
155
+ 'inbox_thread_comment',
156
+ 'saved_thread',
157
+ 'saved_thread_comment',
158
+ 'conversation',
159
+ 'message',
160
+ ];
161
+ /**
162
+ * Maps the link parser's type names onto the types this SDK exposes. A name
163
+ * that is missing here is one this SDK does not know about, and is treated as
164
+ * an unrecognised URL rather than surfaced as a value outside the union.
165
+ */
166
+ const LINK_TYPE_BY_PARSED_NAME = {
167
+ Channel: 'channel',
168
+ Thread: 'thread',
169
+ ThreadComment: 'thread_comment',
170
+ InboxThread: 'inbox_thread',
171
+ InboxThreadComment: 'inbox_thread_comment',
172
+ SavedThread: 'saved_thread',
173
+ SavedThreadComment: 'saved_thread_comment',
174
+ Conversation: 'conversation',
175
+ Message: 'message',
176
+ };
177
+ /**
178
+ * Parse a Comms URL into the entity it points at.
179
+ *
180
+ * @param url The URL to parse.
181
+ * @returns The entity the URL points at, or `null` if it is not a Comms URL.
182
+ */
183
+ export function parseCommsURL(url) {
184
+ const link = parseCommsLink(url);
185
+ if (!link) {
186
+ return null;
187
+ }
188
+ const type = LINK_TYPE_BY_PARSED_NAME[link.type.name];
189
+ if (!type) {
190
+ return null;
191
+ }
192
+ return {
193
+ type,
194
+ workspaceId: link.workspaceId,
195
+ channelId: link.channelId ?? null,
196
+ threadId: link.threadId ?? null,
197
+ commentId: link.commentId ?? null,
198
+ conversationId: link.conversationId ?? null,
199
+ messageId: link.messageId ?? null,
200
+ isQuoteReference: link.isQuoteReference,
201
+ };
202
+ }
@@ -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.
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * Helper functions for creating Comms permalinks (`https://comms.todoist.com/...`).
3
+ *
4
+ * Paths come from `@doist/sdk-kmp`, the shared implementation of Comms link
5
+ * routes, so they stay in step with the other Comms clients. The path builders
6
+ * are used rather than the URL builders so that `getFullCommsURL` can keep
7
+ * serving an arbitrary base URL.
3
8
  */
4
9
  export type CommsURLParams = {
5
10
  workspaceId: number;
@@ -72,6 +77,12 @@ export declare function getSavedThreadURL(params: {
72
77
  workspaceId: number;
73
78
  threadId: string;
74
79
  }): string;
80
+ /** Returns the URL for a comment on a saved thread. */
81
+ export declare function getSavedThreadCommentURL(params: {
82
+ workspaceId: number;
83
+ threadId: string;
84
+ commentId: string;
85
+ }): string;
75
86
  /** Returns the URL for the search root. */
76
87
  export declare function getSearchRootURL(workspaceId: number): string;
77
88
  /** Returns the URL for a search with a query. */
@@ -86,3 +97,33 @@ export declare function getSettingsURL(params: {
86
97
  }): string;
87
98
  /** Returns the URL for the team members root. */
88
99
  export declare function getTeamMembersRootURL(workspaceId: number): string;
100
+ /** The kinds of Comms entity a URL can point at. */
101
+ export declare const COMMS_LINK_TYPES: readonly ["channel", "thread", "thread_comment", "inbox_thread", "inbox_thread_comment", "saved_thread", "saved_thread_comment", "conversation", "message"];
102
+ /** The kind of Comms entity a URL points at. */
103
+ export type CommsLinkType = (typeof COMMS_LINK_TYPES)[number];
104
+ /** The entity a Comms URL points at. */
105
+ export type CommsURLInfo = {
106
+ /** The kind of entity the URL points at. */
107
+ type: CommsLinkType;
108
+ /** The ID of the workspace the entity belongs to. */
109
+ workspaceId: string;
110
+ /** The ID of the channel, when the URL names one. */
111
+ channelId: string | null;
112
+ /** The ID of the thread, when the URL names one. */
113
+ threadId: string | null;
114
+ /** The ID of the comment, when the URL names one. */
115
+ commentId: string | null;
116
+ /** The ID of the conversation, when the URL names one. */
117
+ conversationId: string | null;
118
+ /** The ID of the message, when the URL names one. */
119
+ messageId: string | null;
120
+ /** Whether the URL points at a quoted reference rather than the entity itself. */
121
+ isQuoteReference: boolean;
122
+ };
123
+ /**
124
+ * Parse a Comms URL into the entity it points at.
125
+ *
126
+ * @param url The URL to parse.
127
+ * @returns The entity the URL points at, or `null` if it is not a Comms URL.
128
+ */
129
+ export declare function parseCommsURL(url: string): CommsURLInfo | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/comms-sdk",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
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/",
@@ -15,14 +15,11 @@
15
15
  "node": ">=24",
16
16
  "npm": ">=11"
17
17
  },
18
- "main": "dist/cjs/index.js",
19
- "module": "dist/esm/index.js",
20
18
  "types": "dist/types/index.d.ts",
21
19
  "exports": {
22
20
  ".": {
23
21
  "types": "./dist/types/index.d.ts",
24
- "import": "./dist/esm/index.js",
25
- "require": "./dist/cjs/index.js"
22
+ "default": "./dist/esm/index.js"
26
23
  }
27
24
  },
28
25
  "sideEffects": false,
@@ -31,22 +28,21 @@
31
28
  "test": "vitest run",
32
29
  "test:watch": "vitest",
33
30
  "test:coverage": "vitest run --coverage",
34
- "build:cjs": "npx tsc -p tsconfig.cjs.json",
35
31
  "build:esm": "npx tsc -p tsconfig.esm.json",
36
32
  "build:fix-esm": "node scripts/fix-esm-imports.cjs",
37
33
  "build:fix-dts": "node scripts/fix-dts-imports.cjs",
38
- "build:post": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
39
- "build": "npm-run-all clean build:cjs build:esm build:fix-esm build:fix-dts build:post",
34
+ "build": "npm-run-all clean build:esm build:fix-esm build:fix-dts",
40
35
  "type-check": "npx tsc --noEmit",
41
36
  "check": "oxlint src && oxfmt --check",
42
37
  "check:fix": "oxlint src --fix && oxfmt",
43
- "attw": "attw --pack --ignore-rules fallback-condition false-esm",
38
+ "attw": "attw --pack --ignore-rules cjs-resolves-to-esm",
44
39
  "audit": "npm audit --audit-level=moderate",
45
40
  "integrity-checks": "npm-run-all clean check test build attw",
46
41
  "prepublishOnly": "npm run integrity-checks",
47
42
  "prepare": "npm run build"
48
43
  },
49
44
  "dependencies": {
45
+ "@doist/sdk-kmp": "0.2.3",
50
46
  "camelcase": "9.0.0",
51
47
  "ts-custom-error": "3.3.1",
52
48
  "undici": "7.29.0",
@@ -81,7 +77,6 @@
81
77
  "*.{ts,tsx,json,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
82
78
  },
83
79
  "files": [
84
- "dist/cjs/**/*",
85
80
  "dist/esm/**/*",
86
81
  "dist/types/**/*",
87
82
  "!dist/**/*.test.js",
@@ -1,256 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TOKEN_ENDPOINT_AUTH_METHODS = exports.COMMS_SCOPES = void 0;
4
- exports.getAuthStateParameter = getAuthStateParameter;
5
- exports.getAuthorizationUrl = getAuthorizationUrl;
6
- exports.getAuthToken = getAuthToken;
7
- exports.refreshAuthToken = refreshAuthToken;
8
- exports.revokeAuthToken = revokeAuthToken;
9
- exports.registerClient = registerClient;
10
- const uuid_1 = require("uuid");
11
- const http_client_1 = require("./transport/http-client");
12
- const errors_1 = require("./types/errors");
13
- /**
14
- * Default origin of the Comms authorization server.
15
- *
16
- * The authorization server for Comms is Todoist (`todoist.com`), not the
17
- * resource server (`comms.todoist.com`). Tokens are issued by Todoist and
18
- * accepted by the Comms API. Paths match Todoist's published
19
- * `/.well-known/oauth-authorization-server` metadata.
20
- */
21
- const DEFAULT_AUTH_BASE_URL = 'https://todoist.com';
22
- function getAuthBaseUrl(baseUrl) {
23
- const base = baseUrl ?? DEFAULT_AUTH_BASE_URL;
24
- return base.endsWith('/') ? base.slice(0, -1) : base;
25
- }
26
- /**
27
- * OAuth scopes for the Comms API.
28
- *
29
- * @remarks
30
- * Request only the scopes your application needs:
31
- *
32
- * **User Scopes:**
33
- * - `user:read` - Access user's personal settings
34
- * - `user:write` - Access and update user's personal settings
35
- *
36
- * **Workspace Scopes:**
37
- * - `workspaces:read` - Access teams the user is part of
38
- * - `workspaces:write` - Access and update teams the user is part of
39
- *
40
- * **Channel Scopes:**
41
- * - `channels:read` - Access channels
42
- * - `channels:write` - Access and update channels
43
- * - `channels:remove` - Access, update, and delete channels
44
- *
45
- * **Thread Scopes:**
46
- * - `threads:read` - Access threads
47
- * - `threads:write` - Access and update threads
48
- * - `threads:remove` - Access, update, and delete threads
49
- *
50
- * **Comment Scopes:**
51
- * - `comments:read` - Access comments
52
- * - `comments:write` - Access and update comments
53
- * - `comments:remove` - Access, update, and delete comments
54
- *
55
- * **Group Scopes:**
56
- * - `groups:read` - Access groups
57
- * - `groups:write` - Access and update groups
58
- * - `groups:remove` - Access, update, and delete groups
59
- *
60
- * **Message Scopes:**
61
- * - `messages:read` - Access messages
62
- * - `messages:write` - Access and update messages
63
- * - `messages:remove` - Access, update, and delete messages
64
- *
65
- * **Reaction Scopes:**
66
- * - `reactions:read` - Access reactions
67
- * - `reactions:write` - Access and update reactions
68
- * - `reactions:remove` - Access, update, and delete reactions
69
- *
70
- * **Search Scopes:**
71
- * - `search:read` - Search
72
- *
73
- * **Attachment Scopes:**
74
- * - `attachments:read` - Access attachments
75
- * - `attachments:write` - Access and update attachments
76
- *
77
- * **Notification Scopes:**
78
- * - `notifications:read` - Read user's notifications settings
79
- * - `notifications:write` - Read and update user's notifications settings
80
- */
81
- exports.COMMS_SCOPES = [
82
- 'user:read',
83
- 'user:write',
84
- 'workspaces:read',
85
- 'workspaces:write',
86
- 'channels:read',
87
- 'channels:write',
88
- 'channels:remove',
89
- 'threads:read',
90
- 'threads:write',
91
- 'threads:remove',
92
- 'comments:read',
93
- 'comments:write',
94
- 'comments:remove',
95
- 'groups:read',
96
- 'groups:write',
97
- 'groups:remove',
98
- 'messages:read',
99
- 'messages:write',
100
- 'messages:remove',
101
- 'reactions:read',
102
- 'reactions:write',
103
- 'reactions:remove',
104
- 'search:read',
105
- 'attachments:read',
106
- 'attachments:write',
107
- 'notifications:read',
108
- 'notifications:write',
109
- ];
110
- /** Supported token endpoint authentication methods for dynamic client registration. */
111
- exports.TOKEN_ENDPOINT_AUTH_METHODS = [
112
- 'client_secret_post',
113
- 'client_secret_basic',
114
- 'none',
115
- ];
116
- function getAuthStateParameter() {
117
- return (0, uuid_1.v4)();
118
- }
119
- /**
120
- * Generates the authorization URL for the OAuth2 flow.
121
- *
122
- * The `clientId` can be either a traditional client ID string (e.g. from
123
- * {@link registerClient}) or an HTTPS URL pointing to a client metadata document,
124
- * as defined in {@link https://drafts.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ RFC draft-ietf-oauth-client-id-metadata-document}.
125
- */
126
- function getAuthorizationUrl(clientId, scopes, state, redirectUri, baseUrl) {
127
- if (!scopes?.length) {
128
- throw new Error('At least one scope value is required.');
129
- }
130
- const authBaseUrl = `${getAuthBaseUrl(baseUrl)}/oauth`;
131
- const scope = scopes.join(' ');
132
- const params = new URLSearchParams({
133
- client_id: clientId,
134
- response_type: 'code',
135
- scope,
136
- state,
137
- });
138
- if (redirectUri) {
139
- params.append('redirect_uri', redirectUri);
140
- }
141
- return `${authBaseUrl}/authorize?${params.toString()}`;
142
- }
143
- async function getAuthToken(args, options) {
144
- const tokenUrl = `${getAuthBaseUrl(options?.baseUrl)}/oauth/access_token`;
145
- const payload = {
146
- clientId: args.clientId,
147
- clientSecret: args.clientSecret,
148
- code: args.code,
149
- grantType: 'authorization_code',
150
- ...(args.redirectUri && { redirectUri: args.redirectUri }),
151
- };
152
- const response = await (0, http_client_1.request)({
153
- httpMethod: 'POST',
154
- baseUri: tokenUrl,
155
- relativePath: '',
156
- payload,
157
- customFetch: options?.customFetch,
158
- });
159
- if (!(0, http_client_1.isSuccess)(response) || !response.data?.accessToken) {
160
- throw new errors_1.CommsRequestError('Authentication token exchange failed.', response.status, response.data);
161
- }
162
- return response.data;
163
- }
164
- /**
165
- * Exchanges a refresh token for a new access token using the OAuth2
166
- * `refresh_token` grant.
167
- *
168
- * @example
169
- * ```typescript
170
- * const { accessToken, refreshToken } = await refreshAuthToken({
171
- * clientId: 'client-id',
172
- * clientSecret: 'client-secret',
173
- * refreshToken: storedRefreshToken,
174
- * })
175
- * ```
176
- *
177
- * @returns The refreshed token. The response may include a new `refreshToken`
178
- * (rotated by the server); persist it in place of the previous one when present.
179
- * @throws {@link CommsRequestError} If the refresh fails
180
- */
181
- async function refreshAuthToken(args, options) {
182
- const tokenUrl = `${getAuthBaseUrl(options?.baseUrl)}/oauth/access_token`;
183
- const payload = {
184
- clientId: args.clientId,
185
- clientSecret: args.clientSecret,
186
- refreshToken: args.refreshToken,
187
- grantType: 'refresh_token',
188
- };
189
- const response = await (0, http_client_1.request)({
190
- httpMethod: 'POST',
191
- baseUri: tokenUrl,
192
- relativePath: '',
193
- payload,
194
- customFetch: options?.customFetch,
195
- });
196
- if (!(0, http_client_1.isSuccess)(response) || !response.data?.accessToken) {
197
- throw new errors_1.CommsRequestError('Authentication token refresh failed.', response.status, response.data);
198
- }
199
- return response.data;
200
- }
201
- async function revokeAuthToken(args, options) {
202
- const revokeUrl = `${getAuthBaseUrl(options?.baseUrl)}/api/v1/revoke`;
203
- const response = await (0, http_client_1.request)({
204
- httpMethod: 'POST',
205
- baseUri: revokeUrl,
206
- relativePath: '',
207
- payload: {
208
- clientId: args.clientId,
209
- clientSecret: args.clientSecret,
210
- token: args.accessToken,
211
- },
212
- customFetch: options?.customFetch,
213
- });
214
- return (0, http_client_1.isSuccess)(response);
215
- }
216
- /**
217
- * Registers a new OAuth client via Dynamic Client Registration (RFC 7591).
218
- *
219
- * @example
220
- * ```typescript
221
- * const client = await registerClient({
222
- * redirectUris: ['https://example.com/callback'],
223
- * clientName: 'My App',
224
- * scope: ['user:read', 'channels:read'],
225
- * })
226
- * // Use client.clientId and client.clientSecret for OAuth flows
227
- * ```
228
- *
229
- * @returns The registered client details
230
- * @throws {@link CommsRequestError} If the registration fails
231
- * @see {@link https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591}
232
- */
233
- async function registerClient(args, options) {
234
- const registerUrl = `${getAuthBaseUrl(options?.baseUrl)}/oauth/register`;
235
- const response = await (0, http_client_1.request)({
236
- httpMethod: 'POST',
237
- baseUri: registerUrl,
238
- relativePath: '',
239
- payload: { ...args, scope: args.scope?.join(' ') },
240
- customFetch: options?.customFetch,
241
- });
242
- if (!(0, http_client_1.isSuccess)(response) || !response.data?.clientId) {
243
- throw new errors_1.CommsRequestError('Dynamic client registration failed.', response.status, response.data);
244
- }
245
- const { clientIdIssuedAt, clientSecretExpiresAt, scope, ...rest } = response.data;
246
- return {
247
- ...rest,
248
- scope: scope ? scope.split(' ') : undefined,
249
- clientIdIssuedAt: clientIdIssuedAt !== undefined ? new Date(clientIdIssuedAt * 1000) : undefined,
250
- clientSecretExpiresAt: clientSecretExpiresAt === undefined
251
- ? undefined
252
- : clientSecretExpiresAt === 0
253
- ? null
254
- : new Date(clientSecretExpiresAt * 1000),
255
- };
256
- }