@cavuno/board 4.57.0 → 4.59.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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/{_spec-CDYDY0hB.d.mts → _spec-bmTwonT1.d.mts} +11 -5
  3. package/dist/{_spec-CDYDY0hB.d.ts → _spec-bmTwonT1.d.ts} +11 -5
  4. package/dist/analytics.js +38 -1
  5. package/dist/analytics.mjs +38 -1
  6. package/dist/bin.mjs +27 -14
  7. package/dist/{blog-WupXypHv.d.mts → blog-2rj7bfEp.d.mts} +1 -1
  8. package/dist/{blog-DNGOmaKI.d.ts → blog-BEKRpXYK.d.ts} +1 -1
  9. package/dist/{board-CmQox8FZ.d.ts → board-Bdc--f9u.d.ts} +2 -2
  10. package/dist/{board-DbBBz7Pv.d.mts → board-BrJfw_S7.d.mts} +2 -2
  11. package/dist/{breadcrumbs-DpXiAo9j.d.ts → breadcrumbs-NG9_miP6.d.ts} +2 -2
  12. package/dist/{breadcrumbs-mQTz0vZo.d.mts → breadcrumbs-WferORs2.d.mts} +2 -2
  13. package/dist/browser/cavuno-board.global.min.js +1 -1
  14. package/dist/{chunk-GUXSUQCP.mjs → chunk-LO7D3AIX.mjs} +8 -10
  15. package/dist/doctor.js +27 -14
  16. package/dist/doctor.mjs +27 -14
  17. package/dist/filters.d.mts +2 -2
  18. package/dist/filters.d.ts +2 -2
  19. package/dist/format.d.mts +12 -4
  20. package/dist/format.d.ts +12 -4
  21. package/dist/format.js +6 -1
  22. package/dist/format.mjs +6 -1
  23. package/dist/index.d.mts +13 -11
  24. package/dist/index.d.ts +13 -11
  25. package/dist/index.js +52 -12
  26. package/dist/index.mjs +52 -12
  27. package/dist/{jobs-B-NPmaV0.d.ts → jobs-Cfmfxt86.d.ts} +1 -1
  28. package/dist/{jobs-CgmHLS8j.d.mts → jobs-DmrG_cGZ.d.mts} +1 -1
  29. package/dist/route-contract.d.mts +3 -4
  30. package/dist/route-contract.d.ts +3 -4
  31. package/dist/route-contract.js +8 -10
  32. package/dist/route-contract.mjs +1 -1
  33. package/dist/{search--bO1b3RJ.d.mts → search-CcCXc47H.d.mts} +1 -1
  34. package/dist/{search-BvhQ92ZO.d.ts → search-DwJi7GFS.d.ts} +1 -1
  35. package/dist/{seo-CBRBS8Mc.d.ts → seo-D4DJWYCY.d.ts} +1 -1
  36. package/dist/{seo-C7HjUu_z.d.mts → seo-DSRRhklF.d.mts} +1 -1
  37. package/dist/seo-files.d.mts +2 -2
  38. package/dist/seo-files.d.ts +2 -2
  39. package/dist/seo.d.mts +6 -6
  40. package/dist/seo.d.ts +6 -6
  41. package/dist/server.d.mts +6 -6
  42. package/dist/server.d.ts +6 -6
  43. package/dist/sitemap.d.mts +20 -9
  44. package/dist/sitemap.d.ts +20 -9
  45. package/dist/sitemap.js +12 -3
  46. package/dist/sitemap.mjs +12 -3
  47. package/dist/suggest.d.mts +2 -2
  48. package/dist/suggest.d.ts +2 -2
  49. package/dist/well-known.mjs +1 -1
  50. package/package.json +1 -1
  51. package/skills/cavuno-board-auth/SKILL.md +7 -0
  52. package/skills/cavuno-board-format/SKILL.md +5 -1
  53. package/skills/cavuno-board-products/SKILL.md +135 -0
  54. package/skills/cavuno-board-setup/SKILL.md +1 -0
  55. package/skills/cavuno-board-sitemap/SKILL.md +6 -2
  56. package/skills/manifest.json +8 -1
