Skip to content

src/registry.ts

574 lines · 17.8 KB · TypeScript
Raw
import { plainClone, storageGet } from './plain.ts'
import { kody, packageContext, packageStorage } from 'kody:runtime'

/** Saved package UUID from package_get_git_remote create. Fallback when packageContext is null. */
export const SAVED_PACKAGE_ID = '5bddf97f-09c7-41eb-8402-a7102432dd2e'

export const WEBHOOK_HOST = 'api2.cursor.sh'
export const BOT_PREFIX = 'bot:'
export const ALIAS_PREFIX = 'alias:'

const UUID_LIKE =
	/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

export type BotRecord = {
	id: string
	name: string
	aliases: string[]
	url: string
	secretName: string
	channelId?: string
	createdAt: string
	updatedAt: string
}

export type BotSummary = {
	id: string
	name: string
	aliases: string[]
	secretName: string
	pendingSecret: boolean
	needsHostApproval: boolean
	urlHost: string
	channelId?: string
	emailAddress?: string
	approvalUrl?: string
}

export type WakeSecretMeta = {
	name: string
	scope: string
	package_id: string | null
	allowed_hosts: string[]
	description: string | null
	updated_at: string | null
}

export type SecretUseStatus = {
	exists: boolean
	usableScope: string | null
	needsHostApproval: boolean
	approvalUrl: string | null
	allowedHosts: string[]
}

export function normalizeName(name: string): string {
	return name.trim().toLowerCase()
}

export function isUuidLike(value: string): boolean {
	return UUID_LIKE.test(value.trim())
}

export function secretNameFor(id: string): string {
	return `grokBotWake.${id}`
}

export function botKey(id: string): string {
	return `${BOT_PREFIX}${id}`
}

export function aliasKey(normalized: string): string {
	return `${ALIAS_PREFIX}${normalized}`
}

export function savedPackageId(): string {
	// Always grok-bot — secrets/saveUrl must not inherit a dependent's packageContext.
	return SAVED_PACKAGE_ID
}

/** True when this code is executing under a different package's isolate. */
export function isForeignPackageIsolate(): boolean {
	const fromContext = packageContext?.packageId?.trim()
	return Boolean(fromContext && fromContext !== SAVED_PACKAGE_ID)
}

export function requireId(id: unknown): string {
	if (typeof id !== 'string' || id.trim() === '') {
		throw new Error('id is required and must be a UUID.')
	}
	const trimmed = id.trim()
	if (!isUuidLike(trimmed)) {
		throw new Error('id must be a UUID-like value (8-4-4-4-12 hex).')
	}
	return trimmed
}

export function requireName(name: unknown): string {
	if (typeof name !== 'string' || name.trim() === '') {
		throw new Error('name is required.')
	}
	return name.trim()
}

export function requirePrompt(prompt: unknown): string {
	if (typeof prompt !== 'string' || prompt.trim() === '') {
		throw new Error('prompt is required.')
	}
	return prompt
}

export function requireBotQuery(bot: unknown): string {
	if (typeof bot !== 'string' || bot.trim() === '') {
		throw new Error('bot is required (id or name).')
	}
	return bot.trim()
}

export function parseWebhookUrl(url: unknown): URL {
	if (typeof url !== 'string' || url.trim() === '') {
		throw new Error('url is required and must be https://api2.cursor.sh/…')
	}
	let parsed: URL
	try {
		parsed = new URL(url.trim())
	} catch {
		throw new Error('url must be a valid https URL on api2.cursor.sh')
	}
	if (parsed.protocol !== 'https:') {
		throw new Error('url must use https.')
	}
	if (parsed.hostname !== WEBHOOK_HOST) {
		throw new Error(`url host must be exactly ${WEBHOOK_HOST}.`)
	}
	return parsed
}

export function buildSaveUrl(input: {
	secretName: string
	name: string
	packageId: string
}): string {
	const url = new URL('https://kody.codes/account/secrets/new')
	url.searchParams.set('name', input.secretName)
	url.searchParams.set(
		'description',
		`Grok Bot wake sender key for ${input.name}`,
	)
	url.searchParams.set('allowedHosts', WEBHOOK_HOST)
	url.searchParams.set('scope', 'package')
	url.searchParams.set('packageId', input.packageId)
	return url.toString()
}

