@cavuno/board 2.0.5 → 3.1.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 +22 -0
  2. package/README.md +12 -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 +58 -66
  38. package/skills/cavuno-board-api-client/SKILL.md +78 -0
  39. package/skills/cavuno-board-applications/SKILL.md +42 -52
  40. package/skills/cavuno-board-auth/SKILL.md +39 -60
  41. package/skills/cavuno-board-blog/SKILL.md +64 -49
  42. package/skills/cavuno-board-companies/SKILL.md +75 -53
  43. package/skills/cavuno-board-errors/SKILL.md +32 -45
  44. package/skills/cavuno-board-filters/SKILL.md +68 -75
  45. package/skills/cavuno-board-format/SKILL.md +65 -66
  46. package/skills/cavuno-board-i18n/SKILL.md +43 -68
  47. package/skills/cavuno-board-job-alerts/SKILL.md +72 -57
  48. package/skills/cavuno-board-jobs/SKILL.md +67 -55
  49. package/skills/cavuno-board-messaging/SKILL.md +57 -59
  50. package/skills/cavuno-board-paywall/SKILL.md +52 -54
  51. package/skills/cavuno-board-post-a-job/SKILL.md +122 -0
  52. package/skills/cavuno-board-salaries/SKILL.md +77 -50
  53. package/skills/cavuno-board-search-suggestions/SKILL.md +86 -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 +34 -161
  59. package/skills/cavuno-board-server-sessions/SKILL.md +147 -0
  60. package/skills/cavuno-board-setup/SKILL.md +46 -79
  61. package/skills/cavuno-board-sitemap/SKILL.md +78 -91
  62. package/skills/cavuno-board-smoke-test/SKILL.md +38 -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,44 +1,25 @@
1
1
  ---
2
2
  name: cavuno-board-account
3
- description: Candidate account self-service with the @cavuno/board SDK — me.retrieve/delete, the candidate profile (merge-patch update, handle availability), experience/education CRUD, skills/languages full-replace, avatar upload, resume upload with async parse polling, and notification preferences plus the anonymous token unsubscribe. Use when building account, profile-edit, onboarding, or notification-settings pages for a signed-in board user.
3
+ description: Candidate self-service boundary with @cavuno/board. Use for account, profile, resume onboarding, avatar, experience, education, skills, languages, or notification preferences.
4
4
  ---
5
5
 
6
- # Candidate account self-service
6
+ # Candidate self-service boundary
7
7
 
8
- Everything the signed-in candidate manages about themselves lives under `board.me`. Every call here requires a board-user bearer token — in the browser the SDK reads it from `auth.storage` after login; on the server pass it per call via `options.headers` (see `cavuno-board-auth` for the httpOnly-cookie pattern). The one exception is the anonymous token unsubscribe at the end.
8
+ Signed-in candidate data lives under `board.me`. Browser calls use the bearer token in `auth.storage`; server calls pass it per request in `options.headers`. See `cavuno-board-auth` for cookie-based server sessions.
9
9
 
10
- ## When to use
10
+ The only anonymous method here is token-based email unsubscribe. Applications, employer companies, messaging, and alerts have their own skills.
11
11
 
12
- - Account page: show the signed-in user, delete the account.
13
- - Profile editor: bio/headline/handle, experience, education, skills, languages, avatar.
14
- - Resume-driven onboarding (upload → parse → auto-populated profile).
15
- - Email notification settings + the unsubscribe link in emails.
12
+ The host application owns forms, file pickers, and cookie plumbing; this SDK surface supplies data operations.
16
13
 
17
- ## When not to use
14
+ ## Account and profile
18
15
 
19
- - Login/registration/tokens — `cavuno-board-auth`.
20
- - Applications and saved jobs — `cavuno-board-applications`.
21
- - The employer facet (`me.companies.*`), messaging (`me.conversations.*`), job alerts (`me.alerts.*`).
22
-
23
- Out of scope — do not invent exports: the SDK ships no form components, file-picker UI, or upload widgets; the host app owns all UI and cookie plumbing.
24
-
25
- ## Account: retrieve and delete
16
+ Account deletion is a synchronous, irreversible cascade over the profile, collections, saved jobs, alerts, avatar, and resume. Obtain explicit confirmation before calling it.
26
17
 
