@cavuno/board 1.2.1 → 1.4.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
3
+ * the installed package) — kept out of the isomorphic core and exposed via the
4
+ * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
5
+ * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
6
+ * stay fed from one source.
7
+ */
8
+ interface SkillManifestEntry {
9
+ name: string;
10
+ description: string;
11
+ /** Path to the SKILL.md, relative to the package root. */
12
+ path: string;
13
+ /** Framework slug for flavor skills; `null` for framework-agnostic core skills. */
14
+ framework: string | null;
15
+ category: 'core' | 'flavor';
16
+ }
17
+ interface SkillManifest {
18
+ version: string;
19
+ skills: SkillManifestEntry[];
20
+ }
21
+ interface LoadedSkill extends SkillManifestEntry {
22
+ content: string;
23
+ }
24
+ interface SkillCorpus {
25
+ version: string;
26
+ skills: LoadedSkill[];
27
+ }
28
+ /** Resolve a package-root-relative path (e.g. a manifest `path`) to an absolute path. */
29
+ declare function resolveFromPackageRoot(relativePath: string): string;
30
+ declare function loadSkillManifest(): SkillManifest;
31
+ /**
32
+ * Load the full skill corpus — manifest metadata plus each skill's markdown
33
+ * `content`. The sidekick injects `content` into its agent context; an external
34
+ * Claude Code instead receives the files copied by the setup command.
35
+ */
36
+ declare function loadSkillCorpus(): SkillCorpus;
37
+
38
+ export { type LoadedSkill, type SkillCorpus, type SkillManifest, type SkillManifestEntry, loadSkillCorpus, loadSkillManifest, resolveFromPackageRoot };
package/dist/skills.js ADDED
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/skills.ts
21
+ var skills_exports = {};
22
+ __export(skills_exports, {
23
+ loadSkillCorpus: () => loadSkillCorpus,
24
+ loadSkillManifest: () => loadSkillManifest,
25
+ resolveFromPackageRoot: () => resolveFromPackageRoot
26
+ });
27
+ module.exports = __toCommonJS(skills_exports);
28
+
29
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.14_tsx@4.21.0_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/cjs_shims.js
30
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
31
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
32
+
33
+ // src/skills.ts
34
+ var import_node_fs = require("fs");
35
+ var import_node_path = require("path");
36
+ var import_node_url = require("url");
37
+ function packageRoot() {
38
+ return (0, import_node_path.resolve)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl)), "..");
39
+ }
40
+ function resolveFromPackageRoot(relativePath) {
41
+ return (0, import_node_path.resolve)(packageRoot(), relativePath);
42
+ }
43
+ function loadSkillManifest() {
44
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json");
45
+ return JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
46
+ }
47
+ function loadSkillCorpus() {
48
+ const manifest = loadSkillManifest();
49
+ return {
50
+ version: manifest.version,
51
+ skills: manifest.skills.map((skill) => ({
52
+ ...skill,
53
+ content: (0, import_node_fs.readFileSync)(resolveFromPackageRoot(skill.path), "utf8")
54
+ }))
55
+ };
56
+ }
57
+ // Annotate the CommonJS export names for ESM import in node:
58
+ 0 && (module.exports = {
59
+ loadSkillCorpus,
60
+ loadSkillManifest,
61
+ resolveFromPackageRoot
62
+ });
@@ -0,0 +1,29 @@
1
+ // src/skills.ts
2
+ import { readFileSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+ function packageRoot() {
6
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ }
8
+ function resolveFromPackageRoot(relativePath) {
9
+ return resolve(packageRoot(), relativePath);
10
+ }
11
+ function loadSkillManifest() {
12
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json");
13
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
14
+ }
15
+ function loadSkillCorpus() {
16
+ const manifest = loadSkillManifest();
17
+ return {
18
+ version: manifest.version,
19
+ skills: manifest.skills.map((skill) => ({
20
+ ...skill,
21
+ content: readFileSync(resolveFromPackageRoot(skill.path), "utf8")
22
+ }))
23
+ };
24
+ }
25
+ export {
26
+ loadSkillCorpus,
27
+ loadSkillManifest,
28
+ resolveFromPackageRoot
29
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,9 @@
15
15
  "main": "./dist/index.js",
16
16
  "module": "./dist/index.mjs",
17
17
  "types": "./dist/index.d.ts",
18
+ "bin": {
19
+ "cavuno-board": "./dist/bin.mjs"
20
+ },
18
21
  "exports": {
19
22
  ".": {
20
23
  "import": {
@@ -25,10 +28,22 @@
25
28
  "types": "./dist/index.d.ts",
26
29
  "default": "./dist/index.js"
27
30
  }
28
- }
31
+ },
32
+ "./skills": {
33
+ "import": {
34
+ "types": "./dist/skills.d.mts",
35
+ "default": "./dist/skills.mjs"
36
+ },
37
+ "require": {
38
+ "types": "./dist/skills.d.ts",
39
+ "default": "./dist/skills.js"
40
+ }
41
+ },
42
+ "./skills/*": "./skills/*"
29
43
  },
30
44
  "files": [
31
- "dist"
45
+ "dist",
46
+ "skills"
32
47
  ],
33
48
  "publishConfig": {
34
49
  "access": "public"
@@ -36,15 +51,17 @@
36
51
  "scripts": {
37
52
  "build": "tsup",
38
53
  "clean": "git clean -xdf .turbo dist node_modules",
39
- "typecheck": "tsgo --noEmit",
54
+ "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
40
55
  "test": "vitest run",
56
+ "gen:skills-manifest": "tsx scripts/generate-skills-manifest.ts",
41
57
  "assert-publish-target": "node -e \"const p=require('./package.json'); if(p.name!=='@cavuno/board'){throw new Error('Refusing to publish: package.json name is '+p.name+', expected @cavuno/board')}; if(p.private){throw new Error('Refusing to publish: package.json has private:true')}\"",
42
- "prepublishOnly": "pnpm run assert-publish-target && pnpm run build"
58
+ "prepublishOnly": "pnpm run assert-publish-target && pnpm run gen:skills-manifest && pnpm run build"
43
59
  },
44
60
  "devDependencies": {
45
61
  "@kit/tsconfig": "workspace:*",
46
62
  "@types/node": "catalog:",
47
63
  "tsup": "^8.4.0",
64
+ "tsx": "^4.19.2",
48
65
  "vitest": "^2.1.9"
49
66
  }
50
67
  }
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: cavuno-board-auth
3
+ description: Authenticate board users with the @cavuno/board SDK — register, login, refresh, logout, email verification and password reset. Covers bearer-JWT storage modes, the deliberate no-auto-refresh-on-401 rule (and single-flight handling), and the server-side httpOnly-cookie pattern that keeps tokens out of the browser.
4
+ ---
5
+
6
+ # Board-user authentication
7
+
8
+ Board users (candidates, employers) authenticate with a short-lived bearer access token plus a refresh token. The SDK manages the pair via pluggable async storage. There is exactly one auth mode — bearer JWT (no cookie/session mode).
9
+
10
+ ## When to use
11
+
12
+ - Sign-up / sign-in / sign-out for board users.
13
+ - Email verification and password reset flows.
14
+ - Wiring authenticated calls (`me`, saved jobs, applications).
15
+
16
+ ## When not to use
17
+
18
+ - Anonymous reads (jobs/companies/blog) — no auth needed.
19
+ - Board-password gating — that's a separate grant; see `cavuno-board-errors`.
20
+
21
+ ## Register and login
22
+
23
+ ```ts snippet
24
+ await board.auth.register({
25
+ role: 'candidate',
26
+ method: 'emailpass',
27
+ email: 'ada@example.com',
28
+ password: 'a-strong-password',
29
+ displayName: 'Ada',
30
+ });
31
+
32
+ const session = await board.auth.login({
33
+ email: 'ada@example.com',
34
+ password: 'a-strong-password',
35
+ });
36
+ session.boardUser.email; // the signed-in user
37
+ session.accessToken; // bearer; never expose to the browser bundle on SSR
38
+ ```
39
+
40
+ `register`/`login`/`refresh` persist the returned token pair to storage; `logout` clears it. The SDK never navigates — your app owns redirects and verification UX.
41
+
42
+ ## Storage modes
43
+
44
+ `auth.storage` is `'memory'` | `'nostore'` | a `CustomStorage`. Defaults: **`memory` in the browser, `nostore` on the server**. Browser login works out of the box; shared SSR instances stay stateless.
45
+
46
+ ```ts
47
+ import { createBoardClient } from '@cavuno/board';
48
+
49
+ // Browser/SPA: token held in memory on the instance.
50
+ const board = createBoardClient({
51
+ baseUrl: 'https://api.cavuno.com',
52
+ board: 'pk_a8f3...',
53
+ auth: { storage: 'memory' },
54
+ });
55
+ ```
56
+
57
+ A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with `localStorage`, IndexedDB, Redis, or your own store.
58
+
59
+ ## No auto-refresh on 401 — handle it explicitly
60
+
61
+ An expired access token surfaces as a `BoardApiError` you detect with `isUnauthorized`. The SDK does **not** silently refresh — refresh tokens are single-use with atomic rotation, so safe refresh under concurrency needs a single-flight guard you own.
62
+
63
+ ```ts snippet
64
+ import { isUnauthorized } from '@cavuno/board';
65
+
66
+ try {
67
+ return await board.me.retrieve();
68
+ } catch (err) {
69
+ if (isUnauthorized(err)) {
70
+ await board.auth.refresh(); // reads the stored refresh token, rotates the pair
71
+ return await board.me.retrieve();
72
+ }
73
+ throw err;
74
+ }
75
+ ```
76
+
77
+ Under concurrency, wrap `auth.refresh()` in a single-flight promise so parallel 401s trigger exactly one rotation (the reference flavor encodes this once — see `cavuno-board-tanstack-start`).
78
+
79
+ ## refresh / logout token sourcing
80
+
81
+ Both accept an optional body `{ refreshToken }`. When omitted, the SDK reads the token from storage and throws if neither exists — so `nostore` (server) callers must pass it explicitly:
82
+
83
+ ```ts snippet
84
+ await board.auth.refresh({ refreshToken }); // server: explicit
85
+ await board.auth.logout({ refreshToken }); // revokes server-side, clears storage
86
+ ```
87
+
88
+ ## Server-side pattern (keep tokens out of the browser)
89
+
90
+ On SSR, do not hold the session on a shared instance. Keep the token pair in an httpOnly cookie owned by your app and pass it per call:
91
+
92
+ ```ts snippet
93
+ // `accessToken` comes from your httpOnly cookie, read in server code.
94
+ // Per-call options are the 2nd argument; the 1st is `query` (pass undefined).
95
+ const me = await board.me.retrieve(undefined, {
96
+ headers: { authorization: `Bearer ${accessToken}` },
97
+ });
98
+ ```
99
+
100
+ ## Email verification & password reset
101
+
102
+ ```ts snippet
103
+ await board.auth.verifyEmail({ token }); // token from the email link
104
+ await board.auth.forgotPassword({ email }); // sends the reset email
105
+ await board.auth.resetPassword({ token, password }); // token from the email link
106
+ ```
107
+
108
+ ## Checklist
109
+
110
+ - [ ] Bearer tokens never reach the browser bundle on SSR (httpOnly cookie + per-call header).
111
+ - [ ] 401s handled explicitly with `isUnauthorized` + `auth.refresh()`.
112
+ - [ ] Concurrent refresh guarded by single-flight.
113
+ - [ ] `nostore` callers pass `{ refreshToken }` to `refresh`/`logout`.
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: cavuno-board-client
3
+ description: Create and configure the @cavuno/board client — baseUrl and the pk_ board identifier, global headers, request/response hooks, per-call FetchOptions caching passthrough, the client.fetch escape hatch, and the rule that keeps one shared instance safe under SSR.
4
+ ---
5
+
6
+ # The Board API client
7
+
8
+ `createBoardClient` returns a typed client whose namespaces (`jobs`, `companies`, `blog`, `auth`, `me`, …) all route through one request pipeline: board-identifier base path, default headers, bearer token from storage, and your hooks.
9
+
10
+ ## When to use
11
+
12
+ - Creating the client instance your whole app shares.
13
+ - Adding global headers, logging, request/response hooks, or framework caching.
14
+ - Calling an endpoint the SDK doesn't expose yet (`client.fetch`).
15
+
16
+ ## When not to use
17
+
18
+ - Per-surface usage (listing jobs, auth) — see the surface skills; they assume the client exists.
19
+
20
+ ## Create the client
21
+
22
+ ```ts
23
+ import { createBoardClient } from '@cavuno/board';
24
+
25
+ const board = createBoardClient({
26
+ baseUrl: 'https://api.cavuno.com',
27
+ board: 'pk_a8f3...', // pk_ key (preferred) | boards_ id | slug
28
+ });
29
+ ```
30
+
31
+ Use the `pk_…` publishable key for `board`, not the slug: the slug is operator-mutable and renames break deployed frontends. The `pk_…` key is immutable and client-safe.
32
+
33
+ ## Configuration
34
+
35
+ ```ts no-check
36
+ const board = createBoardClient({
37
+ baseUrl: process.env.PUBLIC_CAVUNO_API_URL!,
38
+ board: process.env.PUBLIC_CAVUNO_BOARD!,
39
+ globalHeaders: { 'Accept-Language': 'en' },
40
+ onRequest: async (req) => req, // mutate/replace the request (locale, URL rewrite)
41
+ onResponse: async (res, req) => {}, // side effects only (logging, analytics)
42
+ logger: console,
43
+ auth: { storage: 'memory' }, // see cavuno-board-auth
44
+ });
45
+ ```
46
+
47
+ The `onRequest`/`onResponse` hooks are first-class — do not monkey-patch the client. `onResponse` receives a **clone** of the response, so reading its body never disturbs the SDK's own parsing.
48
+
49
+ ## Per-call FetchOptions ride straight to fetch
50
+
51
+ Every method takes a trailing `options?` of type `FetchOptions` (`Omit<RequestInit,'body'>` plus `query`). Anything besides `body`/`query` passes through to `fetch` untouched, so framework caching works with zero SDK knowledge:
52
+
53
+ ```ts snippet
54
+ // Next.js ISR
55
+ await board.jobs.list({ limit: 20 }, { next: { revalidate: 60, tags: ['jobs'] } });
56
+ // Cloudflare Workers
57
+ await board.jobs.list({ limit: 20 }, { cf: { cacheTtl: 60 } } as never);
58
+ // Standard fetch + abort
59
+ const ac = new AbortController();
60
+ await board.jobs.list({ limit: 20 }, { cache: 'force-cache', signal: ac.signal });
61
+ ```
62
+
63
+ ## One shared instance is SSR-safe — if state stays per-call
64
+
65
+ A single Workers isolate serves many users at once. A module-scoped client is safe **only** while per-user state is passed per call (`options.headers`), not stored on the instance. For browser/SPA usage, the instance may hold the session (`auth.storage`); for SSR, keep the session in an httpOnly cookie and pass `{ headers: { authorization } }` per call (see `cavuno-board-auth`).
66
+
67
+ ```ts
68
+ import { createBoardClient } from '@cavuno/board';
69
+
70
+ // Safe: shared, stateless. Per-user token rides per call.
71
+ export const board = createBoardClient({
72
+ baseUrl: process.env.PUBLIC_CAVUNO_API_URL!,
73
+ board: process.env.PUBLIC_CAVUNO_BOARD!,
74
+ });
75
+ ```
76
+
77
+ ## Escape hatch: client.fetch
78
+
79
+ `board.client.fetch<T>(path, init)` is public and first-class — it runs the full pipeline (base path, headers, token, hooks), not a raw `fetch`. Use it to call an endpoint before the SDK ships a typed method for it:
80
+
81
+ ```ts snippet
82
+ const data = await board.client.fetch<{ object: 'list'; data: unknown[] }>(
83
+ '/some-new-endpoint',
84
+ { query: { limit: 5 } },
85
+ );
86
+ ```
87
+
88
+ ## Checklist
89
+
90
+ - [ ] One client created from env values, reused everywhere.
91
+ - [ ] `board` is the `pk_…` key, not the slug.
92
+ - [ ] No per-user state on a shared SSR instance.
93
+ - [ ] Caching done via per-call `FetchOptions`, not a wrapper.
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: cavuno-board-errors
3
+ description: Handle errors and access gating with the @cavuno/board SDK — the BoardApiError shape, the typed guards (isNotFound, isUnauthorized, isValidationError, isRateLimited, isForbidden, isConflict), and the board-password flow (isBoardPasswordRequired → password.verify → X-Board-Access grant).
4
+ ---
5
+
6
+ # Errors and access gating
7
+
8
+ Every SDK method throws on a non-2xx response. The error keeps the server's full typed envelope — never string-match messages.
9
+
10
+ ## When to use
11
+
12
+ - Branching on failures (not found, unauthorized, validation, rate limit).
13
+ - Unlocking a password-protected board.
14
+
15
+ ## The BoardApiError shape
16
+
17
+ ```ts no-check
18
+ class BoardApiError extends Error {
19
+ status: number;
20
+ code: string; // `<domain>_<snake_reason>`
21
+ details?: unknown; // structured, per-code
22
+ requestId?: string;
23
+ raw: unknown; // parsed body, untouched
24
+ }
25
+ ```
26
+
27
+ ## Branch with the typed guards
28
+
29
+ ```ts snippet
30
+ import {
31
+ isBoardApiError,
32
+ isNotFound,
33
+ isUnauthorized,
34
+ isValidationError,
35
+ isRateLimited,
36
+ isForbidden,
37
+ isConflict,
38
+ } from '@cavuno/board';
39
+
40
+ try {
41
+ return await board.jobs.retrieve('senior-chef');
42
+ } catch (err) {
43
+ if (isNotFound(err)) return null; // 404 → render not-found
44
+ if (isUnauthorized(err)) { // 401 → refresh or sign in
45
+ /* see cavuno-board-auth */
46
+ }
47
+ if (isValidationError(err)) { // 400 validation_bad_request → field errors in err.details
48
+ }
49
+ if (isRateLimited(err)) { // 429 → back off
50
+ }
51
+ if (isBoardApiError(err)) {
52
+ console.error(err.code, err.requestId); // log code + requestId for support
53
+ }
54
+ throw err;
55
+ }
56
+ ```
57
+
58
+ `isForbidden` (403) and `isConflict` (409) round out the set. Use `err.requestId` in support reports.
59
+
60
+ ## Password-protected boards
61
+
62
+ A gated board answers reads with `isBoardPasswordRequired` until the visitor presents a grant. Exchange the password once with `password.verify()`; the SDK stores the grant and attaches it as `X-Board-Access` on every subsequent read automatically.
63
+
64
+ ```ts snippet
65
+ import { isBoardPasswordRequired } from '@cavuno/board';
66
+
67
+ try {
68
+ await board.jobs.list({ limit: 20 });
69
+ } catch (err) {
70
+ if (isBoardPasswordRequired(err)) {
71
+ await board.password.verify(userEnteredPassword); // stores the grant
72
+ await board.jobs.list({ limit: 20 }); // now passes the wall
73
+ } else {
74
+ throw err;
75
+ }
76
+ }
77
+ ```
78
+
79
+ The grant is identical for every visitor of the board and is not a user session. On SSR (`nostore`), pass it per call as `{ headers: { 'x-board-access': grant } }` instead of relying on stored state.
80
+
81
+ ## Checklist
82
+
83
+ - [ ] Failures branched via guards, never message string-matching.
84
+ - [ ] `404` → handled not-found path (not a crash).
85
+ - [ ] `err.requestId` logged for support.
86
+ - [ ] Password-gated boards call `password.verify()` on `isBoardPasswordRequired`.
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: cavuno-board-jobs
3
+ description: Browse, search, and render jobs with the @cavuno/board SDK — jobs.list, jobs.search, jobs.retrieve, jobs.similar. Covers the slim card vs full job shapes, storefront pagination (count/limit/offset + opaque cursor), filters, and the candidate-paywall gatedCount.
4
+ ---
5
+
6
+ # Jobs: browse, search, detail
7
+
8
+ The highest-traffic surface. Listing and search return slim `PublicJobCard`s; the detail endpoint returns the full `PublicJob`.
9
+
10
+ ## When to use
11
+
12
+ - Listing/search/keyword/location pages.
13
+ - The job-detail page and its "similar jobs" rail.
14
+
15
+ ## When not to use
16
+
17
+ - Company-scoped listings — use `companies.listJobs` (same card shape).
18
+ - The ungated embeddable widget — use `embed.jobs`.
19
+
20
+ ## List and render cards
21
+
22
+ `jobs.list` returns a `JobCardListEnvelope`: storefront pagination fields plus `data: PublicJobCard[]` and optional `relatedSearches`.
23
+
24
+ ```ts snippet
25
+ const page = await board.jobs.list({ limit: 20, seniority: ['senior', 'lead'] });
26
+ page.count; // total matches ("X jobs")
27
+ page.hasMore; // more pages exist
28
+ page.nextCursor; // opaque forward token, or null
29
+ for (const card of page.data) {
30
+ card.title;
31
+ card.company?.name;
32
+ card.links.public; // canonical /companies/:companySlug/jobs/:jobSlug
33
+ }
34
+ ```
35
+
36
+ ### Filters and pagination
37
+
38
+ `JobsListQuery` supports `limit` (1–100), `offset` (takes precedence over `cursor`), `cursor`, and filters: `companyId`, `remoteOption`, `employmentType`, `seniority` (single or repeated → OR-matched), `location` + `radius` (km), `category`, `skill`. Paginate by passing back `nextCursor`, or use numbered pages with `offset`:
39
+
40
+ ```ts snippet
41
+ const p1 = await board.jobs.list({ limit: 20 });
42
+ const p2 = p1.nextCursor
43
+ ? await board.jobs.list({ limit: 20, cursor: p1.nextCursor })
44
+ : null;
45
+ // or numbered pages:
46
+ const page3 = await board.jobs.list({ limit: 20, offset: 40 });
47
+ ```
48
+
49
+ ## Search
50
+
51
+ `jobs.search` posts a `JobsSearchBody` (free-text `query` + structured `filters`) and returns a `JobCardSearchEnvelope`:
52
+
53
+ ```ts snippet
54
+ const results = await board.jobs.search({
55
+ query: 'chef',
56
+ filters: {
57
+ seniority: ['senior'],
58
+ remoteOption: ['remote'],
59
+ publishedAt: { gte: '2026-01-01T00:00:00Z' },
60
+ },
61
+ limit: 20,
62
+ });
63
+ ```
64
+
65
+ ## Detail and similar
66
+
67
+ ```ts snippet
68
+ const job = await board.jobs.retrieve('senior-chef'); // full PublicJob
69
+ job.description; // HTML
70
+ job.officeLocations;
71
+ job.company?.slug;
72
+ const rail = await board.jobs.similar('senior-chef', { limit: 5 });
73
+ ```
74
+
75
+ ## The candidate paywall: gatedCount
76
+
77
+ On gated boards, some results are hidden from anonymous/unentitled viewers. `gatedCount` is how many were withheld for the current viewer (absent/0 when entitled). Surface it as an upsell rather than pretending the list is complete:
78
+
79
+ ```ts snippet
80
+ const page = await board.jobs.list({ limit: 20 });
81
+ if (page.gatedCount && page.gatedCount > 0) {
82
+ // e.g. "Sign in to see N more roles"
83
+ }
84
+ ```
85
+
86
+ A board-user bearer token on the same call returns the entitled (ungated) view — the endpoint is optional-auth, one URL for both anonymous and personalized reads.
87
+
88
+ ## Checklist
89
+
90
+ - [ ] Listing/search use `PublicJobCard`; detail uses `PublicJob`.
91
+ - [ ] Job links use `links.public` (canonical `/companies/:companySlug/jobs/:jobSlug`).
92
+ - [ ] Pagination via `nextCursor` or `offset`, not client-side slicing.
93
+ - [ ] `gatedCount` surfaced as an upsell, not hidden.
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: cavuno-board-setup
3
+ description: End-to-end orchestrator for building a headless Cavuno job board with the @cavuno/board SDK. Start here after `npx @cavuno/board setup` copies the skills — detect the framework, wire the client, render board context, jobs browsing and detail, board-user auth and saved jobs, handle errors and access gating, then verify.
4
+ ---
5
+
6
+ # Setting up a Cavuno board
7
+
8
+ `@cavuno/board` is a thin, isomorphic, typed client for the Cavuno Board API (`/v1/boards/:identifier/*`). It brings the commerce of a job board — jobs, companies, blog, search, auth, saved jobs, alerts — to the framework you already use. You bring the framework and own the layout; the SDK brings the data contract.
9
+
10
+ This skill is the orchestrator. Work top to bottom, delegating each surface to its focused skill.
11
+
12
+ ## When to use
13
+
14
+ - Standing up a new headless board frontend against the Board API.
15
+ - Adding board data (jobs/companies/blog/auth) to an existing app.
16
+ - After `npx @cavuno/board setup` has installed the package and copied these skills.
17
+
18
+ ## When not to use
19
+
20
+ - Authoring the hosted board inside the Cavuno admin (that's the operator Puck builder, not the SDK).
21
+ - Building the operator/admin REST API client — that's `@kit/api-client`, not `@cavuno/board`.
22
+
23
+ ## Inspect the app
24
+
25
+ Read `package.json` and the project layout before writing anything. Identify: the framework (see below), whether it renders on a server (SSR/RSC) or only in the browser, and where server-only secrets are read. Match the project's existing conventions — do not introduce a new data-fetching style.
26
+
27
+ ## Detect the framework
28
+
29
+ `@cavuno/board` is framework-agnostic: it needs only `fetch` and runs in the browser, Node ≥ 20, and Cloudflare Workers. Detect the framework from dependencies and adapt:
30
+
31
+ - `@tanstack/react-start` → the reference flavor. Read `cavuno-board-tanstack-start` for SSR-loader + cookie wiring.
32
+ - `next` → use Server Components + per-call `FetchOptions` (`next: { revalidate, tags }`).
33
+ - Anything else (Nuxt, SvelteKit, Astro, SolidStart, plain JS) → use the core skills directly; the SDK surface is identical.
34
+
35
+ ## Use standard environment names
36
+
37
+ Read these two values from the environment; never hard-code them:
38
+
39
+ - `PUBLIC_CAVUNO_API_URL` — the API base, e.g. `https://api.cavuno.com`.
40
+ - `PUBLIC_CAVUNO_BOARD` — the board identifier. Use the `pk_…` publishable key, not the slug (the slug is operator-mutable and breaks deployed frontends on rename).
41
+
42
+ Both are public-safe (the `pk_…` key is client-safe by design). Use your framework's public-env convention for the variable name (`VITE_`, `PUBLIC_`, `NEXT_PUBLIC_`); the values are the same.
43
+
44
+ ## Keep credentials server-side
45
+
46
+ The board identifier is public. A board-user **bearer JWT is not** — it must never reach the browser bundle. On a server framework, keep the session in an httpOnly cookie owned by your app and pass it per call; see `cavuno-board-auth`.
47
+
48
+ ## Use board route conventions
49
+
50
+ The canonical public job-detail URL is `/companies/:companySlug/jobs/:jobSlug` — a job needs both slugs. `/jobs`, `/jobs/:keyword`, `/jobs/locations/:slug` are listing/search pages, never individual jobs. Mirror these paths so a board migrating hosted → headless keeps its indexed URLs.
51
+
52
+ ## Wire the client
53
+
54
+ Create one client and reuse it. See `cavuno-board-client`.
55
+
56
+ ```ts
57
+ import { createBoardClient } from '@cavuno/board';
58
+
59
+ export const board = createBoardClient({
60
+ baseUrl: process.env.PUBLIC_CAVUNO_API_URL!,
61
+ board: process.env.PUBLIC_CAVUNO_BOARD!,
62
+ });
63
+ ```
64
+
65
+ ## Build the board shell from context
66
+
67
+ `board.context()` (a root method on the client) returns identity, theme, analytics, and the board's capability `features` flags. Render branding from it and **gate every optional surface on its capability flag** — only build a route when its feature is enabled. (A dedicated context skill ships with a later slice; the return type is self-describing until then.)
68
+
69
+ ## Build jobs browsing + detail
70
+
71
+ The core surface. `jobs.list` / `jobs.search` for listing pages, `jobs.retrieve` for the detail page, `jobs.similar` for the related rail. Honor storefront pagination and the candidate-paywall `gatedCount`. See `cavuno-board-jobs`.
72
+
73
+ ## Add board users + saved jobs
74
+
75
+ Register/login/refresh/logout, then `me.retrieve` and `me.savedJobs.*`. There is **no auto-refresh on 401** — handle it explicitly. See `cavuno-board-auth`.
76
+
77
+ ## Handle errors + gating
78
+
79
+ Every method throws `BoardApiError` on a non-2xx; branch with the typed guards. Password-protected boards need a `password.verify()` grant. See `cavuno-board-errors`.
80
+
81
+ ## App-owned concerns (out of scope)
82
+
83
+ The SDK serves data only. Your app owns: page layout and chrome copy, marketing/legal prose, and the authoring of SEO artifacts (sitemap, RSS, OG images) — built from API data, not served as documents. The Board API never returns layouts or page-builder JSON.
84
+
85
+ ## Verification
86
+
87
+ - `board.context()` resolves and returns your board's name.
88
+ - A listing page renders cards from `board.jobs.list()`.
89
+ - A detail page renders from `board.jobs.retrieve(slug)`.
90
+ - An invalid slug surfaces a handled `isNotFound(err)` path, not a crash.
91
+
92
+ When the `cavuno-board-smoke-test` skill is present, run it against your `pk_…` to verify end to end.
93
+
94
+ ## Stop conditions
95
+
96
+ Stop and ask the human when: no `pk_…` board identifier or API URL is available; the framework is unrecognized and has no server boundary for secrets; or a surface you need (e.g. job alerts, applications) has no corresponding skill yet — the SDK only exposes endpoints that are live, so a missing skill means the endpoint isn't shipped.