{"record":{"id":"a606a7664933e910","repo":"koala73/worldmonitor","slug":"custom-scorecard-blocs-require-2-30-members","errorCode":null,"errorMessage":"Custom scorecard blocs require 2-30 members.","messagePattern":"Custom scorecard blocs require 2-30 members\\.","errorType":"validation","errorClass":"Error","httpStatus":400,"severity":"error","filePath":"server/worldmonitor/scorecard/v1/_bloc-presets.ts","lineNumber":36,"sourceCode":"  ASEAN: { id: 'ASEAN', label: 'ASEAN', members: ['BN', 'KH', 'ID', 'LA', 'MY', 'MM', 'PH', 'SG', 'TH', 'TL', 'VN'] },\n  NATO: {\n    id: 'NATO',\n    label: 'NATO',\n    members: ['AL', 'BE', 'BG', 'CA', 'HR', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IS', 'IT', 'LV', 'LT', 'LU', 'ME', 'NL', 'MK', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'TR', 'GB', 'US'],\n  },\n};\n\nexport function resolveBlocSelection(input: { preset?: string; members?: string[] }): ScorecardBlocSelection {\n  const preset = String(input.preset || '').trim();\n  const members = Array.isArray(input.members) ? input.members : [];\n  if (Boolean(preset) === (members.length > 0)) {\n    throw new Error('Select exactly one scorecard bloc preset or custom member list.');\n  }\n  if (preset) {\n    if (!(preset in SCORECARD_BLOC_PRESETS)) throw new Error(`Unknown scorecard bloc preset: ${preset}`);\n    return SCORECARD_BLOC_PRESETS[preset as ScorecardBlocPreset];\n  }\n  if (members.length < 2 || members.length > 30) throw new Error('Custom scorecard blocs require 2-30 members.');\n  if (members.some((member) => !/^[A-Z]{2}$/.test(member))) {\n    throw new Error('Custom scorecard bloc members must be uppercase ISO-2 codes.');\n  }\n  if (new Set(members).size !== members.length) throw new Error('Custom scorecard bloc members must be unique.');\n  if (members.some((member) => !isInRankableUniverse(member))) {\n    throw new Error('Custom scorecard bloc members must belong to the public rankable country universe.');\n  }\n  const sorted = [...members].sort();\n  return { id: `custom:${sorted.join('-')}`, label: sorted.join(' + '), members: sorted };\n}\n","sourceCodeStart":18,"sourceCodeEnd":47,"githubUrl":"https://github.com/koala73/worldmonitor/blob/9361220cc013571781071f0206e4d80fd14b2f7f/server/worldmonitor/scorecard/v1/_bloc-presets.ts#L18-L47","documentation":"When a custom member list (no preset) is supplied, resolveBlocSelection requires between 2 and 30 country codes inclusive. Lists of length 0 hit the XOR error first; lists of exactly 1 or longer than 30 hit this error. The bounds keep scorecard comparisons statistically meaningful and the request payload/cache key bounded.","triggerScenarios":"Calling getBlocScorecardWithReader with { members: ['US'] } (only 1 member) or with more than 30 members (e.g., dumping all ~200 ISO codes into a custom bloc). Zero members instead raises 'Select exactly one scorecard bloc preset or custom member list.'","commonSituations":"1) A UI lets users pick a single country for comparison — must use the single-country scorecard API instead of a bloc; 2) 'select all' in a country picker sends the full universe; 3) scripted requests that join multiple blocs into one 40-member list instead of separate calls.","solutions":["Provide 2–30 members; if you only need one country, call the single-country resilience score endpoint instead.","For larger comparisons, split into multiple bloc requests of ≤30 and merge client-side.","Cap the country picker UI at 30 selections before allowing submit.","Deduplicate first — duplicates count toward array length but fail later on the uniqueness check."],"exampleFix":"// before\nresolveBlocSelection({ members: ['US'] }); // throws: only 1 member\n// after\nif (members.length < 2) return getResilienceScore({ countryCode: members[0] });\nresolveBlocSelection({ members: members.slice(0, 30) });","handlingStrategy":"validation","validationCode":"const members = (sel.members ?? []).filter(Boolean);\nif (members.length < 2 || members.length > 30) throw new Error('Custom scorecard blocs require 2-30 members.');","typeGuard":null,"tryCatchPattern":"try {\n  const bloc = await getBlocScorecard({ members });\n} catch (err) {\n  if (err.message.includes('require 2-30 members')) {\n    if (members.length === 1) return getResilienceScore({ countryCode: members[0] });\n    return badRequest('select between 2 and 30 countries');\n  }\n  throw err;\n}","preventionTips":["Cap the multi-select UI at 30 and require at least 2","Route single-country comparisons to the score endpoint, not blocs","Split oversized comparisons into multiple ≤30-member calls","Deduplicate before counting length"],"tags":["validation","scorecard","limits","bad-request"],"backgroundTag":"invalid-bloc-selection","analyzedSha":"9361220cc013571781071f0206e4d80fd14b2f7f","analyzedAt":"2026-09-01T10:32:37.851Z","contentChangedAt":"2026-09-01T10:32:37.851Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}