27
18
  ```ts snippet
28
- const me = await board.me.retrieve(); // the authenticated BoardUser
29
- await board.me.delete(); // 204 — synchronous, irreversible cascade
30
- ```
31
-
32
- `delete` removes the account and all dependent data (profile, collections, saved jobs, alerts, avatar/resume files). Gate it behind an explicit confirmation.
33
-
34
- ## Profile: retrieve, update, handle availability
19
+ const me = await board.me.retrieve();
20
+ await board.me.delete();
35
21
 
36
- The profile is a singleton. `update` is a merge-patch — omitted fields stay unchanged.
37
-
38
- ```ts snippet
39
22
  const profile = await board.me.profile.retrieve();
40
- profile.handle; profile.headline; profile.jobSearchStatus;
41
-
42
23
  await board.me.profile.update({
43
24
  headline: 'Staff Engineer',
44
25
  jobSearchStatus: 'open_to_offers',
@@ -48,33 +29,40 @@ await board.me.profile.update({
48
29
  const { available } = await board.me.profile.handleAvailable('jane');
49
30
  ```
50
31
 
51
- `handleAvailable` is advisory (my current handle counts as available); `update` re-checks on write.
32
+ Profile updates are merge-patches. Handle availability is advisory—your current handle counts as available—and the write re-checks uniqueness.
52
33
 
53
- ## Experience and education: per-entry CRUD
34
+ ## Experience and education
54
35
 
55
- Both collections are id-keyed CRUD; updates are merge-patch. `createExperience` requires `title`, `companyName`, `startDate`; `createEducation` requires `institutionName`.
36
+ Both collections are id-keyed CRUD with merge-patch updates. Experience creation requires `title`, `companyName`, and `startDate`; education creation requires `institutionName`.
56
37
 
57
38
  ```ts snippet
58
- const { data: entries } = await board.me.profile.listExperience();
59
- const entry = await board.me.profile.createExperience({
39
+ const page = await board.me.profile.listExperience();
40
+ const experience = await board.me.profile.createExperience({
60
41
  title: 'Staff Engineer',
61
42
  companyName: 'Acme',
62
43
  startDate: '2022-01',
63
44
  });
64
- await board.me.profile.updateExperience(entry.id, { endDate: '2025-06' });
65
- await board.me.profile.deleteExperience(entry.id);
66
- // Education mirrors this: listEducation / createEducation /
67
- // updateEducation(id, body) / deleteEducation(id).
45
+ await board.me.profile.updateExperience(experience.id, {
46
+ endDate: '2025-06',
47
+ });
48
+ await board.me.profile.deleteExperience(experience.id);
49
+
50
+ const education = await board.me.profile.createEducation({
51
+ institutionName: 'Example University',
52
+ });
53
+ await board.me.profile.listEducation();
54
+ await board.me.profile.updateEducation(education.id, {});
55
+ await board.me.profile.deleteEducation(education.id);
68
56
  ```
69
57
 
70
- ## Skills and languages: PUT full-replace
58
+ ## Full-set fields
71
59
 
72
- `updateSkills` / `updateLanguages` replace the **whole set** — there is no add/remove-one endpoint. Sending only the new item wipes the rest. Round-trip the current list:
60
+ `updateSkills` and `updateLanguages` replace the whole ordered set. Read-modify-write any value the user intends to retain.
73
61
 
74
62
  ```ts snippet
75
63
  const current = await board.me.profile.listSkills();
76
64
  await board.me.profile.updateSkills({
77
- skills: [...current.data.map((s) => s.name), 'TypeScript'], // ordered names
65
+ skills: [...current.data.map((skill) => skill.name), 'TypeScript'],
78
66
  });
79
67
 
80
68
  await board.me.profile.updateLanguages({
@@ -82,53 +70,53 @@ await board.me.profile.updateLanguages({
82
70
  });
83
71
  ```
84
72
 
85
- Both return the full updated list envelope, so you can render straight from the response.
73
+ Both updates return the complete updated list.
86
74
 
87
- ## Avatar upload
75
+ ## Files
88
76
 
89
- Pass a `Blob`/`File` — the SDK builds the multipart `FormData` (field `file`). JPEG/PNG/WebP, ≤ 5 MB.
77
+ The SDK builds multipart `FormData` with field `file` from a `Blob` or `File`. Avatar uploads accept JPEG, PNG, or WebP up to 5 MB.
90
78
 
91
79
  ```ts snippet
92
80
  const { avatarUrl } = await board.me.profile.uploadAvatar(file);
93
81
  ```
94
82
 
