import { createAuthenticatedFetch } from 'kody:runtime' import { inferRequiredScope } from './scopes.ts' export const DEFAULT_SECRET_NAME = 'sentryAuthToken' export const DEFAULT_INTEGRATION = 'sentry' const SECRET_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/ const INTEGRATION_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/ export type SentryAuthSelection = { secretName?: string integration?: string } const authenticatedFetchCache = new Map() export function secretSetupUrl(secretName = DEFAULT_SECRET_NAME): string { const params = new URLSearchParams({ name: secretName, description: 'Sentry auth token for organizations, projects, issues, and events', allowedHosts: 'sentry.io,us.sentry.io,de.sentry.io', scope: 'user', }) return 'https://kody.codes/account/secrets/new?' + params.toString() } export function oauthConnectUrl(provider = DEFAULT_INTEGRATION): string { const params = new URLSearchParams({ provider, authorizeUrl: 'https://sentry.io/oauth/authorize/', tokenUrl: 'https://sentry.io/oauth/token/', flow: 'confidential', pkce: 'true', scopes: 'org:read project:read event:read', allowedHosts: 'sentry.io,us.sentry.io,de.sentry.io', apiBaseUrl: 'https://sentry.io/api/0/', dashboardUrl: 'https://sentry.io/settings/account/api/applications/', }) return 'https://kody.codes/connect/oauth?' + params.toString() } export function resolveSecretName(auth: SentryAuthSelection = {}): string { const name = (auth.secretName ?? DEFAULT_SECRET_NAME).trim() if (!SECRET_NAME_PATTERN.test(name)) { throw new Error('secretName must be a Kody secret name such as sentryAuthToken or sentryAuthTokenWork.') } return name } export function resolveIntegrationName(auth: SentryAuthSelection = {}): string | undefined { const raw = typeof auth.integration === 'string' ? auth.integration.trim().toLowerCase() : '' if (!raw) return undefined if (!INTEGRATION_NAME_PATTERN.test(raw)) { throw new Error("integration must be a saved Kody OAuth integration name such as 'sentry' or 'sentry-work'.") } return raw } export function authorizationHeader(auth: SentryAuthSelection = {}): string { return 'Bearer {{secret:' + resolveSecretName(auth) + '|scope=user}}' } export async function getSentryFetch(auth: SentryAuthSelection = {}): Promise { const integration = resolveIntegrationName(auth) if (!integration) return fetch const cached = authenticatedFetchCache.get(integration) if (cached) return cached const authed = await createAuthenticatedFetch(integration) authenticatedFetchCache.set(integration, authed) return authed } export function nextSetupStep( auth: SentryAuthSelection, kind: 'missing-token' | 'missing-scope', scope?: string, ): string { const integration = resolveIntegrationName(auth) if (integration) { if (kind === 'missing-scope' && scope) { return ( 'Reconnect the ' + integration + ' OAuth integration with the ' + scope + ' scope: ' + oauthConnectUrl(integration) ) } return 'Reconnect the ' + integration + ' OAuth integration: ' + oauthConnectUrl(integration) } const secretName = resolveSecretName(auth) if (kind === 'missing-scope' && scope) { return ( 'Create a new Sentry auth token that includes ' + scope + ', then update secret ' + secretName + ' at ' + secretSetupUrl(secretName) ) } return 'Save a Sentry auth token as secret ' + secretName + ' at ' + secretSetupUrl(secretName) } function detailMessage(details: unknown): string | null { if (!details || typeof details !== 'object') { return typeof details === 'string' && details.length > 0 ? details : null } const record = details as Record if (typeof record.detail === 'string' && record.detail.length > 0) return record.detail if (typeof record.message === 'string' && record.message.length > 0) return record.message return null } export function sentryErrorMessage( auth: SentryAuthSelection, response: { status: number; statusText: string }, method: string, path: string, details: unknown, ): { message: string; missingScope: string | null } { const detail = detailMessage(details) if (response.status === 401) { return { message: 'Sentry authentication failed (401). The token is missing, invalid, or expired. ' + nextSetupStep(auth, 'missing-token'), missingScope: null, } } if (response.status === 403) { const missingScope = inferRequiredScope(method, path) return { message: 'Sentry request was forbidden (403)' + (detail ? ': ' + detail : '') + '. Missing likely scope: ' + missingScope + '. ' + nextSetupStep(auth, 'missing-scope', missingScope), missingScope, } } return { message: 'Sentry API request failed: ' + response.status + ' ' + response.statusText + (detail ? ' (' + detail + ')' : ''), missingScope: null, } }