@cavuno/board 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
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 (golden-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.
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: cavuno-board-sitemap
3
+ description: Build the board's sitemap with @cavuno/board/sitemap — the hosted 8-bucket model (marketing, jobs taxonomies, job details, companies, salaries, blog), 45k chunking, XML rendering, and the buildBucketUrls walker that enumerates a board's content through the BoardSdk with the hosted SEO rules built in (feature gating, ≥5-job thin-content floor, pagination backstops). Use when adding /sitemap.xml and /sitemap/:file routes to a custom board frontend.
4
+ ---
5
+
6
+ # Sitemap: the 8-bucket model
7
+
8
+ `@cavuno/board/sitemap` ships the hosted board's sitemap architecture: a
9
+ sitemap INDEX at `/sitemap.xml` pointing at one file per content bucket,
10
+ each an ordinary `<urlset>`. The XML byte layout and the bucket rules are
11
+ golden-tested against the hosted implementation, so a custom frontend's
12
+ sitemap corpus lines up bucket-for-bucket with what cavuno.com would emit.
13
+
14
+ Two tiers, use both:
15
+
16
+ - **XML primitives** (pure, no I/O): `SITEMAP_BUCKETS`,
17
+ `SITEMAP_CHUNK_SIZE` (45,000), `chunk`, `bucketFilename` /
18
+ `parseBucketFilename`, `renderUrlset`, `renderSitemapIndex`.
19
+ - **The walker** (opinionated, injected I/O): `listedBuckets(board)` and
20
+ `buildBucketUrls(board, origin, bucket)` take your `BoardSdk` instance
21
+ and return plain URL strings with the hosted rules applied — you never
22
+ re-derive the SEO policy.
23
+
24
+ ## When to use
25
+
26
+ - Adding `/sitemap.xml` + `/sitemap/:file` routes to a board frontend.
27
+ - Deciding which listing pages deserve indexing (the walker already does).
28
+
29
+ ## When not to use
30
+
31
+ - Structured data / meta tags — `cavuno-board-seo`.
32
+ - Feeds (RSS/Atom) — app-owned.
33
+
34
+ ## The two routes
35
+
36
+ The index lists one entry per bucket; each bucket route parses its filename
37
+ back to a bucket + chunk and renders a urlset. Empty buckets render a valid
38
+ empty urlset — only `blog` is dropped from the index when the feature is
39
+ off (`listedBuckets` handles that).
40
+
41
+ ```ts snippet
42
+ import {
43
+ SITEMAP_CHUNK_SIZE,
44
+ buildBucketUrls,
45
+ bucketFilename,
46
+ chunk,
47
+ listedBuckets,
48
+ parseBucketFilename,
49
+ renderSitemapIndex,
50
+ renderUrlset,
51
+ } from '@cavuno/board/sitemap';
52
+
53
+ // GET /sitemap.xml — one <sitemap> per listed bucket (plus -2, -3… chunks).
54
+ const origin = 'https://jobs.example.com';
55
+ const buckets = await listedBuckets(board);
56
+ const locs: string[] = [];
57
+ for (const bucket of buckets) {
58
+ const urls = await buildBucketUrls(board, origin, bucket);
59
+ const chunks = chunk(urls, SITEMAP_CHUNK_SIZE);
60
+ for (let i = 0; i < Math.max(chunks.length, 1); i += 1) {
61
+ locs.push(`${origin}/sitemap/${bucketFilename(bucket, i)}`);
62
+ }
63
+ }
64
+ return xmlResponse(renderSitemapIndex(locs));
65
+ ```
66
+
67
+ ```ts snippet
68
+ // GET /sitemap/:file — parse, enumerate, slice, render. Unknown file → 404.
69
+ const parsed = parseBucketFilename(params.file);
70
+ if (!parsed) return notFound();
71
+ const urls = await buildBucketUrls(board, origin, parsed.bucket);
72
+ const page = chunk(urls, SITEMAP_CHUNK_SIZE)[parsed.chunkIndex] ?? [];
73
+ return xmlResponse(renderUrlset(page));
74
+ ```
75
+
76
+ `renderUrlset` also accepts `{ url, lastModified?, images? }` entries when
77
+ you have per-URL dates (a `Date` serializes to ISO 8601). `changefreq` and
78
+ `priority` are deliberately unsupported — the hosted board never emits them.
79
+
80
+ ## robots.txt
81
+
82
+ Point crawlers at the index — one line, at the site root:
83
+
84
+ ```txt
85
+ Sitemap: https://jobs.example.com/sitemap.xml
86
+ ```
87
+
88
+ ## The rules the walker enforces (don't re-implement)
89
+
90
+ - **Thin-content floor**: a category/skill/location listing page is emitted
91
+ only with ≥5 distinct jobs (`MIN_JOBS_PER_INDEXED_PAGE`) — below that,
92
+ the page is thin content that wastes crawl budget.
93
+ - **Feature gating**: `/impressum`, `/talent`, `/employers` appear in the
94
+ marketing bucket only when `context().features` enables them; the blog
95
+ bucket exists only when `features.blog` is on.
96
+ - **Board language**: salary-index reads pass `context().language`, so a
97
+ non-English board emits board-language canonical salary slugs (jobs,
98
+ companies, and blog slugs arrive canonical on the wire already). No
99
+ locale parameter to thread.
100
+ - **Pagination backstops**: offset enumeration runs in parallel when the
101
+ envelope carries `count` (capped at the API's 10,000-offset window);
102
+ cursor walks cap at 200 pages. Both warn on truncation instead of
103
+ hanging a build.
104
+
105
+ ## Stable order caveat (cursor walks)
106
+
107
+ Cursor and offset enumeration order can shift between requests on a large,
108
+ churning board — two chunk requests may see slightly different orderings.
109
+ That is harmless for a sitemap (URLs dedupe and the ≥5 counts are a
110
+ heuristic), but do NOT reuse the walker as a general "export all jobs in
111
+ order" tool; for stable ordering, pass an explicit sort/query to
112
+ `board.jobs.list` yourself.
113
+
114
+ ## Named exclusions (v1 API gap — not bugs)
115
+
116
+ Two URL families the HOSTED sitemap emits are deliberately absent, because
117
+ v1 exposes them only per-slug and a bulk-pairs endpoint doesn't exist yet
118
+ (per-slug N+1 is ~1k+ calls per build):
119
+
120
+ - Cross-axis salary pages (title×location, skill×location,
121
+ company×category, and the per-entity `/locations` · `/titles` ·
122
+ `/skills` salary index pages).
123
+ - Jobs place×category / place×skill combination listings.
124
+
125
+ Both stay reachable through internal links. When the bulk endpoint lands,
126
+ the walker picks them up additively — don't hand-enumerate them.
127
+
128
+ ## Anti-patterns
129
+
130
+ ```ts snippet
131
+ // NEVER emit every taxonomy page unconditionally — the ≥5-job floor exists
132
+ // to keep thin pages out of the index:
133
+ for (const c of allCategories) urls.push(`${origin}/jobs/${c.slug}`); // wrong
134
+ // NEVER hand-build the XML with a template string per route — use
135
+ // renderUrlset/renderSitemapIndex (escaping + byte-parity with hosted).
136
+ // NEVER fetch every salary detail page to discover cross-axis pairs (N+1).
137
+ ```
138
+
139
+ ## Verify
140
+
141
+ - [ ] `/sitemap.xml` lists one file per bucket (no `blog` entry when the
142
+ feature is off) and every listed file returns valid XML.
143
+ - [ ] A category with 4 jobs is absent from `jobs-categories.xml`; one with
144
+ 5 is present.
145
+ - [ ] `jobs-details-2.xml` style chunk names round-trip through
146
+ `parseBucketFilename` and unknown filenames 404.
147
+ - [ ] robots.txt points at `/sitemap.xml`.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.26.0",
2
+ "version": "1.28.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -106,6 +106,20 @@
106
106
  "framework": null,
107
107
  "category": "core"
108
108
  },
109
+ {
110
+ "name": "cavuno-board-seo",
111
+ "description": "Structured data + head builders for board frontends with @cavuno/board/seo — Google for Jobs JobPosting JSON-LD, breadcrumbs, blog Article/ProfilePage, salary-page Occupation/FAQ structured data, and listing <head> descriptors. Use when building a job-detail, listing, blog, or salary page and it needs JSON-LD or SEO meta tags, or whenever \"structured data\", \"rich results\", or \"Google for Jobs\" comes up.",
112
+ "path": "skills/cavuno-board-seo/SKILL.md",
113
+ "framework": null,
114
+ "category": "core"
115
+ },
116
+ {
117
+ "name": "cavuno-board-server",
118
+ "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).",
119
+ "path": "skills/cavuno-board-server/SKILL.md",
120
+ "framework": null,
121
+ "category": "core"
122
+ },
109
123
  {
110
124
  "name": "cavuno-board-setup",
111
125
  "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.",
@@ -113,6 +127,13 @@
113
127
  "framework": null,
114
128
  "category": "core"
115
129
  },
130
+ {
131
+ "name": "cavuno-board-sitemap",
132
+ "description": "Build the board's sitemap with @cavuno/board/sitemap — the hosted 8-bucket model (marketing, jobs taxonomies, job details, companies, salaries, blog), 45k chunking, XML rendering, and the buildBucketUrls walker that enumerates a board's content through the BoardSdk with the hosted SEO rules built in (feature gating, ≥5-job thin-content floor, pagination backstops). Use when adding /sitemap.xml and /sitemap/:file routes to a custom board frontend.",
133
+ "path": "skills/cavuno-board-sitemap/SKILL.md",
134
+ "framework": null,
135
+ "category": "core"
136
+ },
116
137
  {
117
138
  "name": "cavuno-board-smoke-test",
118
139
  "description": "Runtime verification for a wired Cavuno board frontend — literal curl probes against the Board API plus per-surface behavioral checks and a mandatory production-build pass. Run after cavuno-board-setup finishes, after upgrading @cavuno/board, or whenever board wiring is suspect. Type checks are not enough for board wiring.",