95
- ## Resume: upload, then poll the parse
96
-
97
- `upload` starts an **async parse** that auto-populates the profile. It returns the resume with `parseStatus: 'parsing'` — poll `retrieve()` until `parsed` or `failed`, then re-read `profile.*` for the populated fields. Options: `keepResumeOnFile`, `importMode: 'append_only' | 'replace_all'`, `confirmReplaceAll`.
83
+ Resume upload starts an asynchronous parse that may populate the profile. Poll within a fixed budget, surface failure, and keep manual editing available if parsing runs long.
98
84
 
99
85
  ```ts snippet
100
- let resume = await board.me.resume.upload(file, { keepResumeOnFile: true });
101
- // Bounded poll — a stuck parse job must not hang the UI forever.
102
- const MAX_POLLS = 30; // ~60s at 2s cadence
103
- for (let i = 0; resume.parseStatus === 'parsing' && i < MAX_POLLS; i++) {
104
- await new Promise((r) => setTimeout(r, 2000));
86
+ let resume = await board.me.resume.upload(file, {
87
+ keepResumeOnFile: true,
88
+ });
89
+
90
+ const maxPolls = 30;
91
+ for (let poll = 0; resume.parseStatus === 'parsing' && poll < maxPolls; poll++) {
92
+ await new Promise((resolve) => setTimeout(resolve, 2000));
105
93
  resume = await board.me.resume.retrieve();
106
94
  }
95
+
107
96
  if (resume.parseStatus === 'parsed') {
108
- const profile = await board.me.profile.retrieve(); // auto-populated
97
+ await board.me.profile.retrieve();
109
98
  } else if (resume.parseStatus === 'failed') {
110
- resume.parseFailureReason; // surface it
99
+ showParseFailure(resume.parseFailureReason);
111
100
  } else {
112
- // still 'parsing' after the window: show "taking longer than usual",
113
- // keep the manual profile editor usable — never block on the parse.
101
+ showDelayedParseState();
114
102
  }
115
103
  ```
116
104
 
117
- `resume.file` holds the stored file (`url` is a short-lived signed download URL). `parseStatus` is `null` when no parse has run. `board.me.resume.delete()` removes the stored file and withdraws keep-on-file consent — parsed profile fields stay.
105
+ Upload options also include `importMode: 'append_only' | 'replace_all'` and `confirmReplaceAll`. `resume.file.url` is a short-lived signed URL. `parseStatus` is null before any parse. `board.me.resume.delete()` removes the stored file and keep-on-file consent while retaining imported profile fields.
118
106
 
119
- ## Notification preferences + anonymous unsubscribe
107
+ ## Notification preferences
120
108
 
121
- Two channels today: `messageEmails` and `applicationEmails`. `update` toggles one and returns the full updated set.
109
+ Authenticated settings expose `messageEmails` and `applicationEmails`. Updating one channel returns the full set.
122
110
 
123
111
  ```ts snippet
124
- const { data: prefs } = await board.me.notificationPreferences.retrieve();
112
+ const preferences = await board.me.notificationPreferences.retrieve();
125
113
  await board.me.notificationPreferences.update({
126
114
  channel: 'messageEmails',
127
115
  subscribed: false,
128
116
  });
129
117
  ```
130
118
 
131
- The one-click unsubscribe link in emails is **anonymous** — no session; the HMAC token is the authorization. All three fields come from the link's query string; resolves void (204):
119
+ Email links use an anonymous HMAC token as authorization. Read all three inputs from the link query:
132
120
 
133
121
  ```ts snippet
134
122
  await board.me.notificationPreferences.unsubscribeWithToken({
@@ -138,10 +126,14 @@ await board.me.notificationPreferences.unsubscribeWithToken({
138
126
  });
139
127
  ```
140
128
 
141
- ## Verify
129
+ ## Completion gate
130
+
131
+ - Profile update is visible after retrieval.
132
+ - Adding a skill preserves every existing skill.
133
+ - Uploaded `avatarUrl` renders.
134
+ - Resume parsing reaches `parsed`, reports `parseFailureReason`, or reaches the explicit delayed state without blocking editing.
135
+ - Anonymous unsubscribe works in a logged-out browser.
136
+
137
+ ## Cavuno SDK reference
142
138
 
