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

  1. Import COMPANY_MONITORING_RPC_SCOPES from shared/company-monitoring-contract and submit only its exact values — never hardcode scope strings.
  2. Dedupe before submitting: [...new Set(scopes)].
  3. Omit scopes (undefined or empty array) for a key with no Company Monitoring access — that path is valid and provisions no account.
  4. 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

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


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)