@@ -1,10 +1,10 @@
1
1
  import { BoardSdk } from './index.mjs';
2
- import './_spec-CDYDY0hB.mjs';
3
- import './search--bO1b3RJ.mjs';
4
- import './jobs-CgmHLS8j.mjs';
5
- import './board-DbBBz7Pv.mjs';
6
- import './seo-C7HjUu_z.mjs';
7
- import './blog-WupXypHv.mjs';
2
+ import './_spec-bmTwonT1.mjs';
3
+ import './search-CcCXc47H.mjs';
4
+ import './jobs-DmrG_cGZ.mjs';
5
+ import './board-BrJfw_S7.mjs';
6
+ import './seo-DSRRhklF.mjs';
7
+ import './blog-2rj7bfEp.mjs';
8
8
 
9
9
  /**
10
10
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
@@ -89,7 +89,18 @@ interface ListedSitemapBucket {
89
89
  * freshness stamp survives.
90
90
  */
91
91
  declare function listedBucketEntries(board: BoardSdk): Promise<ListedSitemapBucket[]>;
92
- declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: SitemapBucket): Promise<string[]>;
92
+ /** Options for `buildBucketUrls` and `buildBucketEntries`. */
93
+ interface BuildBucketOptions {
94
+ /**
95
+ * Whether your site publishes `/impressum`, usually because it has
96
+ * Impressum content of its own. When set, the marketing bucket lists
97
+ * `/impressum` exactly when this is `true`, whatever the board's published
98
+ * sitemap or the deprecated `features.impressum` says. Leave it unset to
99
+ * keep following the board.
100
+ */
101
+ impressum?: boolean;
102
+ }
103
+ declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: SitemapBucket, options?: BuildBucketOptions): Promise<string[]>;
93
104
  /**
94
105
  * Like `buildBucketUrls`, but keeps each URL's `lastModified` where the board
95
106
  * publishes one. The result feeds `renderUrlset` directly, so every `<url>`
@@ -100,6 +111,6 @@ declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: Sitema
100
111
  * so entries come back as `{ url }` only — identical output to
101
112
  * `buildBucketUrls`, just in the wider shape.
102
113
  */
103
- declare function buildBucketEntries(board: BoardSdk, origin: string, bucket: SitemapBucket): Promise<SitemapUrlEntry[]>;
114
+ declare function buildBucketEntries(board: BoardSdk, origin: string, bucket: SitemapBucket, options?: BuildBucketOptions): Promise<SitemapUrlEntry[]>;
104
115
 
105
- export { type ListedSitemapBucket, MIN_JOBS_PER_INDEXED_PAGE, SITEMAP_BUCKETS, SITEMAP_CHUNK_SIZE, type SitemapBucket, type SitemapIndexEntry, type SitemapUrlEntry, bucketFilename, buildBucketEntries, buildBucketUrls, chunk, listedBucketEntries, listedBuckets, parseBucketFilename, renderSitemapIndex, renderUrlset, xmlEscape };
116
+ export { type BuildBucketOptions, type ListedSitemapBucket, MIN_JOBS_PER_INDEXED_PAGE, SITEMAP_BUCKETS, SITEMAP_CHUNK_SIZE, type SitemapBucket, type SitemapIndexEntry, type SitemapUrlEntry, bucketFilename, buildBucketEntries, buildBucketUrls, chunk, listedBucketEntries, listedBuckets, parseBucketFilename, renderSitemapIndex, renderUrlset, xmlEscape };
package/dist/sitemap.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { BoardSdk } from './index.js';
2
- import './_spec-CDYDY0hB.js';
3
- import './search-BvhQ92ZO.js';
4
- import './jobs-B-NPmaV0.js';
5
- import './board-CmQox8FZ.js';
6
- import './seo-CBRBS8Mc.js';
7
- import './blog-DNGOmaKI.js';
2
+ import './_spec-bmTwonT1.js';
3
+ import './search-DwJi7GFS.js';
4
+ import './jobs-Cfmfxt86.js';
5
+ import './board-Bdc--f9u.js';
6
+ import './seo-D4DJWYCY.js';
7
+ import './blog-BEKRpXYK.js';
8
8
 