143
- - [ ] A profile `update` followed by `retrieve` shows the new field values.
144
- - [ ] Adding a skill preserves the existing ones (the full-replace round-trip is in place).
145
- - [ ] After `uploadAvatar`, the returned `avatarUrl` renders.
146
- - [ ] Resume upload reaches `parsed` (or surfaces `failed` + `parseFailureReason`), and the profile shows parsed data.
147
- - [ ] The email unsubscribe link works in a logged-out browser.
139
+ For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: cavuno-board-api-client
3
+ description: Configure the @cavuno/board API client. Use when creating the shared client, adding global hooks or headers, passing framework fetch options, or calling an endpoint through the typed escape hatch.
4
+ ---
5
+
6
+ # Configure the Board API client
7
+
8
+ Create one client and keep its shared state board-scoped, not user-scoped.
9
+
10
+ ## Create the shared client
11
+
12
+ ```ts
13
+ import { createBoardClient } from '@cavuno/board';
14
+
15
+ export const board = createBoardClient({
16
+ board: process.env.PUBLIC_CAVUNO_BOARD!,
17
+ });
18
+ ```
19
+
20
+ The `board` option accepts a `pk_…` key, `boards_…` ID, or slug. Prefer the immutable, publishable `pk_…` key for deployed frontends. The client defaults to `https://api.cavuno.com`; use `baseUrl` only for a Cavuno-supplied alternate origin.
21
+
22
+ **Complete when:** the app imports one reused client and `board.context()` identifies the expected board.
23
+
24
+ ## Configure the request pipeline
25
+
26
+ Every namespace uses the same pipeline: board base path, global headers, stored browser credentials, request/response hooks, and per-call options.
27
+
28
+ ```ts no-check
29
+ const board = createBoardClient({
30
+ board: process.env.PUBLIC_CAVUNO_BOARD!,
31
+ globalHeaders: { 'Accept-Language': 'en' },
32
+ onRequest: async (request) => request,
33
+ onResponse: async (response, request) => {
34
+ console.info(request.url, response.status);
35
+ },
36
+ logger: console,
37
+ });
38
+ ```
39
+
40
+ Use `onRequest` to replace or adjust the request. Use `onResponse` for observation; it receives a response clone, so reading it leaves SDK parsing intact.
41
+
42
+ **Complete when:** cross-cutting behavior is expressed once in client configuration and a request proves the hook or header is active.
43
+
44
+ ## Pass runtime state per call
45
+
46
+ Every method accepts a trailing `FetchOptions`: `RequestInit` without `body`, plus `query`. Fetch-native and framework-specific fields pass through unchanged.
47
+
48
+ ```ts snippet
49
+ await board.jobs.list(
50
+ { limit: 20 },
51
+ { next: { revalidate: 60, tags: ['jobs'] } });
52
+
53
+ const controller = new AbortController();
54
+ await board.jobs.list(
55
+ { limit: 20 },
56
+ { cache: 'force-cache', signal: controller.signal });
57
+ ```
58
+
59
+ A module-scoped client is safe for concurrent SSR requests when it uses the server default `nostore` and each request supplies its own bearer or grant headers. `cavuno-board-server-sessions` is the authority for that session pattern. Browser persistence belongs to `cavuno-board-auth`.
60
+
61
+ **Complete when:** a shared server client contains no per-user state and each authenticated request supplies its own headers.
62
+
63
+ ## Use the escape hatch for an untyped endpoint
64
+
65
+ `board.client.fetch<T>(path, options)` keeps the full request pipeline while providing a response type locally.
66
+
67
+ ```ts snippet
68
+ const data = await board.client.fetch<{
69
+ object: 'list';
70
+ data: unknown[];
71
+ }>('/some-new-endpoint', { query: { limit: 5 } });
72
+ ```
73
+
74
+ **Complete when:** the call uses a board-relative path, supplies an honest response type, and no parallel raw-fetch client was introduced.
75
+
76
+ ## Cavuno SDK reference
77
+
78
+ For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
@@ -1,110 +1,100 @@
1
1
  ---
2
2
  name: cavuno-board-applications
3
- description: The native apply flow with the @cavuno/board SDK — jobs.apply (signed-in or guest), attaching a resume via jobs.uploadApplicationResume, the jobs.myApplication "have I applied?" check, managing submitted applications (me.applications list/retrieve/updateFacts/withdraw), and saved jobs (me.savedJobs). Use when building an apply form, an application-tracker page, or a save-job button.
3
+ description: Candidate application handoff with @cavuno/board. Use for native or guest apply, application tracking, resume attachment, withdrawal, and saved jobs.
4
4
  ---