export function buildHostApprovalUrl(input: {
	scope: string
	secretName: string
}): string {
	return `https://kody.codes/account/secrets/${encodeURIComponent(input.scope)}/${encodeURIComponent(input.secretName)}?allowed-host=${WEBHOOK_HOST}`
}

/**
 * Known per-bot wake sender keys. New bots need a matching kody.credentials
 * slot and a case here before wake can stamp-seal after republish.
 */
export const KNOWN_GROK_BOT_WAKE_SECRETS = [
	'grokBotWake.021d6409-01e7-4af0-a59c-4cbf2785cb50',
	'grokBotWake.57072205-edee-46ed-b4fa-a24b657ef369',
	'grokBotWake.58b84955-b713-4814-b0ca-b7982b4b5829',
	'grokBotWake.60f91f03-6b17-4b1a-a590-529613c79977',
	'grokBotWake.6519d644-b6ac-4e61-989c-3d96d0b63cca',
	'grokBotWake.71e7550e-746d-417f-b253-05165975ff69',
	'grokBotWake.775984a5-71ac-4e8c-bba3-b93de8ff6a06',
	'grokBotWake.a08ff635-d146-4205-bb62-d538e9723d77',
	'grokBotWake.a2600d4e-d242-4709-8a9f-f756ad2d7e82',
	'grokBotWake.b5cf16d8-dde6-4d3d-b5b8-4452bfade775',
	'grokBotWake.f835c77a-c93f-424e-a0f1-f191c2235e19',
] as const

export type KnownGrokBotWakeSecret = (typeof KNOWN_GROK_BOT_WAKE_SECRETS)[number]

/**
 * Return a literal stamped `{{secret:…}}` placeholder for a known wake key.
 * Scope suffixes are removed — org credentials assigned to the package replace
 * package/user `|scope=` buckets for the credential stamp.
 */
export function secretPlaceholder(secretName: string, _scope?: string | null): string {
	switch (secretName) {
		case 'grokBotWake.021d6409-01e7-4af0-a59c-4cbf2785cb50':
			return '{{secret:grokBotWake.021d6409-01e7-4af0-a59c-4cbf2785cb50}}'
		case 'grokBotWake.57072205-edee-46ed-b4fa-a24b657ef369':
			return '{{secret:grokBotWake.57072205-edee-46ed-b4fa-a24b657ef369}}'
		case 'grokBotWake.58b84955-b713-4814-b0ca-b7982b4b5829':
			return '{{secret:grokBotWake.58b84955-b713-4814-b0ca-b7982b4b5829}}'
		case 'grokBotWake.60f91f03-6b17-4b1a-a590-529613c79977':
			return '{{secret:grokBotWake.60f91f03-6b17-4b1a-a590-529613c79977}}'
		case 'grokBotWake.6519d644-b6ac-4e61-989c-3d96d0b63cca':
			return '{{secret:grokBotWake.6519d644-b6ac-4e61-989c-3d96d0b63cca}}'
		case 'grokBotWake.71e7550e-746d-417f-b253-05165975ff69':
			return '{{secret:grokBotWake.71e7550e-746d-417f-b253-05165975ff69}}'
		case 'grokBotWake.775984a5-71ac-4e8c-bba3-b93de8ff6a06':
			return '{{secret:grokBotWake.775984a5-71ac-4e8c-bba3-b93de8ff6a06}}'
		case 'grokBotWake.a08ff635-d146-4205-bb62-d538e9723d77':
			return '{{secret:grokBotWake.a08ff635-d146-4205-bb62-d538e9723d77}}'
		case 'grokBotWake.a2600d4e-d242-4709-8a9f-f756ad2d7e82':
			return '{{secret:grokBotWake.a2600d4e-d242-4709-8a9f-f756ad2d7e82}}'
		case 'grokBotWake.b5cf16d8-dde6-4d3d-b5b8-4452bfade775':
			return '{{secret:grokBotWake.b5cf16d8-dde6-4d3d-b5b8-4452bfade775}}'
		case 'grokBotWake.f835c77a-c93f-424e-a0f1-f191c2235e19':
			return '{{secret:grokBotWake.f835c77a-c93f-424e-a0f1-f191c2235e19}}'
		default:
			throw new Error(
				`Wake secret "${secretName}" is not stamp-declared. Add a kody.credentials slot and a secretPlaceholder case, then republish.`,
			)
	}
}

