koala73/worldmonitor · error · ConvexError
INVALID_HASH
INVALID_HASH
Error message
INVALID_HASH
What it means
createEmbedKey in convex/embedKeys.ts throws "INVALID_HASH" as a ConvexError when the client-supplied keyHash fails the strict check /^[a-f0-9]{64}$/. The API expects the client to generate the random embed key locally, SHA-256 hash it, and send only the lowercase 64-character hex digest; the plaintext key is never stored server-side. This error means the hash argument is not a well-formed SHA-256 hex string, so the mutation aborts before any database work.
Solutions
- Compute the digest as lowercase hex: new Uint8Array(await crypto.subtle.digest('SHA-256', encoded)) then map each byte with toString(16).padStart(2, '0') and join.
- Validate client-side before calling the mutation: /^[a-f0-9]{64}$/.test(keyHash), and abort with a friendly message if it fails.
- Check that you are hashing the actual key material (not a JSON wrapper or the key's display name) and that no encoding step (base64, uppercasing, trimming) altered the digest.
- If the hash comes from another service or stored config, verify the source value with a hex decoder to confirm it is 32 raw bytes / 64 hex chars.
Example fix
// before
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
const keyHash = encodeBase64(digest); // INVALID_HASH: not 64-char hex
// after
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)));
const keyHash = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); // lowercase 64-char hex Defensive patterns
Strategy: validation
Validate before calling
const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
function isValidKeyHash(keyHash) {
return typeof keyHash === 'string' && SHA256_HEX_RE.test(keyHash);
}
if (!isValidKeyHash(keyHash)) throw new Error('keyHash must be 64-char lowercase hex SHA-256'); Type guard
function isSha256Hex(v: unknown): v is string {
return typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
} Try / catch
try {
await client.mutation(api.embedKeys.createEmbedKey, { name, keyPrefix, keyHash });
} catch (e) {
if (String(e).includes('INVALID_HASH')) {
showUserError('Key could not be created: the key digest is malformed. Please regenerate the key.');
} else throw e;
} Prevention
- Always encode SHA-256 output as lowercase hex (padStart(2,'0') per byte); never base64 or uppercase.
- Centralize hash generation in one utility used by every caller.
- Assert the 64-hex-char invariant in unit tests for the hash helper.
- Never send the plaintext key or key name where keyHash is expected.
When it happens
Trigger: Calling the createEmbedKey mutation with keyHash that is: not exactly 64 hex characters (e.g. a base64 digest, a truncated hash, a SHA-1/MD5 digest, or the raw plaintext key itself); contains uppercase hex letters (A-F) because the digest was uppercased; has whitespace, a '0x' prefix, or JSON-encoding artifacts; or was built with a hashing step that silently failed and produced undefined/empty string.
Common situations: Developers hashing with output encoding other than hex (e.g. base64 from crypto.subtle mishandling or a library default), calling .toUpperCase() on the digest, hashing a template literal that interpolated 'undefined', or porting code from the apiKeys flow with a different hash format. Frontend fetch wrappers that coerce args to strings can also inject whitespace or quotes.
Understand the failure class
Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.
Related errors
- INVALID_API_KEY_SCOPES
- INCOMPATIBLE_DELIVERY
- COUNTRIES_LIMIT_EXCEEDED
- TICKERS_LIMIT_EXCEEDED
- digestHour must be an integer 0–23
AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15).
Data as JSON: /api/errors/5709f0a36a2071f0.
Report an issue: GitHub.
Appendix: source
Thrown at convex/embedKeys.ts:92
// billing event happened to rewrite their row.
const merged = entitlement
? {
features: mergeEntitlementFeatures(entitlement.planKey, entitlement.features),
validUntil: entitlement.validUntil,
}
: null;
if (!hasAccountEmbedAccess(identity?.plan, merged, Date.now())) {
throw new ConvexError("EMBED_ACCESS_REQUIRED");
}
if (!args.name.trim()) {
throw new ConvexError("INVALID_NAME");
}
if (!/^wme_[a-f0-9]{5}$/.test(args.keyPrefix)) {
throw new ConvexError("INVALID_PREFIX");
}
if (!/^[a-f0-9]{64}$/.test(args.keyHash)) {
throw new ConvexError("INVALID_HASH");
}
const allowedOrigins = normalizeAllowedOrigins(args.allowedOrigins);
const active = await ctx.db
.query("embedKeys")
.withIndex("by_userId_revokedAt", (q) =>
q.eq("userId", userId).eq("revokedAt", undefined),
)
.collect();
if (active.length >= MAX_EMBED_KEYS_PER_USER) {
throw new ConvexError("KEY_LIMIT_REACHED");
}
// Guard against duplicate hash (astronomically unlikely, but belt-and-suspenders)
const dup = await ctx.db
.query("embedKeys")
.withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash))
.first();View on GitHub (pinned to 7d06c8633d)