Skip to content

src/roster-fingerprints.ts

142 lines · 4.2 KB · TypeScript
Raw
import { plainClone } from './plain.ts'
import { listBotRecords, urlHostOf, type BotRecord } from './registry.ts'

export type BotUrlFingerprint = {
	id: string
	name: string
	updatedAt: string
	createdAt: string
	channelId?: string
	urlHost: string
	/** Pathname with the final segment removed (e.g. `/automations/webhook`). */
	pathPrefix: string
	/** Number of non-empty path segments. */
	pathDepth: number
	/** Length of the final path segment (never the segment itself). */
	lastSegLen: number
	/** Stable non-reversible hex digest of the final path segment (SHA-256, 16 chars). */
	lastSegHash: string
	/** Full registered URL string length (shape only; never the URL). */
	urlLen: number
	hasQuery: boolean
}

export type RosterFingerprintsResult = {
	bots: BotUrlFingerprint[]
	/** Bot names that share the same pathPrefix+lastSegHash+lastSegLen+urlLen key. */
	duplicateGroups: string[][]
}

function hexOf(buffer: ArrayBuffer): string {
	return [...new Uint8Array(buffer)]
		.map((byte) => byte.toString(16).padStart(2, '0'))
		.join('')
}

async function hashLastSegment(segment: string): Promise<string> {
	const digest = await crypto.subtle.digest(
		'SHA-256',
		new TextEncoder().encode(segment),
	)
	return hexOf(digest).slice(0, 16)
}

function pathParts(pathname: string): {
	pathPrefix: string
	lastSeg: string
	pathDepth: number
} {
	const normalized = pathname.replace(/\/+$/, '') || '/'
	const depth = normalized === '/' ? 0 : normalized.split('/').filter(Boolean).length
	const lastSlash = normalized.lastIndexOf('/')
	if (lastSlash <= 0) {
		return {
			pathPrefix: '/',
			lastSeg: lastSlash === 0 ? normalized.slice(1) : normalized,
			pathDepth: depth,
		}
	}
	return {
		pathPrefix: normalized.slice(0, lastSlash) || '/',
		lastSeg: normalized.slice(lastSlash + 1),
		pathDepth: depth,
	}
}

async function fingerprintOf(record: BotRecord): Promise<BotUrlFingerprint> {
	const base: BotUrlFingerprint = {
		id: record.id,
		name: record.name,
		updatedAt: record.updatedAt,
		createdAt: record.createdAt,
		urlHost: '',
		pathPrefix: '',
		pathDepth: 0,
		lastSegLen: 0,
		lastSegHash: '',
		urlLen: 0,
		hasQuery: false,
		...(record.channelId ? { channelId: record.channelId } : {}),
	}

	const raw = typeof record.url === 'string' ? record.url.trim() : ''
	if (!raw) return base

	base.urlLen = raw.length
	base.urlHost = urlHostOf(raw)

	try {
		const parsed = new URL(raw)
		const parts = pathParts(parsed.pathname)
		base.pathPrefix = parts.pathPrefix
		base.pathDepth = parts.pathDepth
		base.lastSegLen = parts.lastSeg.length
		base.lastSegHash = parts.lastSeg
			? await hashLastSegment(parts.lastSeg)
			: ''
		base.hasQuery = parsed.search.length > 1
	} catch {
		// Leave shape fields empty when the stored URL cannot be parsed.
	}

	return base
}

function fingerprintKey(bot: BotUrlFingerprint): string {
	return `${bot.pathPrefix}|${bot.lastSegHash}|${bot.lastSegLen}|${bot.urlLen}`
}

/**
 * Safe webhook-shape fingerprints for every registered Grok Bot.
 *
 * Never returns raw webhook URLs or Authorization material. Use this to
 * compare Cole vs Patch path shape / detect duplicate webhook registrations
 * without leaking secrets.
 *
 * @returns bots with host/path shape fields plus duplicateGroups of names
 * @example
 * import fingerprints from 'kody:@kentcdodds/grok-bot/roster-fingerprints'
 * const { bots, duplicateGroups } = await fingerprints()
 * // => { bots: [{ id, name, urlHost, pathPrefix, pathDepth, lastSegLen, lastSegHash, urlLen, hasQuery, ... }], duplicateGroups }
 */
export default async function rosterFingerprints(): Promise<RosterFingerprintsResult> {
	const records = await listBotRecords()
	const bots = await Promise.all(records.map((record) => fingerprintOf(record)))
	bots.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))

	const groups = new Map<string, string[]>()
	for (const bot of bots) {
		if (!bot.lastSegHash && bot.urlLen === 0) continue
		const key = fingerprintKey(bot)
		const names = groups.get(key) ?? []
		names.push(bot.name)
		groups.set(key, names)
	}

	const duplicateGroups = [...groups.values()]
		.filter((names) => names.length > 1)
		.map((names) => [...names].sort((a, b) => a.localeCompare(b)))
		.sort((a, b) => a[0]!.localeCompare(b[0]!))

	return plainClone({ bots, duplicateGroups })
}