export function parseBotRecord(value: unknown): BotRecord | null {
	let parsed = value
	if (typeof parsed === 'string') {
		try {
			parsed = JSON.parse(parsed)
		} catch {
			return null
		}
	}
	if (!parsed || typeof parsed !== 'object') return null
	const row = parsed as Record<string, unknown>
	const id = typeof row.id === 'string' ? row.id.trim() : ''
	const name = typeof row.name === 'string' ? row.name.trim() : ''
	const url = typeof row.url === 'string' ? row.url.trim() : ''
	const channelId = typeof row.channelId === 'string' ? row.channelId.trim() : ''
	if (!id || !name) return null
	const aliases = Array.isArray(row.aliases)
		? row.aliases
				.filter((item): item is string => typeof item === 'string')
				.map((item) => normalizeName(item))
				.filter(Boolean)
		: [normalizeName(name)]
	const uniqueAliases = Array.from(new Set(aliases))
	if (!uniqueAliases.includes(normalizeName(name))) {
		uniqueAliases.push(normalizeName(name))
	}
	return {
		id,
		name,
		aliases: uniqueAliases,
		url,
		secretName: secretNameFor(id),
		...(channelId ? { channelId } : {}),
		createdAt:
			typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString(),
		updatedAt:
			typeof row.updatedAt === 'string' ? row.updatedAt : new Date().toISOString(),
	}
}

async function readAlias(normalized: string): Promise<string | null> {
	const raw = await storageGet((key) => packageStorage().get(key), aliasKey(normalized))
	if (typeof raw === 'string' && raw.trim()) return raw.trim()
	if (raw && typeof raw === 'object' && 'id' in raw) {
		const id = (raw as { id?: unknown }).id
		return typeof id === 'string' ? id.trim() : null
	}
	return null
}

export async function resolveBot(bot: string): Promise<BotRecord | null> {
	const raw = bot.trim()
	if (!raw) return null
	const bucket = packageStorage()
	if (isUuidLike(raw)) {
		const byId = parseBotRecord(await storageGet((key) => bucket.get(key), botKey(raw)))
		if (byId) return byId
	}
	const idFromAlias = await readAlias(normalizeName(raw))
	if (idFromAlias) {
		const byAlias = parseBotRecord(
			await storageGet((key) => bucket.get(key), botKey(idFromAlias)),
		)
		if (byAlias) return byAlias
	}
	return parseBotRecord(await storageGet((key) => bucket.get(key), botKey(raw)))
}

export async function writeBotRecord(record: BotRecord): Promise<void> {
	const bucket = packageStorage()
	const payload = JSON.stringify({
		id: record.id,
		name: record.name,
		aliases: record.aliases,
		url: record.url,
		secretName: record.secretName,
		...(record.channelId ? { channelId: record.channelId } : {}),
		createdAt: record.createdAt,
		updatedAt: record.updatedAt,
	})
	await bucket.set(botKey(record.id), payload)
	for (const alias of record.aliases) {
		await bucket.set(aliasKey(alias), record.id)
	}
}

export async function deleteAlias(normalized: string): Promise<void> {
	await packageStorage().delete(aliasKey(normalized))
}

export async function deleteBotRecord(record: BotRecord): Promise<void> {
	const bucket = packageStorage()
	await bucket.delete(botKey(record.id))
	for (const alias of record.aliases) {
		await bucket.delete(aliasKey(alias))
	}
}