9
9
  /**
10
10
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
@@ -89,7 +89,18 @@ interface ListedSitemapBucket {
89
89
  * freshness stamp survives.
90
90
  */
91
91
  declare function listedBucketEntries(board: BoardSdk): Promise<ListedSitemapBucket[]>;
92
- declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: SitemapBucket): Promise<string[]>;
92
+ /** Options for `buildBucketUrls` and `buildBucketEntries`. */
93
+ interface BuildBucketOptions {
94
+ /**
95
+ * Whether your site publishes `/impressum`, usually because it has
96
+ * Impressum content of its own. When set, the marketing bucket lists
97
+ * `/impressum` exactly when this is `true`, whatever the board's published
98
+ * sitemap or the deprecated `features.impressum` says. Leave it unset to
99
+ * keep following the board.
100
+ */
101
+ impressum?: boolean;
102
+ }
103
+ declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: SitemapBucket, options?: BuildBucketOptions): Promise<string[]>;
93
104
  /**
94
105
  * Like `buildBucketUrls`, but keeps each URL's `lastModified` where the board
95
106
  * publishes one. The result feeds `renderUrlset` directly, so every `<url>`
@@ -100,6 +111,6 @@ declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: Sitema
100
111
  * so entries come back as `{ url }` only — identical output to
101
112
  * `buildBucketUrls`, just in the wider shape.
102
113
  */
103
- declare function buildBucketEntries(board: BoardSdk, origin: string, bucket: SitemapBucket): Promise<SitemapUrlEntry[]>;
114
+ declare function buildBucketEntries(board: BoardSdk, origin: string, bucket: SitemapBucket, options?: BuildBucketOptions): Promise<SitemapUrlEntry[]>;
104
115
 
105
- export { type ListedSitemapBucket, MIN_JOBS_PER_INDEXED_PAGE, SITEMAP_BUCKETS, SITEMAP_CHUNK_SIZE, type SitemapBucket, type SitemapIndexEntry, type SitemapUrlEntry, bucketFilename, buildBucketEntries, buildBucketUrls, chunk, listedBucketEntries, listedBuckets, parseBucketFilename, renderSitemapIndex, renderUrlset, xmlEscape };
116
+ export { type BuildBucketOptions, type ListedSitemapBucket, MIN_JOBS_PER_INDEXED_PAGE, SITEMAP_BUCKETS, SITEMAP_CHUNK_SIZE, type SitemapBucket, type SitemapIndexEntry, type SitemapUrlEntry, bucketFilename, buildBucketEntries, buildBucketUrls, chunk, listedBucketEntries, listedBuckets, parseBucketFilename, renderSitemapIndex, renderUrlset, xmlEscape };
package/dist/sitemap.js CHANGED
@@ -335,11 +335,20 @@ async function listedBucketEntries(board) {
335
335
  );
336
336
  }
337
337
  }
