@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.
- package/dist/index.d.mts +154 -126
- package/dist/index.d.ts +154 -126
- package/dist/index.js +60 -4
- package/dist/index.mjs +60 -4
- package/dist/server.d.mts +111 -0
- package/dist/server.d.ts +111 -0
- package/dist/server.js +199 -0
- package/dist/server.mjs +176 -0
- package/package.json +11 -1
- package/skills/cavuno-board-auth/SKILL.md +6 -5
- package/skills/cavuno-board-server/SKILL.md +229 -0
- package/skills/manifest.json +8 -1
package/dist/server.mjs
ADDED
|
@@ -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.
|
|
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:
|
|
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: '
|
|
54
|
+
auth: { storage: 'local' },
|
|
54
55
|
});
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with
|
|
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.
|
package/skills/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
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.",
|