@cavuno/board 2.0.3 → 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.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +0 -11
- package/README.md +8 -7
- package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
- package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
- package/dist/bin.mjs +40 -57
- package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
- package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
- package/dist/doctor.js +21 -47
- package/dist/doctor.mjs +24 -50
- package/dist/filters.d.mts +2 -2
- package/dist/filters.d.ts +2 -2
- package/dist/format.d.mts +3 -3
- package/dist/format.d.ts +3 -3
- package/dist/index.d.mts +11 -27
- package/dist/index.d.ts +11 -27
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
- package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
- package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
- package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
- package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
- package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
- package/dist/seo.d.mts +4 -4
- package/dist/seo.d.ts +4 -4
- package/dist/server.d.mts +5 -5
- package/dist/server.d.ts +5 -5
- package/dist/sitemap.d.mts +5 -5
- package/dist/sitemap.d.ts +5 -5
- package/dist/skills.d.mts +15 -4
- package/dist/skills.d.ts +15 -4
- package/dist/skills.js +30 -7
- package/dist/skills.mjs +30 -8
- package/dist/suggest.d.mts +2 -2
- package/dist/suggest.d.ts +2 -2
- package/package.json +3 -13
- package/skills/cavuno-board-account/SKILL.md +54 -66
- package/skills/cavuno-board-api-client/SKILL.md +74 -0
- package/skills/cavuno-board-applications/SKILL.md +38 -52
- package/skills/cavuno-board-auth/SKILL.md +36 -61
- package/skills/cavuno-board-blog/SKILL.md +60 -49
- package/skills/cavuno-board-companies/SKILL.md +71 -53
- package/skills/cavuno-board-errors/SKILL.md +29 -46
- package/skills/cavuno-board-filters/SKILL.md +64 -75
- package/skills/cavuno-board-format/SKILL.md +63 -68
- package/skills/cavuno-board-i18n/SKILL.md +33 -62
- package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
- package/skills/cavuno-board-jobs/SKILL.md +63 -55
- package/skills/cavuno-board-messaging/SKILL.md +53 -59
- package/skills/cavuno-board-paywall/SKILL.md +48 -54
- package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
- package/skills/cavuno-board-salaries/SKILL.md +73 -50
- package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
- package/skills/cavuno-board-seo/BLOG.md +40 -0
- package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
- package/skills/cavuno-board-seo/LISTING.md +36 -0
- package/skills/cavuno-board-seo/SALARY.md +61 -0
- package/skills/cavuno-board-seo/SKILL.md +32 -163
- package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
- package/skills/cavuno-board-setup/SKILL.md +43 -80
- package/skills/cavuno-board-sitemap/SKILL.md +76 -93
- package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
- package/skills/manifest.json +43 -57
- package/dist/theme.d.mts +0 -115
- package/dist/theme.d.ts +0 -115
- package/dist/theme.js +0 -195
- package/dist/theme.mjs +0 -174
- package/skills/cavuno-board-client/SKILL.md +0 -89
- package/skills/cavuno-board-job-posting/SKILL.md +0 -116
- package/skills/cavuno-board-server/SKILL.md +0 -229
- package/skills/cavuno-board-suggest/SKILL.md +0 -119
- package/skills/cavuno-board-theme/SKILL.md +0 -57
- package/skills/flavors/tanstack-start/SKILL.md +0 -100
|
@@ -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,100 +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 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
|
-
board: process.env.PUBLIC_CAVUNO_BOARD!,
|
|
23
|
-
});
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Read in server loaders; cache via FetchOptions
|
|
27
|
-
|
|
28
|
-
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:
|
|
29
|
-
|
|
30
|
-
```ts no-check
|
|
31
|
-
import { createFileRoute } from '@tanstack/react-start';
|
|
32
|
-
import { board } from '~/lib/board';
|
|
33
|
-
|
|
34
|
-
export const Route = createFileRoute('/jobs')({
|
|
35
|
-
loader: async () => {
|
|
36
|
-
// `cf` rides straight to the Workers fetch — the SDK needs no framework knowledge.
|
|
37
|
-
return board.jobs.list({ limit: 20 }, { cf: { cacheTtl: 60 } } as never);
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
## Session in an `__Host-` httpOnly cookie owned by the app
|
|
43
|
-
|
|
44
|
-
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:
|
|
45
|
-
|
|
46
|
-
```ts no-check
|
|
47
|
-
import { board } from '~/lib/board';
|
|
48
|
-
import { readSessionCookie } from '~/lib/session.server';
|
|
49
|
-
|
|
50
|
-
export async function loadMe(request: Request) {
|
|
51
|
-
const { accessToken } = readSessionCookie(request);
|
|
52
|
-
// Pass the token via `options` (2nd arg). The 1st arg is `query`.
|
|
53
|
-
return board.me.retrieve(undefined, {
|
|
54
|
-
headers: { authorization: `Bearer ${accessToken}` },
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Single-flight refresh
|
|
60
|
-
|
|
61
|
-
Refresh tokens are single-use; concurrent 401s must trigger exactly one rotation. Encode it once and reuse everywhere:
|
|
62
|
-
|
|
63
|
-
```ts no-check
|
|
64
|
-
import { isUnauthorized } from '@cavuno/board';
|
|
65
|
-
import { board } from '~/lib/board';
|
|
66
|
-
|
|
67
|
-
let inflight: Promise<void> | null = null;
|
|
68
|
-
|
|
69
|
-
// `getAccessToken`/`getRefreshToken` read your __Host- cookie (the SDK never
|
|
70
|
-
// touches it). On a 401 we refresh exactly once — concurrent callers await the
|
|
71
|
-
// same `inflight` promise — then retry `run` with the rotated token. Your
|
|
72
|
-
// refresh handler must write the new pair to the cookie so the retry reads it.
|
|
73
|
-
export async function withFreshSession<T>(
|
|
74
|
-
getAccessToken: () => string,
|
|
75
|
-
getRefreshToken: () => string,
|
|
76
|
-
run: (accessToken: string) => Promise<T>): Promise<T> {
|
|
77
|
-
try {
|
|
78
|
-
return await run(getAccessToken());
|
|
79
|
-
} catch (err) {
|
|
80
|
-
if (!isUnauthorized(err)) throw err;
|
|
81
|
-
inflight ??= board.auth
|
|
82
|
-
.refresh({ refreshToken: getRefreshToken() })
|
|
83
|
-
.then(() => undefined)
|
|
84
|
-
.finally(() => (inflight = null));
|
|
85
|
-
await inflight;
|
|
86
|
-
return run(getAccessToken()); // retry with the rotated token
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Canonical URLs
|
|
92
|
-
|
|
93
|
-
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`.
|
|
94
|
-
|
|
95
|
-
## Checklist
|
|
96
|
-
|
|
97
|
-
- [ ] All SDK calls are server-side; no bearer token in the browser bundle.
|
|
98
|
-
- [ ] Session in an `__Host-` httpOnly cookie owned by the app, passed per call.
|
|
99
|
-
- [ ] Single-flight refresh wraps concurrent 401s.
|
|
100
|
-
- [ ] Public route paths mirror the hosted board's canonical URLs.
|