@cavuno/board 1.27.0 → 1.28.1
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 +67 -4
- package/dist/index.mjs +67 -4
- package/dist/server.d.mts +111 -0
- package/dist/server.d.ts +111 -0
- package/dist/server.js +206 -0
- package/dist/server.mjs +183 -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/index.js
CHANGED
|
@@ -30,12 +30,15 @@ __export(src_exports, {
|
|
|
30
30
|
createBoardClient: () => createBoardClient,
|
|
31
31
|
isBoardApiError: () => isBoardApiError,
|
|
32
32
|
isBoardPasswordRequired: () => isBoardPasswordRequired,
|
|
33
|
+
isColdRule: () => isColdRule,
|
|
33
34
|
isConflict: () => isConflict,
|
|
34
35
|
isForbidden: () => isForbidden,
|
|
35
36
|
isNotFound: () => isNotFound,
|
|
37
|
+
isOwnMessage: () => isOwnMessage,
|
|
36
38
|
isRateLimited: () => isRateLimited,
|
|
37
39
|
isUnauthorized: () => isUnauthorized,
|
|
38
40
|
isValidationError: () => isValidationError,
|
|
41
|
+
lastOwnMessageId: () => lastOwnMessageId,
|
|
39
42
|
paginate: () => paginate
|
|
40
43
|
});
|
|
41
44
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -176,7 +179,7 @@ var BoardApiError = class extends Error {
|
|
|
176
179
|
}
|
|
177
180
|
};
|
|
178
181
|
function isBoardApiError(e) {
|
|
179
|
-
return e instanceof BoardApiError;
|
|
182
|
+
return e instanceof Error && e.name === "BoardApiError" && typeof e.status === "number" && typeof e.code === "string";
|
|
180
183
|
}
|
|
181
184
|
function isNotFound(e) {
|
|
182
185
|
return isBoardApiError(e) && e.status === 404;
|
|
@@ -218,6 +221,16 @@ function toSearchParams(query) {
|
|
|
218
221
|
return params;
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
// src/scope.ts
|
|
225
|
+
function scopeToken(board) {
|
|
226
|
+
let hash = 5381;
|
|
227
|
+
for (let i = 0; i < board.length; i++) {
|
|
228
|
+
hash = (hash << 5) + hash + board.charCodeAt(i) >>> 0;
|
|
229
|
+
}
|
|
230
|
+
const sanitized = board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
231
|
+
return `${sanitized}-${hash.toString(36)}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
// src/storage.ts
|
|
222
235
|
var ACCESS_TOKEN_KEY = "cavuno_board_access_token";
|
|
223
236
|
var REFRESH_TOKEN_KEY = "cavuno_board_refresh_token";
|
|
@@ -237,14 +250,42 @@ function memoryStorage() {
|
|
|
237
250
|
}
|
|
238
251
|
};
|
|
239
252
|
}
|
|
253
|
+
function browserStorage(mode, scope) {
|
|
254
|
+
if (!isBrowser()) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`storage mode '${mode}' is browser-only \u2014 use 'nostore' + per-call headers on the server`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
let backing;
|
|
260
|
+
try {
|
|
261
|
+
backing = mode === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
262
|
+
if (!backing) throw new Error("unavailable");
|
|
263
|
+
} catch {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`storage mode '${mode}' is unavailable in this browsing context (storage access is blocked \u2014 e.g. a sandboxed or third-party iframe). Use 'memory', or 'nostore' + per-call headers.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const suffix = scope ? `:${scopeToken(scope)}` : "";
|
|
269
|
+
return {
|
|
270
|
+
getItem: (key) => backing.getItem(`${key}${suffix}`),
|
|
271
|
+
setItem: (key, value) => {
|
|
272
|
+
backing.setItem(`${key}${suffix}`, value);
|
|
273
|
+
},
|
|
274
|
+
removeItem: (key) => {
|
|
275
|
+
backing.removeItem(`${key}${suffix}`);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
240
280
|
var NOSTORE = {
|
|
281
|
+
[NOSTORE_BRAND]: true,
|
|
241
282
|
getItem: () => null,
|
|
242
283
|
setItem: () => {
|
|
243
284
|
},
|
|
244
285
|
removeItem: () => {
|
|
245
286
|
}
|
|
246
287
|
};
|
|
247
|
-
function resolveStorage(mode) {
|
|
288
|
+
function resolveStorage(mode, scope) {
|
|
248
289
|
const resolved = mode ?? (isBrowser() ? "memory" : "nostore");
|
|
249
290
|
if (typeof resolved === "object") return resolved;
|
|
250
291
|
switch (resolved) {
|
|
@@ -252,6 +293,9 @@ function resolveStorage(mode) {
|
|
|
252
293
|
return memoryStorage();
|
|
253
294
|
case "nostore":
|
|
254
295
|
return NOSTORE;
|
|
296
|
+
case "local":
|
|
297
|
+
case "session":
|
|
298
|
+
return browserStorage(resolved, scope);
|
|
255
299
|
default:
|
|
256
300
|
throw new Error(`Unknown storage mode '${String(resolved)}'`);
|
|
257
301
|
}
|
|
@@ -266,7 +310,7 @@ async function clearSession(storage) {
|
|
|
266
310
|
}
|
|
267
311
|
|
|
268
312
|
// src/version.ts
|
|
269
|
-
var SDK_VERSION = "1.
|
|
313
|
+
var SDK_VERSION = "1.28.1";
|
|
270
314
|
|
|
271
315
|
// src/client.ts
|
|
272
316
|
function isRawBody(body) {
|
|
@@ -2547,12 +2591,31 @@ function paginate(listFn, query, options) {
|
|
|
2547
2591
|
};
|
|
2548
2592
|
}
|
|
2549
2593
|
|
|
2594
|
+
// src/messaging-derive.ts
|
|
2595
|
+
function isOwnMessage(message, counterpartyId) {
|
|
2596
|
+
return message.authorBoardUserId !== counterpartyId;
|
|
2597
|
+
}
|
|
2598
|
+
function isColdRule(messages, counterpartyId) {
|
|
2599
|
+
const theyReplied = messages.some(
|
|
2600
|
+
(m) => m.authorBoardUserId === counterpartyId
|
|
2601
|
+
);
|
|
2602
|
+
const iSent = messages.some((m) => m.authorBoardUserId !== counterpartyId);
|
|
2603
|
+
return iSent && !theyReplied;
|
|
2604
|
+
}
|
|
2605
|
+
function lastOwnMessageId(messages, counterpartyId) {
|
|
2606
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2607
|
+
const message = messages[i];
|
|
2608
|
+
if (message.authorBoardUserId !== counterpartyId) return message.id;
|
|
2609
|
+
}
|
|
2610
|
+
return null;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2550
2613
|
// src/index.ts
|
|
2551
2614
|
function createBoardClient(options) {
|
|
2552
2615
|
const client = new BoardClient({
|
|
2553
2616
|
baseUrl: options.baseUrl,
|
|
2554
2617
|
board: options.board,
|
|
2555
|
-
storage: resolveStorage(options.auth?.storage),
|
|
2618
|
+
storage: resolveStorage(options.auth?.storage, options.board),
|
|
2556
2619
|
globalHeaders: options.globalHeaders,
|
|
2557
2620
|
onRequest: options.onRequest,
|
|
2558
2621
|
onResponse: options.onResponse,
|
package/dist/index.mjs
CHANGED
|
@@ -134,7 +134,7 @@ var BoardApiError = class extends Error {
|
|
|
134
134
|
}
|
|
135
135
|
};
|
|
136
136
|
function isBoardApiError(e) {
|
|
137
|
-
return e instanceof BoardApiError;
|
|
137
|
+
return e instanceof Error && e.name === "BoardApiError" && typeof e.status === "number" && typeof e.code === "string";
|
|
138
138
|
}
|
|
139
139
|
function isNotFound(e) {
|
|
140
140
|
return isBoardApiError(e) && e.status === 404;
|
|
@@ -176,6 +176,16 @@ function toSearchParams(query) {
|
|
|
176
176
|
return params;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// src/scope.ts
|
|
180
|
+
function scopeToken(board) {
|
|
181
|
+
let hash = 5381;
|
|
182
|
+
for (let i = 0; i < board.length; i++) {
|
|
183
|
+
hash = (hash << 5) + hash + board.charCodeAt(i) >>> 0;
|
|
184
|
+
}
|
|
185
|
+
const sanitized = board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
186
|
+
return `${sanitized}-${hash.toString(36)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
179
189
|
// src/storage.ts
|
|
180
190
|
var ACCESS_TOKEN_KEY = "cavuno_board_access_token";
|
|
181
191
|
var REFRESH_TOKEN_KEY = "cavuno_board_refresh_token";
|
|
@@ -195,14 +205,42 @@ function memoryStorage() {
|
|
|
195
205
|
}
|
|
196
206
|
};
|
|
197
207
|
}
|
|
208
|
+
function browserStorage(mode, scope) {
|
|
209
|
+
if (!isBrowser()) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`storage mode '${mode}' is browser-only \u2014 use 'nostore' + per-call headers on the server`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
let backing;
|
|
215
|
+
try {
|
|
216
|
+
backing = mode === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
217
|
+
if (!backing) throw new Error("unavailable");
|
|
218
|
+
} catch {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`storage mode '${mode}' is unavailable in this browsing context (storage access is blocked \u2014 e.g. a sandboxed or third-party iframe). Use 'memory', or 'nostore' + per-call headers.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const suffix = scope ? `:${scopeToken(scope)}` : "";
|
|
224
|
+
return {
|
|
225
|
+
getItem: (key) => backing.getItem(`${key}${suffix}`),
|
|
226
|
+
setItem: (key, value) => {
|
|
227
|
+
backing.setItem(`${key}${suffix}`, value);
|
|
228
|
+
},
|
|
229
|
+
removeItem: (key) => {
|
|
230
|
+
backing.removeItem(`${key}${suffix}`);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
198
235
|
var NOSTORE = {
|
|
236
|
+
[NOSTORE_BRAND]: true,
|
|
199
237
|
getItem: () => null,
|
|
200
238
|
setItem: () => {
|
|
201
239
|
},
|
|
202
240
|
removeItem: () => {
|
|
203
241
|
}
|
|
204
242
|
};
|
|
205
|
-
function resolveStorage(mode) {
|
|
243
|
+
function resolveStorage(mode, scope) {
|
|
206
244
|
const resolved = mode ?? (isBrowser() ? "memory" : "nostore");
|
|
207
245
|
if (typeof resolved === "object") return resolved;
|
|
208
246
|
switch (resolved) {
|
|
@@ -210,6 +248,9 @@ function resolveStorage(mode) {
|
|
|
210
248
|
return memoryStorage();
|
|
211
249
|
case "nostore":
|
|
212
250
|
return NOSTORE;
|
|
251
|
+
case "local":
|
|
252
|
+
case "session":
|
|
253
|
+
return browserStorage(resolved, scope);
|
|
213
254
|
default:
|
|
214
255
|
throw new Error(`Unknown storage mode '${String(resolved)}'`);
|
|
215
256
|
}
|
|
@@ -224,7 +265,7 @@ async function clearSession(storage) {
|
|
|
224
265
|
}
|
|
225
266
|
|
|
226
267
|
// src/version.ts
|
|
227
|
-
var SDK_VERSION = "1.
|
|
268
|
+
var SDK_VERSION = "1.28.1";
|
|
228
269
|
|
|
229
270
|
// src/client.ts
|
|
230
271
|
function isRawBody(body) {
|
|
@@ -2505,12 +2546,31 @@ function paginate(listFn, query, options) {
|
|
|
2505
2546
|
};
|
|
2506
2547
|
}
|
|
2507
2548
|
|
|
2549
|
+
// src/messaging-derive.ts
|
|
2550
|
+
function isOwnMessage(message, counterpartyId) {
|
|
2551
|
+
return message.authorBoardUserId !== counterpartyId;
|
|
2552
|
+
}
|
|
2553
|
+
function isColdRule(messages, counterpartyId) {
|
|
2554
|
+
const theyReplied = messages.some(
|
|
2555
|
+
(m) => m.authorBoardUserId === counterpartyId
|
|
2556
|
+
);
|
|
2557
|
+
const iSent = messages.some((m) => m.authorBoardUserId !== counterpartyId);
|
|
2558
|
+
return iSent && !theyReplied;
|
|
2559
|
+
}
|
|
2560
|
+
function lastOwnMessageId(messages, counterpartyId) {
|
|
2561
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2562
|
+
const message = messages[i];
|
|
2563
|
+
if (message.authorBoardUserId !== counterpartyId) return message.id;
|
|
2564
|
+
}
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2508
2568
|
// src/index.ts
|
|
2509
2569
|
function createBoardClient(options) {
|
|
2510
2570
|
const client = new BoardClient({
|
|
2511
2571
|
baseUrl: options.baseUrl,
|
|
2512
2572
|
board: options.board,
|
|
2513
|
-
storage: resolveStorage(options.auth?.storage),
|
|
2573
|
+
storage: resolveStorage(options.auth?.storage, options.board),
|
|
2514
2574
|
globalHeaders: options.globalHeaders,
|
|
2515
2575
|
onRequest: options.onRequest,
|
|
2516
2576
|
onResponse: options.onResponse,
|
|
@@ -2572,11 +2632,14 @@ export {
|
|
|
2572
2632
|
createBoardClient,
|
|
2573
2633
|
isBoardApiError,
|
|
2574
2634
|
isBoardPasswordRequired,
|
|
2635
|
+
isColdRule,
|
|
2575
2636
|
isConflict,
|
|
2576
2637
|
isForbidden,
|
|
2577
2638
|
isNotFound,
|
|
2639
|
+
isOwnMessage,
|
|
2578
2640
|
isRateLimited,
|
|
2579
2641
|
isUnauthorized,
|
|
2580
2642
|
isValidationError,
|
|
2643
|
+
lastOwnMessageId,
|
|
2581
2644
|
paginate
|
|
2582
2645
|
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BoardSdk } from './index.mjs';
|
|
2
|
+
import './jobs-DK5mPBgq.mjs';
|
|
3
|
+
import './salaries-CrJsaZe6.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session cookie codec — pure (no framework imports, no node imports) so it
|
|
7
|
+
* stays hermetically testable and platform-neutral: helpers speak cookie
|
|
8
|
+
* STRINGS (`Set-Cookie` values / `Cookie` headers), never framework response
|
|
9
|
+
* objects. The session is the SDK bearer pair plus the access-token expiry;
|
|
10
|
+
* it lives in ONE httpOnly cookie owned by the host app (the SDK never sees
|
|
11
|
+
* storage on the server — ADR-0006).
|
|
12
|
+
*/
|
|
13
|
+
interface BoardSession {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
/** Access-token expiry, epoch ms (from board_auth_session). */
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
}
|
|
19
|
+
declare const SESSION_COOKIE_NAME = "__Host-cavuno_board_session";
|
|
20
|
+
/**
|
|
21
|
+
* The session cookie name, optionally board-scoped. One origin can serve
|
|
22
|
+
* multiple boards (the hosted platform scopes its grant cookie per account
|
|
23
|
+
* for this reason) — a multi-board host MUST pass its board identifier so
|
|
24
|
+
* sessions cannot clobber each other; single-board apps omit it.
|
|
25
|
+
*/
|
|
26
|
+
declare function sessionCookieName(board?: string): string;
|
|
27
|
+
declare function serializeSessionCookie(session: BoardSession, options?: {
|
|
28
|
+
board?: string;
|
|
29
|
+
}): string;
|
|
30
|
+
declare function clearSessionCookie(options?: {
|
|
31
|
+
board?: string;
|
|
32
|
+
}): string;
|
|
33
|
+
declare function parseSessionCookie(cookieHeader: string | null, options?: {
|
|
34
|
+
board?: string;
|
|
35
|
+
}): BoardSession | null;
|
|
36
|
+
declare function isExpiringSoon(session: BoardSession, now: number, windowMs?: number): boolean;
|
|
37
|
+
|
|
38
|
+
declare const BOARD_ACCESS_COOKIE_NAME = "__Host-cavuno_board_access";
|
|
39
|
+
/**
|
|
40
|
+
* The grant cookie name, optionally board-scoped — hosted scopes its grant
|
|
41
|
+
* cookie per account (`board_access_<accountId>`) because one app instance
|
|
42
|
+
* can gate multiple boards. Multi-board hosts MUST pass their board
|
|
43
|
+
* identifier; single-board apps omit it.
|
|
44
|
+
*/
|
|
45
|
+
declare function grantCookieName(board?: string): string;
|
|
46
|
+
declare function serializeGrantCookie(token: string, options?: {
|
|
47
|
+
board?: string;
|
|
48
|
+
}): string;
|
|
49
|
+
declare function clearGrantCookie(options?: {
|
|
50
|
+
board?: string;
|
|
51
|
+
}): string;
|
|
52
|
+
declare function parseGrantCookie(cookieHeader: string | null, options?: {
|
|
53
|
+
board?: string;
|
|
54
|
+
}): string | null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open-redirect guards — pure URL-safety logic (no cookies involved),
|
|
58
|
+
* transcribed from the hosted board's `validate-redirect-path.ts` and
|
|
59
|
+
* golden-tested against it input-for-input.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Guard a `?redirect=` / `?next=` param: a same-origin absolute path only
|
|
63
|
+
* ('/', not '//', no scheme), else `defaultPath` — hosted's
|
|
64
|
+
* `getSafeRedirectPath(path, defaultPath = '/')` shape (the two-arg form is
|
|
65
|
+
* live on the employer sign-up page, which falls back to
|
|
66
|
+
* '/account/connect').
|
|
67
|
+
*/
|
|
68
|
+
declare function safeRedirectPath(path: string | undefined | null, defaultPath?: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* The current page path (from the request `Referer`) for the /password
|
|
71
|
+
* redirect-back, guarded by `safeRedirectPath`. Pure — the framework-owned
|
|
72
|
+
* header read happens in the host app's middleware and the value is passed
|
|
73
|
+
* in here, so this stays platform-neutral.
|
|
74
|
+
*/
|
|
75
|
+
declare function currentPathFromReferer(referer: string | null): string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Single-flight session refresh — dedupes concurrent refreshes for the same
|
|
79
|
+
* session WITHIN one process/isolate (the rotation race, ADR-0057 wart #4,
|
|
80
|
+
* mitigated at the one layer a client library can reach).
|
|
81
|
+
*
|
|
82
|
+
* Refresh tokens are single-use: two concurrent refreshes for the same
|
|
83
|
+
* session burn the pair — the loser 401s and the user is signed out
|
|
84
|
+
* mid-session. This helper keys an in-flight slot per refreshToken so every
|
|
85
|
+
* concurrent caller awaits the SAME rotation; sequential calls after settle
|
|
86
|
+
* start a fresh one.
|
|
87
|
+
*
|
|
88
|
+
* Scope honestly stated: the dedupe is PER PROCESS/ISOLATE (an in-memory
|
|
89
|
+
* map). Two simultaneous requests served by different instances can still
|
|
90
|
+
* race the token; the proactive `isExpiringSoon` window keeps that rare, it
|
|
91
|
+
* does not eliminate it. Pair with `storage: 'nostore'` on the shared
|
|
92
|
+
* server client — `auth.refresh` persists the rotated pair into
|
|
93
|
+
* `client.storage`, and any persistent shared storage would bleed one
|
|
94
|
+
* user's tokens into another's requests.
|
|
95
|
+
*
|
|
96
|
+
* Returns the rotated `BoardSession` (persist it back to the cookie), or
|
|
97
|
+
* `null` on a 401 — the token is burned or revoked: clear the cookie and
|
|
98
|
+
* continue signed out, never retry. Other errors (network, 5xx, 429)
|
|
99
|
+
* rethrow untouched.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const refreshSession = createSessionRefresher(board);
|
|
103
|
+
* // in the session middleware:
|
|
104
|
+
* if (isExpiringSoon(session, Date.now())) {
|
|
105
|
+
* const next = await refreshSession(session);
|
|
106
|
+
* setCookie(next ? serializeSessionCookie(next) : clearSessionCookie());
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
declare function createSessionRefresher(board: Pick<BoardSdk, 'auth' | 'client'>): (session: BoardSession) => Promise<BoardSession | null>;
|
|
110
|
+
|
|
111
|
+
export { BOARD_ACCESS_COOKIE_NAME, type BoardSession, SESSION_COOKIE_NAME, clearGrantCookie, clearSessionCookie, createSessionRefresher, currentPathFromReferer, grantCookieName, isExpiringSoon, parseGrantCookie, parseSessionCookie, safeRedirectPath, serializeGrantCookie, serializeSessionCookie, sessionCookieName };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BoardSdk } from './index.js';
|
|
2
|
+
import './jobs-DK5mPBgq.js';
|
|
3
|
+
import './salaries-CXt6Vkrp.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session cookie codec — pure (no framework imports, no node imports) so it
|
|
7
|
+
* stays hermetically testable and platform-neutral: helpers speak cookie
|
|
8
|
+
* STRINGS (`Set-Cookie` values / `Cookie` headers), never framework response
|
|
9
|
+
* objects. The session is the SDK bearer pair plus the access-token expiry;
|
|
10
|
+
* it lives in ONE httpOnly cookie owned by the host app (the SDK never sees
|
|
11
|
+
* storage on the server — ADR-0006).
|
|
12
|
+
*/
|
|
13
|
+
interface BoardSession {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
/** Access-token expiry, epoch ms (from board_auth_session). */
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
}
|
|
19
|
+
declare const SESSION_COOKIE_NAME = "__Host-cavuno_board_session";
|
|
20
|
+
/**
|
|
21
|
+
* The session cookie name, optionally board-scoped. One origin can serve
|
|
22
|
+
* multiple boards (the hosted platform scopes its grant cookie per account
|
|
23
|
+
* for this reason) — a multi-board host MUST pass its board identifier so
|
|
24
|
+
* sessions cannot clobber each other; single-board apps omit it.
|
|
25
|
+
*/
|
|
26
|
+
declare function sessionCookieName(board?: string): string;
|
|
27
|
+
declare function serializeSessionCookie(session: BoardSession, options?: {
|
|
28
|
+
board?: string;
|
|
29
|
+
}): string;
|
|
30
|
+
declare function clearSessionCookie(options?: {
|
|
31
|
+
board?: string;
|
|
32
|
+
}): string;
|
|
33
|
+
declare function parseSessionCookie(cookieHeader: string | null, options?: {
|
|
34
|
+
board?: string;
|
|
35
|
+
}): BoardSession | null;
|
|
36
|
+
declare function isExpiringSoon(session: BoardSession, now: number, windowMs?: number): boolean;
|
|
37
|
+
|
|
38
|
+
declare const BOARD_ACCESS_COOKIE_NAME = "__Host-cavuno_board_access";
|
|
39
|
+
/**
|
|
40
|
+
* The grant cookie name, optionally board-scoped — hosted scopes its grant
|
|
41
|
+
* cookie per account (`board_access_<accountId>`) because one app instance
|
|
42
|
+
* can gate multiple boards. Multi-board hosts MUST pass their board
|
|
43
|
+
* identifier; single-board apps omit it.
|
|
44
|
+
*/
|
|
45
|
+
declare function grantCookieName(board?: string): string;
|
|
46
|
+
declare function serializeGrantCookie(token: string, options?: {
|
|
47
|
+
board?: string;
|
|
48
|
+
}): string;
|
|
49
|
+
declare function clearGrantCookie(options?: {
|
|
50
|
+
board?: string;
|
|
51
|
+
}): string;
|
|
52
|
+
declare function parseGrantCookie(cookieHeader: string | null, options?: {
|
|
53
|
+
board?: string;
|
|
54
|
+
}): string | null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open-redirect guards — pure URL-safety logic (no cookies involved),
|
|
58
|
+
* transcribed from the hosted board's `validate-redirect-path.ts` and
|
|
59
|
+
* golden-tested against it input-for-input.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Guard a `?redirect=` / `?next=` param: a same-origin absolute path only
|
|
63
|
+
* ('/', not '//', no scheme), else `defaultPath` — hosted's
|
|
64
|
+
* `getSafeRedirectPath(path, defaultPath = '/')` shape (the two-arg form is
|
|
65
|
+
* live on the employer sign-up page, which falls back to
|
|
66
|
+
* '/account/connect').
|
|
67
|
+
*/
|
|
68
|
+
declare function safeRedirectPath(path: string | undefined | null, defaultPath?: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* The current page path (from the request `Referer`) for the /password
|
|
71
|
+
* redirect-back, guarded by `safeRedirectPath`. Pure — the framework-owned
|
|
72
|
+
* header read happens in the host app's middleware and the value is passed
|
|
73
|
+
* in here, so this stays platform-neutral.
|
|
74
|
+
*/
|
|
75
|
+
declare function currentPathFromReferer(referer: string | null): string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Single-flight session refresh — dedupes concurrent refreshes for the same
|
|
79
|
+
* session WITHIN one process/isolate (the rotation race, ADR-0057 wart #4,
|
|
80
|
+
* mitigated at the one layer a client library can reach).
|
|
81
|
+
*
|
|
82
|
+
* Refresh tokens are single-use: two concurrent refreshes for the same
|
|
83
|
+
* session burn the pair — the loser 401s and the user is signed out
|
|
84
|
+
* mid-session. This helper keys an in-flight slot per refreshToken so every
|
|
85
|
+
* concurrent caller awaits the SAME rotation; sequential calls after settle
|
|
86
|
+
* start a fresh one.
|
|
87
|
+
*
|
|
88
|
+
* Scope honestly stated: the dedupe is PER PROCESS/ISOLATE (an in-memory
|
|
89
|
+
* map). Two simultaneous requests served by different instances can still
|
|
90
|
+
* race the token; the proactive `isExpiringSoon` window keeps that rare, it
|
|
91
|
+
* does not eliminate it. Pair with `storage: 'nostore'` on the shared
|
|
92
|
+
* server client — `auth.refresh` persists the rotated pair into
|
|
93
|
+
* `client.storage`, and any persistent shared storage would bleed one
|
|
94
|
+
* user's tokens into another's requests.
|
|
95
|
+
*
|
|
96
|
+
* Returns the rotated `BoardSession` (persist it back to the cookie), or
|
|
97
|
+
* `null` on a 401 — the token is burned or revoked: clear the cookie and
|
|
98
|
+
* continue signed out, never retry. Other errors (network, 5xx, 429)
|
|
99
|
+
* rethrow untouched.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const refreshSession = createSessionRefresher(board);
|
|
103
|
+
* // in the session middleware:
|
|
104
|
+
* if (isExpiringSoon(session, Date.now())) {
|
|
105
|
+
* const next = await refreshSession(session);
|
|
106
|
+
* setCookie(next ? serializeSessionCookie(next) : clearSessionCookie());
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
declare function createSessionRefresher(board: Pick<BoardSdk, 'auth' | 'client'>): (session: BoardSession) => Promise<BoardSession | null>;
|
|
110
|
+
|
|
111
|
+
export { BOARD_ACCESS_COOKIE_NAME, type BoardSession, SESSION_COOKIE_NAME, clearGrantCookie, clearSessionCookie, createSessionRefresher, currentPathFromReferer, grantCookieName, isExpiringSoon, parseGrantCookie, parseSessionCookie, safeRedirectPath, serializeGrantCookie, serializeSessionCookie, sessionCookieName };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
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/server/index.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
BOARD_ACCESS_COOKIE_NAME: () => BOARD_ACCESS_COOKIE_NAME,
|
|
24
|
+
SESSION_COOKIE_NAME: () => SESSION_COOKIE_NAME,
|
|
25
|
+
clearGrantCookie: () => clearGrantCookie,
|
|
26
|
+
clearSessionCookie: () => clearSessionCookie,
|
|
27
|
+
createSessionRefresher: () => createSessionRefresher,
|
|
28
|
+
currentPathFromReferer: () => currentPathFromReferer,
|
|
29
|
+
grantCookieName: () => grantCookieName,
|
|
30
|
+
isExpiringSoon: () => isExpiringSoon,
|
|
31
|
+
parseGrantCookie: () => parseGrantCookie,
|
|
32
|
+
parseSessionCookie: () => parseSessionCookie,
|
|
33
|
+
safeRedirectPath: () => safeRedirectPath,
|
|
34
|
+
serializeGrantCookie: () => serializeGrantCookie,
|
|
35
|
+
serializeSessionCookie: () => serializeSessionCookie,
|
|
36
|
+
sessionCookieName: () => sessionCookieName
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(server_exports);
|
|
39
|
+
|
|
40
|
+
// src/scope.ts
|
|
41
|
+
function scopeToken(board) {
|
|
42
|
+
let hash = 5381;
|
|
43
|
+
for (let i = 0; i < board.length; i++) {
|
|
44
|
+
hash = (hash << 5) + hash + board.charCodeAt(i) >>> 0;
|
|
45
|
+
}
|
|
46
|
+
const sanitized = board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
47
|
+
return `${sanitized}-${hash.toString(36)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/server/cookie.ts
|
|
51
|
+
var COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
|
|
52
|
+
function buildCookie(name, value, maxAgeSeconds) {
|
|
53
|
+
return `${name}=${value}; Max-Age=${maxAgeSeconds}; ${COOKIE_ATTRIBUTES}`;
|
|
54
|
+
}
|
|
55
|
+
function buildClearCookie(name) {
|
|
56
|
+
return `${name}=; Max-Age=0; ${COOKIE_ATTRIBUTES}`;
|
|
57
|
+
}
|
|
58
|
+
function readCookie(header, name) {
|
|
59
|
+
if (!header) return null;
|
|
60
|
+
const pair = header.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
|
|
61
|
+
if (!pair) return null;
|
|
62
|
+
const raw = pair.slice(name.length + 1);
|
|
63
|
+
if (!raw) return null;
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/server/session.ts
|
|
72
|
+
var SESSION_COOKIE_NAME = "__Host-cavuno_board_session";
|
|
73
|
+
function sessionCookieName(board) {
|
|
74
|
+
return board ? `${SESSION_COOKIE_NAME}_${scopeToken(board)}` : SESSION_COOKIE_NAME;
|
|
75
|
+
}
|
|
76
|
+
var COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
77
|
+
var EXPIRY_WINDOW_MS = 5 * 60 * 1e3;
|
|
78
|
+
function serializeSessionCookie(session, options) {
|
|
79
|
+
return buildCookie(
|
|
80
|
+
sessionCookieName(options?.board),
|
|
81
|
+
encodeURIComponent(JSON.stringify(session)),
|
|
82
|
+
COOKIE_MAX_AGE_SECONDS
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function clearSessionCookie(options) {
|
|
86
|
+
return buildClearCookie(sessionCookieName(options?.board));
|
|
87
|
+
}
|
|
88
|
+
function parseSessionCookie(cookieHeader, options) {
|
|
89
|
+
const raw = readCookie(cookieHeader, sessionCookieName(options?.board));
|
|
90
|
+
if (!raw) return null;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const session = parsed;
|
|
98
|
+
if (!session || typeof session.accessToken !== "string" || typeof session.refreshToken !== "string" || typeof session.expiresAt !== "number") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
accessToken: session.accessToken,
|
|
103
|
+
refreshToken: session.refreshToken,
|
|
104
|
+
expiresAt: session.expiresAt
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function isExpiringSoon(session, now, windowMs = EXPIRY_WINDOW_MS) {
|
|
108
|
+
return session.expiresAt - now < windowMs;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/server/board-access.ts
|
|
112
|
+
var BOARD_ACCESS_COOKIE_NAME = "__Host-cavuno_board_access";
|
|
113
|
+
function grantCookieName(board) {
|
|
114
|
+
return board ? `${BOARD_ACCESS_COOKIE_NAME}_${scopeToken(board)}` : BOARD_ACCESS_COOKIE_NAME;
|
|
115
|
+
}
|
|
116
|
+
var COOKIE_MAX_AGE_SECONDS2 = 24 * 60 * 60;
|
|
117
|
+
function serializeGrantCookie(token, options) {
|
|
118
|
+
return buildCookie(
|
|
119
|
+
grantCookieName(options?.board),
|
|
120
|
+
encodeURIComponent(token),
|
|
121
|
+
COOKIE_MAX_AGE_SECONDS2
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
function clearGrantCookie(options) {
|
|
125
|
+
return buildClearCookie(grantCookieName(options?.board));
|
|
126
|
+
}
|
|
127
|
+
function parseGrantCookie(cookieHeader, options) {
|
|
128
|
+
return readCookie(cookieHeader, grantCookieName(options?.board));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/server/redirect-guard.ts
|
|
132
|
+
function safeRedirectPath(path, defaultPath = "/") {
|
|
133
|
+
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
|
134
|
+
return defaultPath;
|
|
135
|
+
}
|
|
136
|
+
if (path.includes("://")) return defaultPath;
|
|
137
|
+
try {
|
|
138
|
+
const SENTINEL_ORIGIN = "https://redirect.invalid";
|
|
139
|
+
if (new URL(path, SENTINEL_ORIGIN).origin !== SENTINEL_ORIGIN) {
|
|
140
|
+
return defaultPath;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return defaultPath;
|
|
144
|
+
}
|
|
145
|
+
return path;
|
|
146
|
+
}
|
|
147
|
+
function currentPathFromReferer(referer) {
|
|
148
|
+
if (!referer) return "/";
|
|
149
|
+
try {
|
|
150
|
+
const url = new URL(referer);
|
|
151
|
+
return safeRedirectPath(url.pathname + url.search);
|
|
152
|
+
} catch {
|
|
153
|
+
return "/";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/errors.ts
|
|
158
|
+
function isBoardApiError(e) {
|
|
159
|
+
return e instanceof Error && e.name === "BoardApiError" && typeof e.status === "number" && typeof e.code === "string";
|
|
160
|
+
}
|
|
161
|
+
function isUnauthorized(e) {
|
|
162
|
+
return isBoardApiError(e) && e.status === 401;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/storage.ts
|
|
166
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
167
|
+
function isNoStore(storage) {
|
|
168
|
+
return storage[NOSTORE_BRAND] === true;
|
|
169
|
+
}
|
|
170
|
+
var NOSTORE = {
|
|
171
|
+
[NOSTORE_BRAND]: true,
|
|
172
|
+
getItem: () => null,
|
|
173
|
+
setItem: () => {
|
|
174
|
+
},
|
|
175
|
+
removeItem: () => {
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/server/refresher.ts
|
|
180
|
+
function createSessionRefresher(board) {
|
|
181
|
+
if (typeof globalThis.document === "undefined" && !isNoStore(board.client.storage)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"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."
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
187
|
+
return async function refresh(session) {
|
|
188
|
+
const key = session.refreshToken;
|
|
189
|
+
const existing = inflight.get(key);
|
|
190
|
+
if (existing) return existing;
|
|
191
|
+
const attempt = board.auth.refresh({ refreshToken: key }).then(
|
|
192
|
+
(rotated) => ({
|
|
193
|
+
accessToken: rotated.accessToken,
|
|
194
|
+
refreshToken: rotated.refreshToken,
|
|
195
|
+
expiresAt: rotated.expiresAt
|
|
196
|
+
})
|
|
197
|
+
).catch((error) => {
|
|
198
|
+
if (isUnauthorized(error)) return null;
|
|
199
|
+
throw error;
|
|
200
|
+
}).finally(() => {
|
|
201
|
+
inflight.delete(key);
|
|
202
|
+
});
|
|
203
|
+
inflight.set(key, attempt);
|
|
204
|
+
return attempt;
|
|
205
|
+
};
|
|
206
|
+
}
|