Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/sentry

src/auth.ts

154 lines · 4.8 KB · TypeScript
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,
	}
}