@cavuno/board 1.27.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,176 @@
1
+ // src/server/cookie.ts
2
+ var COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
3
+ function buildCookie(name, value, maxAgeSeconds) {
4
+ return `${name}=${value}; Max-Age=${maxAgeSeconds}; ${COOKIE_ATTRIBUTES}`;
5
+ }
6
+ function buildClearCookie(name) {
7
+ return `${name}=; Max-Age=0; ${COOKIE_ATTRIBUTES}`;
8
+ }
9
+ function readCookie(header, name) {
10
+ if (!header) return null;
11
+ const pair = header.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
12
+ if (!pair) return null;
13
+ const raw = pair.slice(name.length + 1);
14
+ if (!raw) return null;
15
+ try {
16
+ return decodeURIComponent(raw);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ function cookieScope(board) {
22
+ return board.replace(/[^a-zA-Z0-9_-]/g, "_");
23
+ }
24
+
25
+ // src/server/session.ts
26
+ var SESSION_COOKIE_NAME = "__Host-cavuno_board_session";
27
+ function sessionCookieName(board) {
28
+ return board ? `${SESSION_COOKIE_NAME}_${cookieScope(board)}` : SESSION_COOKIE_NAME;
29
+ }
30
+ var COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
31
+ var EXPIRY_WINDOW_MS = 5 * 60 * 1e3;
32
+ function serializeSessionCookie(session, options) {
33
+ return buildCookie(
34
+ sessionCookieName(options?.board),
35
+ encodeURIComponent(JSON.stringify(session)),
36
+ COOKIE_MAX_AGE_SECONDS
37
+ );
38
+ }
39
+ function clearSessionCookie(options) {
40
+ return buildClearCookie(sessionCookieName(options?.board));
41
+ }
42
+ function parseSessionCookie(cookieHeader, options) {
43
+ const raw = readCookie(cookieHeader, sessionCookieName(options?.board));
44
+ if (!raw) return null;
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ } catch {
49
+ return null;
50
+ }
51
+ const session = parsed;
52
+ if (!session || typeof session.accessToken !== "string" || typeof session.refreshToken !== "string" || typeof session.expiresAt !== "number") {
53
+ return null;
54
+ }
55
+ return {
56
+ accessToken: session.accessToken,
57
+ refreshToken: session.refreshToken,
58
+ expiresAt: session.expiresAt
59
+ };
60
+ }
61
+ function isExpiringSoon(session, now, windowMs = EXPIRY_WINDOW_MS) {
62
+ return session.expiresAt - now < windowMs;
63
+ }
64
+
65
+ // src/server/board-access.ts
66
+ var BOARD_ACCESS_COOKIE_NAME = "__Host-cavuno_board_access";
67
+ function grantCookieName(board) {
68
+ return board ? `${BOARD_ACCESS_COOKIE_NAME}_${cookieScope(board)}` : BOARD_ACCESS_COOKIE_NAME;
69
+ }
70
+ var COOKIE_MAX_AGE_SECONDS2 = 24 * 60 * 60;
71
+ function serializeGrantCookie(token, options) {
72
+ return buildCookie(
73
+ grantCookieName(options?.board),
74
+ encodeURIComponent(token),
75
+ COOKIE_MAX_AGE_SECONDS2
76
+ );
77
+ }
78
+ function clearGrantCookie(options) {
79
+ return buildClearCookie(grantCookieName(options?.board));
80
+ }
81
+ function parseGrantCookie(cookieHeader, options) {
82
+ return readCookie(cookieHeader, grantCookieName(options?.board));
83
+ }
84
+
85
+ // src/server/redirect-guard.ts
86
+ function safeRedirectPath(path, defaultPath = "/") {
87
+ if (!path || !path.startsWith("/") || path.startsWith("//")) {
88
+ return defaultPath;
89
+ }
90
+ if (path.includes("://")) return defaultPath;
91
+ try {
92
+ const SENTINEL_ORIGIN = "https://redirect.invalid";
93
+ if (new URL(path, SENTINEL_ORIGIN).origin !== SENTINEL_ORIGIN) {
94
+ return defaultPath;
95
+ }
96
+ } catch {
97
+ return defaultPath;
98
+ }
99
+ return path;
100
+ }
101
+ function currentPathFromReferer(referer) {
102
+ if (!referer) return "/";
103
+ try {
104
+ const url = new URL(referer);
105
+ return safeRedirectPath(url.pathname + url.search);
106
+ } catch {
107
+ return "/";
108
+ }
109
+ }
110
+
111
+ // src/errors.ts
112
+ function isBoardApiError(e) {
113
+ return e instanceof Error && e.name === "BoardApiError";
114
+ }
115
+ function isUnauthorized(e) {
116
+ return isBoardApiError(e) && e.status === 401;
117
+ }
118
+
119
+ // src/storage.ts
120
+ var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
121
+ function isNoStore(storage) {
122
+ return storage[NOSTORE_BRAND] === true;
123
+ }
124
+ var NOSTORE = {
125
+ [NOSTORE_BRAND]: true,
126
+ getItem: () => null,
127
+ setItem: () => {
128
+ },
129
+ removeItem: () => {
130
+ }
131
+ };
132
+
133
+ // src/server/refresher.ts
134
+ function createSessionRefresher(board) {
135
+ if (typeof globalThis.document === "undefined" && !isNoStore(board.client.storage)) {
136
+ throw new Error(
137
+ "createSessionRefresher requires the server client to use storage: 'nostore' \u2014 a shared persistent store would leak rotated tokens across requests. Keep the session in the httpOnly cookie and pass tokens per call."
138
+ );
139
+ }
140
+ const inflight = /* @__PURE__ */ new Map();
141
+ return async function refresh(session) {
142
+ const key = session.refreshToken;
143
+ const existing = inflight.get(key);
144
+ if (existing) return existing;
145
+ const attempt = board.auth.refresh({ refreshToken: key }).then(
146
+ (rotated) => ({
147
+ accessToken: rotated.accessToken,
148
+ refreshToken: rotated.refreshToken,
149
+ expiresAt: rotated.expiresAt
150
+ })
151
+ ).catch((error) => {
152
+ if (isUnauthorized(error)) return null;
153
+ throw error;
154
+ }).finally(() => {
155
+ inflight.delete(key);
156
+ });
157
+ inflight.set(key, attempt);
158
+ return attempt;
159
+ };
160
+ }
161
+ export {
162
+ BOARD_ACCESS_COOKIE_NAME,
163
+ SESSION_COOKIE_NAME,
164
+ clearGrantCookie,
165
+ clearSessionCookie,
166
+ createSessionRefresher,
167
+ currentPathFromReferer,
168
+ grantCookieName,
169
+ isExpiringSoon,
170
+ parseGrantCookie,
171
+ parseSessionCookie,
172
+ safeRedirectPath,
173
+ serializeGrantCookie,
174
+ serializeSessionCookie,
175
+ sessionCookieName
176
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -80,6 +80,16 @@
80
80
  "default": "./dist/sitemap.js"
81
81
  }
82
82
  },
