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<string, typeof fetch>()
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<typeof fetch> {
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<string, unknown>
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,
}
}