function normalizeWakeSecrets(result: unknown): WakeSecretMeta[] {
	if (!result || typeof result !== 'object') return []
	const secrets = (result as { secrets?: unknown }).secrets
	if (!Array.isArray(secrets)) return []
	return secrets.flatMap((item) => {
		if (!item || typeof item !== 'object') return []
		const row = item as Record<string, unknown>
		const name = typeof row.name === 'string' ? row.name : ''
		if (!name.startsWith('grokBotWake.')) return []
		const hosts = row.allowed_hosts ?? row.allowedHosts
		return [
			{
				name,
				scope: typeof row.scope === 'string' ? row.scope : '',
				package_id:
					typeof row.package_id === 'string'
						? row.package_id
						: typeof row.packageId === 'string'
							? row.packageId
							: null,
				allowed_hosts: Array.isArray(hosts)
					? hosts.filter((host): host is string => typeof host === 'string')
					: [],
				description: typeof row.description === 'string' ? row.description : null,
				updated_at:
					typeof row.updated_at === 'string'
						? row.updated_at
						: typeof row.updatedAt === 'string'
							? row.updatedAt
							: null,
			},
		]
	})
}

/**
 * Direct `secretList` only. In the grok-bot package isolate (jobs, webhooks,
 * exports serving in package context) this returns package-scoped `grokBotWake.*`
 * keys. From ad hoc execute it usually does not — callers use stamp-optimistic
 * package-bucket fallbacks instead of subscription dispatch.
 */
export async function listWakeSecretsFromSecretList(): Promise<WakeSecretMeta[] | null> {
	try {
		const seen = new Map<string, WakeSecretMeta>()
		const listings = [plainClone(await kody.secretList({}))]
		for (const scope of ['package', 'user'] as const) {
			try {
				listings.push(plainClone(await kody.secretList({ scope })))
			} catch {
				// Package-scoped listing may not be visible from execute yet.
			}
		}
		for (const listed of listings) {
			for (const secret of normalizeWakeSecrets(plainClone(listed))) {
				seen.set(`${secret.scope}:${secret.name}`, secret)
			}
		}
		return [...seen.values()]
	} catch {
		return null
	}
}

function hasPackageWakeSecrets(secrets: WakeSecretMeta[]): boolean {
	return secrets.some(
		(secret) => secret.scope === 'package' && secret.name.startsWith('grokBotWake.'),
	)
}

function sortWakeSecrets(secrets: WakeSecretMeta[]): WakeSecretMeta[] {
	return [...secrets].sort(
		(a, b) => a.name.localeCompare(b.name) || a.scope.localeCompare(b.scope),
	)
}

function metaFromPackageBucket(secretName: string, botName?: string): WakeSecretMeta {
	return {
		name: secretName,
		scope: 'package',
		package_id: SAVED_PACKAGE_ID,
		allowed_hosts: [WEBHOOK_HOST],
		description: botName
			? `Grok Bot wake sender key for ${botName}`
			: 'Grok Bot wake sender key',
		updated_at: null,
	}
}

/**
 * Stamp-aligned / package-bucket existence for a single wake key.
 * Prefer authoritative listings; when listing is blind (execute), fall back to
 * stamp-optimistic package scope for wake (no subscription dispatch).
 */
export async function packageWakeSecretExists(
	secretName: string,
): Promise<boolean | null> {
	const name = secretName.trim()
	if (!name) return false
	const listed = await listWakeSecretsFromSecretList()
	if (listed && listed.some((secret) => secret.name === name)) return true
	if (listed && hasPackageWakeSecrets(listed)) return false

	// Unbound listing: stamp fetch may still resolve package keys.
	if (name.startsWith('grokBotWake.')) return null
	return false
}

export async function listWakeSecrets(): Promise<WakeSecretMeta[] | null> {
	const fromList = await listWakeSecretsFromSecretList()
	if (fromList === null) return null
	return sortWakeSecrets(fromList)
}

