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 { 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 { 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 { 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() 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 }) }