{"record":{"id":"d2407102fef41f8e","repo":"koala73/worldmonitor","slug":"mcp-internal-hmac-secret-not-configured","errorCode":null,"errorMessage":"MCP_INTERNAL_HMAC_SECRET not configured","messagePattern":"MCP_INTERNAL_HMAC_SECRET not configured","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"api/mcp/auth.ts","lineNumber":146,"sourceCode":"  if (!url || !token) return null;\n  mcpAnonRatelimit = new Ratelimit({\n    redis: new Redis({ url, token, retry: false }),\n    limiter: Ratelimit.slidingWindow(60, '60 s'),\n    prefix: 'rl:mcp:anon',\n    analytics: false,\n  });\n  return mcpAnonRatelimit;\n}\n\n/**\n * Build the Authorization header set for a downstream `_execute` fetch.\n *\n *   - env_key → `X-WorldMonitor-Key: <apiKey>` (legacy operator keys).\n *   - pro / user_key → `X-WM-MCP-Internal: <ts>.<sig>` + `X-WM-MCP-User-Id`.\n *               Signature binds method+pathname+queryHash+bodyHash+userId.\n *               Identity-resolved dashboard keys use this path so the\n *               gateway does not increment the shared daily account meter\n *               a second time after MCP already reserved the tool weight.\n *\n * `body` MUST be the EXACT bytes the caller passes to `fetch()` so the\n * signed payload matches the wire bytes. For JSON, pre-stringify on the\n * caller side and pass the same string here.\n */\nexport async function buildAuthHeaders(\n  context: McpAuthContext,\n  method: string,\n  url: string,\n  body: BodyInit | null | undefined,\n): Promise<Record<string, string>> {\n  if (context.kind === 'env_key') {\n    return { 'X-WorldMonitor-Key': context.apiKey };\n  }\n  if (context.kind === 'free') {\n    // U7: a free-tier context has no principal to authenticate as, so there is\n    // nothing honest to sign. Throwing is the fail-closed choice — the\n    // alternative (falling through to the HMAC branch below) would mint","sourceCodeStart":128,"sourceCodeEnd":164,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/api/mcp/auth.ts#L128-L164","documentation":"requireCurrentConvexUser(userId, action) backs every account-bound billing operation: it calls waitForConvexAuthForUser(userId) and throws `Account changed while ${action}. Try again.` when the shared ConvexClient's auth no longer belongs to the initiating user (different barrier user, Clerk user changed, superseded setAuth generation, or the 10s token wait expired); assertAccountStillCurrent then re-verifies after success. This stops one account from claiming or mutating another's billing state.","triggerScenarios":"Sign-out or account switch mid-billing-operation; a concurrent auth rebind superseding the barrier; token propagation slower than the 10s timeout during operations like claiming Pro activation.","commonSituations":"Users switching accounts in another tab; token refresh storms after sign-in; slow networks stretching the barrier wait.","solutions":["Retry the billing action once sign-in settles; the operation is idempotent from the caller's perspective","Keep the same account signed in across tabs for the duration of the operation","Check console logs for repeated rebind/authGeneration churn if it persists","For activation claims: a failed claim does not consume the one-time presentation; the claimNonce can be retried"],"exampleFix":"// before\nawait requireCurrentConvexUser(userId, 'claiming Pro activation'); // throws 'Account changed...'\n\n// after: capture identity, verify stability, retry once\nconst userId = getCurrentClerkUser()?.id;\nif (!userId) { openSignIn(); return; }\ntry {\n  const status = await claimProActivationPresentation(activationKey, claimNonce);\n} catch (e) {\n  if (e instanceof Error && e.message.startsWith('Account changed') && getCurrentClerkUser()?.id === userId) {\n    await claimProActivationPresentation(activationKey, claimNonce);\n  } else throw e;\n}","handlingStrategy":"retry","validationCode":"const userId = getCurrentClerkUser()?.id;\nif (!userId) { openSignIn(); return; }\nif (getCurrentClerkUser()?.id !== userId) { abortStaleOperation(); return; }\nawait claimProActivationPresentation(activationKey, claimNonce);","typeGuard":"const isAccountChangedError = (e: unknown): e is Error =>\n  e instanceof Error && e.message.startsWith('Account changed');","tryCatchPattern":"try {\n  await claimProActivationPresentation(activationKey, claimNonce);\n} catch (e) {\n  if (isAccountChangedError(e) && getCurrentClerkUser()?.id === userId) {\n    await new Promise(r => setTimeout(r, 1000));\n    await claimProActivationPresentation(activationKey, claimNonce);\n  } else throw e;\n}","preventionTips":["Capture the initiating userId before any await; never trust a plain auth-wait across account switches","Failed claims do not consume the one-time presentation; retry with the same claimNonce","Keep sign-in stable across tabs for the duration of billing operations"],"tags":["clerk","convex","auth-race","account-switch","billing"],"backgroundTag":"session-user-mismatch","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}