338
- async function buildBucketUrls(board, origin, bucket) {
339
- const entries = await buildBucketEntries(board, origin, bucket);
338
+ async function buildBucketUrls(board, origin, bucket, options) {
339
+ const entries = await buildBucketEntries(board, origin, bucket, options);
340
340
  return entries.map((entry) => entry.url);
341
341
  }
342
- async function buildBucketEntries(board, origin, bucket) {
342
+ async function buildBucketEntries(board, origin, bucket, options) {
343
+ const entries = await boardBucketEntries(board, origin, bucket);
344
+ if (bucket !== "marketing" || options?.impressum === void 0) {
345
+ return entries;
346
+ }
347
+ const impressumUrl = `${origin}${BOARD_PATHS.impressum}`;
348
+ const listed = entries.filter((entry) => entry.url !== impressumUrl);
349
+ return options.impressum ? [...listed, { url: impressumUrl }] : listed;
350
+ }
351
+ async function boardBucketEntries(board, origin, bucket) {
343
352
  try {
344
353
  return await mirrorBucketEntries(board, origin, bucket);
345
354
  } catch (error) {
package/dist/sitemap.mjs CHANGED
@@ -201,11 +201,20 @@ async function listedBucketEntries(board) {
201
201
  );
202
202
  }
203
203
  }
204
- async function buildBucketUrls(board, origin, bucket) {
205
- const entries = await buildBucketEntries(board, origin, bucket);
204
+ async function buildBucketUrls(board, origin, bucket, options) {
205
+ const entries = await buildBucketEntries(board, origin, bucket, options);
206
206
  return entries.map((entry) => entry.url);
207
207
  }
208
- async function buildBucketEntries(board, origin, bucket) {
208
+ async function buildBucketEntries(board, origin, bucket, options) {
209
+ const entries = await boardBucketEntries(board, origin, bucket);
210
+ if (bucket !== "marketing" || options?.impressum === void 0) {
211
+ return entries;
212
+ }
213
+ const impressumUrl = `${origin}${BOARD_PATHS.impressum}`;
214
+ const listed = entries.filter((entry) => entry.url !== impressumUrl);
215
+ return options.impressum ? [...listed, { url: impressumUrl }] : listed;
216
+ }
217
+ async function boardBucketEntries(board, origin, bucket) {
209
218
  try {
210
219
  return await mirrorBucketEntries(board, origin, bucket);
211
220
  } catch (error) {
@@ -1,5 +1,5 @@
1
- import { h as SuggestionItem, b as SearchSuggestQuery, F as FetchOptions, g as SuggestResult } from './search--bO1b3RJ.mjs';
2
- import './_spec-CDYDY0hB.mjs';
1
+ import { h as SuggestionItem, b as SearchSuggestQuery, F as FetchOptions, g as SuggestResult } from './search-CcCXc47H.mjs';
2
+ import './_spec-bmTwonT1.mjs';
3
3
 
4
4
  /**
5
5
  * `@cavuno/board/suggest` — framework-agnostic headless search-suggest
package/dist/suggest.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { h as SuggestionItem, b as SearchSuggestQuery, F as FetchOptions, g as SuggestResult } from './search-BvhQ92ZO.js';
2
- import './_spec-CDYDY0hB.js';
1
+ import { h as SuggestionItem, b as SearchSuggestQuery, F as FetchOptions, g as SuggestResult } from './search-DwJi7GFS.js';
2
+ import './_spec-bmTwonT1.js';
3
3
 
4
4
  /**
5
5
  * `@cavuno/board/suggest` — framework-agnostic headless search-suggest
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  compileManifest
3
- } from "./chunk-GUXSUQCP.mjs";
3
+ } from "./chunk-LO7D3AIX.mjs";
4
4
  import "./chunk-MSY7ERBY.mjs";
5
5
 
6
6
  // src/well-known/handler.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "4.57.0",
3
+ "version": "4.59.0",
4
4
  "description": "Build custom job boards and careers pages with Cavuno’s TypeScript SDK.",
5
5
  "keywords": [
6
6
  "api-client",
@@ -90,3 +90,10 @@ The signed-in OTP verification, resend, magic-link, and OAuth branches use their
90
90
  ## Cavuno SDK reference
91
91
 
92
92
  For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
93
+
94
+
95
+ ### OAuth browser proof
96
+
97
+ SSO sign-ins require proof from the browser that started authorization. Google and LinkedIn sign-ins accept and check the same proof whenever it is supplied, and it will become required for them in a later release. The browser SDK establishes and retains a random secret before `getOAuthAuthorizationUrl` or `getSsoAuthorizationUrl`, sends only its SHA-256 hash to authorization, and supplies the secret when `exchangeOAuth` runs. Keep browser storage available through the provider round trip. Never take a binding secret from a completion URL or copy another browser's completion token.
98
+
99
+ Server callers (a Worker or other server runtime without browser storage) should generate a random secret, retain it securely in the visitor's session (for example an HttpOnly cookie), pass its lowercase SHA-256 hex as `browserBindingHash` to `getOAuthAuthorizationUrl`, and pass the original secret as `browserBinding` to `exchangeOAuth`. Without a `browserBindingHash`, the SDK sends a Google or LinkedIn request unbound from a server runtime; that is accepted today so existing deployments keep working, and will be refused once proof becomes required. A sign-in started with proof must finish with the matching secret: failed proof leaves the one-time token unconsumed. Automatic OAuth proof is cleared if authorization fails or once exchange succeeds; a rejected exchange keeps the proof available for retry.
@@ -59,7 +59,9 @@ formatSalaryStatRange(language, 90000, 120000, detail.currency);
59
59
  formatSalaryStatRange(language, 90000, 120000, detail.currency, 'standard');
60
60
 
61
61
  // Custom fields — language first. number → kind: 'number' (raw);
62
- // multi_select → kind: 'multi_select' with values: string[] (labels).
62
+ // multi_select → kind: 'multi_select' with values: string[] (labels);
63
+ // rich_text → kind: 'rich_text' with API-sanitized HTML: render it as HTML.
64
+ // kind: 'text' stays plain text, so keep escaping it.
63
65
  // Do not String(n). Do not join multi-select in the SDK — app owns list
64
66
  // style (conjunction / short / chips):
65
67
  // new Intl.ListFormat(language, { style: 'long', type: 'conjunction' })
@@ -121,6 +123,8 @@ const jobBadges = resolveCustomFieldDisplay(
121
123
  switch (entry.kind) {
122
124
  case 'text': // a single_select resolves to its option label
123
125
  return [entry.value];
126
+ case 'rich_text': // sanitized HTML: render in a detail section, not a badge
127
+ return [];
124
128
  case 'multi_select':
125
129
  return entry.values;
126
130
  case 'boolean':
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: cavuno-board-products
3
+ description: Digital product storefront with @cavuno/board. Use for product lists and pages, reviews, free claims, embedded paid checkout, the purchase download page with its email confirmation, downloads, buyer reviews, resending the link, or the signed-in buyer's purchases.
4
+ ---
5
+
6
+ # Digital product storefront
7
+
8
+ Operators create products in the Cavuno dashboard or the Operator API. The frontend lists them, sells them and serves the buyer's download page. `board.products` covers the catalogue and checkout; `board.purchases` covers everything after the purchase, keyed by the purchase token in the buyer's download link.
9
+
10
+ Routes: `BOARD_PATHS.products` (`/products`), `productPath(slug)`, the download page at `purchasePath(token)`, and `BOARD_PATHS.mePurchases` (`/me/purchases`), all from `@cavuno/board/paths`. Link to the `downloadUrl` the API returns instead of building the download path: it follows the board's route manifest.
11
+
12
+ ## List and render products
13
+
14
+ ```ts snippet
15
+ const { data: products, nextCursor } = await board.products.list({
16
+ sort: 'featured', // 'newest' | 'rating' | 'price_asc'
17
+ price: 'paid', // or 'free'; omit for both
18
+ limit: 24,
19
+ });
20
+
21
+ const product = await board.products.get('salary-guide');
22
+ if (product.ratingsEnabled) {
23
+ const { data: reviews } = await board.products.reviews('salary-guide');
24
+ }
25
+ ```
26
+
27
+ Only listed products appear in `list`; show a Products nav item only when it returns any. `get` also answers for an unlisted product by its exact slug: render it, but keep `isListed: false` pages out of lists, sitemaps and search indexes. An unknown, archived or undeliverable product is `products_not_found` (404). `price.kind` is `free`, `fixed` or `pay_what_you_want` (`amountCents` is the minimum, `suggestedCents` the prefill). `details` rows are `kind: 'text'` (`label`, `value`) or `kind: 'check'` (`label`, `checked`): render a check row as a tick or a cross with "Yes"/"No" as its accessible text, not as a word. Catalogue reads are public and cacheable.
28
+
29
+ ## Checkout: free and paid
30
+
31
+ Collect the email and the answers to `product.questions` (text → string, checkbox → boolean, dropdown → one of `options`) in your own form, then call `checkout` from the server.
32
+
33
+ ```ts snippet
34
+ const result = await board.products.checkout('salary-guide', {
35
+ email: 'ada@example.com',
36
+ answers: [{ questionId: 'waiver', value: true }],
37
+ jobAlertOptIn: false, // free products only; render unticked
38
+ returnPath: '/products/salary-guide',
39
+ website: '', // honeypot: render hidden, send empty
40
+ });
41
+
42
+ if (result.status === 'complete') {
43
+ redirect(result.downloadUrl); // free claim recorded and emailed
44
+ } else {
45
+ mountEmbeddedCheckout(result); // paid: Stripe mount kit
46
+ }
47
+ ```
48
+
49
+ `status: 'open'` returns `publishableKey`, `stripeAccountId`, `clientSecret` and `sessionId`. Mount with `loadStripe(publishableKey, { stripeAccount: stripeAccountId })` and `initEmbeddedCheckout({ clientSecret })`. The session expires after 30 minutes. For pay what you want, send `amountCents` (at least the minimum) or omit it to let the buyer choose on Stripe's page; `amountCents: 0` on a $0+ product is a free claim.
50
+
51
+ After Stripe returns with `session_id`, poll briefly:
52
+
53
+ ```ts snippet
54
+ const status = await board.products.getCheckout('salary-guide', sessionId, {
55
+ cache: 'no-store',
56
+ });
57
+ if (status.status === 'paid') redirect(status.downloadUrl);
58
+ // still 'awaiting_payment' after ~10 s: "We have emailed your download link"
59
+ ```
60
+
61
+ Checkout errors: `product_sold_out` (409), `product_email_invalid` (400), `product_email_blocked` (403), `product_amount_below_minimum` (400, `details.minimumCents`), `product_amount_invalid` (400), `product_answers_invalid` (400, `details.questionId`), `stripe_not_connected` (409), `rate_limited` (429), `rate_limit_unavailable` (503); `getCheckout` with an unknown session is `product_checkout_not_found`.
62
+
63
+ ## Download page and the shared-link check
64
+
65
+ The token is the buyer's credential: `no-store`, `noindex`, and never in analytics, sitemaps or cache keys. The buyer's first open and a signed-in buyer pass; a forwarded link gets `purchase_email_confirmation_required` (403). A server-side frontend never forwards the API's confirmation cookie, so keep the returned `confirmationToken` in your own per-purchase cookie and pass it back as the `confirmationToken` option (sent as `PURCHASE_CONFIRMATION_HEADER`).
66
+
67
+ ```ts snippet
68
+ import { isBoardApiError } from '@cavuno/board';
69
+
70
+ try {
71
+ const purchase = await board.purchases.get(token, {
72
+ confirmationToken,
73
+ cache: 'no-store',
74
+ });
75
+ if (purchase.confirmationToken) saveConfirmation(purchase.confirmationToken);
76
+ } catch (error) {
77
+ if (
78
+ isBoardApiError(error) &&
79
+ error.code === 'purchase_email_confirmation_required'
80
+ ) {
81
+ renderEmailPrompt(); // then:
82
+ const confirmation = await board.purchases.confirmEmail(token, {
83
+ email: typedEmail,
84
+ }); // purchase_email_mismatch (403) on a wrong email
85
+ saveConfirmation(confirmation.confirmationToken);
86
+ } else throw error;
87
+ }
88
+ ```
89
+
90
+ `purchase.files` holds files (`state`: `ready`, `preparing`, `limit_reached`; `downloadsLeft`, `sizeBytes`, `pageCount`, `stamped`) and links (`url`, opened directly). Also `downloadCap`, `stamping`, `receiptUrl` (null for free), `refundPolicy`, `ratingsEnabled` and the buyer's own `review`.
91
+
92
+ ## Downloads
93
+
94
+ Each call counts a download, so trigger it from a button, then redirect to the short-lived signed `url` at once.
95
+
96
+ ```ts snippet
97
+ const { url } = await board.purchases.download(token, fileId, {
98
+ confirmationToken,
99
+ });
100
+ const zip = await board.purchases.downloadAll(token, { confirmationToken });
101
+ // zip.preparing / zip.limitReached: file ids left out of the ZIP
102
+ ```
103
+
104
+ `purchase_file_preparing` (409): the stamped PDF is being made, nothing counted, the buyer is emailed when ready. `purchase_download_limit_reached` (409): permanent until the board resets it; never retry it as a rate limit. `purchase_download_all_unavailable` (503): offer single files. Also `purchase_awaiting_payment` (409), `purchase_access_removed` (410), `purchase_unavailable` (410), `purchase_file_not_found` and `purchases_not_found` (404). Purchase-token routes ignore the board password and plan gates.
105
+
106
+ ## Reviews, resend and the library
107
+
108
+ ```ts snippet
109
+ await board.purchases.review(
110
+ token,
111
+ { stars: 5, comment: 'Exactly what I needed' }, // displayName optional
112
+ { confirmationToken });
113
+
114
+ await board.purchases.resend({
115
+ email: 'ada@example.com',
116
+ productSlug: 'salary-guide',
117
+ website: '',
118
+ }); // always 202: never reveals whether a purchase exists
119
+
120
+ const { data: mine } = await board.purchases.mine({ cache: 'no-store' });
121
+ ```
122
+
123
+ `review` replaces the buyer's earlier review; `product_reviews_disabled` (404) when ratings are off, `purchase_refunded` (409), `purchase_review_invalid` (400). `mine` needs a board user session and lists purchases made while signed in plus those made with the user's verified email, each with `downloadUrl`.
124
+
125
+ ## Completion gate
126
+
127
+ - The list shows listed products only; a link-only product page renders with `noindex`.
128
+ - A free claim redirects to `downloadUrl`; a paid test checkout mounts, returns with `session_id`, and `getCheckout` reaches `paid`.
129
+ - A forwarded download link asks for the email, and the confirmation survives a reload through your own cookie.
130
+ - Downloads start from a button; `purchase_download_limit_reached` disables the button instead of retrying.
131
+ - Download pages, checkout status and `mine()` are `no-store`; purchase paths never reach analytics or the sitemap.
132
+
133
+ ## Cavuno SDK reference
134
+
135
+ For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk/build-your-board/digital-products).
@@ -64,6 +64,7 @@ Route each enabled capability to its focused skill:
64
64
  | Saved jobs and applications | `cavuno-board-applications` |
65
65
  | Messaging | `cavuno-board-messaging` |
66
66
  | Candidate paywall | `cavuno-board-paywall` |
67
+ | Digital products | `cavuno-board-products` |
67
68
  | Employer talent access | `cavuno-board-talent-access` |
68
69
  | Public job posting | `cavuno-board-post-a-job` |
69
70
  | Localized chrome | `cavuno-board-i18n` |
@@ -132,8 +132,12 @@ it):
132
132
 
133
133
  - Taxonomy and location pages enter the sitemap at five distinct jobs,
134
134
  `MIN_JOBS_PER_INDEXED_PAGE`.
135
- - Marketing URLs follow context feature flags for impressum, talent directory,
136
- and employers. Blog is listed only when `features.blog` is enabled.
135
+ - Marketing URLs follow context feature flags for talent directory and
136
+ employers. Blog is listed only when `features.blog` is enabled.
137
+ - `/impressum` follows the board unless you pass the `impressum` option
138
+ (`buildBucketEntries(board, origin, bucket, { impressum })`). Pass it when
139
+ your site owns its Impressum content, so the sitemap lists `/impressum`
140
+ exactly when the page exists. `features.impressum` is deprecated.
137
141
  - Salary index reads use `context().language`, producing board-language
138
142
  canonical salary slugs. Job, company, market, and blog slugs arrive
139
143
  canonical on the wire.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.57.0",
2
+ "version": "4.59.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -113,6 +113,13 @@
113
113
  "framework": null,
114
114
  "category": "core"
115
115
  },
116
+ {
117
+ "name": "cavuno-board-products",
118
+ "description": "Digital product storefront with @cavuno/board. Use for product lists and pages, reviews, free claims, embedded paid checkout, the purchase download page with its email confirmation, downloads, buyer reviews, resending the link, or the signed-in buyer's purchases.",
119
+ "path": "skills/cavuno-board-products/SKILL.md",
120
+ "framework": null,
121
+ "category": "core"
122
+ },
116
123
  {
117
124
  "name": "cavuno-board-salaries",
118
125
  "description": "Salary read models with @cavuno/board. Use for title, skill, or location salary indexes and detail, cross-axis pages, or the salary companies hub.",