@cavuno/board 2.0.5 → 3.0.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 (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -3
  3. package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
  4. package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
  5. package/dist/bin.mjs +40 -57
  6. package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
  7. package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
  8. package/dist/doctor.js +21 -47
  9. package/dist/doctor.mjs +24 -50
  10. package/dist/filters.d.mts +2 -2
  11. package/dist/filters.d.ts +2 -2
  12. package/dist/format.d.mts +3 -3
  13. package/dist/format.d.ts +3 -3
  14. package/dist/index.d.mts +11 -27
  15. package/dist/index.d.ts +11 -27
  16. package/dist/index.js +3 -3
  17. package/dist/index.mjs +3 -3
  18. package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
  19. package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
  20. package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
  21. package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
  22. package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
  23. package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
  24. package/dist/seo.d.mts +4 -4
  25. package/dist/seo.d.ts +4 -4
  26. package/dist/server.d.mts +5 -5
  27. package/dist/server.d.ts +5 -5
  28. package/dist/sitemap.d.mts +5 -5
  29. package/dist/sitemap.d.ts +5 -5
  30. package/dist/skills.d.mts +15 -4
  31. package/dist/skills.d.ts +15 -4
  32. package/dist/skills.js +30 -7
  33. package/dist/skills.mjs +30 -8
  34. package/dist/suggest.d.mts +2 -2
  35. package/dist/suggest.d.ts +2 -2
  36. package/package.json +1 -11
  37. package/skills/cavuno-board-account/SKILL.md +54 -66
  38. package/skills/cavuno-board-api-client/SKILL.md +74 -0
  39. package/skills/cavuno-board-applications/SKILL.md +38 -52
  40. package/skills/cavuno-board-auth/SKILL.md +36 -61
  41. package/skills/cavuno-board-blog/SKILL.md +60 -49
  42. package/skills/cavuno-board-companies/SKILL.md +71 -53
  43. package/skills/cavuno-board-errors/SKILL.md +29 -46
  44. package/skills/cavuno-board-filters/SKILL.md +64 -75
  45. package/skills/cavuno-board-format/SKILL.md +63 -68
  46. package/skills/cavuno-board-i18n/SKILL.md +33 -62
  47. package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
  48. package/skills/cavuno-board-jobs/SKILL.md +63 -55
  49. package/skills/cavuno-board-messaging/SKILL.md +53 -59
  50. package/skills/cavuno-board-paywall/SKILL.md +48 -54
  51. package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
  52. package/skills/cavuno-board-salaries/SKILL.md +73 -50
  53. package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
  54. package/skills/cavuno-board-seo/BLOG.md +40 -0
  55. package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
  56. package/skills/cavuno-board-seo/LISTING.md +36 -0
  57. package/skills/cavuno-board-seo/SALARY.md +61 -0
  58. package/skills/cavuno-board-seo/SKILL.md +32 -163
  59. package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
  60. package/skills/cavuno-board-setup/SKILL.md +43 -80
  61. package/skills/cavuno-board-sitemap/SKILL.md +76 -93
  62. package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
  63. package/skills/manifest.json +43 -57
  64. package/dist/theme.d.mts +0 -115
  65. package/dist/theme.d.ts +0 -115
  66. package/dist/theme.js +0 -195
  67. package/dist/theme.mjs +0 -174
  68. package/skills/cavuno-board-client/SKILL.md +0 -89
  69. package/skills/cavuno-board-job-posting/SKILL.md +0 -116
  70. package/skills/cavuno-board-server/SKILL.md +0 -229
  71. package/skills/cavuno-board-suggest/SKILL.md +0 -119
  72. package/skills/cavuno-board-theme/SKILL.md +0 -57
  73. package/skills/flavors/tanstack-start/SKILL.md +0 -102
@@ -1,116 +0,0 @@
1
- ---
2
- name: cavuno-board-job-posting
3
- description: Build the anonymous "Post a job" funnel with the @cavuno/board SDK — jobPosting.plans, create and its status-discriminated JobPostingResult (checkout / published / pending_approval / invoice_sent), logo upload + fetchLogoByDomain, and the email-verified billing helpers (sendBillingVerification, getBillingOptions).
4
- ---
5
-
6
- # Job posting: the anonymous submission funnel
7
-
8
- The no-account "Post a job" wizard: pick a plan, upload a logo, submit — then branch on the status-discriminated result. Anonymous by design (no board-user token) and rate-limited server-side.
9
-
10
- Out of scope — do not invent exports: the `checkout` branch returns a Stripe Checkout **URL** — the full-page redirect is host-app-owned, and the webhook publishes the job after payment. The SDK ships no Stripe code and no publish/confirm method for the paid path.
11
-
12
- ## When to use
13
-
14
- - The public `/post` wizard: plan picker, logo step, submission, and the post-submit screen.
15
- - Letting an existing plan/bundle holder submit against remaining capacity via email verification.
16
-
17
- ## When not to use
18
-
19
- - Authenticated employer job management (drafts, edit, republish, ATS) — that is `board.me.companies.jobs.*`.
20
- - The candidate-access paywall — see `cavuno-board-paywall`.
21
-
22
- ## Plans
23
-
24
- The chosen plan's `id` goes in `submission.selectedPlan`:
25
-
26
- ```ts snippet
27
- const { data: plans } = await board.jobPosting.plans();
28
- for (const plan of plans) {
29
- plan.id; // → submission.selectedPlan
30
- plan.prices; // [{ currency, amountCents, isActive }]
31
- plan.invoiceOnly; // collect invoiceBilling instead of paying now
32
- plan.isRecommended; // highlight in the picker
33
- plan.features; // [{ key, value }] rows for the plan card
34
- }
35
- ```
36
-
37
- ## Logo
38
-
39
- Upload a file, or fetch by company domain (Brandfetch). Both return a stored `publicUrl` you pass back as `logoUrl` on `create(...)`:
40
-
41
- ```ts snippet
42
- const logo = await board.jobPosting.uploadLogo(file); // JPEG/PNG/WebP/GIF, ≤2 MB
43
- // or:
44
- const fetched = await board.jobPosting.fetchLogoByDomain('acme.com');
45
- // a BoardApiError code 'job_posting_logo_not_found' = no usable logo — continue without one
46
- ```
47
-
48
- ## Submit and branch on the result
49
-
50
- `create` returns a `JobPostingResult` — a discriminated union on `.status` with exactly four variants. The host app **must** branch on it; a rejected submission throws a `BoardApiError` instead.
51
-
52
- ```ts snippet
53
- const result = await board.jobPosting.create({
54
- submission: {
55
- companyName: 'Acme',
56
- contactName: 'Ada',
57
- contactEmail: 'ada@acme.com',
58
- title: 'Staff Engineer',
59
- description: '<p>…</p>',
60
- employmentType: 'full_time',
61
- remoteOption: 'remote',
62
- officeLocations: [],
63
- applicationUrl: 'https://acme.com/apply',
64
- salaryRangeEnabled: false,
65
- selectedPlan: plan.id,
66
- },
67
- logoUrl: logo.publicUrl,
68
- });
69
-
70
- switch (result.status) {
71
- case 'checkout': // paid plan → send the poster to Stripe
72
- location.href = result.checkoutUrl; // webhook publishes on payment
73
- break;
74
- case 'published': // live now
75
- result.jobSlug; // link straight to the job
76
- break;
77
- case 'pending_approval': // moderated board — live after review
78
- result.jobId;
79
- break;
80
- case 'invoice_sent': // invoice plan — a Stripe invoice was emailed
81
- result.jobId;
82
- break;
83
- }
84
- ```
85
-
86
- The five billing paths map onto those four statuses: paid-Stripe → `checkout`; free and existing bundle/subscription credit publish without payment (`published`, or `pending_approval` on moderated boards); invoice plans → `invoice_sent`.
87
-
88
- ## Existing credit: the verified-email branch
89
-
90
- A submitter whose email already holds a plan/bundle can post against remaining capacity — no new charge. Verification is the gate: there is deliberately no pre-check by bare email (that would let anyone probe whether an address holds billing). Send the verification email, then read the options with the token; an option's fields become `selectedBilling`:
91
-
92
- ```ts snippet
93
- await board.jobPosting.sendBillingVerification({ email }); // emails a token
94
- // …the poster pastes/clicks the token…
95
- const { options } = await board.jobPosting.getBillingOptions({
96
- verificationToken,
97
- });
98
- if (options.length > 0) {
99
- const option = options[0]!;
100
- option.jobsRemaining; // capacity left
101
- option.featuredRemaining; // featured slots left
102
- option.renewsAt;
103
-
104
- await board.jobPosting.create({
105
- submission, // as above — selectedPlan not needed on this path
106
- selectedBilling: { type: option.type, id: option.id, planId: option.planId },
107
- });
108
- }
109
- ```
110
-
111
- ## Verify
112
-
113
- - [ ] Every `create` call site handles all four `result.status` variants — no fallthrough that assumes `published`.
114
- - [ ] A paid-plan submission lands on Stripe via `result.checkoutUrl`, and the job appears on the board only after payment (webhook), not on redirect.
115
- - [ ] A free-plan submission on an unmoderated board returns `published` with a `jobSlug` that resolves.
116
- - [ ] `fetchLogoByDomain` on a bogus domain surfaces the not-found error path, and the wizard still submits without a logo.
@@ -1,229 +0,0 @@
1
- ---
2
- name: cavuno-board-server
3
- description: SSR session plumbing with @cavuno/board/server — the __Host- httpOnly session-cookie codec (BoardSession serialize/parse/clear + the 5-minute isExpiringSoon window), the board-password grant-cookie codec with the open-redirect guards (safeRedirectPath, currentPathFromReferer), and createSessionRefresher, the single-flight rotation helper for the single-use refresh token. Use when wiring board-user auth or the board-password gate into any server-rendered frontend (TanStack Start, Next.js, Remix, Workers).
4
- ---
5
-
6
- # Server session plumbing
7
-
8
- `@cavuno/board/server` ships the pieces every SSR board frontend re-invents
9
- around auth: cookie codecs and the refresh-rotation helper. Everything is
10
- pure and platform-neutral — helpers take and return cookie **strings**
11
- (`Set-Cookie` values, `Cookie` headers), never framework request/response
12
- objects. The middleware objects themselves stay framework-owned: you write
13
- ~20 lines of glue per framework (the `cavuno-board-tanstack-start` flavor
14
- skill has the reference wiring), and everything inside the glue comes from
15
- here.
16
-
17
- ## When to use
18
-
19
- - Holding a board-user session (bearer pair) in an httpOnly cookie on SSR.
20
- - Refreshing the single-use refresh token safely under concurrency.
21
- - Wiring the board-password gate (`board.password.verify()` grant) with a
22
- safe `?redirect=` round-trip.
23
-
24
- ## When not to use
25
-
26
- - Browser-only SPAs with no server — use `auth.storage: 'local'` /
27
- `'session'` / `'memory'` instead (see `cavuno-board-auth`).
28
- - Framework middleware objects, CSRF protection, cookie encryption — all
29
- app-owned. The session cookie is httpOnly + JSON; if you want an encrypted
30
- cookie, wrap the codec output yourself.
31
-
32
- ## The session cookie
33
-
34
- The SDK never sees server storage (create the client with no `auth.storage`
35
- → `nostore`). The bearer pair lives in ONE `__Host-` httpOnly cookie owned
36
- by your app; `BoardSession` is `{ accessToken, refreshToken, expiresAt }`.
37
-
38
- ```ts snippet
39
- import {
40
- clearSessionCookie,
41
- parseSessionCookie,
42
- serializeSessionCookie,
43
- } from '@cavuno/board/server';
44
-
45
- // After login: persist the pair (BoardAuthSession already carries expiresAt).
46
- const session = await board.auth.login({ email, password });
47
- setCookieHeader = serializeSessionCookie({
48
- accessToken: session.accessToken,
49
- refreshToken: session.refreshToken,
50
- expiresAt: session.expiresAt,
51
- });
52
-
53
- // On every request: parse the Cookie header your framework hands you.
54
- const current = parseSessionCookie(request.headers.get('cookie'));
55
-
56
- // On logout (after board.auth.logout({ refreshToken })): expire it.
57
- setCookieHeader = clearSessionCookie();
58
- ```
59
-
60
- `serializeSessionCookie` locks the attributes (`__Host-` prefix, `Path=/`,
61
- `HttpOnly`, `Secure`, `SameSite=Lax`, 30-day `Max-Age` matching the refresh
62
- token's server-side lifetime). `parseSessionCookie` returns `null` for
63
- absent, malformed, or wrong-shape cookies — treat `null` as signed out.
64
-
65
- ## Single-flight refresh in a session middleware
66
-
67
- Refresh tokens are single-use: two concurrent requests that both refresh
68
- burn the pair — the loser 401s and the user is signed out mid-session.
69
- `createSessionRefresher` dedupes concurrent rotations per refreshToken, and
70
- `isExpiringSoon` gives you the proactive 5-minute window so the old access
71
- token still works while you rotate.
72
-
73
- ```ts snippet
74
- import {
75
- clearSessionCookie,
76
- createSessionRefresher,
77
- isExpiringSoon,
78
- parseSessionCookie,
79
- serializeSessionCookie,
80
- } from '@cavuno/board/server';
81
-
82
- // Module scope: ONE refresher per board client, shared by all requests —
83
- // that sharing IS the single-flight guarantee — WITHIN one process/isolate. Across instances (serverless/multi-pod) simultaneous refreshes can still race; the proactive isExpiringSoon window keeps that rare, it does not eliminate it.
84
- const refreshSession = createSessionRefresher(board);
85
-
86
- async function resolveSession(cookieHeader: string | null) {
87
- const session = parseSessionCookie(cookieHeader);
88
- if (!session) return { session: null, setCookie: null };
89
- if (!isExpiringSoon(session, Date.now())) {
90
- return { session, setCookie: null };
91
- }
92
- const next = await refreshSession(session);
93
- return next
94
- ? { session: next, setCookie: serializeSessionCookie(next) }
95
- : { session: null, setCookie: clearSessionCookie() }; // burned/revoked → signed out
96
- }
97
- ```
98
-
99
- The contract: a rotated `BoardSession` on success (write it back to the
100
- cookie), `null` on a 401 (the token is burned or the session revoked —
101
- clear the cookie and continue signed out, **never retry**), and anything
102
- else (network error, 5xx, 429) rethrows for your error handling. Calls with
103
- the SAME refreshToken while one rotation is in flight await that same
104
- rotation; after it settles, the next call starts fresh.
105
-
106
- Pass the access token per SDK call — the client itself stays stateless:
107
-
108
- ```ts snippet
109
- const me = await board.me.retrieve(undefined, {
110
- headers: { authorization: `Bearer ${session.accessToken}` },
111
- });
112
- ```
113
-
114
- ## The board-password grant cookie
115
-
116
- On password-protected boards, `board.password.verify({ password })` returns
117
- an HMAC grant token; gated reads take it as the `X-Board-Access` header.
118
- Same codec pattern, 24-hour cookie (hosted parity):
119
-
120
- ```ts snippet
121
- import {
122
- clearGrantCookie,
123
- parseGrantCookie,
124
- serializeGrantCookie,
125
- } from '@cavuno/board/server';
126
-
127
- const { token } = await board.password.verify({ password });
128
- setCookieHeader = serializeGrantCookie(token);
129
-
130
- // Thread it to gated reads:
131
- const grant = parseGrantCookie(request.headers.get('cookie'));
132
- const jobs = await board.jobs.list(undefined, {
133
- headers: grant ? { 'X-Board-Access': grant } : {},
134
- });
135
- ```
136
-
137
- When a read still fails with `isBoardPasswordRequired` (grant expired or
138
- the password rotated), send the visitor to your `/password` page and clear
139
- the stale cookie with `clearGrantCookie()`.
140
-
141
- ## Open-redirect guards
142
-
143
- The `/password?redirect=` round-trip is the classic open-redirect hole.
144
- `safeRedirectPath` transcribes the hosted board's guard (tested
145
- against it): same-origin absolute paths pass through, everything else —
146
- `//evil.com`, full URLs, `javascript:`, backslash and control-character
147
- normalization bypasses — collapses to `'/'`.
148
-
149
- ```ts snippet
150
- import {
151
- currentPathFromReferer,
152
- safeRedirectPath,
153
- } from '@cavuno/board/server';
154
-
155
- // Where to send the visitor after a correct password:
156
- redirect(safeRedirectPath(query.redirect));
157
-
158
- // Building the challenge link: derive the come-back path from the Referer
159
- // your framework read for you (already guarded internally).
160
- const backTo = currentPathFromReferer(request.headers.get('referer'));
161
- redirect(`/password?redirect=${encodeURIComponent(backTo)}`);
162
- ```
163
-
164
- ## Thread derivations (core entry, not /server)
165
-
166
- The messaging thread's pure rules ship on the main entry — they're
167
- client-safe view logic, not server plumbing:
168
-
169
- ```ts snippet
170
- import { isColdRule, isOwnMessage, lastOwnMessageId } from '@cavuno/board';
171
-
172
- const mine = isOwnMessage(message, counterpartyId); // bubble side + actions
173
- const composerLocked = isColdRule(messages, counterpartyId); // cold-message cap
174
- const seenTargetId = lastOwnMessageId(messages, counterpartyId); // "Seen" row
175
- ```
176
-
177
- `isColdRule` mirrors the server gate (`messaging_cold_rule` 403) so the
178
- composer can disable itself instead of surfacing the error — the server
179
- stays authoritative.
180
-
181
- ## Multi-board origins
182
-
183
- One origin serving MULTIPLE boards must scope its cookies and storage per
184
- board — otherwise one board's login clobbers another's (hosted scopes its
185
- grant cookie per account for exactly this reason). Pass the board
186
- identifier to the codecs; the browser storage modes scope automatically via
187
- `createBoardClient`'s own `board`:
188
-
189
- ```ts snippet
190
- import {
191
- serializeSessionCookie,
192
- parseSessionCookie,
193
- sessionCookieName,
194
- } from '@cavuno/board/server';
195
-
196
- const scope = { board: 'pk_boardA' };
197
- const cookie = serializeSessionCookie(session, scope);
198
- const restored = parseSessionCookie(cookieHeader, scope);
199
- ```
200
-
201
- Single-board deployments (one board per hostname — the starter model) omit
202
- the scope; the cookie names stay stable.
203
-
204
- ## Anti-patterns
205
-
206
- ```ts no-check
207
- // NEVER auto-retry a null refresh — the token is single-use; a retry loop
208
- // hammers the API with burned tokens:
209
- while (!(await refreshSession(session))) {} // wrong
210
-
211
- // NEVER create a refresher per request — per-request instances can't
212
- // dedupe, which defeats the single-flight design:
213
- const refreshSession = createSessionRefresher(board); // must be module scope
214
-
215
- // NEVER redirect to the raw query param:
216
- redirect(query.redirect); // open redirect — use safeRedirectPath
217
-
218
- // NEVER put the session in auth.storage on the server — auth.refresh persists the rotated pair into client.storage, so a shared persistent store bleeds one user's tokens into other requests (cross-user session leak). 'nostore' + per-call headers is the contract — a shared instance
219
- // leaks one user's tokens into another's request. Cookie + per-call header.
220
- ```
221
-
222
- ## Checklist
223
-
224
- - [ ] Session cookie parsed on every request; `null` treated as signed out.
225
- - [ ] `createSessionRefresher` instance is module-scoped (shared across
226
- requests) and its `null` result clears the cookie without retrying.
227
- - [ ] Rotated sessions are written back with `serializeSessionCookie`.
228
- - [ ] Every `?redirect=` consumer goes through `safeRedirectPath`.
229
- - [ ] Bearer tokens ride per-call headers; the shared client has no storage.
@@ -1,119 +0,0 @@
1
- ---
2
- name: cavuno-board-suggest
3
- description: Federated keyword search suggestions with the @cavuno/board SDK — board.search.suggest for companies + taxonomy terms in one server-ranked list, and the headless createSuggestController under @cavuno/board/suggest (debounce, abort, stale-drop). Use when building a search dropdown, typeahead, or autocomplete that mirrors the hosted board.
4
- ---
5
-
6
- # Search suggest: dropdown typeahead
7
-
8
- `board.search.suggest` returns ONE interleaved, server-ordered list of
9
- company and taxonomy-term suggestions for a keyword — the hosted search
10
- dropdown contract. `@cavuno/board/suggest` wraps that call in a headless
11
- controller so debounce / abort / stale-drop ship with the SDK and patch via
12
- npm update.
13
-
14
- ## When to use
15
-
16
- - The global search input's suggestion dropdown.
17
- - Any typeahead that should offer companies and category/skill terms
18
- together, ranked the way the hosted board ranks them.
19
-
20
- ## When not to use
21
-
22
- - Location autocomplete — `board.taxonomy.places.list({ q })`.
23
- - Taxonomy-only option lists for filter sidebars —
24
- `board.taxonomy.categories/skills/suggestions.list`.
25
- - Job results themselves — `board.jobs.list` / `board.jobs.search`.
26
-
27
- ## Call the endpoint directly
28
-
29
- ```ts snippet
30
- const { items, query } = await board.search.suggest({ q: 'acme', limit: 10 });
31
- // items is server-ranked — do NOT re-sort.
32
- for (const item of items) {
33
- if (item.type === 'company') {
34
- item.slug; // public company URL slug
35
- item.name;
36
- item.jobCount;
37
- } else {
38
- // item.type === 'term'
39
- item.termType; // 'category' | 'skill'
40
- item.displayName;
41
- item.canonicalSlug; // for links
42
- item.sourceSlug; // for job filters
43
- }
44
- }
45
- ```
46
-
47
- Queries under two characters return an empty `items` array. `limit` is
48
- 1–25 (default 25). Order is the contract.
49
-
50
- ## Prefer the headless controller for UI
51
-
52
- The controller owns debounce (default 250ms), min-chars (default 2),
53
- in-flight abort, and stale-drop. Compatible with `useSyncExternalStore`.
54
-
55
- ```ts snippet
56
- import { createSuggestController } from '@cavuno/board/suggest';
57
-
58
- const suggest = createSuggestController(board, { limit: 10 });
59
- const unsub = suggest.subscribe(() => {
60
- const { items, status, query } = suggest.getState();
61
- // render dropdown; previous items are retained while status === 'loading'
62
- });
63
- suggest.setQuery(inputValue);
64
- // Hide the currently-applied company filter from the list:
65
- suggest.setExcludedCompanySlugs(appliedCompanySlugs);
66
- // teardown:
67
- unsub();
68
- suggest.dispose();
69
- ```
70
-
71
- `setExcludedCompanySlugs` is the ONE permitted view-level filter (company
72
- items only, re-filters current items synchronously — no new request). Never
73
- reshape or re-sort the rest of the response.
74
-
75
- ## Wire a company pick into the listing
76
-
77
- Company slugs are the URL identity. Map them onto the jobs surface as
78
- `companySlug` — never resolve slug→id client-side:
79
-
80
- ```ts snippet
81
- import { parseListingFilters } from '@cavuno/board/filters';
82
-
83
- const filters = parseListingFilters(rawSearchParams);
84
- // filters.company is string[] of public slugs
85
-
86
- const page = await board.jobs.list({
87
- limit: 20,
88
- companySlug: filters.company,
89
- });
90
- ```
91
-
92
- Unknown slugs are dropped server-side (not an error). If the entire company
93
- filter resolves to no known companies, the listing is empty (`count: 0`).
94
- Combined with `companyId` as a union when both are set.
95
-
96
- ## Anti-patterns
97
-
98
- ```ts no-check
99
- // NEVER re-sort server-ranked items:
100
- items.sort((a, b) => a.name.localeCompare(b.name));
101
- // NEVER resolve company slug → id on the client:
102
- const id = await lookupCompanyId(item.slug);
103
- board.jobs.list({ companyId: [id] });
104
- // NEVER hand-roll debounce/abort — use the controller so patches ship via npm.
105
- ```
106
-
107
- ## Out of scope — do not invent exports
108
-
109
- No React components, no dropdown UI, no keyboard navigation — this package
110
- is the data + controller layer. Location suggest stays on `taxonomy.places`.
111
-
112
- ## Verify
113
-
114
- - [ ] Typing under 2 characters shows no suggestions and no network call
115
- from the controller.
116
- - [ ] Fast typing cancels the previous request; only the final query's
117
- results render.
118
- - [ ] Selecting a company writes its public slug into the URL and the
119
- listing request uses `companySlug`, not a client-resolved id.
@@ -1,57 +0,0 @@
1
- ---
2
- name: cavuno-board-theme
3
- description: Optional hosted-board theme compatibility with @cavuno/board/theme. Use only when a custom frontend deliberately mirrors or migrates an existing Cavuno hosted theme; the consuming application normally owns tokens, fonts, and color mode.
4
- ---
5
-
6
- # Optional hosted-board theme compatibility
7
-
8
- The custom frontend owns its visual system by default: semantic design tokens, component styles, font loading, and light/dark-mode state belong in the application. Do not make remote `board.context().theme` the default source of truth for a new SDK frontend.
9
-
10
- `@cavuno/board/theme` exists for a narrower job: mirroring an existing hosted board or providing a starting point during a hosted-to-custom migration.
11
-
12
- ## When to use
13
-
14
- - The human explicitly wants the custom frontend to mirror the current hosted board.
15
- - A migration needs a temporary compatibility layer while tokens move into the application.
16
- - One application intentionally renders many Cavuno boards with different operator-owned themes.
17
-
18
- ## When not to use
19
-
20
- - A normal custom frontend whose design system already defines tokens and fonts.
21
- - Component-level styling.
22
- - As a replacement for the framework's theme provider or font system.
23
-
24
- ## Read compatibility values
25
-
26
- ```ts snippet
27
- import {
28
- boardThemeToCss,
29
- googleFontsUrl,
30
- themeMode,
31
- } from '@cavuno/board/theme';
32
-
33
- const context = await board.context();
34
- const hostedTheme = {
35
- css: boardThemeToCss(context.theme),
36
- fontUrl: googleFontsUrl(context.theme),
37
- mode: themeMode(context.theme),
38
- };
39
- ```
40
-
41
- `boardThemeToCss` validates the stored colors before emitting CSS. A null theme returns no CSS, so the application default remains intact.
42
-
43
- ## Migration guidance
44
-
45
- 1. Compare the returned colors, fonts, and mode with the consuming design system.
46
- 2. Move the chosen values into the application's own semantic tokens and font loader.
47
- 3. Remove the runtime theme injection when the application becomes authoritative.
48
-
49
- If permanent multi-board mirroring is an explicit requirement, inject the validated CSS after the static theme and make the framework's theme provider apply the returned mode. Do not interpolate raw stored colors or build font URLs yourself.
50
-
51
- ## Verify
52
-
53
- - [ ] The application renders correctly when `context.theme` is null.
54
- - [ ] New custom frontends do not fetch theme data merely to establish basic styling.
55
- - [ ] Compatibility CSS never contains unsafe stored color syntax.
56
- - [ ] Font loading and color-mode ownership are explicit and tested.
57
- - [ ] A migration has a documented point where the application becomes authoritative.
@@ -1,102 +0,0 @@
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 conformance starter ([`wollemiahq/cavuno-board-starter`](https://github.com/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
- For a complete, clone-and-own frontend rather than the conformance fixture, use the [Cavuno TanStack Start + shadcn/ui job board template](https://github.com/wollemiahq/cavuno-tanstack-start-shadcn-job-board-template).
11
-
12
- ## When to use
13
-
14
- - The project depends on `@tanstack/react-start`.
15
- - You need the SSR-loader + httpOnly-cookie + single-flight-refresh patterns.
16
-
17
- ## One shared, stateless client
18
-
19
- ```ts
20
- import { createBoardClient } from '@cavuno/board';
21
-
22
- // Module-scoped, no auth.storage → safe across concurrent Workers requests.
23
- export const board = createBoardClient({
24
- board: process.env.PUBLIC_CAVUNO_BOARD!,
25
- });
26
- ```
27
-
28
- ## Read in server loaders; cache via FetchOptions
29
-
30
- 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:
31
-
32
- ```ts no-check
33
- import { createFileRoute } from '@tanstack/react-start';
34
- import { board } from '~/lib/board';
35
-
36
- export const Route = createFileRoute('/jobs')({
37
- loader: async () => {
38
- // `cf` rides straight to the Workers fetch — the SDK needs no framework knowledge.
39
- return board.jobs.list({ limit: 20 }, { cf: { cacheTtl: 60 } } as never);
40
- },
41
- });
42
- ```
43
-
44
- ## Session in an `__Host-` httpOnly cookie owned by the app
45
-
46
- 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:
47
-
48
- ```ts no-check
49
- import { board } from '~/lib/board';
50
- import { readSessionCookie } from '~/lib/session.server';
51
-
52
- export async function loadMe(request: Request) {
53
- const { accessToken } = readSessionCookie(request);
54
- // Pass the token via `options` (2nd arg). The 1st arg is `query`.
55
- return board.me.retrieve(undefined, {
56
- headers: { authorization: `Bearer ${accessToken}` },
57
- });
58
- }
59
- ```
60
-
61
- ## Single-flight refresh
62
-
63
- Refresh tokens are single-use; concurrent 401s must trigger exactly one rotation. Encode it once and reuse everywhere:
64
-
65
- ```ts no-check
66
- import { isUnauthorized } from '@cavuno/board';
67
- import { board } from '~/lib/board';
68
-
69
- let inflight: Promise<void> | null = null;
70
-
71
- // `getAccessToken`/`getRefreshToken` read your __Host- cookie (the SDK never
72
- // touches it). On a 401 we refresh exactly once — concurrent callers await the
73
- // same `inflight` promise — then retry `run` with the rotated token. Your
74
- // refresh handler must write the new pair to the cookie so the retry reads it.
75
- export async function withFreshSession<T>(
76
- getAccessToken: () => string,
77
- getRefreshToken: () => string,
78
- run: (accessToken: string) => Promise<T>): 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.