{"record":{"id":"e7da7cd4c33c7f80","repo":"Hmbown/CodeWhale","slug":"invalid-draft-key","errorCode":null,"errorMessage":"invalid draft key","messagePattern":"invalid draft key","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/lib/community-agent.ts","lineNumber":316,"sourceCode":"  } catch {\n    return null;\n  }\n}\n\nexport async function listDrafts(kv: KVNamespace | undefined, prefix = \"draft:\"): Promise<AgentDraft[]> {\n  if (!kv) return [];\n  const listed = await kv.list({ prefix, limit: 100 });\n  const drafts: AgentDraft[] = [];\n  for (const k of listed.keys) {\n    const draft = await getDraft(kv, k.name);\n    if (draft) drafts.push(draft);\n  }\n  return drafts;\n}\n\nexport async function deleteDraft(kv: KVNamespace | undefined, key: string): Promise<void> {\n  if (!kv) return;\n  if (!parseDraftKey(key)) throw new Error(\"invalid draft key\");\n  await kv.delete(key);\n}\n\n// --- Admin session helpers ---\n\nconst SESSION_PREFIX = \"session:admin:\";\nconst SESSION_TTL_SEC = 60 * 60 * 24; // 24h\n\nfunction toBase64Url(bytes: Uint8Array): string {\n  let s = \"\";\n  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n  return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nexport async function safeEqual(a: string, b: string): Promise<boolean> {\n  const enc = new TextEncoder();\n  const ha = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", enc.encode(a)));\n  const hb = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", enc.encode(b)));","sourceCodeStart":298,"sourceCodeEnd":334,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/8880682c63083a91624de936797efa3ce9e498fd/web/lib/community-agent.ts#L298-L334","documentation":"deleteDraft() only deletes keys that round-trip through parseDraftKey(): exactly draft:<type>:<id> where type is in AGENT_DRAFT_TYPES and id matches the strict id pattern. Any other string - a bare id, a wrong prefix, an unknown type - is rejected before kv.delete() runs, protecting the namespace from arbitrary deletions.","triggerScenarios":"Passing the draft id instead of the full KV key; a key whose type segment is not one of the known draft types; sweeping admin routes that forward arbitrary keys.","commonSituations":"The front-end sends {id} and the handler forwards it as key; a new draft type was added to constants but not to AGENT_DRAFT_TYPES; maintenance scripts iterating the whole namespace.","solutions":["Pass the exact key returned by listDrafts()/getDraft(), never a bare id","Build keys only via draftKey(type, id)","Guard with parseDraftKey() and skip/400 on non-draft keys"],"exampleFix":"// before\nawait deleteDraft(kv, id); // id like 'abc123' -> throws\n\n// after\nconst key = draftKey(type, id); // 'draft:dispatch:abc123'\nawait deleteDraft(kv, key);","handlingStrategy":"type-guard","validationCode":"import { parseDraftKey } from './community-agent';\nif (parseDraftKey(key) === null) {\n  return new Response('invalid draft key', { status: 400 });\n}","typeGuard":"import { parseDraftKey } from './community-agent';\nfunction isDraftKey(key) {\n  return typeof key === 'string' && parseDraftKey(key) !== null;\n}\n// usage: if (!isDraftKey(key)) continue; // skip foreign KV keys during sweeps","tryCatchPattern":null,"preventionTips":["Only delete keys produced by draftKey() or returned by listDrafts()","When adding a draft type, add it to AGENT_DRAFT_TYPES in the same commit","In admin routes, validate keys before any kv.delete() call"],"tags":["validation","kv-storage","admin","cloudflare-workers"],"backgroundTag":null,"analyzedSha":"8880682c63083a91624de936797efa3ce9e498fd","analyzedAt":"2026-08-16T11:31:27.956Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}