@taodigital/connectors 0.0.0-stage → 1.2.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.
- package/dist/azuredevops.d.ts +35 -0
- package/dist/azuredevops.js +116 -0
- package/dist/confluence.d.ts +22 -0
- package/dist/confluence.js +114 -0
- package/dist/connectors.test.d.ts +10 -0
- package/dist/connectors.test.js +407 -0
- package/dist/figma.d.ts +57 -0
- package/dist/figma.js +141 -0
- package/dist/github.d.ts +43 -0
- package/dist/github.js +120 -0
- package/dist/gitlab.d.ts +42 -0
- package/dist/gitlab.js +137 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +45 -0
- package/dist/jira.d.ts +93 -0
- package/dist/jira.js +354 -0
- package/dist/pagerduty.d.ts +25 -0
- package/dist/pagerduty.js +76 -0
- package/dist/salesforce.d.ts +35 -0
- package/dist/salesforce.js +93 -0
- package/dist/servicenow.d.ts +40 -0
- package/dist/servicenow.js +96 -0
- package/package.json +16 -4
- package/src/azuredevops.ts +141 -0
- package/src/confluence.ts +120 -0
- package/src/connectors.test.ts +424 -0
- package/src/figma.ts +194 -0
- package/src/github.ts +139 -0
- package/src/gitlab.ts +166 -0
- package/src/index.ts +9 -0
- package/src/jira.ts +372 -0
- package/src/pagerduty.ts +78 -0
- package/src/salesforce.ts +109 -0
- package/src/servicenow.ts +112 -0
- package/tsconfig.json +8 -0
- package/README.md +0 -3
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Security-focused tests for all connectors.
|
|
4
|
+
* Tests SSRF guard, missing credentials, and happy-path API call shape.
|
|
5
|
+
* No real network calls are made — fetch is mocked globally.
|
|
6
|
+
*
|
|
7
|
+
* Requires DATABASE_URL and MIGRATION_DATABASE_URL env vars to satisfy Prisma's
|
|
8
|
+
* constructor validation (triggered transitively via @taodigital/core → @taodigital/database).
|
|
9
|
+
* The test script in package.json sets mock values for these.
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
const node_test_1 = require("node:test");
|
|
49
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
50
|
+
// --- helpers -----------------------------------------------------------------
|
|
51
|
+
/** Make a minimal fetch mock that always returns ok with a JSON body. */
|
|
52
|
+
function mockFetch(body, status = 200) {
|
|
53
|
+
const stub = node_test_1.mock.fn(async (_url, _init) => ({
|
|
54
|
+
ok: status >= 200 && status < 300,
|
|
55
|
+
status,
|
|
56
|
+
json: async () => body,
|
|
57
|
+
text: async () => JSON.stringify(body),
|
|
58
|
+
}));
|
|
59
|
+
globalThis.fetch = stub;
|
|
60
|
+
return stub;
|
|
61
|
+
}
|
|
62
|
+
function restoreFetch(original) {
|
|
63
|
+
globalThis.fetch = original;
|
|
64
|
+
}
|
|
65
|
+
// Private / IMDS addresses that the SSRF guard must block
|
|
66
|
+
const PRIVATE_URLS = [
|
|
67
|
+
'https://169.254.169.254/latest/meta-data',
|
|
68
|
+
'https://10.0.0.1',
|
|
69
|
+
'https://192.168.1.1',
|
|
70
|
+
];
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Jira
|
|
73
|
+
// =============================================================================
|
|
74
|
+
const jira = __importStar(require("./jira.js"));
|
|
75
|
+
const JIRA_CREDS = {
|
|
76
|
+
baseUrl: 'https://example.atlassian.net',
|
|
77
|
+
email: 'test@example.com',
|
|
78
|
+
apiToken: 'token123',
|
|
79
|
+
projectKey: 'PROJ',
|
|
80
|
+
};
|
|
81
|
+
(0, node_test_1.test)('jira.listProjects: SSRF guard fires for private baseUrl', async () => {
|
|
82
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
83
|
+
const creds = { ...JIRA_CREDS, baseUrl: privateUrl };
|
|
84
|
+
await strict_1.default.rejects(() => jira.listProjects(creds), /private or internal|HTTPS/i, `Expected SSRF rejection for ${privateUrl}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
(0, node_test_1.test)('jira.createBug: missing projectKey throws before network call', async () => {
|
|
88
|
+
const orig = globalThis.fetch;
|
|
89
|
+
const stub = mockFetch({});
|
|
90
|
+
try {
|
|
91
|
+
const creds = { ...JIRA_CREDS, projectKey: undefined };
|
|
92
|
+
await strict_1.default.rejects(() => jira.createBug(creds, { summary: 'Bug', description: 'Desc' }), /projectKey required/i);
|
|
93
|
+
strict_1.default.equal(stub.mock.calls.length, 0, 'fetch should not be called when projectKey is missing');
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
restoreFetch(orig);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
(0, node_test_1.test)('jira.listProjects: happy path — correct auth header and endpoint used', async () => {
|
|
100
|
+
const orig = globalThis.fetch;
|
|
101
|
+
const stub = mockFetch({ values: [{ key: 'PROJ', name: 'Project', id: '1' }] });
|
|
102
|
+
try {
|
|
103
|
+
const result = await jira.listProjects(JIRA_CREDS);
|
|
104
|
+
strict_1.default.equal(result.length, 1);
|
|
105
|
+
strict_1.default.equal(result[0].key, 'PROJ');
|
|
106
|
+
strict_1.default.equal(stub.mock.calls.length, 1);
|
|
107
|
+
const [url, init] = stub.mock.calls[0].arguments;
|
|
108
|
+
strict_1.default.ok(url.includes('/rest/api/3/project/search'), 'should call JIRA project search');
|
|
109
|
+
strict_1.default.ok(init.headers.Authorization.startsWith('Basic '), 'Basic auth when no bearerToken');
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
restoreFetch(orig);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Confluence
|
|
117
|
+
// =============================================================================
|
|
118
|
+
const confluence = __importStar(require("./confluence.js"));
|
|
119
|
+
const CONFLUENCE_CREDS = {
|
|
120
|
+
baseUrl: 'https://example.atlassian.net',
|
|
121
|
+
email: 'test@example.com',
|
|
122
|
+
apiToken: 'token123',
|
|
123
|
+
spaceKey: 'QA',
|
|
124
|
+
};
|
|
125
|
+
(0, node_test_1.test)('confluence.listPages: SSRF guard fires for private baseUrl', async () => {
|
|
126
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
127
|
+
const creds = { ...CONFLUENCE_CREDS, baseUrl: privateUrl };
|
|
128
|
+
await strict_1.default.rejects(() => confluence.listPages(creds), /private or internal|HTTPS/i, `Expected SSRF rejection for ${privateUrl}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
(0, node_test_1.test)('confluence.testConnection: returns ok:false when server errors, does not throw', async () => {
|
|
132
|
+
const orig = globalThis.fetch;
|
|
133
|
+
mockFetch({}, 401);
|
|
134
|
+
try {
|
|
135
|
+
const result = await confluence.testConnection(CONFLUENCE_CREDS);
|
|
136
|
+
strict_1.default.equal(result.ok, false);
|
|
137
|
+
strict_1.default.ok(result.error?.includes('401'));
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
restoreFetch(orig);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
(0, node_test_1.test)('confluence.listPages: happy path — calls correct endpoint with spaceKey', async () => {
|
|
144
|
+
const orig = globalThis.fetch;
|
|
145
|
+
const stub = mockFetch({ results: [{ id: '123', title: 'Page A' }] });
|
|
146
|
+
try {
|
|
147
|
+
const result = await confluence.listPages(CONFLUENCE_CREDS);
|
|
148
|
+
strict_1.default.equal(result.length, 1);
|
|
149
|
+
strict_1.default.equal(result[0].title, 'Page A');
|
|
150
|
+
const [url] = stub.mock.calls[0].arguments;
|
|
151
|
+
strict_1.default.ok(url.includes('spaceKey=QA'), 'space key should be included in query');
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
restoreFetch(orig);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
// =============================================================================
|
|
158
|
+
// GitHub
|
|
159
|
+
// =============================================================================
|
|
160
|
+
const github = __importStar(require("./github.js"));
|
|
161
|
+
const GITHUB_CREDS = {
|
|
162
|
+
token: 'ghp_test_token',
|
|
163
|
+
repo: 'owner/repo',
|
|
164
|
+
};
|
|
165
|
+
(0, node_test_1.test)('github.testConnection: SSRF guard fires for http:// (non-HTTPS)', async () => {
|
|
166
|
+
// GitHub always calls api.github.com but assertResolvesToPublicAddress rejects http://
|
|
167
|
+
// We test this by verifying the module-level check works — since GitHub hardcodes
|
|
168
|
+
// the URL, the SSRF guard is exercised via assertResolvesToPublicAddress on a known public host.
|
|
169
|
+
// The guard rejects any http:// scheme, but api.github.com is always https — verify
|
|
170
|
+
// the normal path proceeds (the guard doesn't reject it as private).
|
|
171
|
+
const orig = globalThis.fetch;
|
|
172
|
+
mockFetch({ login: 'octocat' });
|
|
173
|
+
try {
|
|
174
|
+
const result = await github.testConnection(GITHUB_CREDS);
|
|
175
|
+
strict_1.default.equal(result.ok, true);
|
|
176
|
+
strict_1.default.equal(result.login, 'octocat');
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
restoreFetch(orig);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
(0, node_test_1.test)('github.createIssue: missing token causes auth failure (401 treated as error)', async () => {
|
|
183
|
+
const orig = globalThis.fetch;
|
|
184
|
+
mockFetch({ message: 'Bad credentials' }, 401);
|
|
185
|
+
try {
|
|
186
|
+
await strict_1.default.rejects(() => github.createIssue({ ...GITHUB_CREDS, token: '' }, 'title', 'body'), /GitHub error: 401/);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
restoreFetch(orig);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
(0, node_test_1.test)('github.listRecentCommits: happy path — correct repo URL and Bearer header', async () => {
|
|
193
|
+
const orig = globalThis.fetch;
|
|
194
|
+
const stub = mockFetch([{ sha: 'abc1234', commit: { message: 'Initial', author: { name: 'Dev', date: '2025-01-01' } } }]);
|
|
195
|
+
try {
|
|
196
|
+
const result = await github.listRecentCommits(GITHUB_CREDS);
|
|
197
|
+
strict_1.default.equal(result.length, 1);
|
|
198
|
+
strict_1.default.equal(result[0].sha, 'abc1234');
|
|
199
|
+
const [url, init] = stub.mock.calls[0].arguments;
|
|
200
|
+
strict_1.default.ok(url.includes('/repos/owner/repo/commits'), 'URL must reference the correct repo');
|
|
201
|
+
strict_1.default.equal(init.headers.Authorization, 'Bearer ghp_test_token');
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
restoreFetch(orig);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// =============================================================================
|
|
208
|
+
// Salesforce
|
|
209
|
+
// =============================================================================
|
|
210
|
+
const salesforce = __importStar(require("./salesforce.js"));
|
|
211
|
+
// Use a real, resolvable public hostname for SSRF-guarded connectors.
|
|
212
|
+
// The SSRF guard does actual DNS resolution — fake hostnames cause "Could not
|
|
213
|
+
// resolve destination hostname" before fetch is even reached.
|
|
214
|
+
const SF_CREDS = {
|
|
215
|
+
instanceUrl: 'https://example.com',
|
|
216
|
+
clientId: 'clientId123',
|
|
217
|
+
clientSecret: 'secret123',
|
|
218
|
+
};
|
|
219
|
+
(0, node_test_1.test)('salesforce.listObjects: SSRF guard fires for private instanceUrl', async () => {
|
|
220
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
221
|
+
const creds = { ...SF_CREDS, instanceUrl: privateUrl };
|
|
222
|
+
await strict_1.default.rejects(() => salesforce.listObjects(creds), /private or internal|HTTPS/i, `Expected SSRF rejection for ${privateUrl}`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
(0, node_test_1.test)('salesforce.testConnection: returns ok:false when OAuth token request fails', async () => {
|
|
226
|
+
const orig = globalThis.fetch;
|
|
227
|
+
mockFetch({ error: 'invalid_client' }, 401);
|
|
228
|
+
try {
|
|
229
|
+
const result = await salesforce.testConnection(SF_CREDS);
|
|
230
|
+
strict_1.default.equal(result.ok, false);
|
|
231
|
+
// Error string contains the status code or OAuth error description
|
|
232
|
+
strict_1.default.ok(result.error != null, 'error field should be set on failure');
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
restoreFetch(orig);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
(0, node_test_1.test)('salesforce.createCase: happy path — obtains token then POSTs case', async () => {
|
|
239
|
+
const orig = globalThis.fetch;
|
|
240
|
+
// First call = OAuth token endpoint, second call = create case
|
|
241
|
+
let callCount = 0;
|
|
242
|
+
globalThis.fetch = async (_url, _init) => {
|
|
243
|
+
callCount++;
|
|
244
|
+
if (callCount === 1) {
|
|
245
|
+
// Salesforce token endpoint
|
|
246
|
+
return { ok: true, status: 200, json: async () => ({ access_token: 'tok' }), text: async () => '' };
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, status: 200, json: async () => ({ id: 'case001' }), text: async () => '' };
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
const result = await salesforce.createCase(SF_CREDS, { subject: 'Test', description: 'Desc' });
|
|
252
|
+
strict_1.default.equal(result.id, 'case001');
|
|
253
|
+
strict_1.default.ok(result.url.includes('case001'));
|
|
254
|
+
strict_1.default.equal(callCount, 2, 'should make OAuth token call then case creation call');
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
restoreFetch(orig);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// =============================================================================
|
|
261
|
+
// ServiceNow
|
|
262
|
+
// =============================================================================
|
|
263
|
+
const servicenow = __importStar(require("./servicenow.js"));
|
|
264
|
+
const SN_CREDS = {
|
|
265
|
+
instanceUrl: 'https://example.com',
|
|
266
|
+
username: 'admin',
|
|
267
|
+
password: 'pass123',
|
|
268
|
+
};
|
|
269
|
+
(0, node_test_1.test)('servicenow.createIncident: SSRF guard fires for private instanceUrl', async () => {
|
|
270
|
+
for (const privateUrl of PRIVATE_URLS) {
|
|
271
|
+
const creds = { ...SN_CREDS, instanceUrl: privateUrl };
|
|
272
|
+
await strict_1.default.rejects(() => servicenow.createIncident(creds, { shortDescription: 'Test', description: 'Desc' }), /private or internal|HTTPS/i, `Expected SSRF rejection for ${privateUrl}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
(0, node_test_1.test)('servicenow.testConnection: returns ok:false when server returns 401', async () => {
|
|
276
|
+
const orig = globalThis.fetch;
|
|
277
|
+
mockFetch({ error: 'Unauthorized' }, 401);
|
|
278
|
+
try {
|
|
279
|
+
const result = await servicenow.testConnection(SN_CREDS);
|
|
280
|
+
strict_1.default.equal(result.ok, false);
|
|
281
|
+
strict_1.default.ok(result.error != null, 'error field should be set on failure');
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
restoreFetch(orig);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
(0, node_test_1.test)('servicenow.createIncident: happy path — POSTs to incident table with correct fields', async () => {
|
|
288
|
+
const orig = globalThis.fetch;
|
|
289
|
+
const stub = mockFetch({ result: { sys_id: 'sys001', number: 'INC0001' } });
|
|
290
|
+
try {
|
|
291
|
+
const result = await servicenow.createIncident(SN_CREDS, {
|
|
292
|
+
shortDescription: 'Login broken',
|
|
293
|
+
description: 'Full description',
|
|
294
|
+
urgency: 1,
|
|
295
|
+
});
|
|
296
|
+
strict_1.default.equal(result.sysId, 'sys001');
|
|
297
|
+
strict_1.default.equal(result.number, 'INC0001');
|
|
298
|
+
const [url, init] = stub.mock.calls[0].arguments;
|
|
299
|
+
strict_1.default.ok(url.includes('/api/now/table/incident'), 'must POST to incident table');
|
|
300
|
+
const body = JSON.parse(init.body);
|
|
301
|
+
strict_1.default.equal(body.urgency, '1');
|
|
302
|
+
strict_1.default.equal(body.short_description, 'Login broken');
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
restoreFetch(orig);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// =============================================================================
|
|
309
|
+
// Figma
|
|
310
|
+
// =============================================================================
|
|
311
|
+
const figma = __importStar(require("./figma.js"));
|
|
312
|
+
const FIGMA_CREDS = {
|
|
313
|
+
accessToken: 'figd_test_token',
|
|
314
|
+
fileKey: 'testFileKey123',
|
|
315
|
+
};
|
|
316
|
+
(0, node_test_1.test)('figma.testConnection: returns ok:false when token is rejected (403)', async () => {
|
|
317
|
+
const orig = globalThis.fetch;
|
|
318
|
+
mockFetch({ message: 'Forbidden' }, 403);
|
|
319
|
+
try {
|
|
320
|
+
const result = await figma.testConnection(FIGMA_CREDS);
|
|
321
|
+
strict_1.default.equal(result.ok, false);
|
|
322
|
+
strict_1.default.ok(result.error?.includes('403'));
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
restoreFetch(orig);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
(0, node_test_1.test)('figma.testConnection: uses X-Figma-Token header for PAT', async () => {
|
|
329
|
+
const orig = globalThis.fetch;
|
|
330
|
+
const stub = mockFetch({ handle: 'design_user', email: 'design@example.com' });
|
|
331
|
+
try {
|
|
332
|
+
const result = await figma.testConnection(FIGMA_CREDS);
|
|
333
|
+
strict_1.default.equal(result.ok, true);
|
|
334
|
+
strict_1.default.equal(result.name, 'design_user');
|
|
335
|
+
const [_url, init] = stub.mock.calls[0].arguments;
|
|
336
|
+
strict_1.default.equal(init.headers['X-Figma-Token'], 'figd_test_token', 'PAT must be sent as X-Figma-Token');
|
|
337
|
+
strict_1.default.equal(init.headers.Authorization, undefined, 'no Bearer header for PAT');
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
restoreFetch(orig);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
(0, node_test_1.test)('figma.testConnection: uses Bearer header for OAuth token', async () => {
|
|
344
|
+
const orig = globalThis.fetch;
|
|
345
|
+
const stub = mockFetch({ handle: 'oauth_user' });
|
|
346
|
+
try {
|
|
347
|
+
await figma.testConnection({ ...FIGMA_CREDS, oauthToken: 'oauth_token_abc' });
|
|
348
|
+
const [_url, init] = stub.mock.calls[0].arguments;
|
|
349
|
+
strict_1.default.equal(init.headers.Authorization, 'Bearer oauth_token_abc');
|
|
350
|
+
strict_1.default.equal(init.headers['X-Figma-Token'], undefined, 'no PAT header when oauthToken is set');
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
restoreFetch(orig);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
// =============================================================================
|
|
357
|
+
// PagerDuty
|
|
358
|
+
// =============================================================================
|
|
359
|
+
const pagerduty = __importStar(require("./pagerduty.js"));
|
|
360
|
+
const PD_CREDS = {
|
|
361
|
+
apiKey: 'pd_api_key_test',
|
|
362
|
+
serviceId: 'SVCABC',
|
|
363
|
+
};
|
|
364
|
+
(0, node_test_1.test)('pagerduty.createIncident: missing serviceId throws before network call', async () => {
|
|
365
|
+
const orig = globalThis.fetch;
|
|
366
|
+
const stub = mockFetch({});
|
|
367
|
+
try {
|
|
368
|
+
const creds = { apiKey: 'pd_api_key_test' };
|
|
369
|
+
await strict_1.default.rejects(() => pagerduty.createIncident(creds, { title: 'Alert', body: 'Details', severity: 'critical' }), /serviceId is required/i);
|
|
370
|
+
strict_1.default.equal(stub.mock.calls.length, 0, 'fetch should not be called when serviceId is missing');
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
restoreFetch(orig);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
(0, node_test_1.test)('pagerduty.testConnection: returns ok:false on 401', async () => {
|
|
377
|
+
const orig = globalThis.fetch;
|
|
378
|
+
mockFetch({ error: 'Invalid credentials' }, 401);
|
|
379
|
+
try {
|
|
380
|
+
const result = await pagerduty.testConnection(PD_CREDS);
|
|
381
|
+
strict_1.default.equal(result.ok, false);
|
|
382
|
+
strict_1.default.ok(result.error?.includes('401'));
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
restoreFetch(orig);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
(0, node_test_1.test)('pagerduty.createIncident: happy path — correct token header and incident shape', async () => {
|
|
389
|
+
const orig = globalThis.fetch;
|
|
390
|
+
const stub = mockFetch({ incident: { id: 'INC001', html_url: 'https://example.pagerduty.com/incidents/INC001' } });
|
|
391
|
+
try {
|
|
392
|
+
const result = await pagerduty.createIncident(PD_CREDS, {
|
|
393
|
+
title: 'Critical failure',
|
|
394
|
+
body: 'System down',
|
|
395
|
+
severity: 'critical',
|
|
396
|
+
});
|
|
397
|
+
strict_1.default.equal(result.id, 'INC001');
|
|
398
|
+
const [url, init] = stub.mock.calls[0].arguments;
|
|
399
|
+
strict_1.default.ok(url.includes('pagerduty.com/incidents'), 'must post to PD incidents endpoint');
|
|
400
|
+
strict_1.default.ok(init.headers.Authorization.startsWith('Token token='), 'PD uses Token auth scheme');
|
|
401
|
+
const body = JSON.parse(init.body);
|
|
402
|
+
strict_1.default.equal(body.incident.urgency, 'high', 'critical severity maps to high urgency');
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
restoreFetch(orig);
|
|
406
|
+
}
|
|
407
|
+
});
|
package/dist/figma.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma connector — reads design files and extracts screens + components.
|
|
3
|
+
* Access token stored encrypted per project.
|
|
4
|
+
*/
|
|
5
|
+
export interface FigmaCredentials {
|
|
6
|
+
accessToken: string;
|
|
7
|
+
fileKey?: string;
|
|
8
|
+
oauthToken?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function testConnection(creds: FigmaCredentials): Promise<{
|
|
11
|
+
ok: boolean;
|
|
12
|
+
name?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function getFile(creds: FigmaCredentials, fileKey?: string): Promise<any>;
|
|
16
|
+
export interface ListScreensOptions {
|
|
17
|
+
nodeTypes?: string[];
|
|
18
|
+
excludePrefixes?: string[];
|
|
19
|
+
}
|
|
20
|
+
/** Extract top-level frames (screens) from a Figma file.
|
|
21
|
+
* Only takes DIRECT children of each page — not recursively nested components.
|
|
22
|
+
* This gives you actual screens (what designers think of as a screen),
|
|
23
|
+
* not every sub-component and icon frame inside them (which can number in the hundreds).
|
|
24
|
+
*/
|
|
25
|
+
export declare function listScreens(creds: FigmaCredentials, fileKey?: string, options?: ListScreensOptions): Promise<{
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
type: string;
|
|
29
|
+
node: any;
|
|
30
|
+
}[]>;
|
|
31
|
+
export interface GetScreenImagesOptions {
|
|
32
|
+
imageScale?: number;
|
|
33
|
+
chunkSize?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Get image URLs for specific node IDs in a Figma file. */
|
|
36
|
+
export declare function getScreenImages(creds: FigmaCredentials, fileKey: string, nodeIds: string[], scale?: number, options?: GetScreenImagesOptions): Promise<Record<string, string>>;
|
|
37
|
+
export interface FigmaElement {
|
|
38
|
+
name: string;
|
|
39
|
+
type: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
isInteractive: boolean;
|
|
42
|
+
reactions?: any[];
|
|
43
|
+
destinationIds?: string[];
|
|
44
|
+
boundingBox?: {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
width: number;
|
|
48
|
+
height: number;
|
|
49
|
+
};
|
|
50
|
+
componentId?: string;
|
|
51
|
+
}
|
|
52
|
+
/** Extract interactive elements from a Figma frame node.
|
|
53
|
+
* Uses reactions/interactions as the primary interactivity signal.
|
|
54
|
+
* FRAME and GROUP are included — they cover buttons, cards, modals, navbars.
|
|
55
|
+
* Primitive types (RECTANGLE, TEXT, etc.) are only collected when they have explicit reactions.
|
|
56
|
+
*/
|
|
57
|
+
export declare function extractElements(node: any): FigmaElement[];
|
package/dist/figma.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Figma connector — reads design files and extracts screens + components.
|
|
4
|
+
* Access token stored encrypted per project.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.testConnection = testConnection;
|
|
8
|
+
exports.getFile = getFile;
|
|
9
|
+
exports.listScreens = listScreens;
|
|
10
|
+
exports.getScreenImages = getScreenImages;
|
|
11
|
+
exports.extractElements = extractElements;
|
|
12
|
+
function headers(creds) {
|
|
13
|
+
if (creds.oauthToken) {
|
|
14
|
+
return { Authorization: `Bearer ${creds.oauthToken}`, 'Content-Type': 'application/json' };
|
|
15
|
+
}
|
|
16
|
+
return { 'X-Figma-Token': creds.accessToken, 'Content-Type': 'application/json' };
|
|
17
|
+
}
|
|
18
|
+
async function testConnection(creds) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('https://api.figma.com/v1/me', { headers: headers(creds) });
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
return { ok: false, error: `Figma returned ${res.status}` };
|
|
23
|
+
const data = (await res.json());
|
|
24
|
+
return { ok: true, name: data.handle };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { ok: false, error: String(err) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function getFile(creds, fileKey) {
|
|
31
|
+
const key = fileKey ?? creds.fileKey;
|
|
32
|
+
if (!key)
|
|
33
|
+
throw new Error('Figma file key is required.');
|
|
34
|
+
const res = await fetch(`https://api.figma.com/v1/files/${key}`, { headers: headers(creds) });
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(`Figma error: ${res.status}`);
|
|
37
|
+
return res.json();
|
|
38
|
+
}
|
|
39
|
+
/** Extract top-level frames (screens) from a Figma file.
|
|
40
|
+
* Only takes DIRECT children of each page — not recursively nested components.
|
|
41
|
+
* This gives you actual screens (what designers think of as a screen),
|
|
42
|
+
* not every sub-component and icon frame inside them (which can number in the hundreds).
|
|
43
|
+
*/
|
|
44
|
+
async function listScreens(creds, fileKey, options = {}) {
|
|
45
|
+
const file = await getFile(creds, fileKey);
|
|
46
|
+
const screens = [];
|
|
47
|
+
const nodeTypes = options.nodeTypes ?? ['FRAME', 'COMPONENT', 'SECTION'];
|
|
48
|
+
const excludePrefixes = options.excludePrefixes ?? ['_', '.'];
|
|
49
|
+
(file.document?.children ?? []).forEach((page) => {
|
|
50
|
+
// Only direct children of pages = top-level frames = actual screens
|
|
51
|
+
(page.children ?? []).forEach((node) => {
|
|
52
|
+
if (nodeTypes.includes(node.type)) {
|
|
53
|
+
// Skip utility frames: those starting with _ or . (designer convention for hidden/utility)
|
|
54
|
+
if (!excludePrefixes.some((p) => node.name.startsWith(p))) {
|
|
55
|
+
screens.push({ id: node.id, name: node.name, type: node.type, node });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return screens;
|
|
61
|
+
}
|
|
62
|
+
/** Get image URLs for specific node IDs in a Figma file. */
|
|
63
|
+
async function getScreenImages(creds, fileKey, nodeIds, scale, options = {}) {
|
|
64
|
+
// Batch into chunks of 50 to avoid HTTP 414 (URI Too Long) on large files
|
|
65
|
+
const { getSystemConfig } = await import('@taodigital/core');
|
|
66
|
+
const figmaCfg = await getSystemConfig('connectors_figma');
|
|
67
|
+
const resolvedScale = options.imageScale ?? scale ?? parseInt(String(figmaCfg?.imageScale ?? '2'), 10);
|
|
68
|
+
const CHUNK = options.chunkSize ?? parseInt(String(figmaCfg?.chunkSize ?? '50'), 10);
|
|
69
|
+
const result = {};
|
|
70
|
+
for (let i = 0; i < nodeIds.length; i += CHUNK) {
|
|
71
|
+
const chunk = nodeIds.slice(i, i + CHUNK);
|
|
72
|
+
const ids = chunk.join(',');
|
|
73
|
+
const res = await fetch(`https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(ids)}&scale=${resolvedScale}&format=png`, { headers: headers(creds) });
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new Error(`Figma error: ${res.status}`);
|
|
76
|
+
const data = (await res.json());
|
|
77
|
+
Object.assign(result, data.images);
|
|
78
|
+
}
|
|
79
|
+
return result; // nodeId → image URL
|
|
80
|
+
}
|
|
81
|
+
/** Extract interactive elements from a Figma frame node.
|
|
82
|
+
* Uses reactions/interactions as the primary interactivity signal.
|
|
83
|
+
* FRAME and GROUP are included — they cover buttons, cards, modals, navbars.
|
|
84
|
+
* Primitive types (RECTANGLE, TEXT, etc.) are only collected when they have explicit reactions.
|
|
85
|
+
*/
|
|
86
|
+
function extractElements(node) {
|
|
87
|
+
const elements = [];
|
|
88
|
+
const CONTAINER_TYPES = new Set(['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'COMPONENT_SET']);
|
|
89
|
+
const PRIMITIVE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'TEXT', 'VECTOR', 'STAR', 'POLYGON', 'LINE']);
|
|
90
|
+
function destinationIds(reactions) {
|
|
91
|
+
const ids = [];
|
|
92
|
+
for (const r of reactions) {
|
|
93
|
+
const actions = r.actions ?? (r.action ? [r.action] : []);
|
|
94
|
+
for (const a of actions) {
|
|
95
|
+
if (a?.destinationId)
|
|
96
|
+
ids.push(a.destinationId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ids;
|
|
100
|
+
}
|
|
101
|
+
function walk(n) {
|
|
102
|
+
if (!n?.name || n.name.startsWith('_') || n.name.startsWith('.')) {
|
|
103
|
+
(n?.children ?? []).forEach(walk);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// REST API uses `interactions`; Plugin API uses `reactions` — support both.
|
|
107
|
+
const reactions = n.interactions ?? n.reactions ?? [];
|
|
108
|
+
const hasReactions = reactions.length > 0;
|
|
109
|
+
if (CONTAINER_TYPES.has(n.type)) {
|
|
110
|
+
elements.push({
|
|
111
|
+
name: n.name,
|
|
112
|
+
type: n.type,
|
|
113
|
+
description: n.description ?? undefined,
|
|
114
|
+
isInteractive: hasReactions,
|
|
115
|
+
...(hasReactions ? { reactions, destinationIds: destinationIds(reactions) } : {}),
|
|
116
|
+
boundingBox: n.absoluteBoundingBox ?? undefined,
|
|
117
|
+
componentId: n.componentId ?? undefined,
|
|
118
|
+
});
|
|
119
|
+
// Don't recurse into INSTANCE internals — the instance is the unit of interest.
|
|
120
|
+
if (n.type !== 'INSTANCE')
|
|
121
|
+
(n.children ?? []).forEach(walk);
|
|
122
|
+
}
|
|
123
|
+
else if (PRIMITIVE_TYPES.has(n.type) && hasReactions) {
|
|
124
|
+
// Only collect primitives that are explicitly interactive (e.g. clickable text links).
|
|
125
|
+
elements.push({
|
|
126
|
+
name: n.name,
|
|
127
|
+
type: n.type,
|
|
128
|
+
isInteractive: true,
|
|
129
|
+
reactions,
|
|
130
|
+
destinationIds: destinationIds(reactions),
|
|
131
|
+
boundingBox: n.absoluteBoundingBox ?? undefined,
|
|
132
|
+
});
|
|
133
|
+
(n.children ?? []).forEach(walk);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
(n.children ?? []).forEach(walk);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
walk(node);
|
|
140
|
+
return elements;
|
|
141
|
+
}
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub connector — credentials passed in, never read from env.
|
|
3
|
+
* Token is stored encrypted in Connector.config per project.
|
|
4
|
+
*/
|
|
5
|
+
export interface GitHubCredentials {
|
|
6
|
+
token: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
defaultBranch?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Test that the stored credentials actually work. */
|
|
11
|
+
export declare function testConnection(creds: GitHubCredentials): Promise<{
|
|
12
|
+
ok: boolean;
|
|
13
|
+
login?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function listRecentCommits(creds: GitHubCredentials, branch?: string): Promise<{
|
|
17
|
+
sha: any;
|
|
18
|
+
message: any;
|
|
19
|
+
author: any;
|
|
20
|
+
date: any;
|
|
21
|
+
}[]>;
|
|
22
|
+
export declare function createIssue(creds: GitHubCredentials, title: string, body: string, labels?: string[], opts?: {
|
|
23
|
+
defaultLabels?: string[];
|
|
24
|
+
}): Promise<{
|
|
25
|
+
number: any;
|
|
26
|
+
url: any;
|
|
27
|
+
title: any;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function listPullRequests(creds: GitHubCredentials, state?: 'open' | 'closed' | 'all'): Promise<{
|
|
30
|
+
number: any;
|
|
31
|
+
title: any;
|
|
32
|
+
state: any;
|
|
33
|
+
branch: any;
|
|
34
|
+
url: any;
|
|
35
|
+
}[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Push (create or update) a single file in a GitHub repository.
|
|
38
|
+
* Uses the GitHub Contents API (PUT /repos/:owner/:repo/contents/:path).
|
|
39
|
+
*/
|
|
40
|
+
export declare function pushFile(creds: GitHubCredentials, filePath: string, content: string, commitMessage: string, branch?: string): Promise<{
|
|
41
|
+
url: string;
|
|
42
|
+
sha: string;
|
|
43
|
+
}>;
|