export function secretUseStatusFrom(
	secrets: WakeSecretMeta[] | null,
	secretName: string,
): SecretUseStatus {
	if (!secrets) {
		return {
			exists: false,
			usableScope: null,
			needsHostApproval: false,
			approvalUrl: null,
			allowedHosts: [],
		}
	}
	const matches = secrets.filter((secret) => secret.name === secretName)
	if (!matches.length) {
		return {
			exists: false,
			usableScope: null,
			needsHostApproval: false,
			approvalUrl: null,
			allowedHosts: [],
		}
	}
	const usable = matches.find((secret) => secret.allowed_hosts.includes(WEBHOOK_HOST))
	const first = matches[0]
	return {
		exists: true,
		usableScope: usable?.scope ?? null,
		needsHostApproval: !usable,
		approvalUrl: first
			? buildHostApprovalUrl({ scope: first.scope || 'user', secretName })
			: null,
		allowedHosts: Array.from(new Set(matches.flatMap((secret) => secret.allowed_hosts))),
	}
}

export async function getSecretUseStatus(secretName: string): Promise<SecretUseStatus> {
	const listed = await listWakeSecrets()
	const fromList = secretUseStatusFrom(listed, secretName)
	if (fromList.exists) return fromList

	// Listing blind (execute): stamp-aligned fetch still resolves package-scoped
	// grokBotWake.* keys. Do not false-alarm "not saved".
	if (secretName.startsWith('grokBotWake.')) {
		return secretUseStatusFrom([metaFromPackageBucket(secretName)], secretName)
	}
	return fromList
}

export async function listKnownSecretNames(): Promise<Set<string> | null> {
	const secrets = await listWakeSecrets()
	if (!secrets) return null
	return new Set(secrets.map((secret) => secret.name))
}

export async function isSecretPending(secretName: string): Promise<boolean> {
	const status = await getSecretUseStatus(secretName)
	return !status.exists
}

/**
 * For `./secret-status`: direct secretList / listWakeSecrets only (no dispatch).
 */
export async function listWakeSecretsForStatus(): Promise<WakeSecretMeta[]> {
	return (await listWakeSecrets()) ?? []
}

function listEntries(listed: unknown): Array<{ key?: string; value?: unknown }> {
	if (Array.isArray(listed)) {
		return listed.filter((item) => item && typeof item === 'object') as Array<{
			key?: string
			value?: unknown
		}>
	}
	if (listed && typeof listed === 'object') {
		const entries = (listed as { entries?: unknown }).entries
		if (Array.isArray(entries)) {
			return entries.filter((item) => item && typeof item === 'object') as Array<{
				key?: string
				value?: unknown
			}>
		}
	}
	return []
}

export async function listBotRecords(): Promise<BotRecord[]> {
	const bucket = packageStorage()
	let listed: unknown = null
	try {
		listed = plainClone(await bucket.list({ prefix: BOT_PREFIX }))
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		if (!/Proxy could not be serialized/i.test(message)) throw error
		listed = null
	}
	const records: BotRecord[] = []
	const seen = new Set<string>()
	const keys: string[] = []
	for (const entry of listEntries(listed)) {
		const key = typeof entry.key === 'string' ? entry.key : ''
		if (key && key.startsWith(BOT_PREFIX)) keys.push(key)
	}
	// Prefer per-key storageGet: list values for object rows can be Proxies.
	for (const key of keys) {
		const record = parseBotRecord(await storageGet((k) => bucket.get(k), key))
		if (!record || seen.has(record.id)) continue
		seen.add(record.id)
		records.push(record)
	}
	return records
}

export function urlHostOf(url: string): string {
	if (!url) return ''
	try {
		return new URL(url).hostname
	} catch {
		return WEBHOOK_HOST
	}
}

export function missingBotError(bot: string): Error {
	return new Error(
		`No Grok Bot is registered for "${bot}". Call register({ id, name, url }) first.`,
	)
}

export function missingSecretError(input: {
	name: string
	secretName: string
	saveUrl: string
}): Error {
	return new Error(
		`Wake is refused until the sender key is saved for "${input.name}" as ${input.secretName}. Open ${input.saveUrl} and paste the key there (never in chat). Do not POST until that secret exists.`,
	)
}

export function hostApprovalError(input: {
	name: string
	secretName: string
	approvalUrl: string
}): Error {
	return new Error(
		`Wake is refused until ${WEBHOOK_HOST} is approved for "${input.name}" sender key ${input.secretName}. Open ${input.approvalUrl} and approve that host. Do not POST until the host is approved.`,
	)
}