5
5
 
6
- # Apply flow and application tracking
6
+ # Candidate application handoff
7
7
 
8
- Apply lives on the job (`board.jobs.apply` — optional-auth); everything after submission lives under `board.me.applications` (bearer token required, see `cavuno-board-auth`). Saved jobs follow the same authenticated pattern.
8
+ Treat applying as a handoff: submission belongs to `board.jobs`; the resulting candidate record belongs to `board.me`. The `me.*` side requires a board-user bearer token. See `cavuno-board-auth` for authentication.
9
9
 
10
- ## When to use
10
+ External jobs expose `applicationUrl`; link to it instead of creating a native application. Employer pipeline work belongs to `board.me.companies.applicants.*`.
11
11
 
12
- - The apply form on a job page (native apply, signed-in or guest).
13
- - Attaching a resume to an application.
14
- - "My applications" tracker: list, detail, edit facts, withdraw.
15
- - Save/unsave-job buttons and the saved-jobs page.
12
+ The host application owns apply forms and file-pickers; the SDK owns submission data operations.
16
13
 
17
- ## When not to use
14
+ ## Submit
18
15
 
19
- - Jobs whose `applicationUrl` points off-board (external apply) — just link out.
20
- - The employer's pipeline view of the same data (`me.companies.applicants.*`).
21
- - Candidate profile/resume self-service — `cavuno-board-account`.
22
-
23
- Out of scope — do not invent exports: the SDK provides no apply-form components, file-picker UI, or auth cookie plumbing — the host app owns those. There is also **no guest-claim method** on the SDK: after a guest signs up, their applications are associated via a server-driven magic-link email flow, not an SDK call.
24
-
25
- ## Submit an application
26
-
27
- `jobs.apply` is optional-auth on one URL. Signed in, name/email derive from the candidate profile — send at most a `coverNote`. A guest supplies `name` + `email` (allowed only when the board permits applying without sign-up; otherwise the call fails). Idempotent — a repeat apply returns the existing application.
16
+ `jobs.apply` is optional-auth and idempotent. A signed-in candidate supplies at most `coverNote`; a guest supplies `name` and `email`, when the board permits guest applications.
28
17
 
29
18
  ```ts snippet
30
- // Signed-in candidate:
31
19
  const application = await board.jobs.apply('senior-chef', {
32
20
  coverNote: 'Excited to cook here.',
33
21
  });
34
22
 
35
- // Guest (board must allow applications without sign-up):
36
- const guestApp = await board.jobs.apply('senior-chef', {
23
+ const guestApplication = await board.jobs.apply('senior-chef', {
37
24
  name: 'Ada Lovelace',
38
25
  email: 'ada@example.com',
39
26
  });
40
27
  ```
41
28
 
29
+ Guest applications are claimed through the server-driven magic-link flow; the SDK has no guest-claim method.
30
+
42
31
  ## Attach a resume
43
32
 
44
- A separate multipart call — pass a `Blob`/`File`; the SDK builds the `FormData`. A signed-in candidate targets their own application for the job; a guest passes the `applicationId` returned by `apply`. Returns the updated application (`resumeFilename` set).
33
+ Pass the `Blob` or `File`; the SDK builds multipart `FormData`. A signed-in candidate targets their own application. A guest must pass the id returned by `apply`.
45
34
 
46
35
  ```ts snippet
47
- // Signed-in:
48
36
  await board.jobs.uploadApplicationResume('senior-chef', file);
49
- // Guest:
50
37
  await board.jobs.uploadApplicationResume('senior-chef', file, {
51
- applicationId: guestApp.id,
38
+ applicationId: guestApplication.id,
52
39
  });
53
40
  ```
54
41
 
55
- ## "Have I applied?" — the apply-button state
42
+ This step is complete when the returned application has `resumeFilename` set.
56
43
 
57
- `jobs.myApplication` returns the authenticated candidate's application for the job, and **throws a 404 `BoardApiError` when there is none** — branch with `isNotFound`:
44
+ ## Derive the apply-button state
45
+
46
+ `jobs.myApplication` returns the signed-in candidate's application and throws a 404 when none exists. Derive the UI from that result rather than a local submitted flag.
58
47
 
