|
| 1 | +#!/usr/bin/env bun |
| 2 | +/** |
| 3 | + * Audits that every BYOK provider is wired through all four places it must appear. |
| 4 | + * |
| 5 | + * A hosted tool names its provider once, in `hosting.byokProviderId`, but that id |
| 6 | + * has to be registered in three other files before the feature actually works: |
| 7 | + * |
| 8 | + * tools/types.ts the `BYOKProviderId` union tools compile against |
| 9 | + * lib/api/contracts/byok-keys.ts the zod enum the byok-keys route validates against |
| 10 | + * settings/.../byok.tsx `PROVIDERS` the row the settings page renders |
| 11 | + * settings/.../byok.tsx `SECTIONS` the section that row is grouped under |
| 12 | + * |
| 13 | + * Only the first is enforced by the compiler. The other three fail *silently*: |
| 14 | + * |
| 15 | + * - Missing from `PROVIDERS`, the settings page has no row, so a workspace can |
| 16 | + * never bring its own key and is stuck on the hosted key. |
| 17 | + * - Missing from `PROVIDER_SECTIONS`, the row exists but the sectioned renderer |
| 18 | + * (`byok-key-manager.tsx` filters `providers` by `section.ids.includes(p.id)`) |
| 19 | + * drops it, so the page looks correct in source and renders nothing. |
| 20 | + * - Drifted between the two `BYOKProviderId` declarations, a tool can name a |
| 21 | + * provider the route then rejects at runtime. |
| 22 | + * |
| 23 | + * None of those produce a type error, a test failure, or a log line — which is |
| 24 | + * exactly why they need an audit rather than a convention. |
| 25 | + * |
| 26 | + * Run: `bun run check:byok-providers` |
| 27 | + */ |
| 28 | +import { readFile } from 'node:fs/promises' |
| 29 | +import { dirname, resolve } from 'node:path' |
| 30 | +import { fileURLToPath } from 'node:url' |
| 31 | +import { tools } from '../apps/sim/tools/registry' |
| 32 | + |
| 33 | +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) |
| 34 | +const ROOT = resolve(SCRIPT_DIR, '..') |
| 35 | +const APP = resolve(ROOT, 'apps/sim') |
| 36 | +const TOOL_TYPES = resolve(APP, 'tools/types.ts') |
| 37 | +const CONTRACT = resolve(APP, 'lib/api/contracts/byok-keys.ts') |
| 38 | +const SETTINGS = resolve(APP, 'app/workspace/[workspaceId]/settings/components/byok/byok.tsx') |
| 39 | + |
| 40 | +/** Path as written in an error message, relative to the repo root. */ |
| 41 | +function rel(absolute: string): string { |
| 42 | + return absolute.slice(ROOT.length + 1) |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Returns the source between the brackets opened by the first match of `start`. |
| 47 | + * |
| 48 | + * Bracket-counting rather than a lazy regex: every one of these blocks nests |
| 49 | + * (an object per provider, an array per section), so `[\s\S]*?\]` would stop at |
| 50 | + * the first inner close. |
| 51 | + */ |
| 52 | +function blockAfter(source: string, start: RegExp, open: '[' | '{'): string { |
| 53 | + const match = source.match(start) |
| 54 | + if (match?.index === undefined) { |
| 55 | + throw new Error(`could not locate ${start} — has the declaration been renamed?`) |
| 56 | + } |
| 57 | + const close = open === '[' ? ']' : '}' |
| 58 | + const from = source.indexOf(open, match.index + match[0].length - 1) |
| 59 | + if (from === -1) throw new Error(`no ${open} after ${start}`) |
| 60 | + |
| 61 | + let depth = 0 |
| 62 | + for (let i = from; i < source.length; i++) { |
| 63 | + if (source[i] === open) depth++ |
| 64 | + else if (source[i] === close) { |
| 65 | + depth-- |
| 66 | + if (depth === 0) return source.slice(from + 1, i) |
| 67 | + } |
| 68 | + } |
| 69 | + throw new Error(`unbalanced ${open} after ${start}`) |
| 70 | +} |
| 71 | + |
| 72 | +/** Every single-quoted string literal in a chunk of source, in order. */ |
| 73 | +function quoted(source: string): string[] { |
| 74 | + return [...source.matchAll(/'([a-z0-9_-]+)'/gi)].map((m) => m[1]) |
| 75 | +} |
| 76 | + |
| 77 | +interface Failure { |
| 78 | + file: string |
| 79 | + message: string |
| 80 | + items: string[] |
| 81 | + fix: string |
| 82 | +} |
| 83 | + |
| 84 | +async function main() { |
| 85 | + const [toolTypesSrc, contractSrc, settingsSrc] = await Promise.all([ |
| 86 | + readFile(TOOL_TYPES, 'utf8'), |
| 87 | + readFile(CONTRACT, 'utf8'), |
| 88 | + readFile(SETTINGS, 'utf8'), |
| 89 | + ]) |
| 90 | + |
| 91 | + const unionDecl = toolTypesSrc.match(/export type BYOKProviderId =([\s\S]*?)\n\n/) |
| 92 | + if (!unionDecl) throw new Error(`could not locate BYOKProviderId union in ${rel(TOOL_TYPES)}`) |
| 93 | + const union = new Set(quoted(unionDecl[1])) |
| 94 | + |
| 95 | + const schema = new Set(quoted(blockAfter(contractSrc, /byokProviderIdSchema = z\.enum\(/, '['))) |
| 96 | + |
| 97 | + const settingsProviders = new Set( |
| 98 | + [ |
| 99 | + ...blockAfter(settingsSrc, /const PROVIDERS[^=]*=/, '[').matchAll( |
| 100 | + /\bid:\s*'([a-z0-9_-]+)'/gi |
| 101 | + ), |
| 102 | + ].map((m) => m[1]) |
| 103 | + ) |
| 104 | + |
| 105 | + const sectioned = new Set( |
| 106 | + [ |
| 107 | + ...blockAfter(settingsSrc, /const PROVIDER_SECTIONS[^=]*=/, '[').matchAll( |
| 108 | + /\bids:\s*\[([\s\S]*?)\]/g |
| 109 | + ), |
| 110 | + ].flatMap((m) => quoted(m[1])) |
| 111 | + ) |
| 112 | + |
| 113 | + /** Provider id -> the hosted tools that name it. */ |
| 114 | + const hostedBy = new Map<string, string[]>() |
| 115 | + for (const [toolId, tool] of Object.entries(tools)) { |
| 116 | + const provider = tool.hosting?.byokProviderId |
| 117 | + if (!provider) continue |
| 118 | + const existing = hostedBy.get(provider) |
| 119 | + if (existing) existing.push(toolId) |
| 120 | + else hostedBy.set(provider, [toolId]) |
| 121 | + } |
| 122 | + |
| 123 | + const failures: Failure[] = [] |
| 124 | + const describe = (provider: string) => { |
| 125 | + const owners = hostedBy.get(provider) ?? [] |
| 126 | + return owners.length > 0 |
| 127 | + ? `${provider} (${owners[0]}${owners.length > 1 ? ', …' : ''})` |
| 128 | + : provider |
| 129 | + } |
| 130 | + |
| 131 | + const missingFromSchema = [...hostedBy.keys()].filter((p) => !schema.has(p)).sort() |
| 132 | + if (missingFromSchema.length > 0) { |
| 133 | + failures.push({ |
| 134 | + file: rel(CONTRACT), |
| 135 | + message: 'hosted tools name a provider the byok-keys route would reject', |
| 136 | + items: missingFromSchema.map(describe), |
| 137 | + fix: 'add the id to byokProviderIdSchema', |
| 138 | + }) |
| 139 | + } |
| 140 | + |
| 141 | + const missingFromSettings = [...hostedBy.keys()] |
| 142 | + .filter((p) => schema.has(p) && !settingsProviders.has(p)) |
| 143 | + .sort() |
| 144 | + if (missingFromSettings.length > 0) { |
| 145 | + failures.push({ |
| 146 | + file: rel(SETTINGS), |
| 147 | + message: |
| 148 | + 'hosted tools name a provider with no settings row, so a workspace cannot bring its own key', |
| 149 | + items: missingFromSettings.map(describe), |
| 150 | + fix: 'add an entry to PROVIDERS', |
| 151 | + }) |
| 152 | + } |
| 153 | + |
| 154 | + const unsectioned = [...settingsProviders].filter((p) => !sectioned.has(p)).sort() |
| 155 | + if (unsectioned.length > 0) { |
| 156 | + failures.push({ |
| 157 | + file: rel(SETTINGS), |
| 158 | + message: 'PROVIDERS entries the sectioned renderer drops, so their row never appears', |
| 159 | + items: unsectioned, |
| 160 | + fix: 'add the id to the right PROVIDER_SECTIONS section', |
| 161 | + }) |
| 162 | + } |
| 163 | + |
| 164 | + const orphanedSections = [...sectioned].filter((p) => !settingsProviders.has(p)).sort() |
| 165 | + if (orphanedSections.length > 0) { |
| 166 | + failures.push({ |
| 167 | + file: rel(SETTINGS), |
| 168 | + message: 'PROVIDER_SECTIONS lists ids with no matching PROVIDERS entry', |
| 169 | + items: orphanedSections, |
| 170 | + fix: 'remove the stale id, or add the missing PROVIDERS entry', |
| 171 | + }) |
| 172 | + } |
| 173 | + |
| 174 | + const unionOnly = [...union].filter((p) => !schema.has(p)).sort() |
| 175 | + const schemaOnly = [...schema].filter((p) => !union.has(p)).sort() |
| 176 | + if (unionOnly.length > 0 || schemaOnly.length > 0) { |
| 177 | + failures.push({ |
| 178 | + file: `${rel(TOOL_TYPES)} vs ${rel(CONTRACT)}`, |
| 179 | + message: 'the two BYOKProviderId declarations have drifted', |
| 180 | + items: [ |
| 181 | + ...unionOnly.map((p) => `${p} (union only)`), |
| 182 | + ...schemaOnly.map((p) => `${p} (zod enum only)`), |
| 183 | + ], |
| 184 | + fix: 'keep the union and the zod enum listing the same ids', |
| 185 | + }) |
| 186 | + } |
| 187 | + |
| 188 | + if (failures.length > 0) { |
| 189 | + console.error('\n❌ BYOK provider wiring is incomplete\n') |
| 190 | + for (const failure of failures) { |
| 191 | + console.error(` ${failure.file}: ${failure.message}`) |
| 192 | + for (const item of failure.items) console.error(` - ${item}`) |
| 193 | + console.error(` fix: ${failure.fix}\n`) |
| 194 | + } |
| 195 | + process.exit(1) |
| 196 | + } |
| 197 | + |
| 198 | + console.log( |
| 199 | + `✓ BYOK provider wiring is complete (${hostedBy.size} hosted providers, ${settingsProviders.size} settings rows)` |
| 200 | + ) |
| 201 | +} |
| 202 | + |
| 203 | +main().catch((error) => { |
| 204 | + console.error(`\n❌ check-byok-providers failed: ${error.message}`) |
| 205 | + process.exit(1) |
| 206 | +}) |
0 commit comments