santifer/career-ops · error

csod: no anonymous token on

Error message

csod: no anonymous token on ${cfg.homeUrl}

What it means

The Cornerstone OnDemand (CSOD) provider bootstraps each fetch by GETting the tenant's career-site home page, which embeds an anonymous bearer JWT ("token":"eyJ…") plus session cookies needed by the public search API. When `extractToken(html)` finds no token in that bootstrap HTML, the provider throws because the subsequent POST to /services/x/career-site/v1/search cannot authenticate. This almost always means the fetched page is not the expected ~5 KB bootstrap document.

Solutions

  1. Fix the portal entry so `api:` (or careers_url) points at the real *.csod.com URL matching /ux/ats/careersite/\d+/home?c=<corpName>, not the branded corporate page.
  2. Open cfg.homeUrl in a browser and inspect the page source for "token":"eyJ — if absent, the tenant gates the board; remove or replace the entry.
  3. Verify the siteId and c= corpName parameters are correct (wrong values land on error/redirect pages without a token).
  4. Check the provider version against the tenant's current page markup — if CSOD renamed the embedded token field, extractToken must be updated.
  5. If a redirect is silently followed to a login page, ensure the fetch keeps redirect:'error' semantics so the misconfiguration surfaces before token extraction.

Example fix

// before (portals.yml)
- name: ohb
  careers_url: https://www.ohb.de/karriere
// after
- name: ohb
  careers_url: https://www.ohb.de/karriere
  api: https://career-ohb.csod.com/ux/ats/careersite/4/home?c=career-ohb
Defensive patterns

Strategy: validation

Validate before calling

// validate the entry before scanning
const u = new URL(entry.api || entry.careers_url);
if (!(u.protocol === 'https:' && (u.host === 'csod.com' || u.host.endsWith('.csod.com')) &&
      /\/ux\/ats\/careersite\/\d+\//.test(u.pathname))) {
  throw new Error('entry must point at https://<tenant>.csod.com/ux/ats/careersite/<id>/home?c=<corp>');
}

Try / catch

try {
  await provider.fetch(entry, ctx);
} catch (e) {
  if (e.message.startsWith('csod: no anonymous token')) {
    // board gated or wrong URL — flag entry for manual review, don't retry
    reportUnreachable(entry, e.message);
  } else throw e;
}

Prevention

When it happens

Trigger: fetch() completed the GET of cfg.homeUrl (either via ctx.fetch with cookies, or ctx.fetchText with redirect:'error') and extractToken() on the response body returned falsy — i.e. the HTML contains no embedded `"token":"…"` anonymous JWT. Happens when the homeUrl points at the branded corporate careers page instead of the *.csod.com careersite URL, when the site redirects to a login/consent/captcha page, or when the tenant has changed its page structure so the token is no longer inline.

Common situations: Portals.yml entry where `careers_url` is the company's branded careers page and no `api:` field carries the https://{tenant}.csod.com/ux/ats/careersite/{siteId}/home?c={corpName} URL; a tenant that now gates anonymous access behind a cookie-consent interstitial; a wrong siteId or corpName query param landing on an error page; CSOD changing the bootstrap format in an upgrade.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/c3ef00235e940af1. Report an issue: GitHub.

Appendix: source

Thrown at providers/csod.mjs:207

    // it (older embedders and test mocks), which keeps the pre-cookie
    // behaviour intact for tenants that never needed it.
    //
    // cfg.homeUrl and cfg.searchApi are both built from the same parsed
    // origin, so replaying these cookies cannot reach a third-party host.
    // redirect:'error' on the bootstrap keeps that true: origin validation
    // covers the URL we ask for, not wherever a 3xx would send us.
    let html;
    let cookie = '';
    if (typeof ctx.fetchResponse === 'function') {
      const res = await ctx.fetchResponse(cfg.homeUrl, { redirect: 'error', headers: { accept: 'text/html' } });
      const setCookies = typeof res?.headers?.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
      cookie = cookieHeaderFrom(setCookies);
      html = await res.text();
    } else {
      html = await ctx.fetchText(cfg.homeUrl, { redirect: 'error', headers: { accept: 'text/html' } });
    }
    const token = extractToken(html);
    if (!token) throw new Error(`csod: no anonymous token on ${cfg.homeUrl}`);

    const wait = (ms) => (ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)));
    const maxPages = resolveMaxPages(entry);
    const jobs = [];
    const seen = new Set();
    let total = null;

    for (let page = 1; page <= maxPages; page++) {
      if (page > 1) await wait(PAGE_DELAY_MS);
      const json = await ctx.fetchJson(cfg.searchApi, {
        method: 'POST',
        redirect: 'error',
        headers: {
          'content-type': 'application/json',
          accept: 'application/json',
          authorization: `Bearer ${token}`,
          ...(cookie ? { cookie } : {}),
        },

View on GitHub (pinned to aac998c7ed)