@keystrokehq/sentry 0.0.16-integration-id-canonicalization.0 → 0.0.16
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/credential-sets/index.d.mts +1 -1
- package/dist/credential-sets/index.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/operations/index.d.mts +1 -1
- package/dist/operations/index.mjs +1 -1
- package/dist/{sentry-app.credential-set-4DLRUKF9.d.mts → sentry-app.credential-set-DxK7J7JO.d.mts} +0 -9
- package/dist/sentry.credential-set-CyS3KAfW.mjs +62 -0
- package/dist/{view-organization-notification-actions.operation-BI2aB_Mj.mjs → view-organization-notification-actions.operation-BXT4VBys.mjs} +474 -1
- package/dist/{view-organization-notification-actions.operation-C7pc8cl_.d.mts → view-organization-notification-actions.operation-DfkZimCZ.d.mts} +0 -1585
- package/package.json +1 -2
- package/dist/sentry.credential-set-2b3TPFqv.mjs +0 -696
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keystrokehq/sentry",
|
|
3
|
-
"version": "0.0.16
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
"vitest": "^4.0.18",
|
|
41
41
|
"@keystrokehq/core": "^0.0.12",
|
|
42
42
|
"@keystrokehq/test-utils": "0.0.0",
|
|
43
|
-
"@keystrokehq/credential-connection": "1.0.0",
|
|
44
43
|
"@keystrokehq/typescript-config": "0.0.0",
|
|
45
44
|
"@keystrokehq/integration-authoring": "0.0.9"
|
|
46
45
|
},
|
|
@@ -1,696 +0,0 @@
|
|
|
1
|
-
import { CredentialSet } from "@keystrokehq/core";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
//#region ../../packages/credential-connection/src/token.ts
|
|
5
|
-
/**
|
|
6
|
-
* Shared OAuth 2.0 token-response plumbing.
|
|
7
|
-
*
|
|
8
|
-
* `parseOAuthTokenResponse` handles the two common wire formats (JSON and
|
|
9
|
-
* form-urlencoded, the latter used by GitHub unless you explicitly ask for
|
|
10
|
-
* JSON). `normalizeOAuthTokens` pulls the standard fields out of the parsed
|
|
11
|
-
* body in both the flat form and Slack's nested `authed_user.access_token`
|
|
12
|
-
* form. These helpers are exposed so integration authors overriding
|
|
13
|
-
* `exchangeCode` / `refreshToken` do not have to re-implement them.
|
|
14
|
-
*/
|
|
15
|
-
var TokenExchangeError = class extends Error {
|
|
16
|
-
httpStatus;
|
|
17
|
-
constructor(httpStatus) {
|
|
18
|
-
super(`Token exchange failed: HTTP ${httpStatus}`);
|
|
19
|
-
this.name = "TokenExchangeError";
|
|
20
|
-
this.httpStatus = httpStatus;
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
async function parseOAuthTokenResponse(response) {
|
|
24
|
-
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
25
|
-
if (contentType.includes("application/json")) return await response.json();
|
|
26
|
-
const text = await response.text();
|
|
27
|
-
if (!text.trim()) return {};
|
|
28
|
-
if (contentType.includes("application/x-www-form-urlencoded") || text.includes("=")) {
|
|
29
|
-
const params = new URLSearchParams(text);
|
|
30
|
-
return Object.fromEntries(params.entries());
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`Unsupported OAuth token response content type: ${contentType || "unknown"}`);
|
|
33
|
-
}
|
|
34
|
-
function normalizeOAuthTokens(tokenData) {
|
|
35
|
-
const authedUser = tokenData.authed_user;
|
|
36
|
-
const rawAccessToken = tokenData.access_token ?? authedUser?.access_token;
|
|
37
|
-
const rawRefreshToken = tokenData.refresh_token;
|
|
38
|
-
const rawExpiresIn = tokenData.expires_in;
|
|
39
|
-
const rawInstanceUrl = tokenData.instance_url;
|
|
40
|
-
return {
|
|
41
|
-
accessToken: typeof rawAccessToken === "string" ? rawAccessToken : void 0,
|
|
42
|
-
refreshToken: typeof rawRefreshToken === "string" ? rawRefreshToken : void 0,
|
|
43
|
-
expiresIn: typeof rawExpiresIn === "number" ? rawExpiresIn : typeof rawExpiresIn === "string" ? Number(rawExpiresIn) || void 0 : void 0,
|
|
44
|
-
instanceUrl: typeof rawInstanceUrl === "string" ? rawInstanceUrl : void 0
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
//#endregion
|
|
49
|
-
//#region ../../packages/credential-connection/src/defaults.ts
|
|
50
|
-
function buildAuthUrl(ctx) {
|
|
51
|
-
const { oauthClient, redirectUri, state, connection } = ctx;
|
|
52
|
-
const scopes = ctx.requestedScopes ?? connection.scopes;
|
|
53
|
-
const url = new URL(connection.authUrl);
|
|
54
|
-
url.searchParams.set("client_id", oauthClient.clientId);
|
|
55
|
-
url.searchParams.set("scope", scopes.join(" "));
|
|
56
|
-
url.searchParams.set("redirect_uri", redirectUri);
|
|
57
|
-
url.searchParams.set("state", state);
|
|
58
|
-
url.searchParams.set("response_type", "code");
|
|
59
|
-
return url.toString();
|
|
60
|
-
}
|
|
61
|
-
async function exchangeToken(params) {
|
|
62
|
-
const response = await fetch(params.tokenUrl, {
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: {
|
|
65
|
-
Accept: "application/json",
|
|
66
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
67
|
-
},
|
|
68
|
-
body: params.body
|
|
69
|
-
});
|
|
70
|
-
if (!response.ok) throw new TokenExchangeError(response.status);
|
|
71
|
-
const raw = await parseOAuthTokenResponse(response);
|
|
72
|
-
const { accessToken, refreshToken, expiresIn, instanceUrl } = normalizeOAuthTokens(raw);
|
|
73
|
-
if (!accessToken) throw new Error("No access token in response");
|
|
74
|
-
return {
|
|
75
|
-
accessToken,
|
|
76
|
-
refreshToken,
|
|
77
|
-
expiresIn,
|
|
78
|
-
instanceUrl,
|
|
79
|
-
raw
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
async function exchangeCode(ctx) {
|
|
83
|
-
const { oauthClient, code, redirectUri, connection } = ctx;
|
|
84
|
-
if (!code) throw new Error("Missing authorization code");
|
|
85
|
-
return exchangeToken({
|
|
86
|
-
tokenUrl: connection.tokenUrl,
|
|
87
|
-
body: new URLSearchParams({
|
|
88
|
-
code,
|
|
89
|
-
client_id: oauthClient.clientId,
|
|
90
|
-
client_secret: oauthClient.clientSecret,
|
|
91
|
-
redirect_uri: redirectUri,
|
|
92
|
-
grant_type: "authorization_code"
|
|
93
|
-
})
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
async function refreshToken(ctx) {
|
|
97
|
-
const { oauthClient, refreshToken, connection } = ctx;
|
|
98
|
-
return exchangeToken({
|
|
99
|
-
tokenUrl: connection.tokenUrl,
|
|
100
|
-
body: new URLSearchParams({
|
|
101
|
-
grant_type: "refresh_token",
|
|
102
|
-
refresh_token: refreshToken,
|
|
103
|
-
client_id: oauthClient.clientId,
|
|
104
|
-
client_secret: oauthClient.clientSecret
|
|
105
|
-
})
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
const oauthDefaults = {
|
|
109
|
-
buildAuthUrl,
|
|
110
|
-
exchangeCode,
|
|
111
|
-
exchangeToken,
|
|
112
|
-
refreshToken
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
//#endregion
|
|
116
|
-
//#region src/utils/errors.ts
|
|
117
|
-
/**
|
|
118
|
-
* Thrown from `createSentryClient(...)` methods on every non-2xx response
|
|
119
|
-
* (after the retry-with-backoff loop gives up) and on any DSN-in-bearer-token
|
|
120
|
-
* misuse. Operation files rely on the `kind` field to choose retry vs
|
|
121
|
-
* bail-out behavior.
|
|
122
|
-
*/
|
|
123
|
-
var SentryApiError = class extends Error {
|
|
124
|
-
kind;
|
|
125
|
-
status;
|
|
126
|
-
providerCode;
|
|
127
|
-
requestId;
|
|
128
|
-
retryable;
|
|
129
|
-
retryAfterSec;
|
|
130
|
-
responseBody;
|
|
131
|
-
constructor(init) {
|
|
132
|
-
super(init.message, init.cause !== void 0 ? { cause: init.cause } : void 0);
|
|
133
|
-
this.name = "SentryApiError";
|
|
134
|
-
this.kind = init.kind;
|
|
135
|
-
this.status = init.status;
|
|
136
|
-
this.providerCode = init.providerCode;
|
|
137
|
-
this.requestId = init.requestId;
|
|
138
|
-
this.retryable = init.retryable;
|
|
139
|
-
this.retryAfterSec = init.retryAfterSec;
|
|
140
|
-
this.responseBody = init.responseBody;
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
/**
|
|
144
|
-
* Classifies an HTTP response into a typed `SentryApiError`. The raw body may
|
|
145
|
-
* already have been parsed as JSON or left as text; callers pass whichever
|
|
146
|
-
* they produced.
|
|
147
|
-
*/
|
|
148
|
-
function classifyHttpResponse(response, body) {
|
|
149
|
-
const requestId = response.headers.get("x-request-id");
|
|
150
|
-
const retryAfterSec = parseRetryAfter(response.headers.get("retry-after"));
|
|
151
|
-
const providerCode = extractProviderCode(body);
|
|
152
|
-
const detail = extractDetail(body, response.status);
|
|
153
|
-
if (response.status === 401 || response.status === 403) return new SentryApiError({
|
|
154
|
-
kind: "auth",
|
|
155
|
-
message: `Sentry API authentication failed (${response.status}): ${detail}`,
|
|
156
|
-
status: response.status,
|
|
157
|
-
providerCode,
|
|
158
|
-
requestId,
|
|
159
|
-
retryable: false,
|
|
160
|
-
retryAfterSec,
|
|
161
|
-
responseBody: body
|
|
162
|
-
});
|
|
163
|
-
if (response.status === 404) return new SentryApiError({
|
|
164
|
-
kind: "not_found",
|
|
165
|
-
message: `Sentry API resource not found (404): ${detail}`,
|
|
166
|
-
status: 404,
|
|
167
|
-
providerCode,
|
|
168
|
-
requestId,
|
|
169
|
-
retryable: false,
|
|
170
|
-
retryAfterSec,
|
|
171
|
-
responseBody: body
|
|
172
|
-
});
|
|
173
|
-
if (response.status === 429) return new SentryApiError({
|
|
174
|
-
kind: "rate_limit",
|
|
175
|
-
message: `Sentry API rate limited (429): ${detail}`,
|
|
176
|
-
status: 429,
|
|
177
|
-
providerCode,
|
|
178
|
-
requestId,
|
|
179
|
-
retryable: true,
|
|
180
|
-
retryAfterSec,
|
|
181
|
-
responseBody: body
|
|
182
|
-
});
|
|
183
|
-
if (response.status === 400 || response.status === 422) return new SentryApiError({
|
|
184
|
-
kind: "validation",
|
|
185
|
-
message: `Sentry API validation failed (${response.status}): ${detail}`,
|
|
186
|
-
status: response.status,
|
|
187
|
-
providerCode,
|
|
188
|
-
requestId,
|
|
189
|
-
retryable: false,
|
|
190
|
-
retryAfterSec,
|
|
191
|
-
responseBody: body
|
|
192
|
-
});
|
|
193
|
-
if (response.status >= 500) return new SentryApiError({
|
|
194
|
-
kind: "http",
|
|
195
|
-
message: `Sentry API server error (${response.status}): ${detail}`,
|
|
196
|
-
status: response.status,
|
|
197
|
-
providerCode,
|
|
198
|
-
requestId,
|
|
199
|
-
retryable: true,
|
|
200
|
-
retryAfterSec,
|
|
201
|
-
responseBody: body
|
|
202
|
-
});
|
|
203
|
-
return new SentryApiError({
|
|
204
|
-
kind: "http",
|
|
205
|
-
message: `Sentry API request failed (${response.status}): ${detail}`,
|
|
206
|
-
status: response.status,
|
|
207
|
-
providerCode,
|
|
208
|
-
requestId,
|
|
209
|
-
retryable: false,
|
|
210
|
-
retryAfterSec,
|
|
211
|
-
responseBody: body
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
function parseRetryAfter(header) {
|
|
215
|
-
if (header == null) return null;
|
|
216
|
-
const asNumber = Number(header);
|
|
217
|
-
if (Number.isFinite(asNumber) && asNumber >= 0) return asNumber;
|
|
218
|
-
const asDate = Date.parse(header);
|
|
219
|
-
if (Number.isFinite(asDate)) {
|
|
220
|
-
const diff = Math.ceil((asDate - Date.now()) / 1e3);
|
|
221
|
-
return diff >= 0 ? diff : 0;
|
|
222
|
-
}
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
function extractDetail(body, status) {
|
|
226
|
-
if (body == null) return `HTTP ${status}`;
|
|
227
|
-
if (typeof body === "string") return body || `HTTP ${status}`;
|
|
228
|
-
if (typeof body === "object") {
|
|
229
|
-
const obj = body;
|
|
230
|
-
if (typeof obj.detail === "string") return obj.detail;
|
|
231
|
-
if (typeof obj.error === "string") return obj.error;
|
|
232
|
-
if (typeof obj.message === "string") return obj.message;
|
|
233
|
-
}
|
|
234
|
-
return `HTTP ${status}`;
|
|
235
|
-
}
|
|
236
|
-
function extractProviderCode(body) {
|
|
237
|
-
if (body == null || typeof body !== "object") return null;
|
|
238
|
-
const obj = body;
|
|
239
|
-
if (typeof obj.code === "string") return obj.code;
|
|
240
|
-
if (typeof obj.error_code === "string") return obj.error_code;
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
//#endregion
|
|
245
|
-
//#region src/utils/pagination.ts
|
|
246
|
-
function parseSentryLinkHeader(header) {
|
|
247
|
-
if (header == null || header.length === 0) return {
|
|
248
|
-
previous: null,
|
|
249
|
-
next: null
|
|
250
|
-
};
|
|
251
|
-
let previous = null;
|
|
252
|
-
let next = null;
|
|
253
|
-
for (const entry of splitLinkEntries(header)) {
|
|
254
|
-
const parsed = parseLinkEntry(entry);
|
|
255
|
-
if (!parsed) continue;
|
|
256
|
-
if (parsed.rel === "previous") previous = toPageLink(parsed.url);
|
|
257
|
-
else if (parsed.rel === "next") next = toPageLink(parsed.url);
|
|
258
|
-
}
|
|
259
|
-
return {
|
|
260
|
-
previous,
|
|
261
|
-
next
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Splits a single raw `Link` header value into its comma-separated entries.
|
|
266
|
-
* Naive splitting on `,` is unsafe because cursor strings can legally contain
|
|
267
|
-
* commas; we track angle-bracket depth.
|
|
268
|
-
*/
|
|
269
|
-
function splitLinkEntries(header) {
|
|
270
|
-
const entries = [];
|
|
271
|
-
let depth = 0;
|
|
272
|
-
let start = 0;
|
|
273
|
-
for (let i = 0; i < header.length; i += 1) {
|
|
274
|
-
const ch = header.charAt(i);
|
|
275
|
-
if (ch === "<") depth += 1;
|
|
276
|
-
else if (ch === ">") depth -= 1;
|
|
277
|
-
else if (ch === "," && depth === 0) {
|
|
278
|
-
entries.push(header.slice(start, i));
|
|
279
|
-
start = i + 1;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
entries.push(header.slice(start));
|
|
283
|
-
return entries.map((e) => e.trim()).filter(Boolean);
|
|
284
|
-
}
|
|
285
|
-
function parseLinkEntry(entry) {
|
|
286
|
-
const openBracket = entry.indexOf("<");
|
|
287
|
-
const closeBracket = entry.indexOf(">", openBracket);
|
|
288
|
-
if (openBracket === -1 || closeBracket === -1) return null;
|
|
289
|
-
const url = entry.slice(openBracket + 1, closeBracket);
|
|
290
|
-
const params = parseLinkParams(entry.slice(closeBracket + 1));
|
|
291
|
-
const rel = params.rel ?? "";
|
|
292
|
-
const results = params.results === "true";
|
|
293
|
-
if (!rel) return null;
|
|
294
|
-
return {
|
|
295
|
-
url,
|
|
296
|
-
rel,
|
|
297
|
-
results
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
function parseLinkParams(paramsSection) {
|
|
301
|
-
const params = {};
|
|
302
|
-
for (const part of paramsSection.split(";").map((s) => s.trim())) {
|
|
303
|
-
if (!part) continue;
|
|
304
|
-
const eq = part.indexOf("=");
|
|
305
|
-
if (eq === -1) continue;
|
|
306
|
-
const key = part.slice(0, eq).trim();
|
|
307
|
-
let value = part.slice(eq + 1).trim();
|
|
308
|
-
if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1);
|
|
309
|
-
if (key) params[key] = value;
|
|
310
|
-
}
|
|
311
|
-
return params;
|
|
312
|
-
}
|
|
313
|
-
function toPageLink(url) {
|
|
314
|
-
return {
|
|
315
|
-
url,
|
|
316
|
-
cursor: extractCursor(url),
|
|
317
|
-
results: extractResults(url)
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
function extractCursor(url) {
|
|
321
|
-
const queryIndex = url.indexOf("?");
|
|
322
|
-
if (queryIndex === -1) return null;
|
|
323
|
-
const query = url.slice(queryIndex + 1);
|
|
324
|
-
for (const pair of query.split("&")) {
|
|
325
|
-
const eq = pair.indexOf("=");
|
|
326
|
-
if (eq === -1) continue;
|
|
327
|
-
if (pair.slice(0, eq) === "cursor") return decodeURIComponent(pair.slice(eq + 1));
|
|
328
|
-
}
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
function extractResults(url) {
|
|
332
|
-
const queryIndex = url.indexOf("?");
|
|
333
|
-
if (queryIndex === -1) return false;
|
|
334
|
-
const query = url.slice(queryIndex + 1);
|
|
335
|
-
for (const pair of query.split("&")) {
|
|
336
|
-
const eq = pair.indexOf("=");
|
|
337
|
-
if (eq === -1) continue;
|
|
338
|
-
if (pair.slice(0, eq) === "results") return pair.slice(eq + 1) === "true";
|
|
339
|
-
}
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
/**
|
|
343
|
-
* Detects a Sentry DSN pasted where a bearer auth token was expected. DSNs
|
|
344
|
-
* look like `https://<publicKey>@o<org>.ingest.sentry.io/<projectId>` (or
|
|
345
|
-
* http(s) against a self-hosted host with an `@` before the host). We never
|
|
346
|
-
* want to send these to the management API.
|
|
347
|
-
*/
|
|
348
|
-
function looksLikeSentryDsn(value) {
|
|
349
|
-
if (!value.includes("://")) return false;
|
|
350
|
-
try {
|
|
351
|
-
const parsed = new URL(value);
|
|
352
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
353
|
-
return parsed.username.length > 0;
|
|
354
|
-
} catch {
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
//#endregion
|
|
360
|
-
//#region src/utils/client.ts
|
|
361
|
-
/**
|
|
362
|
-
* Typed fetch-based client for the Sentry management API.
|
|
363
|
-
*
|
|
364
|
-
* The factory accepts the public connection credentials
|
|
365
|
-
* (`SentryCredentials`) and returns a thin object with one method per HTTP
|
|
366
|
-
* verb, plus a `paginate` helper that walks Sentry's `Link`-header cursor
|
|
367
|
-
* chain and yields decoded results one batch at a time.
|
|
368
|
-
*
|
|
369
|
-
* Design notes:
|
|
370
|
-
* - All requests go through `request()` so retry, DSN rejection, and error
|
|
371
|
-
* normalization live in one place.
|
|
372
|
-
* - Retry policy: honours `Retry-After` on 429 and does exponential backoff
|
|
373
|
-
* with jitter on 5xx; all other 4xx responses throw immediately.
|
|
374
|
-
* - Base URL: `SENTRY_REGION_URL` (persisted at install time by the OAuth
|
|
375
|
-
* hook) wins; otherwise default `https://sentry.io`. Self-hosted deployments
|
|
376
|
-
* override the region URL.
|
|
377
|
-
* - The SDK path is `/api/0/` on whatever base URL the caller resolved.
|
|
378
|
-
*/
|
|
379
|
-
const SENTRY_DEFAULT_BASE_URL = "https://sentry.io";
|
|
380
|
-
const DEFAULT_MAX_RETRIES = 3;
|
|
381
|
-
const DEFAULT_BASE_DELAY_MS = 250;
|
|
382
|
-
const DEFAULT_MAX_DELAY_MS = 3e4;
|
|
383
|
-
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
384
|
-
function createSentryClient(credentials, options = {}) {
|
|
385
|
-
const token = credentials.SENTRY_ACCESS_TOKEN;
|
|
386
|
-
if (looksLikeSentryDsn(token)) throw new SentryApiError({
|
|
387
|
-
kind: "dsn_detected",
|
|
388
|
-
message: "A Sentry DSN was passed in place of a management-API bearer token. DSNs can only be used to send events; the management API requires an OAuth access token, auth token, or internal integration token.",
|
|
389
|
-
status: null,
|
|
390
|
-
providerCode: null,
|
|
391
|
-
requestId: null,
|
|
392
|
-
retryable: false,
|
|
393
|
-
retryAfterSec: null
|
|
394
|
-
});
|
|
395
|
-
const baseUrl = (credentials.SENTRY_REGION_URL ?? SENTRY_DEFAULT_BASE_URL).replace(/\/$/u, "");
|
|
396
|
-
const fetchImpl = options.fetch ?? fetch;
|
|
397
|
-
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
398
|
-
const retryBaseDelayMs = options.retryBaseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
399
|
-
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
400
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
401
|
-
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
402
|
-
async function request(init) {
|
|
403
|
-
const url = buildUrl(baseUrl, init.path, init.query);
|
|
404
|
-
const headers = {
|
|
405
|
-
Authorization: `Bearer ${token}`,
|
|
406
|
-
Accept: "application/json",
|
|
407
|
-
...init.headers ?? {}
|
|
408
|
-
};
|
|
409
|
-
let body;
|
|
410
|
-
if (init.rawBody !== void 0) body = init.rawBody;
|
|
411
|
-
else if (init.body !== void 0 && init.method !== "GET" && init.method !== "DELETE") if (init.contentType === "application/x-www-form-urlencoded") {
|
|
412
|
-
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
413
|
-
body = new URLSearchParams(init.body).toString();
|
|
414
|
-
} else {
|
|
415
|
-
headers["Content-Type"] = init.contentType ?? "application/json";
|
|
416
|
-
body = typeof init.body === "string" || init.body instanceof ArrayBuffer ? init.body : JSON.stringify(init.body);
|
|
417
|
-
}
|
|
418
|
-
let attempt = 0;
|
|
419
|
-
while (true) {
|
|
420
|
-
attempt += 1;
|
|
421
|
-
const controller = new AbortController();
|
|
422
|
-
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
423
|
-
let response;
|
|
424
|
-
try {
|
|
425
|
-
response = await fetchImpl(url, {
|
|
426
|
-
method: init.method,
|
|
427
|
-
headers,
|
|
428
|
-
body,
|
|
429
|
-
signal: controller.signal
|
|
430
|
-
});
|
|
431
|
-
} catch (cause) {
|
|
432
|
-
clearTimeout(timeoutHandle);
|
|
433
|
-
if (attempt > maxRetries) throw new SentryApiError({
|
|
434
|
-
kind: "network",
|
|
435
|
-
message: `Sentry API network error: ${extractCauseMessage(cause)}`,
|
|
436
|
-
status: null,
|
|
437
|
-
providerCode: null,
|
|
438
|
-
requestId: null,
|
|
439
|
-
retryable: true,
|
|
440
|
-
retryAfterSec: null,
|
|
441
|
-
cause
|
|
442
|
-
});
|
|
443
|
-
await sleep(computeBackoffDelay(attempt, retryBaseDelayMs, maxDelayMs));
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
clearTimeout(timeoutHandle);
|
|
447
|
-
if (response.ok) {
|
|
448
|
-
const data = await parseResponseBody(response, init.parseAs ?? "json");
|
|
449
|
-
const links = parseSentryLinkHeader(response.headers.get("link"));
|
|
450
|
-
return {
|
|
451
|
-
data,
|
|
452
|
-
status: response.status,
|
|
453
|
-
headers: response.headers,
|
|
454
|
-
nextCursor: links.next?.results ? links.next.cursor : null,
|
|
455
|
-
previousCursor: links.previous?.results ? links.previous.cursor : null
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
const errorBody = await parseErrorBody(response);
|
|
459
|
-
const error = classifyHttpResponse(response, errorBody);
|
|
460
|
-
if (!error.retryable || attempt > maxRetries) throw error;
|
|
461
|
-
await sleep(error.retryAfterSec != null ? Math.min(error.retryAfterSec * 1e3, maxDelayMs) : computeBackoffDelay(attempt, retryBaseDelayMs, maxDelayMs));
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
async function get(path, query) {
|
|
465
|
-
return request({
|
|
466
|
-
method: "GET",
|
|
467
|
-
path,
|
|
468
|
-
query
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
async function post(path, bodyValue, query) {
|
|
472
|
-
return request({
|
|
473
|
-
method: "POST",
|
|
474
|
-
path,
|
|
475
|
-
body: bodyValue,
|
|
476
|
-
query
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
async function put(path, bodyValue, query) {
|
|
480
|
-
return request({
|
|
481
|
-
method: "PUT",
|
|
482
|
-
path,
|
|
483
|
-
body: bodyValue,
|
|
484
|
-
query
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
async function patch(path, bodyValue, query) {
|
|
488
|
-
return request({
|
|
489
|
-
method: "PATCH",
|
|
490
|
-
path,
|
|
491
|
-
body: bodyValue,
|
|
492
|
-
query
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
async function del(path, query) {
|
|
496
|
-
return request({
|
|
497
|
-
method: "DELETE",
|
|
498
|
-
path,
|
|
499
|
-
query,
|
|
500
|
-
parseAs: "none"
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
async function* paginate(path, query = {}) {
|
|
504
|
-
let cursor = null;
|
|
505
|
-
do {
|
|
506
|
-
const merged = { ...query };
|
|
507
|
-
if (cursor != null) merged.cursor = cursor;
|
|
508
|
-
const page = await get(path, merged);
|
|
509
|
-
yield page;
|
|
510
|
-
cursor = page.nextCursor;
|
|
511
|
-
} while (cursor !== null);
|
|
512
|
-
}
|
|
513
|
-
async function listAll(path, query) {
|
|
514
|
-
const acc = [];
|
|
515
|
-
for await (const page of paginate(path, query)) acc.push(...page.data);
|
|
516
|
-
return acc;
|
|
517
|
-
}
|
|
518
|
-
return {
|
|
519
|
-
get,
|
|
520
|
-
post,
|
|
521
|
-
put,
|
|
522
|
-
patch,
|
|
523
|
-
delete: del,
|
|
524
|
-
paginate,
|
|
525
|
-
listAll,
|
|
526
|
-
request,
|
|
527
|
-
resolveBaseUrl: () => baseUrl
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
function buildUrl(baseUrl, path, query) {
|
|
531
|
-
const base = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
532
|
-
if (!query) return base;
|
|
533
|
-
const params = new URLSearchParams();
|
|
534
|
-
for (const [key, value] of Object.entries(query)) {
|
|
535
|
-
if (value == null) continue;
|
|
536
|
-
if (Array.isArray(value)) for (const v of value) {
|
|
537
|
-
if (v == null) continue;
|
|
538
|
-
params.append(key, String(v));
|
|
539
|
-
}
|
|
540
|
-
else if (typeof value === "boolean") params.append(key, value ? "true" : "false");
|
|
541
|
-
else params.append(key, String(value));
|
|
542
|
-
}
|
|
543
|
-
const qs = params.toString();
|
|
544
|
-
return qs ? `${base}?${qs}` : base;
|
|
545
|
-
}
|
|
546
|
-
async function parseResponseBody(response, mode) {
|
|
547
|
-
if (mode === "none") return void 0;
|
|
548
|
-
if (mode === "text") return await response.text();
|
|
549
|
-
if (response.status === 204) return void 0;
|
|
550
|
-
const raw = await response.text();
|
|
551
|
-
if (raw.length === 0) return void 0;
|
|
552
|
-
try {
|
|
553
|
-
return JSON.parse(raw);
|
|
554
|
-
} catch (cause) {
|
|
555
|
-
throw new SentryApiError({
|
|
556
|
-
kind: "http",
|
|
557
|
-
message: `Sentry API returned non-JSON body (status ${response.status})`,
|
|
558
|
-
status: response.status,
|
|
559
|
-
providerCode: null,
|
|
560
|
-
requestId: response.headers.get("x-request-id"),
|
|
561
|
-
retryable: false,
|
|
562
|
-
retryAfterSec: null,
|
|
563
|
-
responseBody: raw,
|
|
564
|
-
cause
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
async function parseErrorBody(response) {
|
|
569
|
-
const raw = await response.text();
|
|
570
|
-
if (raw.length === 0) return null;
|
|
571
|
-
try {
|
|
572
|
-
return JSON.parse(raw);
|
|
573
|
-
} catch {
|
|
574
|
-
return raw;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
function computeBackoffDelay(attempt, baseMs, maxMs) {
|
|
578
|
-
const exponential = baseMs * 2 ** Math.max(0, attempt - 1);
|
|
579
|
-
const jitter = Math.random() * baseMs;
|
|
580
|
-
return Math.min(exponential + jitter, maxMs);
|
|
581
|
-
}
|
|
582
|
-
function extractCauseMessage(cause) {
|
|
583
|
-
if (cause instanceof Error) return cause.message;
|
|
584
|
-
if (typeof cause === "string") return cause;
|
|
585
|
-
return "unknown network error";
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
//#endregion
|
|
589
|
-
//#region src/utils/oauth-connection.ts
|
|
590
|
-
const SENTRY_OAUTH_SCOPES = [
|
|
591
|
-
"org:read",
|
|
592
|
-
"project:read",
|
|
593
|
-
"project:write",
|
|
594
|
-
"project:admin",
|
|
595
|
-
"team:read",
|
|
596
|
-
"team:write",
|
|
597
|
-
"team:admin",
|
|
598
|
-
"member:read",
|
|
599
|
-
"member:write",
|
|
600
|
-
"event:read",
|
|
601
|
-
"event:admin",
|
|
602
|
-
"alerts:read",
|
|
603
|
-
"alerts:write",
|
|
604
|
-
"release:admin"
|
|
605
|
-
];
|
|
606
|
-
const sentryOAuthConnection = {
|
|
607
|
-
kind: "oauth",
|
|
608
|
-
tokenType: "refreshable",
|
|
609
|
-
authUrl: "https://sentry.io/oauth/authorize/",
|
|
610
|
-
tokenUrl: "https://sentry.io/oauth/token/",
|
|
611
|
-
revokeUrl: null,
|
|
612
|
-
scopes: [...SENTRY_OAUTH_SCOPES],
|
|
613
|
-
vault: { accessToken: "SENTRY_ACCESS_TOKEN" },
|
|
614
|
-
async exchangeCode(ctx) {
|
|
615
|
-
const result = await oauthDefaults.exchangeCode(ctx);
|
|
616
|
-
const orgResponse = await fetch(`${SENTRY_DEFAULT_BASE_URL}/api/0/organizations/`, {
|
|
617
|
-
method: "GET",
|
|
618
|
-
headers: {
|
|
619
|
-
Authorization: `Bearer ${result.accessToken}`,
|
|
620
|
-
Accept: "application/json"
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
let firstOrg = null;
|
|
624
|
-
if (orgResponse.ok) {
|
|
625
|
-
const orgs = await orgResponse.json();
|
|
626
|
-
if (Array.isArray(orgs) && orgs.length > 0 && isOrganizationProbe(orgs[0])) firstOrg = orgs[0];
|
|
627
|
-
}
|
|
628
|
-
result.raw._sentryOrganization = firstOrg;
|
|
629
|
-
result.raw._sentryUser = pickUser(result.raw);
|
|
630
|
-
result.raw._sentryScopes = typeof result.raw.scope === "string" ? result.raw.scope : void 0;
|
|
631
|
-
return result;
|
|
632
|
-
},
|
|
633
|
-
extractInstallationInfo({ tokenResult }) {
|
|
634
|
-
const org = tokenResult.raw._sentryOrganization;
|
|
635
|
-
const user = tokenResult.raw._sentryUser;
|
|
636
|
-
const metadata = {};
|
|
637
|
-
if (org?.name) metadata.organizationName = org.name;
|
|
638
|
-
if (org?.slug) metadata.organizationSlug = org.slug;
|
|
639
|
-
const regionUrl = org?.links?.regionUrl;
|
|
640
|
-
if (regionUrl) metadata.regionUrl = regionUrl;
|
|
641
|
-
if (user?.name) metadata.userName = user.name;
|
|
642
|
-
if (user?.email) metadata.userEmail = user.email;
|
|
643
|
-
return {
|
|
644
|
-
externalInstallationId: org?.slug,
|
|
645
|
-
externalWorkspaceId: org?.id,
|
|
646
|
-
externalBotUserId: user?.id,
|
|
647
|
-
metadata
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
function isOrganizationProbe(value) {
|
|
652
|
-
return typeof value === "object" && value !== null;
|
|
653
|
-
}
|
|
654
|
-
function pickUser(raw) {
|
|
655
|
-
const user = raw.user;
|
|
656
|
-
if (user == null || typeof user !== "object") return null;
|
|
657
|
-
const u = user;
|
|
658
|
-
return {
|
|
659
|
-
id: typeof u.id === "string" ? u.id : void 0,
|
|
660
|
-
name: typeof u.name === "string" ? u.name : void 0,
|
|
661
|
-
email: typeof u.email === "string" ? u.email : void 0
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
//#endregion
|
|
666
|
-
//#region src/credential-sets/sentry.credential-set.ts
|
|
667
|
-
const sentryAuthSchema = z.object({
|
|
668
|
-
SENTRY_ACCESS_TOKEN: z.string().min(1),
|
|
669
|
-
SENTRY_REGION_URL: z.string().url().optional(),
|
|
670
|
-
SENTRY_ORG_SLUG: z.string().min(1).optional()
|
|
671
|
-
});
|
|
672
|
-
/**
|
|
673
|
-
* User-facing Sentry credentials.
|
|
674
|
-
*
|
|
675
|
-
* Supports OAuth-installed integrations (Internal/Public Integrations) and
|
|
676
|
-
* manual personal/organization auth tokens via the same vault shape:
|
|
677
|
-
* `SENTRY_ACCESS_TOKEN` holds the bearer token in both cases.
|
|
678
|
-
*/
|
|
679
|
-
const sentryCredentialSet = new CredentialSet({
|
|
680
|
-
id: "sentry",
|
|
681
|
-
name: "Sentry",
|
|
682
|
-
description: "Sentry — issue lifecycle, release tracking, alerts, monitors, replays, feedback, and integration-platform webhook triggers",
|
|
683
|
-
auth: sentryAuthSchema,
|
|
684
|
-
proxy: { hosts: [
|
|
685
|
-
"sentry.io",
|
|
686
|
-
"us.sentry.io",
|
|
687
|
-
"de.sentry.io"
|
|
688
|
-
] },
|
|
689
|
-
connections: [{
|
|
690
|
-
id: "oauth",
|
|
691
|
-
...sentryOAuthConnection
|
|
692
|
-
}]
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
//#endregion
|
|
696
|
-
export { createSentryClient as n, sentryCredentialSet as t };
|