koala73/worldmonitor · error · ConvexError
INVALID_API_KEY_SCOPES
INVALID_API_KEY_SCOPES
Error message
INVALID_API_KEY_SCOPES
What it means
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.
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.
Example fix
// before
await mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes: ['company_monitoring.read', 'company_monitoring.read'] });
// ConvexError: INVALID_API_KEY_SCOPES
// after — drive from the contract, deduped
import { COMPANY_MONITORING_RPC_SCOPES } from '../shared/company-monitoring-contract';
const ALLOWED = Object.values(COMPANY_MONITORING_RPC_SCOPES) as string[];
const scopes = [...new Set(selectedScopes)].filter((s) => ALLOWED.includes(s));
if (selectedScopes.length !== scopes.length) throw new Error('unknown or duplicate scope submitted');
await mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes }); Defensive patterns
Strategy: validation
Validate before calling
// Drive scope selection from the shared contract and dedupe before createApiKey.
import { COMPANY_MONITORING_RPC_SCOPES } from '../shared/company-monitoring-contract';
const ALLOWED_SCOPES = Object.values(COMPANY_MONITORING_RPC_SCOPES) as string[];
const deduped = [...new Set(requestedScopes)];
const unknown = deduped.filter((s) => !ALLOWED_SCOPES.includes(s));
if (unknown.length > 0) {
throw new Error(`unknown scopes: ${unknown.join(', ')}; allowed: ${ALLOWED_SCOPES.join(', ')}`);
}
await mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes: deduped }); Type guard
import { COMPANY_MONITORING_RPC_SCOPES, type CompanyMonitoringApiScope } from '../shared/company-monitoring-contract';
const ALLOWED = new Set<string>(Object.values(COMPANY_MONITORING_RPC_SCOPES));
const isCompanyMonitoringScope = (s: string): s is CompanyMonitoringApiScope => ALLOWED.has(s); Try / catch
try {
await mutateAPI.apiKeys.createApiKey({ name, keyPrefix, keyHash, scopes });
} catch (err) {
if (err instanceof ConvexError && err.data === 'INVALID_API_KEY_SCOPES') {
setScopeError('Remove duplicates and use only scopes from the current contract');
return;
}
throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- INVALID_HASH
- ALREADY_REVOKED
- COMPANY_MONITORING_ADMISSION_QUERY_VERSION_INVALID
- COMPANY_MONITORING_EVIDENCE_REVISION_INVALID
- COMPANY_MONITORING_ _INVALID
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/8bb30b2f930581ee.
Report an issue: GitHub.
Appendix: source
Thrown at convex/apiKeys.ts:20
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { requireUserId, resolveUserId } from "./lib/auth";
import { activeAccountForOwner } from "./companyMonitoring/_shared";
import { ensureActiveAccount } from "./companyMonitoring/accounts";
import {
COMPANY_MONITORING_RPC_SCOPES,
type CompanyMonitoringApiScope,
} from "../shared/company-monitoring-contract";
/** Maximum number of active (non-revoked) API keys per user. */
const MAX_KEYS_PER_USER = 5;
const COMPANY_MONITORING_SCOPES = [
...new Set(Object.values(COMPANY_MONITORING_RPC_SCOPES)),
] as CompanyMonitoringApiScope[];
function normalizeCompanyMonitoringScopes(scopes: string[] | undefined) {
if (!scopes || scopes.length === 0) return undefined;
if (scopes.length > COMPANY_MONITORING_SCOPES.length || new Set(scopes).size !== scopes.length) {
throw new ConvexError("INVALID_API_KEY_SCOPES");
}
if (scopes.some((scope) => !(COMPANY_MONITORING_SCOPES as readonly string[]).includes(scope))) {
throw new ConvexError("INVALID_API_KEY_SCOPES");
}
return [...scopes].sort() as CompanyMonitoringApiScope[];
}
// ---------------------------------------------------------------------------
// Public mutations & queries (require Clerk JWT via ctx.auth)
// ---------------------------------------------------------------------------
/**
* Create a new API key.
*
* The caller must generate the random key client-side (or in the HTTP action)
* and pass the SHA-256 hex hash + the first 8 chars (prefix) here.
* The plaintext key is NEVER stored in Convex.
*View on GitHub (pinned to eeab0a219f)