Skip to content
← Public packages

@charlesmatte/granola

Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.

src/lib/validate.ts

140 lines · 5.7 KB · TypeScript
Raw
/**
 * Strict runtime input checking for agent-facing exports.
 * Unknown keys are rejected so a guessed key never silently changes intent.
 */

export type FieldSpec = (
	| { type: 'string'; pattern?: RegExp; maxLength?: number; allowEmpty?: boolean }
	| { type: 'integer'; min?: number; max?: number }
	| { type: 'boolean' }
	| { type: 'stringArray'; maxItems?: number; allowEmptyArray?: boolean }
	| { type: 'enum'; values: ReadonlyArray<string> }
	| { type: 'object' }
) & { required?: boolean; missingMessage?: string }

export type Spec = Record<string, FieldSpec>

export class InputError extends Error {
	constructor(message: string) {
		super(message)
		this.name = 'GranolaInputError'
	}
}

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

function describeSpec(spec: Spec): string {
	return Object.entries(spec)
		.map(([key, field]) => {
			const type = field.type === 'enum' ? field.values.map((v) => JSON.stringify(v)).join(' | ') : field.type
			return `${key}${field.required ? '' : '?'}: ${type}`
		})
		.join(', ')
}

/**
 * Parse an export input object against a strict spec. Throws on unknown keys, missing required
 * keys, or wrong types. Returns only keys that were provided.
 */
export function parseInput<T extends Record<string, unknown>>(raw: unknown, spec: Spec, exportName: string): T {
	const input = raw === undefined || raw === null ? {} : raw
	if (!isRecord(input)) {
		throw new InputError(`${exportName}: input must be a plain object. Accepted shape: { ${describeSpec(spec)} }`)
	}
	const unknownKeys = Object.keys(input).filter((key) => !(key in spec))
	if (unknownKeys.length > 0) {
		throw new InputError(
			`${exportName}: unknown input key(s) ${unknownKeys.map((k) => JSON.stringify(k)).join(', ')}. ` +
				`Accepted shape: { ${describeSpec(spec)} }`,
		)
	}
	const out: Record<string, unknown> = {}
	for (const [key, field] of Object.entries(spec)) {
		const value = input[key]
		if (value === undefined || value === null) {
			if (field.required) {
				throw new InputError(
					`${exportName}: ${field.missingMessage ?? `missing required "${key}".`} Accepted shape: { ${describeSpec(spec)} }`,
				)
			}
			continue
		}
		out[key] = checkField(exportName, key, field, value)
	}
	return out as T
}

function checkField(exportName: string, key: string, field: FieldSpec, value: unknown): unknown {
	const fail = (expected: string): never => {
		throw new InputError(`${exportName}: "${key}" must be ${expected}.`)
	}
	switch (field.type) {
		case 'string': {
			if (typeof value !== 'string') return fail('a string')
			const trimmed = value.trim()
			if (!field.allowEmpty && trimmed.length === 0) return fail('a non-empty string')
			if (field.maxLength && value.length > field.maxLength) return fail(`at most ${field.maxLength} characters`)
			if (field.pattern && !field.pattern.test(trimmed)) return fail(`a string matching ${String(field.pattern)}`)
			return field.allowEmpty ? value : trimmed
		}
		case 'integer': {
			if (typeof value !== 'number' || !Number.isInteger(value)) return fail('an integer')
			if (field.min !== undefined && value < field.min) return fail(`>= ${field.min}`)
			if (field.max !== undefined && value > field.max) return fail(`<= ${field.max}`)
			return value
		}
		case 'boolean':
			if (typeof value !== 'boolean') return fail('a boolean')
			return value
		case 'stringArray': {
			if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim().length > 0)) {
				return fail('an array of non-empty strings')
			}
			if (field.maxItems && value.length > field.maxItems) return fail(`at most ${field.maxItems} items`)
			return (value as Array<string>).map((item) => item.trim())
		}
		case 'enum':
			if (typeof value !== 'string' || !field.values.includes(value)) {
				return fail(`one of ${field.values.map((v) => JSON.stringify(v)).join(', ')}`)
			}
			return value
		case 'object':
			if (!isRecord(value)) return fail('an object')
			return value
		default:
			return value
	}
}

/** Account names: the `<account>` in secret `granola-<account>`. Lowercase letters, digits, - or _. */
export const ACCOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/
export const SECRET_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,100}$/
export const NOTE_ID_PATTERN = /^not_[a-zA-Z0-9]{14}$/
export const FOLDER_ID_PATTERN = /^fol_[a-zA-Z0-9]{14}$/
export const WEBHOOK_ID_PATTERN = /^whe_[a-zA-Z0-9]{14}$/
export const HOLD_ID_PATTERN = /^lgh_[a-zA-Z0-9]{14}$/
export const CUSTODIAN_ID_PATTERN = /^lhc_[a-zA-Z0-9]{14}$/
export const USER_ID_PATTERN = /^usr_[a-zA-Z0-9]{14}$/
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** `YYYY-MM-DD` or an ISO 8601 date-time (what the Granola API accepts for date filters). */
export const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/

export const accountField: FieldSpec = {
	type: 'string',
	required: true,
	pattern: ACCOUNT_NAME_PATTERN,
	missingMessage:
		'"account" is required on every call: the short name you chose when saving the API key as Kody secret `granola-<account>` (see ./accounts).',
}
export const secretNameField: FieldSpec = { type: 'string', pattern: SECRET_NAME_PATTERN }
/** Fields every Granola call accepts: which saved API key to use. */
export const authSpec: Spec = { account: accountField, secretName: secretNameField }
export const mutationSpec: Spec = { dryRun: { type: 'boolean' }, confirm: { type: 'boolean' } }
/** Pagination inputs for list endpoints (`limit` = total items wanted across pages). */
export const pageSpec = (maxLimit: number): Spec => ({
	limit: { type: 'integer', min: 1, max: maxLimit },
	cursor: { type: 'string', maxLength: 4000 },
	paginate: { type: 'boolean' },
})