@cavuno/board 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{_spec-DePC7a05.d.mts → _spec-B9Ah8dNs.d.mts} +35 -0
  3. package/dist/{_spec-DePC7a05.d.ts → _spec-B9Ah8dNs.d.ts} +35 -0
  4. package/dist/bin.mjs +174 -19
  5. package/dist/{blog-DbFr4VPW.d.mts → blog-BmqWfLZi.d.mts} +1 -1
  6. package/dist/{blog-ummlt6Uq.d.ts → blog-bh07fZ8Q.d.ts} +1 -1
  7. package/dist/{board-zWyHbyi4.d.ts → board-B4jLqmxn.d.mts} +5 -3
  8. package/dist/{board-DxTMBgIf.d.mts → board-De8nLbk7.d.ts} +5 -3
  9. package/dist/{breadcrumbs-kbmspmfk.d.ts → breadcrumbs-B1HVufC7.d.ts} +2 -2
  10. package/dist/{breadcrumbs-P4qh0zML.d.mts → breadcrumbs-BVQL2dNY.d.mts} +2 -2
  11. package/dist/browser/cavuno-board.global.min.js +1 -1
  12. package/dist/{chunk-IWWPHFK2.mjs → chunk-T46JASKG.mjs} +3 -0
  13. package/dist/doctor.d.mts +3 -1
  14. package/dist/doctor.d.ts +3 -1
  15. package/dist/doctor.js +169 -17
  16. package/dist/doctor.mjs +169 -17
  17. package/dist/filters.d.mts +2 -2
  18. package/dist/filters.d.ts +2 -2
  19. package/dist/format.d.mts +4 -4
  20. package/dist/format.d.ts +4 -4
  21. package/dist/index.d.mts +45 -23
  22. package/dist/index.d.ts +45 -23
  23. package/dist/index.js +10 -6
  24. package/dist/index.mjs +8 -7
  25. package/dist/{jobs-DTLDt57G.d.mts → jobs-B7q3BgIK.d.mts} +1 -1
  26. package/dist/{jobs-twt5TO9l.d.ts → jobs-Iv6Wkvvf.d.ts} +1 -1
  27. package/dist/{search-CHD6peIl.d.ts → search-CbDZwdyl.d.ts} +1 -1
  28. package/dist/{search-f2UNlTfv.d.mts → search-DC0jatQN.d.mts} +1 -1
  29. package/dist/seo-BDFUuvXM.d.ts +12 -0
  30. package/dist/seo-HCE7aCkh.d.mts +12 -0
  31. package/dist/seo-files.d.mts +36 -0
  32. package/dist/seo-files.d.ts +36 -0
  33. package/dist/seo-files.js +102 -0
  34. package/dist/seo-files.mjs +81 -0
  35. package/dist/seo.d.mts +6 -6
  36. package/dist/seo.d.ts +6 -6
  37. package/dist/server.d.mts +6 -5
  38. package/dist/server.d.ts +6 -5
  39. package/dist/server.mjs +1 -1
  40. package/dist/sitemap.d.mts +6 -5
  41. package/dist/sitemap.d.ts +6 -5
  42. package/dist/suggest.d.mts +2 -2
  43. package/dist/suggest.d.ts +2 -2
  44. package/package.json +11 -1
  45. package/skills/cavuno-board-account/SKILL.md +3 -1
  46. package/skills/cavuno-board-filters/SKILL.md +17 -3
  47. package/skills/manifest.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  This changelog records changes that affect Board API compatibility, exported
4
4
  types, runtime behavior, or supported integration patterns.
5
5
 
6
+ ## 4.9.0 — 2026-08-27
7
+
8
+ - **`@cavuno/board/seo-files`**: new dependency-free subpath with
9
+ `robotsResponse`, `adsTxtResponse`, `indexNowResponse`, and
10
+ `googleSiteVerificationMeta`. Headless frontends serve `robots.txt`,
11
+ `ads.txt`, and `indexnow-key.txt` byte-identically to hosted boards from
12
+ `board.seo()`; the robots body now includes `Disallow: /go/` and
13
+ `Disallow: /t/` like hosted boards.
14
+ - **Doctor**: `--frontend` probes gained `read.adsTxt`, `read.indexNow`,
15
+ `read.googleVerification`, and `read.oauthCallback`, and `read.robots` now
16
+ checks the `Sitemap:` line and `/go/` disallow. Results gained an additive
17
+ `warn` status (`DoctorSummary.warned`) that never fails the run; a file that
18
+ is served while unconfigured in the dashboard, or a missing OAuth callback
19
+ forward, warns instead of failing. Read probes run serially and retry a
20
+ 503 child sitemap once.
21
+ - **`board.context()`**: exposes `ads` (`enabled`, `clientId`) and
22
+ `jobForm` visibility, typed as `PublicBoardAds` and `PublicBoardJobForm`.
23
+ Employer companies expose `deletionEnabled`.
24
+ - **Taxonomy lists** accept `sort: 'jobCount'` to order collections by live
25
+ job count.
26
+ - **Generated types** cover the `candidate.profile.updated` webhook event
27
+ and the operator candidate sync resources (`CandidateSyncSnapshot`,
28
+ `CandidateReconcileResult`, and related bodies).
29
+
6
30
  ## 4.8.0 — 2026-08-23
