Skip to content

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

Package listing

@kody/jira

src/setup.js

150 lines · 5.5 KB · JavaScript
export const DEFAULT_ACCOUNT = 'jira'
export const EMAIL_SECRET = 'jiraEmail'
export const TOKEN_SECRET = 'jiraApiToken'
export const OAUTH_HOSTS = ['auth.atlassian.com', 'api.atlassian.com']
export const REDIRECT_URI = 'https://kody.codes/connect/oauth'
export const OAUTH_SCOPES = ['offline_access', 'read:jira-user', 'read:jira-work', 'write:jira-work']
export const AUTHORIZE_URL = 'https://auth.atlassian.com/authorize'
export const TOKEN_URL = 'https://auth.atlassian.com/oauth/token'
export const API_BASE_URL = 'https://api.atlassian.com'
export const DEVELOPER_CONSOLE_URL = 'https://developer.atlassian.com/console/myapps/'
export const API_TOKEN_DASHBOARD_URL =
	'https://id.atlassian.com/manage-profile/security/api-tokens'

const ACCOUNT_PATTERN = /^jira(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/

export function assertSafeSecretName(name, fieldName = 'secretName') {
	if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
		throw new Error(
			`Jira ${fieldName} must be a safe secret identifier (letters, numbers, dot, underscore, hyphen).`,
		)
	}
	return name
}

export function accountSuffix(account) {
	if (account === DEFAULT_ACCOUNT) return ''
	return account.slice('jira'.length)
}

export function resolveAccount(input = {}) {
	const raw = input.account ?? input.integrationName ?? DEFAULT_ACCOUNT
	if (typeof raw !== 'string' || raw.trim() === '') {
		throw new Error('Jira account must be "jira" or "jira-<purpose>".')
	}
	const account = raw.trim().toLowerCase()
	if (!ACCOUNT_PATTERN.test(account)) {
		throw new Error(
			`Jira account "${raw}" is invalid. Use "jira" or a name like "jira-work". Do not pass personal project keys.`,
		)
	}
	return account
}

export function secretNamesForAccount(account) {
	const suffix = accountSuffix(account)
	return {
		emailSecret: `${EMAIL_SECRET}${suffix}`,
		tokenSecret: `${TOKEN_SECRET}${suffix}`,
	}
}

export function normalizeSite(site) {
	if (typeof site !== 'string' || site.trim() === '') return null
	const trimmed = site.trim()
	const withoutProtocol = trimmed.replace(/^https?:\/\//i, '')
	const host = withoutProtocol.split('/')[0]?.toLowerCase()
	if (!host) return null
	if (host.endsWith('.atlassian.net')) {
		const handle = host.slice(0, -'.atlassian.net'.length)
		if (!/^[a-z0-9][a-z0-9-]*$/.test(handle)) {
			throw new Error(`Jira site host "${host}" is not a valid Atlassian Cloud site.`)
		}
		return handle
	}
	if (/^[a-z0-9][a-z0-9-]*$/.test(host)) return host
	throw new Error(
		`Jira site must be an Atlassian Cloud handle or {site}.atlassian.net. Received "${site}".`,
	)
}

export function siteHost(site) {
	const handle = normalizeSite(site)
	if (!handle) {
		throw new Error('Jira API-token lane requires site (for example "acme" for acme.atlassian.net).')
	}
	return `${handle}.atlassian.net`
}

export function oauthConnectUrl(account = DEFAULT_ACCOUNT) {
	const params = new URLSearchParams({
		provider: account,
		authorizeUrl: AUTHORIZE_URL,
		tokenUrl: TOKEN_URL,
		apiBaseUrl: API_BASE_URL,
		flow: 'confidential',
		scopes: OAUTH_SCOPES.join(' '),
		allowedHosts: OAUTH_HOSTS.join(','),
		dashboardUrl: DEVELOPER_CONSOLE_URL,
		extraAuthorizeParams: JSON.stringify({
			audience: 'api.atlassian.com',
			prompt: 'consent',
		}),
		providerSetupInstructions:
			'Create an OAuth 2.0 (3LO) app at https://developer.atlassian.com/console/myapps/. Enable Authorization → OAuth 2.0 (3LO). Register redirect URI exactly https://kody.codes/connect/oauth. Under Permissions, add the Jira API scopes read:jira-user, read:jira-work, and write:jira-work. Paste the client id and client secret into this Kody form. Do not paste them into chat.',
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function emailSecretUrl(account = DEFAULT_ACCOUNT, site = null) {
	const { emailSecret } = secretNamesForAccount(account)
	const params = new URLSearchParams({
		name: emailSecret,
		description: 'Atlassian account email used with a Jira Cloud API token (Basic auth username).',
		scope: 'user',
	})
	if (site) params.set('allowedHosts', siteHost(site))
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function tokenSecretUrl(account = DEFAULT_ACCOUNT, site = null) {
	const { tokenSecret } = secretNamesForAccount(account)
	const params = new URLSearchParams({
		name: tokenSecret,
		description: 'Atlassian API token for Jira Cloud REST (Basic auth password).',
		scope: 'user',
	})
	if (site) params.set('allowedHosts', siteHost(site))
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function setupUrls(input = {}) {
	const account = (() => {
		try {
			return resolveAccount(input)
		} catch {
			return DEFAULT_ACCOUNT
		}
	})()
	const site = typeof input.site === 'string' ? normalizeSite(input.site) : null
	const secrets = secretNamesForAccount(account)
	return {
		account,
		emailSecret: secrets.emailSecret,
		tokenSecret: secrets.tokenSecret,
		oauthUrl: oauthConnectUrl(account),
		emailSecretUrl: emailSecretUrl(account, site),
		tokenSecretUrl: tokenSecretUrl(account, site),
		apiTokenDashboardUrl: API_TOKEN_DASHBOARD_URL,
		oauthDashboardUrl: DEVELOPER_CONSOLE_URL,
		redirectUri: REDIRECT_URI,
		oauthHosts: [...OAUTH_HOSTS],
		tokenHost: site ? siteHost(site) : '{site}.atlassian.net',
		scopes: [...OAUTH_SCOPES],
	}
}

export function authSetupMessage(prefix = 'Jira credentials are missing or were rejected.') {
	const setup = setupUrls()
	return `${prefix} Connect OAuth at ${setup.oauthUrl} or save ${setup.emailSecret} and ${setup.tokenSecret} at ${setup.emailSecretUrl} and ${setup.tokenSecretUrl}. Approve host api.atlassian.com (OAuth) or {site}.atlassian.net (API token).`
}