{"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":145,"sourceCode":"    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 `pro` HMAC branch below) would mint\n    // an internally-trusted signature for an anonymous caller, which is the\n    // one outcome the free tier must never produce. A free-tier tool that\n    // reaches here is misconfigured: it declared `_freeTier` while calling a\n    // credentialed downstream.\n    throw new Error('buildAuthHeaders: free-tier context has no credentials — a free-tier tool must not call a credentialed downstream');\n  }\n  // context.kind === 'pro'\n  const secret = process.env.MCP_INTERNAL_HMAC_SECRET ?? '';\n  if (!secret) {\n    // Should never happen in production (deploy gate at U10) — surface as\n    // an error so the tool fetch fails fast rather than silently 401-ing\n    // at the gateway with a confusing \"invalid_internal_mcp_signature\".\n    throw new Error('MCP_INTERNAL_HMAC_SECRET not configured');\n  }\n  const signed = await signInternalMcpRequest({\n    method,\n    url,\n    body,\n    userId: context.userId,\n    secret,\n  });\n  return buildInternalMcpHeaders(signed);\n}\n\nexport const PRODUCTION_DEPS: McpHandlerDeps = {\n  resolveBearerToContext,\n  // Preserve the validator's revoked/transient distinction: revoked grants\n  // are 401 invalid_token, while a Convex/network outage is a retryable 503.\n  validateProMcpToken,\n  getEntitlements,\n  validateUserApiKey,","sourceCodeStart":127,"sourceCodeEnd":163,"githubUrl":"https://github.com/koala73/worldmonitor/blob/a96956387a927b8cd7aa34b0c41fca357e746be9/api/mcp/auth.ts#L127-L163","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":"a96956387a927b8cd7aa34b0c41fca357e746be9","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}