59
48
  ```ts snippet
60
49
  import { isNotFound } from '@cavuno/board';
61
50
 
62
51
  try {
63
52
  const mine = await board.jobs.myApplication('senior-chef');
64
- // already applied — show status instead of the apply button
65
- } catch (err) {
66
- if (!isNotFound(err)) throw err;
67
- // not applied yet — show the apply form
53
+ renderApplicationStatus(mine.status);
54
+ } catch (error) {
55
+ if (!isNotFound(error)) throw error;
56
+ renderApplyForm();
68
57
  }
69
58
  ```
70
59
 
71
- ## Application status — polled, not realtime
72
-
73
- `Application.status` is a coarse candidate-facing state **derived from the employer's pipeline stage**: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. It changes server-side as the employer moves the applicant — the surface is plain REST, so re-fetch to observe updates; nothing pushes to the client. `job` on the application is nullable (the job may have been removed).
60
+ `Application.status` is a polled candidate-facing projection of the employer stage: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. Its `job` may be null after job removal.
74
61
 
75
- ## Manage my applications
62
+ ## Track and manage applications
76
63
 
77
64
  ```ts snippet
78
- const { data, nextCursor } = await board.me.applications.list({ limit: 20 }); // newest first
79
- const app = await board.me.applications.retrieve(applicationId);
65
+ const page = await board.me.applications.list({ limit: 20 }); // newest first
66
+ const application = await board.me.applications.retrieve(applicationId);
80
67
 
81
- // Merge-patch the candidate-facing facts — only while still editable:
82
68
  await board.me.applications.updateFacts(applicationId, {
83
69
  coverNote: 'Updated after our call.',
84
70
  });
85
71
 
86
- await board.me.applications.withdraw(applicationId); // 204 — permanently deletes
72
+ await board.me.applications.withdraw(applicationId);
87
73
  ```
88
74
 
89
- `updateFacts` accepts `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, `coverNote` (all optional). Withdraw is permanent deletion, not a status flip — confirm before calling.
75
+ `updateFacts` merge-patches `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, and `coverNote`. `withdraw` permanently deletes the application, so obtain explicit confirmation first.
90
76
 
91
- ## Saved jobs — the adjacent pattern
77
+ ## Save jobs
92
78
 
93
- Same authenticated `me.*` shape. Each saved row embeds the full `PublicJob`, so the saved-jobs page renders without extra fetches.
79
+ Saved rows embed the full `PublicJob`. `save` converges on the existing row and `unsave` is idempotent.
94
80
 
95
81
  ```ts snippet
96
82
  const saved = await board.me.savedJobs.list({ limit: 20 });
97
- saved.data[0]?.job.title; // full PublicJob embedded
83
+ saved.data[0]?.job.title;
98
84
 
99
- await board.me.savedJobs.save({ jobId: job.id }); // converges — re-saving returns the same row
100
- await board.me.savedJobs.unsave(job.id); // idempotent — unknown ids still 204
85
+ await board.me.savedJobs.save({ jobId: job.id });
86
+ await board.me.savedJobs.unsave(job.id);
101
87
  ```
102
88
 
103
- ## Verify
89
+ ## Completion gate
90
+
91
+ - Signed-in submission omits name and email; guest submission includes both.
92
+ - Repeating `apply` returns the same application id.
93
+ - Resume upload returns an application with `resumeFilename`.
94
+ - `myApplication` plus `isNotFound` drives the apply-button state.
95
+ - Re-fetching after withdrawal removes the application.
96
+ - Repeating `unsave` leaves the saved list consistent.
97
+
98
+ ## Cavuno SDK reference
104
99
 
105
- - [ ] Signed-in apply sends no name/email; guest apply sends both.
106
- - [ ] A second apply to the same job returns the same application id (no duplicate).
107
- - [ ] After `uploadApplicationResume`, the application's `resumeFilename` is set.
108
- - [ ] The apply button flips state via `myApplication` + `isNotFound`, not a local flag.
109
- - [ ] Withdraw removes the row from `me.applications.list` on re-fetch.
110
- - [ ] Unsave leaves the saved-jobs list consistent even when clicked twice.
100
+ For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).
@@ -1,24 +1,25 @@
1
1
  ---
2
2
  name: cavuno-board-auth
3
- description: Authenticate board users with the @cavuno/board SDK — register, login, refresh, logout, email verification and password reset. Covers bearer-JWT storage modes, the deliberate no-auto-refresh-on-401 rule (and single-flight handling), and the server-side httpOnly-cookie pattern that keeps tokens out of the browser.
3
+ description: Authenticate Cavuno board users. Use for registration, login, logout, token refresh, email verification, password recovery, magic links, OAuth, or authenticated board-user calls.
4
4
  ---
