@cavuno/board 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -27,6 +27,9 @@ function isNotFound(e) {
27
27
  function isUnauthorized(e) {
28
28
  return isBoardApiError(e) && e.status === 401;
29
29
  }
30
+ function isBoardPasswordRequired(e) {
31
+ return isBoardApiError(e) && e.status === 401 && e.code === "board_password_required";
32
+ }
30
33
  function isForbidden(e) {
31
34
  return isBoardApiError(e) && e.status === 403;
32
35
  }
@@ -61,6 +64,7 @@ function toSearchParams(query) {
61
64
  // src/storage.ts
62
65
  var ACCESS_TOKEN_KEY = "cavuno_board_access_token";
63
66
  var REFRESH_TOKEN_KEY = "cavuno_board_refresh_token";
67
+ var BOARD_ACCESS_GRANT_KEY = "cavuno_board_access_grant";
64
68
  function isBrowser() {
65
69
  return typeof globalThis.document !== "undefined";
66
70
  }
@@ -105,7 +109,7 @@ async function clearSession(storage) {
105
109
  }
106
110
 
107
111
  // src/version.ts
108
- var SDK_VERSION = "1.2.0";
112
+ var SDK_VERSION = "1.3.0";
109
113
 
110
114
  // src/client.ts
111
115
  function isRawBody(body) {
@@ -147,12 +151,14 @@ var BoardClient = class {
147
151
  }
148
152
  const token = await this.storage.getItem(ACCESS_TOKEN_KEY);
149
153
  if (token !== null) headers.set("authorization", `Bearer ${token}`);
154
+ const grant = await this.storage.getItem(BOARD_ACCESS_GRANT_KEY);
155
+ if (grant !== null) headers.set("x-board-access", grant);
150
156
  if (callHeaders) {
151
157
  new Headers(callHeaders).forEach((value, key) => {
152
158
  headers.set(key, value);
153
159
  });
154
160
  }
155
- if ((method !== "GET" || headers.has("authorization")) && !headers.has("x-cavuno-sdk")) {
161
+ if ((method !== "GET" || headers.has("authorization") || headers.has("x-board-access")) && !headers.has("x-cavuno-sdk")) {
156
162
  headers.set("x-cavuno-sdk", `board@${SDK_VERSION}`);
157
163
  }
158
164
  let req = {
@@ -163,7 +169,8 @@ var BoardClient = class {
163
169
  this.options.logger?.debug(`${method} ${req.url}`);
164
170
  const res = await globalThis.fetch(req.url, req.init);
165
171
  this.options.logger?.debug(`${res.status} ${method} ${req.url}`);
166
- if (this.options.onResponse) await this.options.onResponse(res.clone(), req);
172
+ if (this.options.onResponse)
173
+ await this.options.onResponse(res.clone(), req);
167
174
  if (res.status === 204) return void 0;
168
175
  if (res.ok) return await res.json();
169
176
  let parsed;
@@ -358,6 +365,30 @@ function blogNamespace(client) {
358
365
  `/blog/posts/${encodeURIComponent(postSlug)}`,
359
366
  { ...options, query }
360
367
  );
368
+ },
369
+ /**
370
+ * The previous (older) and next (newer) posts for prev/next navigation.
371
+ *
372
+ * @example
373
+ * const { previous, next } = await board.blog.posts.adjacent('hello-world');
374
+ */
375
+ adjacent(postSlug, options) {
376
+ return client.fetch(
377
+ `/blog/posts/${encodeURIComponent(postSlug)}/adjacent`,
378
+ options
379
+ );
380
+ },
381
+ /**
382
+ * Posts most similar to one post (the related-posts rail).
383
+ *
384
+ * @example
385
+ * const { data } = await board.blog.posts.similar('hello-world', { limit: 6 });
386
+ */
387
+ similar(postSlug, query, options) {
388
+ return client.fetch(
389
+ `/blog/posts/${encodeURIComponent(postSlug)}/similar`,
390
+ { ...options, query }
391
+ );
361
392
  }
362
393
  },
363
394
  tags: {
@@ -463,6 +494,96 @@ function companiesNamespace(client) {
463
494
  };
464
495
  }
465
496
 
497
+ // src/namespaces/embed.ts
498
+ function embedNamespace(client) {
499
+ return {
500
+ /**
501
+ * List published jobs for an embeddable widget — the same featured-ranked
502
+ * cards as `board.jobs.list`, but UNGATED: the candidate paywall never
503
+ * applies, so the full page is always returned and there is no
504
+ * `gatedCount`. Powers the public "Powered by Cavuno" embed. `limit`
505
+ * defaults to 8 and is clamped to a maximum of 50.
506
+ *
507
+ * @example
508
+ * const { data, nextCursor } = await board.embed.jobs({ q: 'chef', limit: 8 });
509
+ */
510
+ jobs(query, options) {
511
+ return client.fetch("/embed/jobs", {
512
+ ...options,
513
+ query
514
+ });
515
+ }
516
+ };
517
+ }
518
+
519
+ // src/namespaces/job-alerts.ts
520
+ function jobAlertsNamespace(client) {
521
+ return {
522
+ /** Subscribe an email to job alerts. Sends a double-opt-in confirmation email. */
523
+ subscribe(input, options) {
524
+ return client.fetch("/job-alerts", {
525
+ ...options,
526
+ method: "POST",
527
+ body: input
528
+ });
529
+ },
530
+ /** Complete double opt-in with the token from the confirmation email. */
531
+ confirm(input, options) {
532
+ return client.fetch("/job-alerts/confirm", {
533
+ ...options,
534
+ method: "POST",
535
+ body: input
536
+ });
537
+ },
538
+ /** Re-send the confirmation email for an unconfirmed subscription. */
539
+ resendConfirmation(input, options) {
540
+ return client.fetch(
541
+ "/job-alerts/resend-confirmation",
542
+ { ...options, method: "POST", body: input }
543
+ );
544
+ },
545
+ /** Read a subscription + its preferences for the manage page (HMAC token). */
546
+ manage(query, options) {
547
+ return client.fetch("/job-alerts/manage", {
548
+ ...options,
549
+ query
550
+ });
551
+ },
552
+ /** Deactivate a subscription via the HMAC manage token. */
553
+ unsubscribe(input, options) {
554
+ return client.fetch("/job-alerts/unsubscribe", {
555
+ ...options,
556
+ method: "POST",
557
+ body: input
558
+ });
559
+ },
560
+ /** Re-activate a previously unsubscribed subscription via the manage token. */
561
+ resubscribe(input, options) {
562
+ return client.fetch("/job-alerts/resubscribe", {
563
+ ...options,
564
+ method: "POST",
565
+ body: input
566
+ });
567
+ },
568
+ /** Edit a preference's filters/frequency via the manage token. */
569
+ updatePreference(input, options) {
570
+ return client.fetch("/job-alerts/preferences", {
571
+ ...options,
572
+ method: "POST",
573
+ body: input
574
+ });
575
+ },
576
+ /** Delete a preference via the manage token. */
577
+ deletePreference(input, options) {
578
+ return client.fetch("/job-alerts/preferences", {
579
+ ...options,
580
+ method: "DELETE",
581
+ body: input
582
+ });
583
+ }
584
+ };
585
+ }
586
+
466
587
  // src/namespaces/jobs.ts
467
588
  function jobsNamespace(client) {
468
589
  return {
@@ -587,6 +708,30 @@ function meNamespace(client) {
587
708
  };
588
709
  }
589
710
 
711
+ // src/namespaces/password.ts
712
+ function passwordNamespace(client) {
713
+ return {
714
+ /**
715
+ * Exchange a board password for an access grant and store it. After this
716
+ * resolves, every subsequent read auto-carries the grant as the
717
+ * `X-Board-Access` header — until the password rotates, after which reads
718
+ * fail with 401 `board_password_required` and you must `verify()` again
719
+ * (the SDK never auto-retries — verify is rate-limited — and never
720
+ * auto-clears; the host re-challenges). On the server (`nostore` storage)
721
+ * the grant is returned but not persisted; pass it per-call instead.
722
+ */
723
+ async verify(password, options) {
724
+ const grant = await client.fetch("/password/verify", {
725
+ ...options,
726
+ method: "POST",
727
+ body: { password }
728
+ });
729
+ await client.storage.setItem(BOARD_ACCESS_GRANT_KEY, grant.token);
730
+ return grant;
731
+ }
732
+ };
733
+ }
734
+
590
735
  // src/namespaces/redirects.ts
591
736
  function redirectsNamespace(client) {
592
737
  return {
@@ -628,9 +773,16 @@ function taxonomyNamespace(client) {
628
773
  skills: taxonomyResolver(client, "skills"),
629
774
  places: {
630
775
  ...taxonomyResolver(client, "places"),
631
- /** List every place used by a published job, with its live job count. */
632
- list(options) {
633
- return client.fetch("/places", options);
776
+ /**
777
+ * Without `query`: list every place used by a published job, with its
778
+ * live job count (the locations directory). With `query.q` (≥2 chars):
779
+ * location autocomplete — the top name matches ranked.
780
+ */
781
+ list(query, options) {
782
+ return client.fetch("/places", {
783
+ ...options,
784
+ query
785
+ });
634
786
  }
635
787
  }
636
788
  };
@@ -675,22 +827,27 @@ function createBoardClient(options) {
675
827
  return client.fetch("/seo", options2);
676
828
  },
677
829
  jobs: jobsNamespace(client),
830
+ embed: embedNamespace(client),
678
831
  companies: companiesNamespace(client),
679
832
  blog: blogNamespace(client),
680
833
  auth: authNamespace(client),
681
834
  me: meNamespace(client),
835
+ password: passwordNamespace(client),
682
836
  taxonomy: taxonomyNamespace(client),
683
- redirects: redirectsNamespace(client)
837
+ redirects: redirectsNamespace(client),
838
+ jobAlerts: jobAlertsNamespace(client)
684
839
  };
685
840
  }
686
841
  export {
687
842
  ACCESS_TOKEN_KEY,
843
+ BOARD_ACCESS_GRANT_KEY,
688
844
  BoardApiError,
689
845
  BoardClient,
690
846
  REFRESH_TOKEN_KEY,
691
847
  SDK_VERSION,
692
848
  createBoardClient,
693
849
  isBoardApiError,
850
+ isBoardPasswordRequired,
694
851
  isConflict,
695
852
  isForbidden,
696
853
  isNotFound,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
3
+ * the installed package) — kept out of the isomorphic core and exposed via the
4
+ * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
5
+ * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
6
+ * stay fed from one source.
7
+ */
8
+ interface SkillManifestEntry {
9
+ name: string;
10
+ description: string;
11
+ /** Path to the SKILL.md, relative to the package root. */
12
+ path: string;
13
+ /** Framework slug for flavor skills; `null` for framework-agnostic core skills. */
14
+ framework: string | null;
15
+ category: 'core' | 'flavor';
16
+ }
17
+ interface SkillManifest {
18
+ version: string;
19
+ skills: SkillManifestEntry[];
20
+ }
21
+ interface LoadedSkill extends SkillManifestEntry {
22
+ content: string;
23
+ }
24
+ interface SkillCorpus {
25
+ version: string;
26
+ skills: LoadedSkill[];
27
+ }
28
+ /** Resolve a package-root-relative path (e.g. a manifest `path`) to an absolute path. */
29
+ declare function resolveFromPackageRoot(relativePath: string): string;
30
+ declare function loadSkillManifest(): SkillManifest;
31
+ /**
32
+ * Load the full skill corpus — manifest metadata plus each skill's markdown
33
+ * `content`. The sidekick injects `content` into its agent context; an external
34
+ * Claude Code instead receives the files copied by the setup command.
35
+ */
36
+ declare function loadSkillCorpus(): SkillCorpus;
37
+
38
+ export { type LoadedSkill, type SkillCorpus, type SkillManifest, type SkillManifestEntry, loadSkillCorpus, loadSkillManifest, resolveFromPackageRoot };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
3
+ * the installed package) — kept out of the isomorphic core and exposed via the
4
+ * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
5
+ * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
6
+ * stay fed from one source.
7
+ */
8
+ interface SkillManifestEntry {
9
+ name: string;
10
+ description: string;
11
+ /** Path to the SKILL.md, relative to the package root. */
12
+ path: string;
13
+ /** Framework slug for flavor skills; `null` for framework-agnostic core skills. */
14
+ framework: string | null;
15
+ category: 'core' | 'flavor';
16
+ }
17
+ interface SkillManifest {
18
+ version: string;
19
+ skills: SkillManifestEntry[];
20
+ }
21
+ interface LoadedSkill extends SkillManifestEntry {
22
+ content: string;
23
+ }
24
+ interface SkillCorpus {
25
+ version: string;
26
+ skills: LoadedSkill[];
27
+ }
28
+ /** Resolve a package-root-relative path (e.g. a manifest `path`) to an absolute path. */
29
+ declare function resolveFromPackageRoot(relativePath: string): string;
30
+ declare function loadSkillManifest(): SkillManifest;
31
+ /**
32
+ * Load the full skill corpus — manifest metadata plus each skill's markdown
33
+ * `content`. The sidekick injects `content` into its agent context; an external
34
+ * Claude Code instead receives the files copied by the setup command.
35
+ */
36
+ declare function loadSkillCorpus(): SkillCorpus;
37
+
38
+ export { type LoadedSkill, type SkillCorpus, type SkillManifest, type SkillManifestEntry, loadSkillCorpus, loadSkillManifest, resolveFromPackageRoot };
package/dist/skills.js ADDED
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/skills.ts
21
+ var skills_exports = {};
22
+ __export(skills_exports, {
23
+ loadSkillCorpus: () => loadSkillCorpus,
24
+ loadSkillManifest: () => loadSkillManifest,
25
+ resolveFromPackageRoot: () => resolveFromPackageRoot
26
+ });
27
+ module.exports = __toCommonJS(skills_exports);
28
+
29
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.14_tsx@4.21.0_typescript@5.9.3_yaml@2.8.4/node_modules/tsup/assets/cjs_shims.js
30
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
31
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
32
+
33
+ // src/skills.ts
34
+ var import_node_fs = require("fs");
35
+ var import_node_path = require("path");
36
+ var import_node_url = require("url");
37
+ function packageRoot() {
38
+ return (0, import_node_path.resolve)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl)), "..");
39
+ }
40
+ function resolveFromPackageRoot(relativePath) {
41
+ return (0, import_node_path.resolve)(packageRoot(), relativePath);
42
+ }
43
+ function loadSkillManifest() {
44
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json");
45
+ return JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
46
+ }
47
+ function loadSkillCorpus() {
48
+ const manifest = loadSkillManifest();
49
+ return {
50
+ version: manifest.version,
51
+ skills: manifest.skills.map((skill) => ({
52
+ ...skill,
53
+ content: (0, import_node_fs.readFileSync)(resolveFromPackageRoot(skill.path), "utf8")
54
+ }))
55
+ };
56
+ }
57
+ // Annotate the CommonJS export names for ESM import in node:
58
+ 0 && (module.exports = {
59
+ loadSkillCorpus,
60
+ loadSkillManifest,
61
+ resolveFromPackageRoot
62
+ });
@@ -0,0 +1,29 @@
1
+ // src/skills.ts
2
+ import { readFileSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+ function packageRoot() {
6
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ }
8
+ function resolveFromPackageRoot(relativePath) {
9
+ return resolve(packageRoot(), relativePath);
10
+ }
11
+ function loadSkillManifest() {
12
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json");
13
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
14
+ }
15
+ function loadSkillCorpus() {
16
+ const manifest = loadSkillManifest();
17
+ return {
18
+ version: manifest.version,
19
+ skills: manifest.skills.map((skill) => ({
20
+ ...skill,
21
+ content: readFileSync(resolveFromPackageRoot(skill.path), "utf8")
22
+ }))
23
+ };
24
+ }
25
+ export {
26
+ loadSkillCorpus,
27
+ loadSkillManifest,
28
+ resolveFromPackageRoot
29
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,6 +15,9 @@
15
15
  "main": "./dist/index.js",
16
16
  "module": "./dist/index.mjs",
17
17
  "types": "./dist/index.d.ts",
18
+ "bin": {
19
+ "cavuno-board": "./dist/bin.mjs"
20
+ },
18
21
  "exports": {
19
22
  ".": {
20
23
  "import": {
@@ -25,10 +28,22 @@
25
28
  "types": "./dist/index.d.ts",
26
29
  "default": "./dist/index.js"
27
30
  }
28
- }
31
+ },
32
+ "./skills": {
33
+ "import": {
34
+ "types": "./dist/skills.d.mts",
35
+ "default": "./dist/skills.mjs"
36
+ },
37
+ "require": {
38
+ "types": "./dist/skills.d.ts",
39
+ "default": "./dist/skills.js"
40
+ }
41
+ },
42
+ "./skills/*": "./skills/*"
29
43
  },
30
44
  "files": [
31
- "dist"
45
+ "dist",
46
+ "skills"
32
47
  ],
33
48
  "publishConfig": {
34
49
  "access": "public"
@@ -36,15 +51,17 @@
36
51
  "scripts": {
37
52
  "build": "tsup",
38
53
  "clean": "git clean -xdf .turbo dist node_modules",
39
- "typecheck": "tsgo --noEmit",
54
+ "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
40
55
  "test": "vitest run",
56
+ "gen:skills-manifest": "tsx scripts/generate-skills-manifest.ts",
41
57
  "assert-publish-target": "node -e \"const p=require('./package.json'); if(p.name!=='@cavuno/board'){throw new Error('Refusing to publish: package.json name is '+p.name+', expected @cavuno/board')}; if(p.private){throw new Error('Refusing to publish: package.json has private:true')}\"",
42
- "prepublishOnly": "pnpm run assert-publish-target && pnpm run build"
58
+ "prepublishOnly": "pnpm run assert-publish-target && pnpm run gen:skills-manifest && pnpm run build"
43
59
  },
44
60
  "devDependencies": {
45
61
  "@kit/tsconfig": "workspace:*",
46
62
  "@types/node": "catalog:",
47
63
  "tsup": "^8.4.0",
64
+ "tsx": "^4.19.2",
48
65
  "vitest": "^2.1.9"
49
66
  }
50
67
  }
@@ -0,0 +1,113 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Board-user authentication
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).
9
+
10
+ ## When to use
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).
15
+
16
+ ## When not to use
17
+
18
+ - Anonymous reads (jobs/companies/blog) — no auth needed.
19
+ - Board-password gating — that's a separate grant; see `cavuno-board-errors`.
20
+
21
+ ## Register and login
22
+
23
+ ```ts snippet
24
+ await board.auth.register({
25
+ role: 'candidate',
26
+ method: 'emailpass',
27
+ email: 'ada@example.com',
28
+ password: 'a-strong-password',
29
+ displayName: 'Ada',
30
+ });
31
+
32
+ const session = await board.auth.login({
33
+ email: 'ada@example.com',
34
+ password: 'a-strong-password',
35
+ });
36
+ session.boardUser.email; // the signed-in user
37
+ session.accessToken; // bearer; never expose to the browser bundle on SSR
38
+ ```
39
+
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'` | a `CustomStorage`. Defaults: **`memory` in the browser, `nostore` on the server**. Browser login works out of the box; shared SSR instances stay stateless.
45
+
46
+ ```ts
47
+ import { createBoardClient } from '@cavuno/board';
48
+
49
+ // Browser/SPA: token held in memory on the instance.
50
+ const board = createBoardClient({
51
+ baseUrl: 'https://api.cavuno.com',
52
+ board: 'pk_a8f3...',
53
+ auth: { storage: 'memory' },
54
+ });
55
+ ```
56
+
57
+ A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with `localStorage`, IndexedDB, Redis, or your own store.
58
+
59
+ ## No auto-refresh on 401 — handle it explicitly
60
+
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.
62
+
63
+ ```ts snippet
64
+ import { isUnauthorized } from '@cavuno/board';
65
+
66
+ try {
67
+ 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;
74
+ }
75
+ ```
76
+
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`).
78
+
79
+ ## refresh / logout token sourcing
80
+
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:
82
+
83
+ ```ts snippet
84
+ await board.auth.refresh({ refreshToken }); // server: explicit
85
+ await board.auth.logout({ refreshToken }); // revokes server-side, clears storage
86
+ ```
87
+
88
+ ## Server-side pattern (keep tokens out of the browser)
89
+
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:
91
+
92
+ ```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
+ });
98
+ ```
99
+
100
+ ## Email verification & password reset
101
+
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
+ ```
107
+
108
+ ## Checklist
109
+
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`.