{"record":{"id":"8bb30b2f930581ee","repo":"koala73/worldmonitor","slug":"invalid-api-key-scopes","errorCode":"INVALID_API_KEY_SCOPES","errorMessage":"INVALID_API_KEY_SCOPES","messagePattern":"INVALID_API_KEY_SCOPES","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/apiKeys.ts","lineNumber":20,"sourceCode":"import { internalMutation, internalQuery, mutation, query } from \"./_generated/server\";\nimport { requireUserId, resolveUserId } from \"./lib/auth\";\nimport { activeAccountForOwner } from \"./companyMonitoring/_shared\";\nimport { ensureActiveAccount } from \"./companyMonitoring/accounts\";\nimport {\n  COMPANY_MONITORING_RPC_SCOPES,\n  type CompanyMonitoringApiScope,\n} from \"../shared/company-monitoring-contract\";\n\n/** Maximum number of active (non-revoked) API keys per user. */\nconst MAX_KEYS_PER_USER = 5;\nconst COMPANY_MONITORING_SCOPES = [\n  ...new Set(Object.values(COMPANY_MONITORING_RPC_SCOPES)),\n] as CompanyMonitoringApiScope[];\n\nfunction normalizeCompanyMonitoringScopes(scopes: string[] | undefined) {\n  if (!scopes || scopes.length === 0) return undefined;\n  if (scopes.length > COMPANY_MONITORING_SCOPES.length || new Set(scopes).size !== scopes.length) {\n    throw new ConvexError(\"INVALID_API_KEY_SCOPES\");\n  }\n  if (scopes.some((scope) => !(COMPANY_MONITORING_SCOPES as readonly string[]).includes(scope))) {\n    throw new ConvexError(\"INVALID_API_KEY_SCOPES\");\n  }\n  return [...scopes].sort() as CompanyMonitoringApiScope[];\n}\n\n// ---------------------------------------------------------------------------\n// Public mutations & queries (require Clerk JWT via ctx.auth)\n// ---------------------------------------------------------------------------\n\n/**\n * Create a new API key.\n *\n * The caller must generate the random key client-side (or in the HTTP action)\n * and pass the SHA-256 hex hash + the first 8 chars (prefix) here.\n * The plaintext key is NEVER stored in Convex.\n *","sourceCodeStart":2,"sourceCodeEnd":38,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/apiKeys.ts#L2-L38","documentation":"normalizeCompanyMonitoringScopes (convex/apiKeys.ts:17) validates the scopes array for API-key creation against the allowed set derived from COMPANY_MONITORING_RPC_SCOPES in shared/company-monitoring-contract. It throws ConvexError('INVALID_API_KEY_SCOPES') when: the array has more entries than the total number of distinct allowed scopes, any entry is duplicated (Set size mismatch), or any entry is not in the allowed list. It runs in createApiKey (public mutation) and also in validateKeyByHash — there a stored invalid scopes array silently nulls the key (auth failure), rather than throwing to the caller.","triggerScenarios":"createApiKey with duplicate scope entries (['read', 'read']); a typo'd or wrong-separator scope string ('company-monitoring:read' vs the contract's exact names); a client built against an older shared/company-monitoring-contract submitting since-renamed scopes; sending the full scope list plus one repeat.","commonSituations":"Version skew between the key-issuing UI and the shared contract after a scope rename/removal; hand-rolled scope strings in admin scripts; multi-select UIs that don't dedupe; legacy keys whose stored scopes became invalid after a contract change — those fail validateKeyByHash and authenticate as nobody, surfacing as confusing 401s instead of INVALID_API_KEY_SCOPES.","solutions":["Import COMPANY_MONITORING_RPC_SCOPES from shared/company-monitoring-contract and submit only its exact values — never hardcode scope strings.","Dedupe before submitting: [...new Set(scopes)].","Omit scopes (undefined or empty array) for a key with no Company Monitoring access — that path is valid and provisions no account.","After any contract rename, migrate stored userApiKeys.scopes rows — stale values break authentication in validateKeyByHash (returns null) without throwing this error."],"exampleFix":"// before\nawait mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes: ['company_monitoring.read', 'company_monitoring.read'] });\n// ConvexError: INVALID_API_KEY_SCOPES\n\n// after — drive from the contract, deduped\nimport { COMPANY_MONITORING_RPC_SCOPES } from '../shared/company-monitoring-contract';\nconst ALLOWED = Object.values(COMPANY_MONITORING_RPC_SCOPES) as string[];\nconst scopes = [...new Set(selectedScopes)].filter((s) => ALLOWED.includes(s));\nif (selectedScopes.length !== scopes.length) throw new Error('unknown or duplicate scope submitted');\nawait mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes });","handlingStrategy":"validation","validationCode":"// Drive scope selection from the shared contract and dedupe before createApiKey.\nimport { COMPANY_MONITORING_RPC_SCOPES } from '../shared/company-monitoring-contract';\n\nconst ALLOWED_SCOPES = Object.values(COMPANY_MONITORING_RPC_SCOPES) as string[];\nconst deduped = [...new Set(requestedScopes)];\nconst unknown = deduped.filter((s) => !ALLOWED_SCOPES.includes(s));\nif (unknown.length > 0) {\n  throw new Error(`unknown scopes: ${unknown.join(', ')}; allowed: ${ALLOWED_SCOPES.join(', ')}`);\n}\nawait mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes: deduped });","typeGuard":"import { COMPANY_MONITORING_RPC_SCOPES, type CompanyMonitoringApiScope } from '../shared/company-monitoring-contract';\n\nconst ALLOWED = new Set<string>(Object.values(COMPANY_MONITORING_RPC_SCOPES));\nconst isCompanyMonitoringScope = (s: string): s is CompanyMonitoringApiScope => ALLOWED.has(s);","tryCatchPattern":"try {\n  await mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes });\n} catch (err) {\n  if (err instanceof ConvexError && err.data === 'INVALID_API_KEY_SCOPES') {\n    setScopeError('Remove duplicates and use only scopes from the current contract');\n    return;\n  }\n  throw err;\n}","preventionTips":["Never hardcode scope strings — import COMPANY_MONITORING_RPC_SCOPES so renames surface at compile time.","Dedupe multi-select output before submission.","After contract changes, migrate stored userApiKeys.scopes rows; stale values make keys fail validateKeyByHash silently (auth returns null).","Treat scope validation failures as caller bugs (400), not server faults."],"tags":["convex","api-keys","scopes","validation","authorization"],"backgroundTag":"invalid-api-scope","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}