5
5
 
6
- # Board-user authentication
6
+ # Authenticate board users
7
7
 
8
- Board users (candidates, employers) authenticate with a short-lived bearer access token plus a refresh token. The SDK manages the pair via pluggable async storage. There is exactly one auth mode — bearer JWT (no cookie/session mode).
8
+ Board users authenticate with a short-lived bearer access token and a single-use refresh token. The SDK persists returned token pairs through its configured async storage and leaves navigation to the app.
9
9
 
10
- ## When to use
10
+ ## 1. Choose the storage boundary
11
11
 
12
- - Sign-up / sign-in / sign-out for board users.
13
- - Email verification and password reset flows.
14
- - Wiring authenticated calls (`me`, saved jobs, applications).
12
+ `auth.storage` accepts `'memory'`, `'local'`, `'session'`, `'nostore'`, or a `CustomStorage` with async `getItem`, `setItem`, and `removeItem` methods.
15
13
 
16
- ## When not to use
14
+ - Browsers default to `'memory'`; choose `'local'` for persistence across tabs and reloads or `'session'` for tab-scoped persistence.
15
+ - Servers default to `'nostore'`. Keep tokens in the app's httpOnly cookie and follow `cavuno-board-server-sessions` for every SSR session and refresh rule.
16
+ - A custom store is appropriate when the app already owns a user-scoped persistence boundary.
17
17
 
18
- - Anonymous reads (jobs/companies/blog) — no auth needed.
19
- - Board-password gating — that's a separate grant; see `cavuno-board-errors`.
18
+ `'local'` and `'session'` are browser runtimes; constructing them off-browser fails immediately.
20
19
 
21
- ## Register and login
20
+ **Complete when:** the selected store matches the runtime and a shared server client remains `nostore`.
21
+
22
+ ## 2. Establish the session
22
23
 
23
24
  ```ts snippet
24
25
  await board.auth.register({
@@ -33,81 +34,59 @@ const session = await board.auth.login({
33
34
  email: 'ada@example.com',
34
35
  password: 'a-strong-password',
35
36
  });
36
- session.boardUser.email; // the signed-in user
37
- session.accessToken; // bearer; never expose to the browser bundle on SSR
38
37
  ```
39
38
 
40
- `register`/`login`/`refresh` persist the returned token pair to storage; `logout` clears it. The SDK never navigates — your app owns redirects and verification UX.
41
-
42
- ## Storage modes
43
-
44
- `auth.storage` is `'memory'` | `'nostore'` | `'local'` | `'session'` | a `CustomStorage`. Defaults: **`memory` in the browser, `nostore` on the server**. Browser login works out of the box; shared SSR instances stay stateless.
39
+ `register`, `login`, `consumeMagicLink`, `exchangeOAuth`, and `refresh` persist the returned session. Use the returned `boardUser` for identity and verification state.
45
40
 
46
- ```ts
47
- import { createBoardClient } from '@cavuno/board';
48
-
49
- // Browser/SPA: 'local' persists the pair across tabs + reloads via
50
- // localStorage; 'session' scopes it to the tab; 'memory' drops it on reload.
51
- const board = createBoardClient({
52
- board: 'pk_a8f3...',
53
- auth: { storage: 'local' },
54
- });
55
- ```
41
+ For alternate entry points, use `requestMagicLink` / `consumeMagicLink` or the OAuth authorization and exchange methods exposed under `board.auth`.
56
42
 
57
- `'local'`/`'session'` are **browser-only** — off-browser they throw loudly at client creation (`storage mode 'local' is browser-only — use 'nostore' + per-call headers on the server`). A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with IndexedDB, Redis, or your own store.
43
+ **Complete when:** the chosen entry flow returns a session and `board.me.retrieve()` resolves as that board user.
58
44
 
59
- ## No auto-refresh on 401 — handle it explicitly
45
+ ## 3. Handle expiry explicitly
60
46
 
61
- An expired access token surfaces as a `BoardApiError` you detect with `isUnauthorized`. The SDK does **not** silently refresh — refresh tokens are single-use with atomic rotation, so safe refresh under concurrency needs a single-flight guard you own.
47
+ The SDK surfaces an expired access token as `BoardApiError`; it does not refresh and replay a failed request automatically. In a browser app, classify the failure with `isUnauthorized`, serialize refresh through one in-flight promise, then retry the original operation once with the rotated session.
62
48
 
