{"record":{"id":"dc80073a4f9dd3e9","repo":"santifer/career-ops","slug":"collage-unrecognized-response-envelope-expected-a-positions","errorCode":null,"errorMessage":"collage: unrecognized response envelope (expected a positions array)","messagePattern":"collage: unrecognized response envelope \\(expected a positions array\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/collage.mjs","lineNumber":74,"sourceCode":"function text(value) { return typeof value === 'string' ? value.trim() : ''; }\n\n/** @param {unknown} value */\nfunction absoluteHttpsUrl(value) {\n  const raw = text(value);\n  if (!raw) return '';\n  try {\n    const parsed = new URL(raw);\n    if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) return '';\n    return parsed.href;\n  } catch {\n    return '';\n  }\n}\n\n/** @param {any} json @param {string} companyName */\nexport function parseCollageResponse(json, companyName) {\n  const rows = Array.isArray(json) ? json : Array.isArray(json?.positions) ? json.positions : null;\n  if (!rows) throw new Error('collage: unrecognized response envelope (expected a positions array)');\n  return rows.filter(j => j && text(j.title)).map(j => {\n    const url = absoluteHttpsUrl(j.hostedUrl) || absoluteHttpsUrl(j.url) || absoluteHttpsUrl(j.applyUrl);\n    if (!url) return null;\n    const location = Array.isArray(j.location) ? j.location.map(text).filter(Boolean).join('; ') : text(j.location);\n    const metadata = [\n      text(j.department) && `Department: ${text(j.department)}`,\n      text(j.commitment) && `Commitment: ${text(j.commitment)}`,\n      text(j.employmentType) && `Employment type: ${text(j.employmentType)}`,\n    ].filter(Boolean).join('\\n');\n    const description = [text(j.descriptionPlain), metadata].filter(Boolean).join('\\n\\n');\n    return {\n      title: text(j.title), url, company: companyName,\n      location, description,\n      postedAt: toEpochMs(j.createdDate ?? j.createdAt ?? j.publishedAt),\n    };\n  }).filter(Boolean);\n}\n","sourceCodeStart":56,"sourceCodeEnd":92,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/collage.mjs#L56-L92","documentation":"parseCollageResponse validates the JSON returned by the Collage HR public job-site API before extracting positions. The library accepts either a bare array of positions or an object with a `positions` array; any other envelope (object, string, null, or an object with differently-named fields) means the endpoint's shape changed or the wrong URL was fetched, so it throws instead of silently returning zero jobs. This guards the scanner against silent API drift.","triggerScenarios":"ctx.fetchJson returns JSON that is neither an array nor `{positions: [...]}` — e.g. an HTML error page parsed loosely, a Collage API version change renaming `positions`, a rate-limit/auth JSON body like `{error: ...}`, or fetching the wrong (non-positions) endpoint.","commonSituations":"Collage changes their careers-api response shape; a portals.yml entry points at a stale or wrong /v1/positions/<site> address; the tenant's job site was deleted so the API returns an error object; a proxy or WAF intercepts the request and returns a non-API JSON body.","solutions":["Log the actual response body for the failing tenant and compare it with the documented `{positions: [...]}` envelope","Check the Collage API URL in portals.yml resolves to the correct /v1/positions/<job-site-address> endpoint (try it with curl)","Check for a provider/library update: Collage may have renamed the field; update parseCollageResponse or the provider to the new envelope","Handle error bodies explicitly: if the JSON has an `error`/`message` field, surface that message instead of the generic envelope error"],"exampleFix":"// before\nfetch('https://api.collage.co/v1/positions/mytenant').then(r => r.json()) // -> {data: [...]} passed straight to parseCollageResponse\n// after\nconst json = await res.json();\nif (Array.isArray(json)) return parseCollageResponse(json, name);\nif (Array.isArray(json?.positions)) return parseCollageResponse(json, name);\nif (Array.isArray(json?.data)) return parseCollageResponse({positions: json.data}, name); // adapt known envelope variants\nthrow new Error('unexpected collage envelope: ' + JSON.stringify(json).slice(0, 200));","handlingStrategy":"type-guard","validationCode":"function isCollageEnvelope(json) {\n  return Array.isArray(json) || (json && typeof json === 'object' && Array.isArray(json.positions));\n}\nif (!isCollageEnvelope(json)) console.warn('skipping tenant: unexpected envelope', json);","typeGuard":"function isCollageEnvelope(json) {\n  return Array.isArray(json) || (json !== null && typeof json === 'object' && Array.isArray(json.positions));\n}","tryCatchPattern":"try {\n  const jobs = parseCollageResponse(json, company);\n} catch (err) {\n  if (String(err.message).includes('unrecognized response envelope')) {\n    logger.warn({company, bodySample: JSON.stringify(json)?.slice(0, 200)}, 'collage envelope changed; skipping');\n  } else throw err;\n}","preventionTips":["Wrap the raw fetch so non-2xx/HTML bodies are rejected before parsing","Log a sample of unexpected response bodies to detect Collage API drift early","Pin and monitor the Collage API version you call (/v1/...)","Test parseCollageResponse against a recorded fixture in CI"],"tags":["api","response-shape","json","provider"],"backgroundTag":"unexpected-response-shape","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}