@doist/comms-sdk 0.1.0-alpha.1

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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/cjs/authentication.js +211 -0
  4. package/dist/cjs/clients/add-comment-helper.js +53 -0
  5. package/dist/cjs/clients/base-client.js +27 -0
  6. package/dist/cjs/clients/channels-client.js +83 -0
  7. package/dist/cjs/clients/comments-client.js +93 -0
  8. package/dist/cjs/clients/conversation-messages-client.js +87 -0
  9. package/dist/cjs/clients/conversations-client.js +103 -0
  10. package/dist/cjs/clients/groups-client.js +71 -0
  11. package/dist/cjs/clients/inbox-client.js +98 -0
  12. package/dist/cjs/clients/reactions-client.js +59 -0
  13. package/dist/cjs/clients/search-client.js +88 -0
  14. package/dist/cjs/clients/threads-client.js +135 -0
  15. package/dist/cjs/clients/users-client.js +199 -0
  16. package/dist/cjs/clients/workspace-users-client.js +140 -0
  17. package/dist/cjs/clients/workspaces-client.js +93 -0
  18. package/dist/cjs/comms-api.js +65 -0
  19. package/dist/cjs/consts/endpoints.js +27 -0
  20. package/dist/cjs/index.js +48 -0
  21. package/dist/cjs/package.json +1 -0
  22. package/dist/cjs/testUtils/msw-handlers.js +51 -0
  23. package/dist/cjs/testUtils/msw-setup.js +21 -0
  24. package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
  25. package/dist/cjs/testUtils/test-defaults.js +102 -0
  26. package/dist/cjs/transport/fetch-with-retry.js +136 -0
  27. package/dist/cjs/transport/http-client.js +56 -0
  28. package/dist/cjs/transport/http-dispatcher.js +143 -0
  29. package/dist/cjs/types/entities.js +411 -0
  30. package/dist/cjs/types/enums.js +37 -0
  31. package/dist/cjs/types/errors.js +12 -0
  32. package/dist/cjs/types/http.js +4 -0
  33. package/dist/cjs/types/index.js +21 -0
  34. package/dist/cjs/types/requests.js +117 -0
  35. package/dist/cjs/utils/case-conversion.js +54 -0
  36. package/dist/cjs/utils/index.js +19 -0
  37. package/dist/cjs/utils/timestamp-conversion.js +49 -0
  38. package/dist/cjs/utils/url-helpers.js +131 -0
  39. package/dist/cjs/utils/uuidv7.js +174 -0
  40. package/dist/esm/authentication.js +203 -0
  41. package/dist/esm/clients/add-comment-helper.js +50 -0
  42. package/dist/esm/clients/base-client.js +23 -0
  43. package/dist/esm/clients/channels-client.js +79 -0
  44. package/dist/esm/clients/comments-client.js +89 -0
  45. package/dist/esm/clients/conversation-messages-client.js +83 -0
  46. package/dist/esm/clients/conversations-client.js +99 -0
  47. package/dist/esm/clients/groups-client.js +67 -0
  48. package/dist/esm/clients/inbox-client.js +94 -0
  49. package/dist/esm/clients/reactions-client.js +55 -0
  50. package/dist/esm/clients/search-client.js +84 -0
  51. package/dist/esm/clients/threads-client.js +131 -0
  52. package/dist/esm/clients/users-client.js +195 -0
  53. package/dist/esm/clients/workspace-users-client.js +136 -0
  54. package/dist/esm/clients/workspaces-client.js +89 -0
  55. package/dist/esm/comms-api.js +61 -0
  56. package/dist/esm/consts/endpoints.js +23 -0
  57. package/dist/esm/index.js +17 -0
  58. package/dist/esm/testUtils/msw-handlers.js +45 -0
  59. package/dist/esm/testUtils/msw-setup.js +18 -0
  60. package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
  61. package/dist/esm/testUtils/test-defaults.js +99 -0
  62. package/dist/esm/transport/fetch-with-retry.js +133 -0
  63. package/dist/esm/transport/http-client.js +51 -0
  64. package/dist/esm/transport/http-dispatcher.js +104 -0
  65. package/dist/esm/types/entities.js +408 -0
  66. package/dist/esm/types/enums.js +34 -0
  67. package/dist/esm/types/errors.js +8 -0
  68. package/dist/esm/types/http.js +1 -0
  69. package/dist/esm/types/index.js +5 -0
  70. package/dist/esm/types/requests.js +114 -0
  71. package/dist/esm/utils/case-conversion.js +47 -0
  72. package/dist/esm/utils/index.js +3 -0
  73. package/dist/esm/utils/timestamp-conversion.js +45 -0
  74. package/dist/esm/utils/url-helpers.js +112 -0
  75. package/dist/esm/utils/uuidv7.js +163 -0
  76. package/dist/types/authentication.d.ts +160 -0
  77. package/dist/types/clients/add-comment-helper.d.ts +12 -0
  78. package/dist/types/clients/base-client.d.ts +24 -0
  79. package/dist/types/clients/channels-client.d.ts +91 -0
  80. package/dist/types/clients/comments-client.d.ts +157 -0
  81. package/dist/types/clients/conversation-messages-client.d.ts +127 -0
  82. package/dist/types/clients/conversations-client.d.ts +206 -0
  83. package/dist/types/clients/groups-client.d.ts +55 -0
  84. package/dist/types/clients/inbox-client.d.ts +20 -0
  85. package/dist/types/clients/reactions-client.d.ts +19 -0
  86. package/dist/types/clients/search-client.d.ts +20 -0
  87. package/dist/types/clients/threads-client.d.ts +344 -0
  88. package/dist/types/clients/users-client.d.ts +123 -0
  89. package/dist/types/clients/workspace-users-client.d.ts +47 -0
  90. package/dist/types/clients/workspaces-client.d.ts +79 -0
  91. package/dist/types/comms-api.d.ts +59 -0
  92. package/dist/types/consts/endpoints.d.ts +19 -0
  93. package/dist/types/index.d.ts +18 -0
  94. package/dist/types/testUtils/msw-handlers.d.ts +28 -0
  95. package/dist/types/testUtils/msw-setup.d.ts +1 -0
  96. package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
  97. package/dist/types/testUtils/test-defaults.d.ts +16 -0
  98. package/dist/types/transport/fetch-with-retry.d.ts +4 -0
  99. package/dist/types/transport/http-client.d.ts +13 -0
  100. package/dist/types/transport/http-dispatcher.d.ts +10 -0
  101. package/dist/types/types/entities.d.ts +1288 -0
  102. package/dist/types/types/enums.d.ts +55 -0
  103. package/dist/types/types/errors.d.ts +6 -0
  104. package/dist/types/types/http.d.ts +54 -0
  105. package/dist/types/types/index.d.ts +5 -0
  106. package/dist/types/types/requests.d.ts +385 -0
  107. package/dist/types/utils/case-conversion.d.ts +8 -0
  108. package/dist/types/utils/index.d.ts +3 -0
  109. package/dist/types/utils/timestamp-conversion.d.ts +13 -0
  110. package/dist/types/utils/url-helpers.d.ts +88 -0
  111. package/dist/types/utils/uuidv7.d.ts +40 -0
  112. package/package.json +93 -0
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.timestampToDate = timestampToDate;
4
+ exports.transformTimestamps = transformTimestamps;
5
+ /**
6
+ * Converts a Unix timestamp (in seconds) to a Date object.
7
+ * @param timestamp - Unix timestamp in seconds
8
+ * @returns Date object
9
+ */
10
+ function timestampToDate(timestamp) {
11
+ return new Date(timestamp * 1000);
12
+ }
13
+ /**
14
+ * Recursively transforms all timestamp fields (ending in 'Ts') in an object to Date objects.
15
+ * Also renames the fields by removing the 'Ts' suffix.
16
+ * @param obj - The object to transform
17
+ * @returns The transformed object with Date fields
18
+ */
19
+ function transformTimestamps(obj) {
20
+ if (obj === null || obj === undefined) {
21
+ return obj;
22
+ }
23
+ if (Array.isArray(obj)) {
24
+ return obj.map((item) => transformTimestamps(item));
25
+ }
26
+ if (typeof obj === 'object') {
27
+ const result = {};
28
+ for (const [key, value] of Object.entries(obj)) {
29
+ // Check if the key ends with 'Ts' and the value is a number
30
+ if (key.endsWith('Ts') && typeof value === 'number') {
31
+ // Remove 'Ts' suffix and convert to Date
32
+ const newKey = key.slice(0, -2);
33
+ // If the base key already exists in the original object, use *Date suffix
34
+ // to avoid overwriting it (e.g. pinned + pinnedTs → pinned + pinnedDate)
35
+ const targetKey = newKey in obj ? `${newKey}Date` : newKey;
36
+ result[targetKey] = timestampToDate(value);
37
+ }
38
+ else if (typeof value === 'object' && value !== null) {
39
+ // Recursively transform nested objects
40
+ result[key] = transformTimestamps(value);
41
+ }
42
+ else {
43
+ result[key] = value;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+ return obj;
49
+ }
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ /**
3
+ * Helper functions for creating Comms permalinks (`https://comms.todoist.com/a/...`).
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getCommsURL = getCommsURL;
7
+ exports.getFullCommsURL = getFullCommsURL;
8
+ exports.getThreadURL = getThreadURL;
9
+ exports.getChannelURL = getChannelURL;
10
+ exports.getConversationURL = getConversationURL;
11
+ exports.getMessageURL = getMessageURL;
12
+ exports.getCommentURL = getCommentURL;
13
+ exports.getThreadsRootURL = getThreadsRootURL;
14
+ exports.getInboxURL = getInboxURL;
15
+ exports.getMessagesRootURL = getMessagesRootURL;
16
+ exports.getUserProfileURL = getUserProfileURL;
17
+ exports.getSavedThreadsRootURL = getSavedThreadsRootURL;
18
+ exports.getSavedThreadURL = getSavedThreadURL;
19
+ exports.getSearchRootURL = getSearchRootURL;
20
+ exports.getSearchQueryURL = getSearchQueryURL;
21
+ exports.getSettingsURL = getSettingsURL;
22
+ exports.getTeamMembersRootURL = getTeamMembersRootURL;
23
+ const COMMS_BASE_URL = 'https://comms.todoist.com';
24
+ /**
25
+ * Builds a relative Comms URL based on the provided parameters
26
+ * @param params - URL parameters including workspace, channel, conversation, thread, etc.
27
+ * @returns A relative URL path
28
+ * @example
29
+ * getCommsURL({ workspaceId: 1, channelId: '7Yp...', threadId: '7Yq...' })
30
+ * // returns '/a/1/ch/7Yp.../t/7Yq.../'
31
+ */
32
+ function getCommsURL(params) {
33
+ const { workspaceId, channelId, conversationId, threadId, commentId, messageId, userId } = params;
34
+ let url = `/a/${workspaceId}/`;
35
+ if (channelId) {
36
+ url += `ch/${channelId}/`;
37
+ if (threadId) {
38
+ url += `t/${threadId}/`;
39
+ if (commentId) {
40
+ url += `c/${commentId}`;
41
+ }
42
+ }
43
+ }
44
+ else if (threadId) {
45
+ url += `inbox/t/${threadId}/`;
46
+ if (commentId) {
47
+ url += `c/${commentId}`;
48
+ }
49
+ }
50
+ else if (conversationId) {
51
+ url += `msg/${conversationId}/`;
52
+ if (messageId) {
53
+ url += `m/${messageId}`;
54
+ }
55
+ }
56
+ else if (userId) {
57
+ url += `people/u/${userId}`;
58
+ }
59
+ return url;
60
+ }
61
+ /**
62
+ * Builds a full Comms URL (with protocol and hostname) based on the provided parameters
63
+ * @param params - URL parameters including workspace, channel, conversation, thread, etc.
64
+ * @param baseUrl - Optional base URL (defaults to 'https://comms.todoist.com')
65
+ */
66
+ function getFullCommsURL(params, baseUrl = COMMS_BASE_URL) {
67
+ return `${baseUrl}${getCommsURL(params)}`;
68
+ }
69
+ /** Returns the URL for a thread in a channel. */
70
+ function getThreadURL(params) {
71
+ return getCommsURL(params);
72
+ }
73
+ /** Returns the URL for a channel. */
74
+ function getChannelURL(params) {
75
+ return getCommsURL(params);
76
+ }
77
+ /** Returns the URL for a conversation. */
78
+ function getConversationURL(params) {
79
+ return getCommsURL(params);
80
+ }
81
+ /** Returns the URL for a specific message in a conversation. */
82
+ function getMessageURL(params) {
83
+ return getCommsURL(params);
84
+ }
85
+ /** Returns the URL for a comment in a thread. */
86
+ function getCommentURL(params) {
87
+ return getCommsURL(params);
88
+ }
89
+ /** Returns the URL for the threads root (channels view). */
90
+ function getThreadsRootURL(workspaceId) {
91
+ return `/a/${workspaceId}/ch`;
92
+ }
93
+ /** Returns the URL for the inbox. */
94
+ function getInboxURL(workspaceId, tab) {
95
+ const tabParam = tab ? `/${tab}` : '';
96
+ return `/a/${workspaceId}/inbox${tabParam}`;
97
+ }
98
+ /** Returns the URL for the messages/conversations root. */
99
+ function getMessagesRootURL(workspaceId) {
100
+ return `/a/${workspaceId}/msg`;
101
+ }
102
+ /** Returns the URL for a user profile. */
103
+ function getUserProfileURL(params) {
104
+ return `/a/${params.workspaceId}/people/u/${params.userId}`;
105
+ }
106
+ /** Returns the URL for the saved threads view. */
107
+ function getSavedThreadsRootURL(workspaceId) {
108
+ return `/a/${workspaceId}/saved`;
109
+ }
110
+ /** Returns the URL for a saved thread. */
111
+ function getSavedThreadURL(params) {
112
+ return `/a/${params.workspaceId}/saved/t/${params.threadId}`;
113
+ }
114
+ /** Returns the URL for the search root. */
115
+ function getSearchRootURL(workspaceId) {
116
+ return `/a/${workspaceId}/search`;
117
+ }
118
+ /** Returns the URL for a search with a query. */
119
+ function getSearchQueryURL(params) {
120
+ return `/a/${params.workspaceId}/search?q=${decodeURIComponent(params.query)}`;
121
+ }
122
+ /** Returns the URL for settings. */
123
+ function getSettingsURL(params) {
124
+ return params.initialLocation
125
+ ? `/a/${params.workspaceId}/settings/${params.initialLocation}`
126
+ : `/a/${params.workspaceId}/settings`;
127
+ }
128
+ /** Returns the URL for the team members root. */
129
+ function getTeamMembersRootURL(workspaceId) {
130
+ return `/a/${workspaceId}/people/u`;
131
+ }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UuidV7Error = void 0;
4
+ exports.encodeUuidToBase58 = encodeUuidToBase58;
5
+ exports.decodeBase58ToUuidBytes = decodeBase58ToUuidBytes;
6
+ exports.generateId = generateId;
7
+ exports.resolveCreateId = resolveCreateId;
8
+ exports.isValidUuidV7Base58 = isValidUuidV7Base58;
9
+ exports.base58FromUuidString = base58FromUuidString;
10
+ exports.uuidStringFromBase58 = uuidStringFromBase58;
11
+ const uuid_1 = require("uuid");
12
+ /**
13
+ * ID utilities for entities that use opaque string identifiers (channels,
14
+ * threads, comments, conversations, messages, groups).
15
+ *
16
+ * Use {@link generateId} to mint a new ID locally and pass it to a creation
17
+ * endpoint. {@link encodeUuidToBase58} / {@link decodeBase58ToUuidBytes}
18
+ * expose the underlying encoding for callers that need to round-trip raw
19
+ * UUID bytes themselves.
20
+ */
21
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
22
+ const BASE58_MAP = (() => {
23
+ const map = {};
24
+ for (let i = 0; i < BASE58_ALPHABET.length; i++) {
25
+ map[BASE58_ALPHABET[i]] = i;
26
+ }
27
+ return map;
28
+ })();
29
+ const UUID_BYTES_LEN = 16;
30
+ const UUID_BASE58_MAX_LEN = 22;
31
+ const HEX_RE = /^[0-9a-f]+$/;
32
+ class UuidV7Error extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'UuidV7Error';
36
+ }
37
+ }
38
+ exports.UuidV7Error = UuidV7Error;
39
+ /** Encode a 16-byte UUID as a base58 string. */
40
+ function encodeUuidToBase58(bytes) {
41
+ if (bytes.length !== UUID_BYTES_LEN) {
42
+ throw new UuidV7Error(`id must be ${UUID_BYTES_LEN} bytes`);
43
+ }
44
+ let zeros = 0;
45
+ while (zeros < bytes.length && bytes[zeros] === 0) {
46
+ zeros++;
47
+ }
48
+ let n = 0n;
49
+ for (const b of bytes) {
50
+ n = (n << 8n) | BigInt(b);
51
+ }
52
+ let out = '';
53
+ while (n > 0n) {
54
+ const rem = Number(n % 58n);
55
+ out = BASE58_ALPHABET[rem] + out;
56
+ n /= 58n;
57
+ }
58
+ return '1'.repeat(zeros) + out;
59
+ }
60
+ /**
61
+ * Decode a base58 string back into 16 UUID bytes. Throws {@link UuidV7Error}
62
+ * if the input is malformed or doesn't decode to exactly 16 bytes.
63
+ */
64
+ function decodeBase58ToUuidBytes(value) {
65
+ if (typeof value !== 'string') {
66
+ throw new UuidV7Error('id must be a base58 string');
67
+ }
68
+ if (value.length === 0) {
69
+ throw new UuidV7Error('id is empty');
70
+ }
71
+ if (value.length > UUID_BASE58_MAX_LEN) {
72
+ throw new UuidV7Error('id is too long');
73
+ }
74
+ let zeros = 0;
75
+ while (zeros < value.length && value[zeros] === '1') {
76
+ zeros++;
77
+ }
78
+ let n = 0n;
79
+ for (let i = 0; i < value.length; i++) {
80
+ const ch = value[i];
81
+ const v = BASE58_MAP[ch];
82
+ if (v === undefined) {
83
+ throw new UuidV7Error(`invalid base58 character: '${ch}'`);
84
+ }
85
+ n = n * 58n + BigInt(v);
86
+ }
87
+ const raw = [];
88
+ while (n > 0n) {
89
+ raw.unshift(Number(n & 0xffn));
90
+ n >>= 8n;
91
+ }
92
+ const padded = new Uint8Array(zeros + raw.length);
93
+ padded.set(raw, zeros);
94
+ if (padded.length !== UUID_BYTES_LEN) {
95
+ throw new UuidV7Error(`id must decode to ${UUID_BYTES_LEN} bytes`);
96
+ }
97
+ return padded;
98
+ }
99
+ function hexToBytes(hex) {
100
+ const out = new Uint8Array(hex.length / 2);
101
+ for (let i = 0; i < out.length; i++) {
102
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * Mint a fresh ID. Callers should generate one of these locally when
108
+ * creating a new channel / thread / comment / conversation / message /
109
+ * group — the backend requires the client to supply the ID on create.
110
+ */
111
+ function generateId() {
112
+ const hex = (0, uuid_1.v7)().replace(/-/g, '');
113
+ return encodeUuidToBase58(hexToBytes(hex));
114
+ }
115
+ /**
116
+ * Resolve the `id` for a create-style API call: validate the caller-supplied
117
+ * value (throwing {@link UuidV7Error} before the request leaves the SDK) or
118
+ * mint a fresh one via {@link generateId}.
119
+ */
120
+ function resolveCreateId(id) {
121
+ if (id === undefined)
122
+ return generateId();
123
+ if (!isValidUuidV7Base58(id)) {
124
+ throw new UuidV7Error(`invalid id ${JSON.stringify(id)} — use generateId() or omit \`id\` and let the SDK mint one.`);
125
+ }
126
+ return id;
127
+ }
128
+ /**
129
+ * Validate that a value matches the expected ID format (the decoded bytes
130
+ * have the v7 version nibble + RFC 4122/9562 variant bits). Does NOT
131
+ * validate the embedded timestamp — the backend may still reject a value
132
+ * that is too far in the future or past.
133
+ */
134
+ function isValidUuidV7Base58(value) {
135
+ if (typeof value !== 'string')
136
+ return false;
137
+ let bytes;
138
+ try {
139
+ bytes = decodeBase58ToUuidBytes(value);
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ const versionNibble = bytes[6] & 0xf0;
145
+ const variantBits = bytes[8] & 0xc0;
146
+ return versionNibble === 0x70 && variantBits === 0x80;
147
+ }
148
+ /**
149
+ * Encode a canonical UUID string (hyphenated or not, any case) as a
150
+ * wire-format ID. Useful when interoperating with systems that hand you
151
+ * UUIDs in canonical form.
152
+ */
153
+ function base58FromUuidString(uuid) {
154
+ const stripped = uuid.replace(/-/g, '').toLowerCase();
155
+ if (stripped.length !== 32 || !HEX_RE.test(stripped)) {
156
+ throw new UuidV7Error('not a valid UUID string');
157
+ }
158
+ return encodeUuidToBase58(hexToBytes(stripped));
159
+ }
160
+ /**
161
+ * Inverse of {@link base58FromUuidString}: takes a wire-format ID and
162
+ * returns the canonical hyphenated UUID string.
163
+ */
164
+ function uuidStringFromBase58(value) {
165
+ const bytes = decodeBase58ToUuidBytes(value);
166
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
167
+ return [
168
+ hex.slice(0, 8),
169
+ hex.slice(8, 12),
170
+ hex.slice(12, 16),
171
+ hex.slice(16, 20),
172
+ hex.slice(20, 32),
173
+ ].join('-');
174
+ }
@@ -0,0 +1,203 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ import { isSuccess, request } from './transport/http-client.js';
3
+ import { CommsRequestError } from './types/errors.js';
4
+ /**
5
+ * OAuth scopes for the Comms API.
6
+ *
7
+ * @remarks
8
+ * Request only the scopes your application needs:
9
+ *
10
+ * **User Scopes:**
11
+ * - `user:read` - Access user's personal settings
12
+ * - `user:write` - Access and update user's personal settings
13
+ *
14
+ * **Workspace Scopes:**
15
+ * - `workspaces:read` - Access teams the user is part of
16
+ * - `workspaces:write` - Access and update teams the user is part of
17
+ *
18
+ * **Channel Scopes:**
19
+ * - `channels:read` - Access channels
20
+ * - `channels:write` - Access and update channels
21
+ * - `channels:remove` - Access, update, and delete channels
22
+ *
23
+ * **Thread Scopes:**
24
+ * - `threads:read` - Access threads
25
+ * - `threads:write` - Access and update threads
26
+ * - `threads:remove` - Access, update, and delete threads
27
+ *
28
+ * **Comment Scopes:**
29
+ * - `comments:read` - Access comments
30
+ * - `comments:write` - Access and update comments
31
+ * - `comments:remove` - Access, update, and delete comments
32
+ *
33
+ * **Group Scopes:**
34
+ * - `groups:read` - Access groups
35
+ * - `groups:write` - Access and update groups
36
+ * - `groups:remove` - Access, update, and delete groups
37
+ *
38
+ * **Message Scopes:**
39
+ * - `messages:read` - Access messages
40
+ * - `messages:write` - Access and update messages
41
+ * - `messages:remove` - Access, update, and delete messages
42
+ *
43
+ * **Reaction Scopes:**
44
+ * - `reactions:read` - Access reactions
45
+ * - `reactions:write` - Access and update reactions
46
+ * - `reactions:remove` - Access, update, and delete reactions
47
+ *
48
+ * **Search Scopes:**
49
+ * - `search:read` - Search
50
+ *
51
+ * **Attachment Scopes:**
52
+ * - `attachments:read` - Access attachments
53
+ * - `attachments:write` - Access and update attachments
54
+ *
55
+ * **Notification Scopes:**
56
+ * - `notifications:read` - Read user's notifications settings
57
+ * - `notifications:write` - Read and update user's notifications settings
58
+ */
59
+ export const COMMS_SCOPES = [
60
+ 'user:read',
61
+ 'user:write',
62
+ 'workspaces:read',
63
+ 'workspaces:write',
64
+ 'channels:read',
65
+ 'channels:write',
66
+ 'channels:remove',
67
+ 'threads:read',
68
+ 'threads:write',
69
+ 'threads:remove',
70
+ 'comments:read',
71
+ 'comments:write',
72
+ 'comments:remove',
73
+ 'groups:read',
74
+ 'groups:write',
75
+ 'groups:remove',
76
+ 'messages:read',
77
+ 'messages:write',
78
+ 'messages:remove',
79
+ 'reactions:read',
80
+ 'reactions:write',
81
+ 'reactions:remove',
82
+ 'search:read',
83
+ 'attachments:read',
84
+ 'attachments:write',
85
+ 'notifications:read',
86
+ 'notifications:write',
87
+ ];
88
+ /** Supported token endpoint authentication methods for dynamic client registration. */
89
+ export const TOKEN_ENDPOINT_AUTH_METHODS = [
90
+ 'client_secret_post',
91
+ 'client_secret_basic',
92
+ 'none',
93
+ ];
94
+ export function getAuthStateParameter() {
95
+ return uuid();
96
+ }
97
+ /**
98
+ * Generates the authorization URL for the OAuth2 flow.
99
+ *
100
+ * The `clientId` can be either a traditional client ID string (e.g. from
101
+ * {@link registerClient}) or an HTTPS URL pointing to a client metadata document,
102
+ * as defined in {@link https://drafts.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ RFC draft-ietf-oauth-client-id-metadata-document}.
103
+ */
104
+ export function getAuthorizationUrl(clientId, scopes, state, redirectUri, baseUrl) {
105
+ if (!scopes?.length) {
106
+ throw new Error('At least one scope value is required.');
107
+ }
108
+ const authBaseUrl = baseUrl ? `${baseUrl}/oauth` : 'https://comms.todoist.com/oauth';
109
+ const scope = scopes.join(' ');
110
+ const params = new URLSearchParams({
111
+ client_id: clientId,
112
+ response_type: 'code',
113
+ scope,
114
+ state,
115
+ });
116
+ if (redirectUri) {
117
+ params.append('redirect_uri', redirectUri);
118
+ }
119
+ return `${authBaseUrl}/authorize?${params.toString()}`;
120
+ }
121
+ export async function getAuthToken(args, options) {
122
+ const tokenUrl = options?.baseUrl
123
+ ? `${options.baseUrl}/oauth/token`
124
+ : 'https://comms.todoist.com/oauth/token';
125
+ const payload = {
126
+ clientId: args.clientId,
127
+ clientSecret: args.clientSecret,
128
+ code: args.code,
129
+ grantType: 'authorization_code',
130
+ ...(args.redirectUri && { redirectUri: args.redirectUri }),
131
+ };
132
+ const response = await request({
133
+ httpMethod: 'POST',
134
+ baseUri: tokenUrl,
135
+ relativePath: '',
136
+ payload,
137
+ customFetch: options?.customFetch,
138
+ });
139
+ if (!isSuccess(response) || !response.data?.accessToken) {
140
+ throw new CommsRequestError('Authentication token exchange failed.', response.status, response.data);
141
+ }
142
+ return response.data;
143
+ }
144
+ export async function revokeAuthToken(args, options) {
145
+ const revokeUrl = options?.baseUrl
146
+ ? `${options.baseUrl}/oauth/revoke`
147
+ : 'https://comms.todoist.com/oauth/revoke';
148
+ const response = await request({
149
+ httpMethod: 'POST',
150
+ baseUri: revokeUrl,
151
+ relativePath: '',
152
+ payload: {
153
+ clientId: args.clientId,
154
+ clientSecret: args.clientSecret,
155
+ token: args.accessToken,
156
+ },
157
+ customFetch: options?.customFetch,
158
+ });
159
+ return isSuccess(response);
160
+ }
161
+ /**
162
+ * Registers a new OAuth client via Dynamic Client Registration (RFC 7591).
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const client = await registerClient({
167
+ * redirectUris: ['https://example.com/callback'],
168
+ * clientName: 'My App',
169
+ * scope: ['user:read', 'channels:read'],
170
+ * })
171
+ * // Use client.clientId and client.clientSecret for OAuth flows
172
+ * ```
173
+ *
174
+ * @returns The registered client details
175
+ * @throws {@link CommsRequestError} If the registration fails
176
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591}
177
+ */
178
+ export async function registerClient(args, options) {
179
+ const registerUrl = options?.baseUrl
180
+ ? `${options.baseUrl}/oauth/register`
181
+ : 'https://comms.todoist.com/oauth/register';
182
+ const response = await request({
183
+ httpMethod: 'POST',
184
+ baseUri: registerUrl,
185
+ relativePath: '',
186
+ payload: { ...args, scope: args.scope?.join(' ') },
187
+ customFetch: options?.customFetch,
188
+ });
189
+ if (!isSuccess(response) || !response.data?.clientId) {
190
+ throw new CommsRequestError('Dynamic client registration failed.', response.status, response.data);
191
+ }
192
+ const { clientIdIssuedAt, clientSecretExpiresAt, scope, ...rest } = response.data;
193
+ return {
194
+ ...rest,
195
+ scope: scope ? scope.split(' ') : undefined,
196
+ clientIdIssuedAt: clientIdIssuedAt !== undefined ? new Date(clientIdIssuedAt * 1000) : undefined,
197
+ clientSecretExpiresAt: clientSecretExpiresAt === undefined
198
+ ? undefined
199
+ : clientSecretExpiresAt === 0
200
+ ? null
201
+ : new Date(clientSecretExpiresAt * 1000),
202
+ };
203
+ }
@@ -0,0 +1,50 @@
1
+ import { ENDPOINT_COMMENTS } from '../consts/endpoints.js';
2
+ import { request } from '../transport/http-client.js';
3
+ import { CommentSchema } from '../types/entities.js';
4
+ import { NOTIFY_AUDIENCE_GROUP_IDS, NOTIFY_AUDIENCES } from '../types/enums.js';
5
+ import { resolveCreateId } from '../utils/uuidv7.js';
6
+ const SENTINEL_GROUP_IDS = new Set(Object.values(NOTIFY_AUDIENCE_GROUP_IDS));
7
+ function isNotifyAudience(value) {
8
+ return typeof value === 'string' && NOTIFY_AUDIENCES.includes(value);
9
+ }
10
+ function collectMarkerOffenses(field, values) {
11
+ if (!values)
12
+ return null;
13
+ const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
14
+ return offending.length > 0 ? { field, offending } : null;
15
+ }
16
+ function applyNotifyAudience(params) {
17
+ const offenses = [
18
+ collectMarkerOffenses('groups', params.groups ?? undefined),
19
+ collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
20
+ ].filter((o) => o !== null);
21
+ if (offenses.length > 0) {
22
+ const details = offenses
23
+ .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
24
+ .join('; ');
25
+ throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` on createComment / closeThread / reopenThread (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
26
+ }
27
+ if (params.notifyAudience == null)
28
+ return params;
29
+ if (!isNotifyAudience(params.notifyAudience)) {
30
+ throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${NOTIFY_AUDIENCES.join(', ')}.`);
31
+ }
32
+ const sentinel = NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
33
+ const { notifyAudience: _stripped, groups, ...rest } = params;
34
+ return { ...rest, groups: [...(groups ?? []), sentinel] };
35
+ }
36
+ export function addCommentRequest(context, params, options) {
37
+ const normalized = applyNotifyAudience(params);
38
+ const withId = { ...normalized, id: resolveCreateId(normalized.id) };
39
+ const payload = options?.threadAction
40
+ ? { ...withId, threadAction: options.threadAction }
41
+ : withId;
42
+ return request({
43
+ httpMethod: 'POST',
44
+ baseUri: context.baseUri,
45
+ relativePath: `${ENDPOINT_COMMENTS}/add`,
46
+ apiToken: context.apiToken,
47
+ payload,
48
+ customFetch: context.customFetch,
49
+ }).then((response) => CommentSchema.parse(response.data));
50
+ }
@@ -0,0 +1,23 @@
1
+ import { getCommsBaseUri } from '../consts/endpoints.js';
2
+ /**
3
+ * Base class for every Comms API client. Centralizes URL handling and
4
+ * config so individual clients stay focused on their endpoints.
5
+ */
6
+ export class BaseClient {
7
+ constructor(config) {
8
+ this.apiToken = config.apiToken;
9
+ this.baseUrl = config.baseUrl;
10
+ this.customFetch = config.customFetch;
11
+ }
12
+ /**
13
+ * Returns the base URI for an API request, with a guaranteed trailing
14
+ * slash so relative paths resolve cleanly through `URL`.
15
+ */
16
+ getBaseUri() {
17
+ if (this.baseUrl) {
18
+ const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
19
+ return `${normalizedBaseUrl}api/v1/`;
20
+ }
21
+ return getCommsBaseUri();
22
+ }
23
+ }