63
49
  ```ts snippet
64
50
  import { isUnauthorized } from '@cavuno/board';
65
51
 
66
52
  try {
67
53
  return await board.me.retrieve();
68
- } catch (err) {
69
- if (isUnauthorized(err)) {
70
- await board.auth.refresh(); // reads the stored refresh token, rotates the pair
71
- return await board.me.retrieve();
72
- }
73
- throw err;
54
+ } catch (error) {
55
+ if (!isUnauthorized(error)) throw error;
56
+ await refreshOnce();
57
+ return board.me.retrieve();
74
58
  }
75
59
  ```
76
60
 
77
- Under concurrency, wrap `auth.refresh()` in a single-flight promise so parallel 401s trigger exactly one rotation (the reference flavor encodes this once — see `cavuno-board-tanstack-start`).
61
+ On SSR, use the module-scoped `createSessionRefresher` pattern in `cavuno-board-server-sessions`; it is the single source of truth for concurrency, cookies, retry limits, and per-call authorization headers.
78
62
 
79
- ## refresh / logout token sourcing
63
+ **Complete when:** concurrent expiry paths share one rotation and the original operation is attempted at most once after a successful refresh.
80
64
 
81
- Both accept an optional body `{ refreshToken }`. When omitted, the SDK reads the token from storage and throws if neither exists — so `nostore` (server) callers must pass it explicitly:
65
+ ## 4. Refresh and logout from the right source
66
+
67
+ `refresh()` and `logout()` read the refresh token from configured storage when their body is omitted. A `nostore` caller passes it explicitly:
82
68
 
83
69
  ```ts snippet
84
- await board.auth.refresh({ refreshToken }); // server: explicit
85
- await board.auth.logout({ refreshToken }); // revokes server-side, clears storage
70
+ await board.auth.refresh({ refreshToken });
71
+ await board.auth.logout({ refreshToken });
86
72
  ```
87
73
 
88
- ## Server-side pattern (keep tokens out of the browser)
74
+ Successful logout revokes the refresh token and clears SDK storage. A failed logout request retains stored tokens so the app can retry revocation or deliberately choose a local-only sign-out.
75
+
76
+ **Complete when:** logout revokes the server session, clears the app-owned cookie or browser store, and an authenticated read is handled as signed out.
89
77
 
90
- On SSR, do not hold the session on a shared instance. Keep the token pair in an httpOnly cookie owned by your app and pass it per call. `@cavuno/board/server` ships the cookie codec + the single-flight refresh helper for exactly this (see `cavuno-board-server`):
78
+ ## Verification and recovery
91
79
 
92
80
  ```ts snippet
93
- // `accessToken` comes from your httpOnly cookie, read in server code.
94
- // Per-call options are the 2nd argument; the 1st is `query` (pass undefined).
95
- const me = await board.me.retrieve(undefined, {
96
- headers: { authorization: `Bearer ${accessToken}` },
97
- });
81
+ await board.auth.verifyEmail({ token });
82
+ await board.auth.forgotPassword({ email: 'ada@example.com' });
83
+ await board.auth.resetPassword({ token, password: 'a-new-password' });
98
84
  ```
99
85
 
100
- ## Email verification & password reset
86
+ The signed-in OTP verification, resend, magic-link, and OAuth branches use their corresponding `board.auth` methods. Password-reset requests preserve account privacy; a successful single-use reset invalidates existing sessions.
101
87
 
102
- ```ts snippet
103
- await board.auth.verifyEmail({ token }); // token from the email link
104
- await board.auth.forgotPassword({ email }); // sends the reset email
105
- await board.auth.resetPassword({ token, password }); // token from the email link
106
- ```
88
+ **Complete when:** each implemented auth route exercises its success state and typed failure state, with bearer tokens absent from server-rendered browser payloads.
107
89
 
108
- ## Checklist
90
+ ## Cavuno SDK reference
109
91
 
110
- - [ ] Bearer tokens never reach the browser bundle on SSR (httpOnly cookie + per-call header).
111
- - [ ] 401s handled explicitly with `isUnauthorized` + `auth.refresh()`.
112
- - [ ] Concurrent refresh guarded by single-flight.
113
- - [ ] `nostore` callers pass `{ refreshToken }` to `refresh`/`logout`.
92
+ For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk).