Skip to content

src/token.ts

179 lines · 5.4 KB · TypeScript
Raw
import { kody, packageStorage } from 'kody:runtime'
import { originFetch } from './http.ts'
import { type OriginInstallation } from './types.ts'

const APP_ID_VALUE = 'originAppId'
const INSTALLATION_ID_VALUE = 'originInstallationId'
const PRIVATE_KEY_SECRET = 'originAppPrivateKey'
const APP_JWT_AUDIENCE = 'origin-apps'
const APP_JWT_LIFETIME_SECONDS = 300

export async function readNamedSetting(name: string): Promise<string> {
	const storage = packageStorage()
	const stored = await storage.get(name)
	if (typeof stored === 'string' && stored.trim()) return stored.trim()
	return ''
}

export async function writeNamedSetting(name: string, value: string) {
	const trimmed = value.trim()
	if (!trimmed) throw new Error(`${name} is required.`)
	await packageStorage().set(name, trimmed)
	return trimmed
}

export async function migrateOriginIdsFromValues() {
	const copied: Array<string> = []
	const appId = await readNamedSetting(APP_ID_VALUE)
	if (appId) copied.push(APP_ID_VALUE)
	const installationId = await readNamedSetting(INSTALLATION_ID_VALUE)
	if (installationId) copied.push(INSTALLATION_ID_VALUE)
	return {
		copied,
		hasAppId: Boolean(appId),
		hasInstallationId: Boolean(installationId),
	}
}

export async function resolveOriginAppId(): Promise<string> {
	const appId = await readNamedSetting(APP_ID_VALUE)
	if (!appId) {
		throw new Error(
			'Save originAppId in this package\'s storage (the Origin App id, app_01…). See coding_guide_get({ guide: "provider_origin" }).',
		)
	}
	return appId
}

export async function signOriginAppJwt(): Promise<string> {
	const appId = await resolveOriginAppId()
	const now = Math.floor(Date.now() / 1000)
	const signed = await signOriginAppJwtWithHost({
		private_key_secret_name: PRIVATE_KEY_SECRET,
		algorithm: 'EdDSA',
		header: { kid: appId },
		claims: {
			iss: appId,
			aud: APP_JWT_AUDIENCE,
			iat: now,
			exp: now + APP_JWT_LIFETIME_SECONDS,
		},
	})
	return signed.jwt
}

export async function resolveOriginInstallationId(
	explicitId?: string,
): Promise<string> {
	const requested = explicitId?.trim()
	if (requested) return requested
	const saved = await readNamedSetting(INSTALLATION_ID_VALUE)
	if (saved) return saved
	const { items } = await listOriginInstallations()
	const first = items[0]
	if (!first) {
		throw new Error(
			'No Origin App installations found. Install the app into a codebase and save originInstallationId in this package\'s storage. See coding_guide_get({ guide: "provider_origin" }).',
		)
	}
	return first.id
}

export async function listOriginInstallations(): Promise<{
	items: Array<OriginInstallation>
}> {
	const result = await originFetch({
		path: '/app/installations',
		bearerToken: await signOriginAppJwt(),
	})
	assertOk(result.status, result.body, 'list Origin installations')
	const payload = asRecord(result.body)
	const raw = Array.isArray(payload.installations) ? payload.installations : []
	return {
		items: raw.map((item) => slimInstallation(asRecord(item))),
	}
}

export async function mintInstallationAccessTokenInternal(input: {
	installationId?: string
	scopes?: Array<string>
} = {}): Promise<{
	token: string
	installationId: string
	expiresAt: string | null
}> {
	const installationId = await resolveOriginInstallationId(input.installationId)
	const result = await originFetch({
		path: `/app/installations/${encodeURIComponent(installationId)}/access_tokens`,
		method: 'POST',
		bearerToken: await signOriginAppJwt(),
		body:
			input.scopes && input.scopes.length > 0 ? { scopes: input.scopes } : {},
	})
	assertOk(result.status, result.body, 'mint Origin installation token')
	const payload = asRecord(result.body)
	const token = typeof payload.token === 'string' ? payload.token : ''
	if (!token) {
		throw new Error('Origin installation token response did not include token.')
	}
	return {
		token,
		installationId,
		expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : null,
	}
}

async function signOriginAppJwtWithHost(input: {
	private_key_secret_name: string
	algorithm: 'EdDSA'
	header: Record<string, unknown>
	claims: Record<string, unknown>
}): Promise<{ jwt: string }> {
	const sign = kody.secretJwtSign as (args: {
		private_key_secret_name: string
		algorithm: 'RS256' | 'EdDSA'
		header?: Record<string, unknown>
		claims: Record<string, unknown>
	}) => Promise<{ jwt: string }>
	return sign(input)
}

function slimInstallation(item: Record<string, unknown>): OriginInstallation {
	const target = isRecord(item.target) ? item.target : null
	const scopes = Array.isArray(item.scopes)
		? item.scopes.filter((scope): scope is string => typeof scope === 'string')
		: []
	return {
		id: stringField(item.id) ?? '',
		appId: stringField(item.appId),
		target: target
			? {
					slug: stringField(target.slug) ?? '',
					id: stringField(target.id) ?? '',
				}
			: null,
		repoSelectionMode: stringField(item.repoSelectionMode),
		scopes,
	}
}

function assertOk(status: number, body: unknown, action: string) {
	if (status >= 200 && status < 300) return
	const message =
		isRecord(body) && typeof body.message === 'string'
			? body.message
			: `HTTP ${String(status)}`
	throw new Error(`Failed to ${action}: ${message}`)
}

function asRecord(value: unknown): Record<string, unknown> {
	return isRecord(value) ? value : {}
}

function isRecord(value: unknown): value is Record<string, unknown> {
	return value !== null && typeof value === 'object' && !Array.isArray(value)
}

function stringField(value: unknown): string | null {
	return typeof value === 'string' && value.length > 0 ? value : null
}