{"record":{"id":"6de00f85596f8963","repo":"santifer/career-ops","slug":"collage-api-url-must-be-v1-positions-job-site-address-url","errorCode":null,"errorMessage":"collage: API URL must be /v1/positions/<job-site-address>: ${url}","messagePattern":"collage: API URL must be /v1/positions/<job-site-address>: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"providers/collage.mjs","lineNumber":22,"sourceCode":"// Collage HR public job-site API.  A job-site address is an explicit tenant\n// identifier, not a company-name slug we should guess.  Entries may provide\n// the exact API URL or a public Collage careers URL from which the final path\n// segment is read.\n\nconst API_ORIGIN = 'https://api.collage.co';\nconst COLLAGE_API_HOST = 'api.collage.co';\nconst COLLAGE_SITE_HOST_RE = /^secure\\.collage\\.co$/;\n\n/** @param {string} url */\nfunction assertCollageApiUrl(url) {\n  let parsed;\n  try { parsed = new URL(url); } catch { throw new Error(`collage: invalid URL: ${url}`); }\n  if (parsed.protocol !== 'https:') throw new Error(`collage: URL must use HTTPS: ${url}`);\n  if (parsed.hostname !== COLLAGE_API_HOST) {\n    throw new Error(`collage: untrusted hostname \"${parsed.hostname}\" — must be ${COLLAGE_API_HOST}`);\n  }\n  if (!/^\\/v1\\/positions\\/[^/?#]+$/.test(parsed.pathname)) {\n    throw new Error(`collage: API URL must be /v1/positions/<job-site-address>: ${url}`);\n  }\n  return url;\n}\n\n/** @param {import('./_types.js').PortalEntry} entry */\nfunction resolveApiUrl(entry) {\n  const explicit = typeof entry.api === 'string' ? entry.api.trim() : '';\n  if (explicit) return assertCollageApiUrl(explicit);\n\n  const raw = typeof entry.careers_url === 'string' ? entry.careers_url.trim() : '';\n  if (!raw) return null;\n  let parsed;\n  try { parsed = new URL(raw); } catch { return null; }\n  if (parsed.protocol !== 'https:' || !COLLAGE_SITE_HOST_RE.test(parsed.hostname)) return null;\n  if (!/^\\/jobs\\/[^/]+(?:\\/)?$/.test(parsed.pathname)) return null;\n  const parts = parsed.pathname.split('/').filter(Boolean);\n  const site = parts.at(-1);\n  if (!site || site.includes('.')) return null;","sourceCodeStart":4,"sourceCodeEnd":40,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/collage.mjs#L4-L40","documentation":"assertCollageApiUrl requires the API URL path to match /v1/positions/<job-site-address> — a single non-empty path segment with no slashes, query string, or fragment. The job-site address is an explicit tenant identifier, not a guessed slug, so anything that doesn't fit this exact shape is rejected. This error fires when protocol and host are correct but the path is wrong.","triggerScenarios":"A portals.yml `api:` value like https://api.collage.co/v1/positions (missing the tenant segment), .../v1/positions/acme/jobs (extra segment), .../v1/positions/acme?page=1 (query string), or an old/renamed endpoint path.","commonSituations":"Appending pagination or filter parameters to the configured URL (pagination belongs to the provider, not the config); copying a browser URL with extra path segments; missing the tenant id entirely.","solutions":["Set the `api:` value to exactly https://api.collage.co/v1/positions/<job-site-address> with no trailing slash, query, or extra segments","Find the job-site address on the tenant's secure.collage.co/jobs/<address> page (the last path segment) and use that","If unsure, configure `careers_url: https://secure.collage.co/jobs/<address>` and let resolveApiUrl derive and validate the API URL"],"exampleFix":"# before (portals.yml)\napi: https://api.collage.co/v1/positions/acme?page=1\n# after\napi: https://api.collage.co/v1/positions/acme","handlingStrategy":"validation","validationCode":"// Validate the /v1/positions/<site> path shape before configuring\nfunction hasCollagePositionsPath(v) {\n  try { return /^\\/v1\\/positions\\/[^/?#]+$/.test(new URL(v).pathname); } catch { return false; }\n}\n","typeGuard":"function isWellFormedCollageApiUrl(v) {\n  try {\n    const u = new URL(v);\n    return u.protocol === 'https:' && u.hostname === 'api.collage.co'\n      && /^\\/v1\\/positions\\/[^/?#]+$/.test(u.pathname);\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  const jobs = await collageProvider.fetch(entry, ctx);\n} catch (err) {\n  if (String(err.message).includes('API URL must be /v1/positions/')) {\n    console.error(`${entry.name}: api must be exactly https://api.collage.co/v1/positions/<job-site-address> — no query, trailing slash, or extra segments`);\n  } else { throw err; }\n}","preventionTips":["Copy the job-site address (last segment of the tenant's secure.collage.co/jobs/... URL) into the API path exactly as-is","Never append pagination, filters, or trailing slashes to the configured api URL — the provider handles paging itself","When in doubt, configure careers_url instead and let resolveApiUrl build and validate the API URL"],"tags":["url-validation","configuration","api-endpoint"],"backgroundTag":"invalid-url-format","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"}