83
+ "./server": {
84
+ "import": {
85
+ "types": "./dist/server.d.mts",
86
+ "default": "./dist/server.mjs"
87
+ },
88
+ "require": {
89
+ "types": "./dist/server.d.ts",
90
+ "default": "./dist/server.js"
91
+ }
92
+ },
83
93
  "./skills": {
84
94
  "import": {
85
95
  "types": "./dist/skills.d.mts",
@@ -41,20 +41,21 @@ session.accessToken; // bearer; never expose to the browser bundle on SSR
41
41
 
42
42
  ## Storage modes
43
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.
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.
45
45
 
46
46
  ```ts
47
47
  import { createBoardClient } from '@cavuno/board';
48
48
 
49
- // Browser/SPA: token held in memory on the instance.
49
+ // Browser/SPA: 'local' persists the pair across tabs + reloads via
50
+ // localStorage; 'session' scopes it to the tab; 'memory' drops it on reload.
50
51
  const board = createBoardClient({
51
52
  baseUrl: 'https://api.cavuno.com',
52
53
  board: 'pk_a8f3...',
53
- auth: { storage: 'memory' },
54
+ auth: { storage: 'local' },
54
55
  });
55
56
  ```
56
57
 
57
- A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with `localStorage`, IndexedDB, Redis, or your own store.
58
+ `'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.
58
59
 
59
60
  ## No auto-refresh on 401 — handle it explicitly
60
61
 
@@ -87,7 +88,7 @@ await board.auth.logout({ refreshToken }); // revokes server-side, clears sto
87
88
 
88
89
  ## Server-side pattern (keep tokens out of the browser)
89
90
 
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
+ 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`):
91
92
 
92
93
  ```ts snippet
93
94
  // `accessToken` comes from your httpOnly cookie, read in server code.
@@ -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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.27.0",
2
+ "version": "1.28.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -113,6 +113,13 @@
113
113
  "framework": null,
114
114
  "category": "core"
115
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
+ },
116
123
  {
117
124
  "name": "cavuno-board-setup",
118
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.",