koala73/worldmonitor · warning
MCP_INTERNAL_HMAC_SECRET not configured
Error message
MCP_INTERNAL_HMAC_SECRET not configured
What it means
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.
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
Example fix
// before
await requireCurrentConvexUser(userId, 'claiming Pro activation'); // throws 'Account changed...'
// after: capture identity, verify stability, retry once
const userId = getCurrentClerkUser()?.id;
if (!userId) { openSignIn(); return; }
try {
const status = await claimProActivationPresentation(activationKey, claimNonce);
} catch (e) {
if (e instanceof Error && e.message.startsWith('Account changed') && getCurrentClerkUser()?.id === userId) {
await claimProActivationPresentation(activationKey, claimNonce);
} else throw e;
} Defensive patterns
Strategy: retry
Validate before calling
const userId = getCurrentClerkUser()?.id;
if (!userId) { openSignIn(); return; }
if (getCurrentClerkUser()?.id !== userId) { abortStaleOperation(); return; }
await claimProActivationPresentation(activationKey, claimNonce); Type guard
const isAccountChangedError = (e: unknown): e is Error =>
e instanceof Error && e.message.startsWith('Account changed'); Try / catch
try {
await claimProActivationPresentation(activationKey, claimNonce);
} catch (e) {
if (isAccountChangedError(e) && getCurrentClerkUser()?.id === userId) {
await new Promise(r => setTimeout(r, 1000));
await claimProActivationPresentation(activationKey, claimNonce);
} else throw e;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: Users switching accounts in another tab; token refresh storms after sign-in; slow networks stretching the barrier wait.
Related errors
- HTTP
- Account changed while creating the embed key. Try again.
- Account changed while revoking the embed key. Try again.
- Authenticated account changed during push setup
- Authentication unavailable while loading Business Pro…
AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-08-21).
Data as JSON: /api/errors/d2407102fef41f8e.
Report an issue: GitHub.
Appendix: source
Thrown at api/mcp/auth.ts:146
if (!url || !token) return null;
mcpAnonRatelimit = new Ratelimit({
redis: new Redis({ url, token, retry: false }),
limiter: Ratelimit.slidingWindow(60, '60 s'),
prefix: 'rl:mcp:anon',
analytics: false,
});
return mcpAnonRatelimit;
}
/**
* Build the Authorization header set for a downstream `_execute` fetch.
*
* - env_key → `X-WorldMonitor-Key: <apiKey>` (legacy operator keys).
* - pro / user_key → `X-WM-MCP-Internal: <ts>.<sig>` + `X-WM-MCP-User-Id`.
* Signature binds method+pathname+queryHash+bodyHash+userId.
* Identity-resolved dashboard keys use this path so the
* gateway does not increment the shared daily account meter
* a second time after MCP already reserved the tool weight.
*
* `body` MUST be the EXACT bytes the caller passes to `fetch()` so the
* signed payload matches the wire bytes. For JSON, pre-stringify on the
* caller side and pass the same string here.
*/
export async function buildAuthHeaders(
context: McpAuthContext,
method: string,
url: string,
body: BodyInit | null | undefined,
): Promise<Record<string, string>> {
if (context.kind === 'env_key') {
return { 'X-WorldMonitor-Key': context.apiKey };
}
if (context.kind === 'free') {
// U7: a free-tier context has no principal to authenticate as, so there is
// nothing honest to sign. Throwing is the fail-closed choice — the
// alternative (falling through to the HMAC branch below) would mintView on GitHub (pinned to 7d06c8633d)