@cavuno/board 1.2.0 → 1.3.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.
- package/dist/bin.mjs +74 -0
- package/dist/index.d.mts +207 -7
- package/dist/index.d.ts +207 -7
- package/dist/index.js +164 -7
- package/dist/index.mjs +164 -7
- package/dist/skills.d.mts +38 -0
- package/dist/skills.d.ts +38 -0
- package/dist/skills.js +62 -0
- package/dist/skills.mjs +29 -0
- package/package.json +22 -5
- package/skills/cavuno-board-auth/SKILL.md +113 -0
- package/skills/cavuno-board-client/SKILL.md +93 -0
- package/skills/cavuno-board-errors/SKILL.md +86 -0
- package/skills/cavuno-board-jobs/SKILL.md +93 -0
- package/skills/cavuno-board-setup/SKILL.md +96 -0
- package/skills/flavors/tanstack-start/SKILL.md +102 -0
- package/skills/manifest.json +47 -0
|
@@ -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.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-tanstack-start
|
|
3
|
+
description: TanStack-Start-on-Cloudflare-Workers reference wiring for a headless Cavuno board — SSR loaders calling @cavuno/board server-side, the session held in an __Host- httpOnly cookie owned by the app, a single-flight refresh helper, and FetchOptions cache passthrough on Workers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reference flavor: TanStack Start on Cloudflare Workers
|
|
7
|
+
|
|
8
|
+
This is the framework-specific layer for the reference starter (`wollemiahq/cavuno-board-starter`). The core skills (`cavuno-board-client`, `-jobs`, `-auth`, `-errors`) define the SDK surface; this skill shows how to wire it into TanStack Start on Workers. Read the core skills first.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- The project depends on `@tanstack/react-start`.
|
|
13
|
+
- You need the SSR-loader + httpOnly-cookie + single-flight-refresh patterns.
|
|
14
|
+
|
|
15
|
+
## One shared, stateless client
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createBoardClient } from '@cavuno/board';
|
|
19
|
+
|
|
20
|
+
// Module-scoped, no auth.storage → safe across concurrent Workers requests.
|
|
21
|
+
export const board = createBoardClient({
|
|
22
|
+
baseUrl: process.env.PUBLIC_CAVUNO_API_URL!,
|
|
23
|
+
board: process.env.PUBLIC_CAVUNO_BOARD!,
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Read in server loaders; cache via FetchOptions
|
|
28
|
+
|
|
29
|
+
Call the SDK only on the server (route loaders / server functions), never from the browser with a token. On Workers, pass cache directives straight through:
|
|
30
|
+
|
|
31
|
+
```ts no-check
|
|
32
|
+
import { createFileRoute } from '@tanstack/react-start';
|
|
33
|
+
import { board } from '~/lib/board';
|
|
34
|
+
|
|
35
|
+
export const Route = createFileRoute('/jobs')({
|
|
36
|
+
loader: async () => {
|
|
37
|
+
// `cf` rides straight to the Workers fetch — the SDK needs no framework knowledge.
|
|
38
|
+
return board.jobs.list({ limit: 20 }, { cf: { cacheTtl: 60 } } as never);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Session in an `__Host-` httpOnly cookie owned by the app
|
|
44
|
+
|
|
45
|
+
The SDK never owns the cookie. Store the bearer pair in an `__Host-`-prefixed httpOnly cookie set by your server functions, read it on each request, and pass the token per call:
|
|
46
|
+
|
|
47
|
+
```ts no-check
|
|
48
|
+
import { board } from '~/lib/board';
|
|
49
|
+
import { readSessionCookie } from '~/lib/session.server';
|
|
50
|
+
|
|
51
|
+
export async function loadMe(request: Request) {
|
|
52
|
+
const { accessToken } = readSessionCookie(request);
|
|
53
|
+
// Pass the token via `options` (2nd arg). The 1st arg is `query`.
|
|
54
|
+
return board.me.retrieve(undefined, {
|
|
55
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Single-flight refresh
|
|
61
|
+
|
|
62
|
+
Refresh tokens are single-use; concurrent 401s must trigger exactly one rotation. Encode it once and reuse everywhere:
|
|
63
|
+
|
|
64
|
+
```ts no-check
|
|
65
|
+
import { isUnauthorized } from '@cavuno/board';
|
|
66
|
+
import { board } from '~/lib/board';
|
|
67
|
+
|
|
68
|
+
let inflight: Promise<void> | null = null;
|
|
69
|
+
|
|
70
|
+
// `getAccessToken`/`getRefreshToken` read your __Host- cookie (the SDK never
|
|
71
|
+
// touches it). On a 401 we refresh exactly once — concurrent callers await the
|
|
72
|
+
// same `inflight` promise — then retry `run` with the rotated token. Your
|
|
73
|
+
// refresh handler must write the new pair to the cookie so the retry reads it.
|
|
74
|
+
export async function withFreshSession<T>(
|
|
75
|
+
getAccessToken: () => string,
|
|
76
|
+
getRefreshToken: () => string,
|
|
77
|
+
run: (accessToken: string) => Promise<T>,
|
|
78
|
+
): Promise<T> {
|
|
79
|
+
try {
|
|
80
|
+
return await run(getAccessToken());
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!isUnauthorized(err)) throw err;
|
|
83
|
+
inflight ??= board.auth
|
|
84
|
+
.refresh({ refreshToken: getRefreshToken() })
|
|
85
|
+
.then(() => undefined)
|
|
86
|
+
.finally(() => (inflight = null));
|
|
87
|
+
await inflight;
|
|
88
|
+
return run(getAccessToken()); // retry with the rotated token
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Canonical URLs
|
|
94
|
+
|
|
95
|
+
Mirror the hosted board's public URLs so indexed links survive a hosted → headless migration: job detail at `/companies/$companySlug/jobs/$jobSlug`, listings at `/jobs`, `/jobs/$keyword`, `/jobs/locations/$slug`.
|
|
96
|
+
|
|
97
|
+
## Checklist
|
|
98
|
+
|
|
99
|
+
- [ ] All SDK calls are server-side; no bearer token in the browser bundle.
|
|
100
|
+
- [ ] Session in an `__Host-` httpOnly cookie owned by the app, passed per call.
|
|
101
|
+
- [ ] Single-flight refresh wraps concurrent 401s.
|
|
102
|
+
- [ ] Public route paths mirror the hosted board's canonical URLs.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.0",
|
|
3
|
+
"skills": [
|
|
4
|
+
{
|
|
5
|
+
"name": "cavuno-board-auth",
|
|
6
|
+
"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.",
|
|
7
|
+
"path": "skills/cavuno-board-auth/SKILL.md",
|
|
8
|
+
"framework": null,
|
|
9
|
+
"category": "core"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "cavuno-board-client",
|
|
13
|
+
"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.",
|
|
14
|
+
"path": "skills/cavuno-board-client/SKILL.md",
|
|
15
|
+
"framework": null,
|
|
16
|
+
"category": "core"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "cavuno-board-errors",
|
|
20
|
+
"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).",
|
|
21
|
+
"path": "skills/cavuno-board-errors/SKILL.md",
|
|
22
|
+
"framework": null,
|
|
23
|
+
"category": "core"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "cavuno-board-jobs",
|
|
27
|
+
"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.",
|
|
28
|
+
"path": "skills/cavuno-board-jobs/SKILL.md",
|
|
29
|
+
"framework": null,
|
|
30
|
+
"category": "core"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "cavuno-board-setup",
|
|
34
|
+
"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.",
|
|
35
|
+
"path": "skills/cavuno-board-setup/SKILL.md",
|
|
36
|
+
"framework": null,
|
|
37
|
+
"category": "core"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "cavuno-board-tanstack-start",
|
|
41
|
+
"description": "TanStack-Start-on-Cloudflare-Workers reference wiring for a headless Cavuno board — SSR loaders calling @cavuno/board server-side, the session held in an __Host- httpOnly cookie owned by the app, a single-flight refresh helper, and FetchOptions cache passthrough on Workers.",
|
|
42
|
+
"path": "skills/flavors/tanstack-start/SKILL.md",
|
|
43
|
+
"framework": "tanstack-start",
|
|
44
|
+
"category": "flavor"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|