7
31
 
8
32
  - **Country-gated Apply flows**: compatible starters can use
@@ -961,6 +961,8 @@ interface components {
961
961
  linkedinUrl: string | null;
962
962
  facebookUrl: string | null;
963
963
  logoUrl: string | null;
964
+ /** @description Whether company admins may delete this company. Mirrors the board Features toggle `employerCompanyDeletionEnabled`. Absent board config defaults to `true`. When `false`, DELETE returns 403 `company_deletion_disabled` and clients should hide the danger zone. */
965
+ deletionEnabled: boolean;
964
966
  };
965
967
  EmployerCreateJobBody: {
966
968
  /** @description Long-form description of the role. Up to 25,000 characters. */
@@ -1616,6 +1618,7 @@ interface components {
1616
1618
  /** @enum {string} */
1617
1619
  channel: "messageEmails" | "applicationEmails" | "recommendedJobEmails";
1618
1620
  subscribed: boolean;
1621
+ waitlisted: boolean;
1619
1622
  updatedAt: number | null;
1620
1623
  };
1621
1624
  PaywallOffer: {
@@ -1789,6 +1792,10 @@ interface components {
1789
1792
  showCavunoBranding: boolean;
1790
1793
  features: {
1791
1794
  jobAlerts: boolean;
1795
+ /** @description Whether personalized candidate job recommendations are available on this board. Absent board config defaults to `true`. */
1796
+ jobRecommendationsEnabled: boolean;
1797
+ /** @description Whether the board-level Recommended talent toggle is enabled. The endpoint additionally requires the Candidate area and a non-off talent directory. Absent board config defaults to `false`. */
1798
+ recommendedTalentEnabled: boolean;
1792
1799
  candidates: boolean;
1793
1800
  employers: boolean;
1794
1801
  blog: boolean;
@@ -1814,11 +1821,37 @@ interface components {
1814
1821
  linkedInPartnerId: string | null;
1815
1822
  cookieConsentRequired: boolean;
1816
1823
  };
1824
+ /** @description Public AdSense switch + publisher id from board advertising settings. Not the slot map (Puck/Cavuno placements) and not `ads.txt` (`board.seo()`). */
1825
+ ads: {
1826
+ /** @description Whether AdSense is turned on for this board (Settings → Advertising). Headless frontends render their own placements only when this is true and `clientId` is a valid `ca-pub-` publisher id. Per-placement slot ids are not on this resource — applications own geometry. */
1827
+ enabled: boolean;
1828
+ /** @description AdSense publisher id (`ca-pub-` + 16 digits), or null when ads are off or the stored value is malformed. Already public on hosted pages as `data-ad-client`. */
1829
+ clientId: string | null;
1830
+ };
1817
1831
  /** @description Operator-defined custom field definitions keyed by model (currently only `job`). Each key holds that model's definitions in display order. Company and talent keys are added when those models ship. The frontend uses these to render and localize each record's opaque `customFieldValues`. Display-only: not filterable or searchable in v1. */
1818
1832
  customFields: {
1819
1833
  /** @description This model's custom-field definitions in display order. Empty when the board defines none for the model. */
1820
1834
  job: components["schemas"]["CustomFieldDefinition"][];
1821
1835
  };
1836
+ /** @description Built-in job-form field visibility from Settings → Job form. Does not include custom fields (`customFields.job`) or operator-only constraints (required, bounds, allowlists). */
1837
+ jobForm: {
1838
+ salary: {
1839
+ /** @description Whether salary is captured on posting forms and shown on public job cards. Absent board config defaults to `true`. */
1840
+ visible: boolean;
1841
+ };
1842
+ seniority: {
1843
+ /** @description Whether seniority is captured on posting forms. Absent board config defaults to `true`. */
1844
+ visible: boolean;
1845
+ };
1846
+ location: {
1847
+ /** @description Whether office location is captured on posting forms. Absent board config defaults to `true`. */
1848
+ visible: boolean;
1849
+ };
1850
+ sponsorship: {
1851
+ /** @description Whether visa sponsorship is captured on posting forms. Absent board config defaults to `true`. */
1852
+ visible: boolean;
1853
+ };
1854
+ };
1822
1855
  /** @description Operator-authored contact and social identity (settings › Contact & company details). Extracted from the former `footer` group in 4.0.0: contact is identity data, not layout. Brand/social URLs are sanitized to absolute http(s). */
1823
1856
  contact: {
1824
1857
  /** @description Public contact email (render as `mailto:`). Operator-set in settings › Contact & company details. */
@@ -2195,6 +2228,8 @@ interface components {
2195
2228
  canonicalSlug: string;
2196
2229
  /** @description Board-language name with source-name fallback. */
2197
2230
  displayName: string;
2231
+ /** @description Live published-job count for this term on the board. Maintained as jobs publish and expire — not a tally of the current list page. */
2232
+ jobCount: number;
2198
2233
  };
2199
2234
  PublicVerifyBoardPasswordBody: {
2200
2235
  /** @description The board password. */
@@ -961,6 +961,8 @@ interface components {
961
961
  linkedinUrl: string | null;
962
962
  facebookUrl: string | null;
963
963
  logoUrl: string | null;
964
+ /** @description Whether company admins may delete this company. Mirrors the board Features toggle `employerCompanyDeletionEnabled`. Absent board config defaults to `true`. When `false`, DELETE returns 403 `company_deletion_disabled` and clients should hide the danger zone. */
965
+ deletionEnabled: boolean;
964
966
  };
965
967
  EmployerCreateJobBody: {
966
968
  /** @description Long-form description of the role. Up to 25,000 characters. */
@@ -1616,6 +1618,7 @@ interface components {
1616
1618
  /** @enum {string} */
1617
1619
  channel: "messageEmails" | "applicationEmails" | "recommendedJobEmails";
1618
1620
  subscribed: boolean;
1621
+ waitlisted: boolean;
1619
1622
  updatedAt: number | null;
1620
1623
  };
1621
1624
  PaywallOffer: {
@@ -1789,6 +1792,10 @@ interface components {
1789
1792
  showCavunoBranding: boolean;
1790
1793
  features: {
1791
1794
  jobAlerts: boolean;
1795
+ /** @description Whether personalized candidate job recommendations are available on this board. Absent board config defaults to `true`. */
1796
+ jobRecommendationsEnabled: boolean;
1797
+ /** @description Whether the board-level Recommended talent toggle is enabled. The endpoint additionally requires the Candidate area and a non-off talent directory. Absent board config defaults to `false`. */
1798
+ recommendedTalentEnabled: boolean;
1792
1799
  candidates: boolean;
1793
1800
  employers: boolean;
1794
1801
  blog: boolean;
@@ -1814,11 +1821,37 @@ interface components {
1814
1821
  linkedInPartnerId: string | null;
1815
1822
  cookieConsentRequired: boolean;
1816
1823
  };
1824
+ /** @description Public AdSense switch + publisher id from board advertising settings. Not the slot map (Puck/Cavuno placements) and not `ads.txt` (`board.seo()`). */
1825
+ ads: {
1826
+ /** @description Whether AdSense is turned on for this board (Settings → Advertising). Headless frontends render their own placements only when this is true and `clientId` is a valid `ca-pub-` publisher id. Per-placement slot ids are not on this resource — applications own geometry. */
1827
+ enabled: boolean;
1828
+ /** @description AdSense publisher id (`ca-pub-` + 16 digits), or null when ads are off or the stored value is malformed. Already public on hosted pages as `data-ad-client`. */
1829
+ clientId: string | null;
1830
+ };
1817
1831
  /** @description Operator-defined custom field definitions keyed by model (currently only `job`). Each key holds that model's definitions in display order. Company and talent keys are added when those models ship. The frontend uses these to render and localize each record's opaque `customFieldValues`. Display-only: not filterable or searchable in v1. */
1818
1832
  customFields: {
1819
1833
  /** @description This model's custom-field definitions in display order. Empty when the board defines none for the model. */
1820
1834
  job: components["schemas"]["CustomFieldDefinition"][];
1821
1835
  };
1836
+ /** @description Built-in job-form field visibility from Settings → Job form. Does not include custom fields (`customFields.job`) or operator-only constraints (required, bounds, allowlists). */
1837
+ jobForm: {
1838
+ salary: {
1839
+ /** @description Whether salary is captured on posting forms and shown on public job cards. Absent board config defaults to `true`. */
1840
+ visible: boolean;
1841
+ };
1842
+ seniority: {
1843
+ /** @description Whether seniority is captured on posting forms. Absent board config defaults to `true`. */
1844
+ visible: boolean;
1845
+ };
1846
+ location: {
1847
+ /** @description Whether office location is captured on posting forms. Absent board config defaults to `true`. */
1848
+ visible: boolean;
1849
+ };
1850
+ sponsorship: {
1851
+ /** @description Whether visa sponsorship is captured on posting forms. Absent board config defaults to `true`. */
1852
+ visible: boolean;
1853
+ };
1854
+ };
1822
1855
  /** @description Operator-authored contact and social identity (settings › Contact & company details). Extracted from the former `footer` group in 4.0.0: contact is identity data, not layout. Brand/social URLs are sanitized to absolute http(s). */
1823
1856
  contact: {
1824
1857
  /** @description Public contact email (render as `mailto:`). Operator-set in settings › Contact & company details. */
@@ -2195,6 +2228,8 @@ interface components {
2195
2228
  canonicalSlug: string;
2196
2229
  /** @description Board-language name with source-name fallback. */
2197
2230
  displayName: string;
2231
+ /** @description Live published-job count for this term on the board. Maintained as jobs publish and expire — not a tally of the current list page. */
2232
+ jobCount: number;
2198
2233
  };
2199
2234
  PublicVerifyBoardPasswordBody: {
2200
2235
  /** @description The board password. */
package/dist/bin.mjs CHANGED
@@ -109,7 +109,14 @@ function summarize(results) {
109
109
  const passed = results.filter((r) => r.status === "pass").map((r) => r.id);
110
110
  const failed = results.filter((r) => r.status === "fail").map((r) => r.id);
111
111
  const skipped = results.filter((r) => r.status === "skip").map((r) => r.id);
112
- return { exitCode: failed.length > 0 ? 1 : 0, passed, failed, skipped };
112
+ const warned = results.filter((r) => r.status === "warn").map((r) => r.id);
113
+ return {
114
+ exitCode: failed.length > 0 ? 1 : 0,
115
+ passed,
116
+ failed,
117
+ skipped,
118
+ warned
119
+ };
113
120
  }
114
121
 
115
122
  // src/doctor/cookie-conformance.ts
@@ -284,7 +291,7 @@ function checkCookieCodecConformance(projectRoot) {
284
291
  }
285
292
 
286
293
  // src/doctor/probe.ts
287
- var FETCH_TIMEOUT_MS = 1e4;
294
+ var FETCH_TIMEOUT_MS = 6e4;
288
295
  async function probe(fetchImpl, url) {
289
296
  try {
290
297
  const response = await fetchImpl(url, {
@@ -310,7 +317,11 @@ var READ = {
310
317
  jobs: record("read.jobs", 2),
311
318
  jsonld: record("read.jsonld", 2),
312
319
  sitemap: record("read.sitemap", 2),
313
- robots: record("read.robots", 2)
320
+ robots: record("read.robots", 2),
321
+ adsTxt: record("read.adsTxt", 2),
322
+ indexNow: record("read.indexNow", 2),
323
+ googleVerification: record("read.googleVerification", 2),
324
+ oauthCallback: record("read.oauthCallback", 2)
314
325
  };
315
326
  function skipReadProbes(reason) {
316
327
  return Object.values(READ).map((make) => make("skip", reason));
@@ -366,7 +377,11 @@ async function probeSitemap(fetchImpl, base) {
366
377
  }
367
378
  let urls = doc.urls;
368
379
  if (doc.kind === "index") {
369
- const child = await probe(fetchImpl, onFrontend(base, urls[0]));
380
+ const childUrl = onFrontend(base, urls[0]);
381
+ let child = await probe(fetchImpl, childUrl);
382
+ if (!child.ok && (child.status === 503 || child.status === 0)) {
383
+ child = await probe(fetchImpl, childUrl);
384
+ }
370
385
  const childDoc = child.ok ? parseSitemap(child.body) : null;
371
386
  if (!childDoc || childDoc.urls.length === 0) {
372
387
  return READ.sitemap(
@@ -380,14 +395,109 @@ async function probeSitemap(fetchImpl, base) {
380
395
  const sample = await probe(fetchImpl, sampleUrl);
381
396
  return sample.ok ? READ.sitemap("pass", `${urls.length} entries; sample page resolves`) : READ.sitemap("fail", `sitemap page ${sampleUrl} \u2192 HTTP ${sample.status}`);
382
397
  }
383
- async function runReadProbes(fetchImpl, frontendUrl) {
398
+ function scoreRobots(robots, seo) {
399
+ if (!robots.ok) {
400
+ return READ.robots("fail", `robots.txt HTTP ${robots.status}`);
401
+ }
402
+ const missing = [];
403
+ if (!/user-agent\s*:/i.test(robots.body)) missing.push("User-agent");
404
+ if (!robots.body.includes("Disallow: /go/")) missing.push("Disallow: /go/");
405
+ if (seo) {
406
+ const canonicalBase = seo.canonicalBase.replace(/\/+$/, "");
407
+ const sitemapLine = `Sitemap: ${canonicalBase}/sitemap.xml`;
408
+ if (!robots.body.includes(sitemapLine)) missing.push(sitemapLine);
409
+ }
410
+ if (missing.includes("User-agent")) {
411
+ return READ.robots(
412
+ "fail",
413
+ "robots.txt returned 200 but has no User-agent directive"
414
+ );
415
+ }
416
+ return missing.length === 0 ? READ.robots("pass", "present") : READ.robots(
417
+ "warn",
418
+ `robots.txt is missing ${missing.join(", ")} (hosted boards emit it)`
419
+ );
420
+ }
421
+ function configuredText(value) {
422
+ return typeof value === "string" && value.length > 0 ? value : null;
423
+ }
424
+ function scoreOptionalTextFile(options) {
425
+ const { make, label, path, seo, probed } = options;
426
+ if (seo == null) {
427
+ return make("skip", "board seo not fetched");
428
+ }
429
+ const expected = configuredText(options.configured);
430
+ if (expected !== null) {
431
+ if (!probed.ok) {
432
+ return make("fail", `${path} HTTP ${probed.status}`);
433
+ }
434
+ if (probed.body.trimEnd() === expected.trimEnd()) {
435
+ return make("pass", `${label} matches dashboard`);
436
+ }
437
+ return make("fail", `${path} body does not match dashboard ${label}`);
438
+ }
439
+ if (probed.status === 404) {
440
+ return make("pass", "unconfigured; 404 as expected");
441
+ }
442
+ if (probed.status === 200) {
443
+ return make(
444
+ "warn",
445
+ `${label} served but not configured in dashboard \u2014 clone leftover?`
446
+ );
447
+ }
448
+ return make("fail", `${path} HTTP ${probed.status}`);
449
+ }
450
+ function escapeRegExp(value) {
451
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
452
+ }
453
+ function hasGoogleSiteVerificationMeta(html, token) {
454
+ const escaped = escapeRegExp(token);
455
+ const quoted = `["']${escaped}["']`;
456
+ const name = `name\\s*=\\s*["']google-site-verification["']`;
457
+ const content = `content\\s*=\\s*${quoted}`;
458
+ return new RegExp(`<meta\\b[^>]*\\b${name}[^>]*\\b${content}`, "i").test(html) || new RegExp(`<meta\\b[^>]*\\b${content}[^>]*\\b${name}`, "i").test(html);
459
+ }
460
+ function scoreGoogleVerification(homeHtml, seo) {
461
+ if (seo == null) {
462
+ return READ.googleVerification("skip", "board seo not fetched");
463
+ }
464
+ const token = configuredText(seo.googleSiteVerification);
465
+ if (token === null) {
466
+ return READ.googleVerification("skip", "no token configured");
467
+ }
468
+ return hasGoogleSiteVerificationMeta(homeHtml, token) ? READ.googleVerification("pass", "google-site-verification meta present") : READ.googleVerification(
469
+ "fail",
470
+ "home HTML is missing the google-site-verification meta tag"
471
+ );
472
+ }
473
+ var OAUTH_PROVIDERS = ["google", "linkedin"];
474
+ function scoreOAuthCallback(probed) {
475
+ const broken = OAUTH_PROVIDERS.filter(
476
+ (provider) => !(probed[provider].status === 400 && /missing code or state/i.test(probed[provider].body))
477
+ );
478
+ return broken.length === 0 ? READ.oauthCallback("pass", "callback paths reach the hosted app") : READ.oauthCallback(
479
+ "warn",
480
+ `${broken.map((p) => `/api/board-auth/oauth/${p}/callback \u2192 HTTP ${probed[p].status}`).join(", ")} \u2014 only matters if you registered this domain's callback with the provider: forward the path to cavuno.com, or register https://cavuno.com/api/board-auth/oauth/{provider}/callback instead`
481
+ );
482
+ }
483
+ async function runReadProbes(fetchImpl, frontendUrl, seo) {
384
484
  const base = frontendUrl.replace(/\/$/, "");
385
- const [home, jobsAndJsonLd, sitemap, robots] = await Promise.all([
386
- probe(fetchImpl, base),
387
- probeJobsAndJsonLd(fetchImpl, base),
388
- probeSitemap(fetchImpl, base),
389
- probe(fetchImpl, `${base}/robots.txt`)
390
- ]);
485
+ const home = await probe(fetchImpl, base);
486
+ const jobsAndJsonLd = await probeJobsAndJsonLd(fetchImpl, base);
487
+ const sitemap = await probeSitemap(fetchImpl, base);
488
+ const robots = await probe(fetchImpl, `${base}/robots.txt`);
489
+ const adsTxt = await probe(fetchImpl, `${base}/ads.txt`);
490
+ const indexNow = await probe(fetchImpl, `${base}/indexnow-key.txt`);
491
+ const oauthCallback = {
492
+ google: await probe(
493
+ fetchImpl,
494
+ `${base}/api/board-auth/oauth/google/callback`
495
+ ),
496
+ linkedin: await probe(
497
+ fetchImpl,
498
+ `${base}/api/board-auth/oauth/linkedin/callback`
499
+ )
500
+ };
391
501
  return [
392
502
  home.ok && /<(html|body|div|main)[\s>]/i.test(home.body) ? READ.home("pass", "home renders") : READ.home(
393
503
  "fail",
@@ -395,10 +505,25 @@ async function runReadProbes(fetchImpl, frontendUrl) {
395
505
  ),
396
506
  ...jobsAndJsonLd,
397
507
  sitemap,
398
- robots.ok && /user-agent\s*:/i.test(robots.body) ? READ.robots("pass", "present") : READ.robots(
399
- "fail",
400
- robots.ok ? "robots.txt returned 200 but has no User-agent directive" : `robots.txt HTTP ${robots.status}`
401
- )
508
+ scoreRobots(robots, seo),
509
+ scoreOptionalTextFile({
510
+ make: READ.adsTxt,
511
+ label: "ads.txt",
512
+ path: "/ads.txt",
513
+ seo,
514
+ configured: seo?.adsTxt,
515
+ probed: adsTxt
516
+ }),
517
+ scoreOptionalTextFile({
518
+ make: READ.indexNow,
519
+ label: "indexnow-key.txt",
520
+ path: "/indexnow-key.txt",
521
+ seo,
522
+ configured: seo?.indexNowKey,
523
+ probed: indexNow
524
+ }),
525
+ scoreGoogleVerification(home.body, seo),
526
+ scoreOAuthCallback(oauthCallback)
402
527
  ];
403
528
  }
404
529
 
@@ -454,6 +579,28 @@ async function checkBoardResolves(fetchImpl, env) {
454
579
  }
455
580
  return STATIC_BOARD("pass", "publishable key resolves the board");
456
581
  }
582
+ async function fetchBoardSeo(fetchImpl, env) {
583
+ const result = await probe(
584
+ fetchImpl,
585
+ `${apiBase(env.apiUrl)}/v1/boards/${encodeURIComponent(env.boardKey)}/seo`
586
+ );
587
+ if (!result.ok) return null;
588
+ try {
589
+ const parsed = JSON.parse(result.body);
590
+ if (typeof parsed !== "object" || parsed === null) return null;
591
+ const rec = parsed;
592
+ if (typeof rec.canonicalBase !== "string") return null;
593
+ const asNullableString = (value) => value === null || typeof value === "string" ? value : null;
594
+ return {
595
+ canonicalBase: rec.canonicalBase,
596
+ adsTxt: asNullableString(rec.adsTxt),
597
+ indexNowKey: asNullableString(rec.indexNowKey),
598
+ googleSiteVerification: asNullableString(rec.googleSiteVerification)
599
+ };
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
457
604
  var SKILL_ROOTS = [
458
605
  ".claude/skills",
459
606
  ".agents/skills",
@@ -515,8 +662,13 @@ async function runDoctor(options) {
515
662
  } else {
516
663
  results.push(STATIC_API("skip", "PUBLIC_CAVUNO_API_URL not set"));
517
664
  }
665
+ let seo = null;
518
666
  if (envOk) {
519
- results.push(await checkBoardResolves(fetchImpl, env));
667
+ const boardResult = await checkBoardResolves(fetchImpl, env);
668
+ results.push(boardResult);
669
+ if (boardResult.status === "pass") {
670
+ seo = await fetchBoardSeo(fetchImpl, env);
671
+ }
520
672
  } else {
521
673
  results.push(STATIC_BOARD("skip", "env checks failed \u2014 fix them first"));
522
674
  }
@@ -525,7 +677,7 @@ async function runDoctor(options) {
525
677
  ...checkCookieCodecConformance(options.projectRoot ?? process.cwd())
526
678
  );
527
679
  results.push(
528
- ...options.frontendUrl ? await runReadProbes(fetchImpl, options.frontendUrl) : skipReadProbes("no --frontend <url> given")
680
+ ...options.frontendUrl ? await runReadProbes(fetchImpl, options.frontendUrl, seo) : skipReadProbes("no --frontend <url> given")
529
681
  );
530
682
  return { results, summary: summarize(results) };
531
683
  }
@@ -596,18 +748,21 @@ async function doctor(argv) {
596
748
  });
597
749
  console.log("\n@cavuno/board doctor");
598
750
  for (const result of results) {
599
- const mark = result.status === "pass" ? "\u2713" : result.status === "fail" ? "\u2717" : "\u2212";
751
+ const mark = result.status === "pass" ? "\u2713" : result.status === "fail" ? "\u2717" : result.status === "warn" ? "!" : "\u2212";
600
752
  console.log(
601
753
  ` ${mark} [tier ${result.tier}] ${result.id} \u2014 ${result.detail}`
602
754
  );
603
755
  }
604
756
  console.log(
605
757
  `
606
- ${summary.passed.length} passed, ${summary.failed.length} failed, ${summary.skipped.length} skipped`
758
+ ${summary.passed.length} passed, ${summary.failed.length} failed, ${summary.skipped.length} skipped, ${summary.warned.length} warned`
607
759
  );
608
760
  if (summary.skipped.length > 0) {
609
761
  console.log(` Skipped (NOT verified): ${summary.skipped.join(", ")}`);
610
762
  }
763
+ if (summary.warned.length > 0) {
764
+ console.log(` Warned: ${summary.warned.join(", ")}`);
765
+ }
611
766
  if (!frontendUrl) {
612
767
  console.log(
613
768
  " Pass --frontend <url> to run the read probes against your board frontend."
@@ -1,4 +1,4 @@
1
- import { S as Schemas } from './_spec-DePC7a05.mjs';
1
+ import { S as Schemas } from './_spec-B9Ah8dNs.mjs';
2
2
 
3
3
  /** Author shape embedded on posts (no `object` discriminator). */
4
4
  type BlogAuthorEmbed = Schemas['PublicBlogAuthorEmbed'];
@@ -1,4 +1,4 @@
1
- import { S as Schemas } from './_spec-DePC7a05.js';
1
+ import { S as Schemas } from './_spec-B9Ah8dNs.js';
2
2
 
3
3
  /** Author shape embedded on posts (no `object` discriminator). */
4
4
  type BlogAuthorEmbed = Schemas['PublicBlogAuthorEmbed'];
@@ -1,5 +1,5 @@
1
- import { S as Schemas } from './_spec-DePC7a05.js';
2
- import { L as ListEnvelope, m as RelatedSearch } from './jobs-twt5TO9l.js';
1
+ import { S as Schemas } from './_spec-B9Ah8dNs.mjs';
2
+ import { L as ListEnvelope, m as RelatedSearch } from './jobs-B7q3BgIK.mjs';
3
3
 
4
4
  type PublicCompany = Schemas['CompanyPublic'];
5
5
  /** A market (sector) a company operates in — name + its source slug. */
@@ -98,6 +98,8 @@ type SalaryDetailQuery = {
98
98
  type PublicBoard = Schemas['PublicBoardContext'];
99
99
  type PublicBoardFeatures = PublicBoard['features'];
100
100
  type PublicBoardAnalytics = PublicBoard['analytics'];
101
+ type PublicBoardAds = PublicBoard['ads'];
102
+ type PublicBoardJobForm = PublicBoard['jobForm'];
101
103
  /**
102
104
  * An operator-defined custom field definition. Board-wide and
103
105
  * model-scoped under `board.context().customFields` (today only `job`);
@@ -109,4 +111,4 @@ type CustomFieldDefinition = Schemas['CustomFieldDefinition'];
109
111
  type CustomFieldType = CustomFieldDefinition['type'];
110
112
  type CustomFieldOption = Schemas['CustomFieldOption'];
111
113
 
112
- export type { TitleLocationSalary as A, TitleLocationsIndex as B, CustomFieldDefinition as C, LocationSalaryDetail as L, PublicBoard as P, SkillSalaryDetail as S, TitleSalaryDetail as T, CompanyCategorySalary as a, CompanySalary as b, CompaniesListQuery as c, CompanyListEnvelope as d, CompaniesSearchBody as e, CompanyJobsListQuery as f, CompanySimilarQuery as g, CompanyMarketsListQuery as h, SalaryDetailQuery as i, CompanyMarket as j, CompanyMarketRef as k, CompanySalarySummary as l, CustomFieldOption as m, CustomFieldType as n, LocationSkillsIndex as o, LocationTitlesIndex as p, PublicBoardAnalytics as q, PublicBoardFeatures as r, PublicCompany as s, PublicCompanyDetail as t, SalaryCompany as u, SalaryLocation as v, SalarySkill as w, SalaryTitle as x, SkillLocationSalary as y, SkillLocationsIndex as z };
114
+ export type { SkillLocationSalary as A, SkillLocationsIndex as B, CustomFieldDefinition as C, TitleLocationSalary as D, TitleLocationsIndex as E, LocationSalaryDetail as L, PublicBoard as P, SkillSalaryDetail as S, TitleSalaryDetail as T, CompanyCategorySalary as a, CompanySalary as b, CompaniesListQuery as c, CompanyListEnvelope as d, CompaniesSearchBody as e, CompanyJobsListQuery as f, CompanySimilarQuery as g, CompanyMarketsListQuery as h, SalaryDetailQuery as i, CompanyMarket as j, CompanyMarketRef as k, CompanySalarySummary as l, CustomFieldOption as m, CustomFieldType as n, LocationSkillsIndex as o, LocationTitlesIndex as p, PublicBoardAds as q, PublicBoardAnalytics as r, PublicBoardFeatures as s, PublicBoardJobForm as t, PublicCompany as u, PublicCompanyDetail as v, SalaryCompany as w, SalaryLocation as x, SalarySkill as y, SalaryTitle as z };
@@ -1,5 +1,5 @@
1
- import { S as Schemas } from './_spec-DePC7a05.mjs';
2
- import { L as ListEnvelope, m as RelatedSearch } from './jobs-DTLDt57G.mjs';
1
+ import { S as Schemas } from './_spec-B9Ah8dNs.js';
2
+ import { L as ListEnvelope, m as RelatedSearch } from './jobs-Iv6Wkvvf.js';
3
3
 
4
4
  type PublicCompany = Schemas['CompanyPublic'];
5
5
  /** A market (sector) a company operates in — name + its source slug. */
@@ -98,6 +98,8 @@ type SalaryDetailQuery = {
98
98
  type PublicBoard = Schemas['PublicBoardContext'];
99
99
  type PublicBoardFeatures = PublicBoard['features'];
100
100
  type PublicBoardAnalytics = PublicBoard['analytics'];
101
+ type PublicBoardAds = PublicBoard['ads'];
102
+ type PublicBoardJobForm = PublicBoard['jobForm'];
101
103
  /**
102
104
  * An operator-defined custom field definition. Board-wide and
103
105
  * model-scoped under `board.context().customFields` (today only `job`);
@@ -109,4 +111,4 @@ type CustomFieldDefinition = Schemas['CustomFieldDefinition'];
109
111
  type CustomFieldType = CustomFieldDefinition['type'];
110
112
  type CustomFieldOption = Schemas['CustomFieldOption'];
111
113
 
112
- export type { TitleLocationSalary as A, TitleLocationsIndex as B, CustomFieldDefinition as C, LocationSalaryDetail as L, PublicBoard as P, SkillSalaryDetail as S, TitleSalaryDetail as T, CompanyCategorySalary as a, CompanySalary as b, CompaniesListQuery as c, CompanyListEnvelope as d, CompaniesSearchBody as e, CompanyJobsListQuery as f, CompanySimilarQuery as g, CompanyMarketsListQuery as h, SalaryDetailQuery as i, CompanyMarket as j, CompanyMarketRef as k, CompanySalarySummary as l, CustomFieldOption as m, CustomFieldType as n, LocationSkillsIndex as o, LocationTitlesIndex as p, PublicBoardAnalytics as q, PublicBoardFeatures as r, PublicCompany as s, PublicCompanyDetail as t, SalaryCompany as u, SalaryLocation as v, SalarySkill as w, SalaryTitle as x, SkillLocationSalary as y, SkillLocationsIndex as z };
114
+ export type { SkillLocationSalary as A, SkillLocationsIndex as B, CustomFieldDefinition as C, TitleLocationSalary as D, TitleLocationsIndex as E, LocationSalaryDetail as L, PublicBoard as P, SkillSalaryDetail as S, TitleSalaryDetail as T, CompanyCategorySalary as a, CompanySalary as b, CompaniesListQuery as c, CompanyListEnvelope as d, CompaniesSearchBody as e, CompanyJobsListQuery as f, CompanySimilarQuery as g, CompanyMarketsListQuery as h, SalaryDetailQuery as i, CompanyMarket as j, CompanyMarketRef as k, CompanySalarySummary as l, CustomFieldOption as m, CustomFieldType as n, LocationSkillsIndex as o, LocationTitlesIndex as p, PublicBoardAds as q, PublicBoardAnalytics as r, PublicBoardFeatures as s, PublicBoardJobForm as t, PublicCompany as u, PublicCompanyDetail as v, SalaryCompany as w, SalaryLocation as x, SalarySkill as y, SalaryTitle as z };
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-zWyHbyi4.js';
2
- import { P as PublicJob } from './jobs-twt5TO9l.js';
1
+ import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-De8nLbk7.js';
2
+ import { P as PublicJob } from './jobs-Iv6Wkvvf.js';
3
3
 
4
4
  /**
5
5
  * Wire enum for `salaryTimeframe`. Defined here (not re-exported from any
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-DxTMBgIf.mjs';
2
- import { P as PublicJob } from './jobs-DTLDt57G.mjs';
1
+ import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-B4jLqmxn.mjs';
2
+ import { P as PublicJob } from './jobs-B7q3BgIK.mjs';
3
3
 
4
4
  /**
5
5
  * Wire enum for `salaryTimeframe`. Defined here (not re-exported from any