@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/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";
|
|
180
183
|
}
|
|
181
184
|
function isNotFound(e) {
|
|
182
185
|
return isBoardApiError(e) && e.status === 404;
|
|
@@ -237,14 +240,45 @@ function memoryStorage() {
|
|
|
237
240
|
}
|
|
238
241
|
};
|
|
239
242
|
}
|
|
243
|
+
function scopeToken(board) {
|
|
244
|
+
return board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
245
|
+
}
|
|
246
|
+
function browserStorage(mode, scope) {
|
|
247
|
+
if (!isBrowser()) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`storage mode '${mode}' is browser-only \u2014 use 'nostore' + per-call headers on the server`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
let backing;
|
|
253
|
+
try {
|
|
254
|
+
backing = mode === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
255
|
+
if (!backing) throw new Error("unavailable");
|
|
256
|
+
} catch {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`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.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const suffix = scope ? `:${scopeToken(scope)}` : "";
|
|
262
|
+
return {
|
|
263
|
+
getItem: (key) => backing.getItem(`${key}${suffix}`),
|
|
264
|
+
setItem: (key, value) => {
|
|
265
|
+
backing.setItem(`${key}${suffix}`, value);
|
|
266
|
+
},
|
|
267
|
+
removeItem: (key) => {
|
|
268
|
+
backing.removeItem(`${key}${suffix}`);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
240
273
|
var NOSTORE = {
|
|
274
|
+
[NOSTORE_BRAND]: true,
|
|
241
275
|
getItem: () => null,
|
|
242
276
|
setItem: () => {
|
|
243
277
|
},
|
|
244
278
|
removeItem: () => {
|
|
245
279
|
}
|
|
246
280
|
};
|
|
247
|
-
function resolveStorage(mode) {
|
|
281
|
+
function resolveStorage(mode, scope) {
|
|
248
282
|
const resolved = mode ?? (isBrowser() ? "memory" : "nostore");
|
|
249
283
|
if (typeof resolved === "object") return resolved;
|
|
250
284
|
switch (resolved) {
|
|
@@ -252,6 +286,9 @@ function resolveStorage(mode) {
|
|
|
252
286
|
return memoryStorage();
|
|
253
287
|
case "nostore":
|
|
254
288
|
return NOSTORE;
|
|
289
|
+
case "local":
|
|
290
|
+
case "session":
|
|
291
|
+
return browserStorage(resolved, scope);
|
|
255
292
|
default:
|
|
256
293
|
throw new Error(`Unknown storage mode '${String(resolved)}'`);
|
|
257
294
|
}
|
|
@@ -266,7 +303,7 @@ async function clearSession(storage) {
|
|
|
266
303
|
}
|
|
267
304
|
|
|
268
305
|
// src/version.ts
|
|
269
|
-
var SDK_VERSION = "1.
|
|
306
|
+
var SDK_VERSION = "1.28.0";
|
|
270
307
|
|
|
271
308
|
// src/client.ts
|
|
272
309
|
function isRawBody(body) {
|
|
@@ -2547,12 +2584,31 @@ function paginate(listFn, query, options) {
|
|
|
2547
2584
|
};
|
|
2548
2585
|
}
|
|
2549
2586
|
|
|
2587
|
+
// src/messaging-derive.ts
|
|
2588
|
+
function isOwnMessage(message, counterpartyId) {
|
|
2589
|
+
return message.authorBoardUserId !== counterpartyId;
|
|
2590
|
+
}
|
|
2591
|
+
function isColdRule(messages, counterpartyId) {
|
|
2592
|
+
const theyReplied = messages.some(
|
|
2593
|
+
(m) => m.authorBoardUserId === counterpartyId
|
|
2594
|
+
);
|
|
2595
|
+
const iSent = messages.some((m) => m.authorBoardUserId !== counterpartyId);
|
|
2596
|
+
return iSent && !theyReplied;
|
|
2597
|
+
}
|
|
2598
|
+
function lastOwnMessageId(messages, counterpartyId) {
|
|
2599
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2600
|
+
const message = messages[i];
|
|
2601
|
+
if (message.authorBoardUserId !== counterpartyId) return message.id;
|
|
2602
|
+
}
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2550
2606
|
// src/index.ts
|
|
2551
2607
|
function createBoardClient(options) {
|
|
2552
2608
|
const client = new BoardClient({
|
|
2553
2609
|
baseUrl: options.baseUrl,
|
|
2554
2610
|
board: options.board,
|
|
2555
|
-
storage: resolveStorage(options.auth?.storage),
|
|
2611
|
+
storage: resolveStorage(options.auth?.storage, options.board),
|
|
2556
2612
|
globalHeaders: options.globalHeaders,
|
|
2557
2613
|
onRequest: options.onRequest,
|
|
2558
2614
|
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";
|
|
138
138
|
}
|
|
139
139
|
function isNotFound(e) {
|
|
140
140
|
return isBoardApiError(e) && e.status === 404;
|
|
@@ -195,14 +195,45 @@ function memoryStorage() {
|
|
|
195
195
|
}
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
|
+
function scopeToken(board) {
|
|
199
|
+
return board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
200
|
+
}
|
|
201
|
+
function browserStorage(mode, scope) {
|
|
202
|
+
if (!isBrowser()) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`storage mode '${mode}' is browser-only \u2014 use 'nostore' + per-call headers on the server`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
let backing;
|
|
208
|
+
try {
|
|
209
|
+
backing = mode === "local" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
210
|
+
if (!backing) throw new Error("unavailable");
|
|
211
|
+
} catch {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`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.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const suffix = scope ? `:${scopeToken(scope)}` : "";
|
|
217
|
+
return {
|
|
218
|
+
getItem: (key) => backing.getItem(`${key}${suffix}`),
|
|
219
|
+
setItem: (key, value) => {
|
|
220
|
+
backing.setItem(`${key}${suffix}`, value);
|
|
221
|
+
},
|
|
222
|
+
removeItem: (key) => {
|
|
223
|
+
backing.removeItem(`${key}${suffix}`);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
198
228
|
var NOSTORE = {
|
|
229
|
+
[NOSTORE_BRAND]: true,
|
|
199
230
|
getItem: () => null,
|
|
200
231
|
setItem: () => {
|
|
201
232
|
},
|
|
202
233
|
removeItem: () => {
|
|
203
234
|
}
|
|
204
235
|
};
|
|
205
|
-
function resolveStorage(mode) {
|
|
236
|
+
function resolveStorage(mode, scope) {
|
|
206
237
|
const resolved = mode ?? (isBrowser() ? "memory" : "nostore");
|
|
207
238
|
if (typeof resolved === "object") return resolved;
|
|
208
239
|
switch (resolved) {
|
|
@@ -210,6 +241,9 @@ function resolveStorage(mode) {
|
|
|
210
241
|
return memoryStorage();
|
|
211
242
|
case "nostore":
|
|
212
243
|
return NOSTORE;
|
|
244
|
+
case "local":
|
|
245
|
+
case "session":
|
|
246
|
+
return browserStorage(resolved, scope);
|
|
213
247
|
default:
|
|
214
248
|
throw new Error(`Unknown storage mode '${String(resolved)}'`);
|
|
215
249
|
}
|
|
@@ -224,7 +258,7 @@ async function clearSession(storage) {
|
|
|
224
258
|
}
|
|
225
259
|
|
|
226
260
|
// src/version.ts
|
|
227
|
-
var SDK_VERSION = "1.
|
|
261
|
+
var SDK_VERSION = "1.28.0";
|
|
228
262
|
|
|
229
263
|
// src/client.ts
|
|
230
264
|
function isRawBody(body) {
|
|
@@ -2505,12 +2539,31 @@ function paginate(listFn, query, options) {
|
|
|
2505
2539
|
};
|
|
2506
2540
|
}
|
|
2507
2541
|
|
|
2542
|
+
// src/messaging-derive.ts
|
|
2543
|
+
function isOwnMessage(message, counterpartyId) {
|
|
2544
|
+
return message.authorBoardUserId !== counterpartyId;
|
|
2545
|
+
}
|
|
2546
|
+
function isColdRule(messages, counterpartyId) {
|
|
2547
|
+
const theyReplied = messages.some(
|
|
2548
|
+
(m) => m.authorBoardUserId === counterpartyId
|
|
2549
|
+
);
|
|
2550
|
+
const iSent = messages.some((m) => m.authorBoardUserId !== counterpartyId);
|
|
2551
|
+
return iSent && !theyReplied;
|
|
2552
|
+
}
|
|
2553
|
+
function lastOwnMessageId(messages, counterpartyId) {
|
|
2554
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2555
|
+
const message = messages[i];
|
|
2556
|
+
if (message.authorBoardUserId !== counterpartyId) return message.id;
|
|
2557
|
+
}
|
|
2558
|
+
return null;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2508
2561
|
// src/index.ts
|
|
2509
2562
|
function createBoardClient(options) {
|
|
2510
2563
|
const client = new BoardClient({
|
|
2511
2564
|
baseUrl: options.baseUrl,
|
|
2512
2565
|
board: options.board,
|
|
2513
|
-
storage: resolveStorage(options.auth?.storage),
|
|
2566
|
+
storage: resolveStorage(options.auth?.storage, options.board),
|
|
2514
2567
|
globalHeaders: options.globalHeaders,
|
|
2515
2568
|
onRequest: options.onRequest,
|
|
2516
2569
|
onResponse: options.onResponse,
|
|
@@ -2572,11 +2625,14 @@ export {
|
|
|
2572
2625
|
createBoardClient,
|
|
2573
2626
|
isBoardApiError,
|
|
2574
2627
|
isBoardPasswordRequired,
|
|
2628
|
+
isColdRule,
|
|
2575
2629
|
isConflict,
|
|
2576
2630
|
isForbidden,
|
|
2577
2631
|
isNotFound,
|
|
2632
|
+
isOwnMessage,
|
|
2578
2633
|
isRateLimited,
|
|
2579
2634
|
isUnauthorized,
|
|
2580
2635
|
isValidationError,
|
|
2636
|
+
lastOwnMessageId,
|
|
2581
2637
|
paginate
|
|
2582
2638
|
};
|
|
@@ -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,199 @@
|
|
|
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/server/cookie.ts
|
|
41
|
+
var COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
|
|
42
|
+
function buildCookie(name, value, maxAgeSeconds) {
|
|
43
|
+
return `${name}=${value}; Max-Age=${maxAgeSeconds}; ${COOKIE_ATTRIBUTES}`;
|
|
44
|
+
}
|
|
45
|
+
function buildClearCookie(name) {
|
|
46
|
+
return `${name}=; Max-Age=0; ${COOKIE_ATTRIBUTES}`;
|
|
47
|
+
}
|
|
48
|
+
function readCookie(header, name) {
|
|
49
|
+
if (!header) return null;
|
|
50
|
+
const pair = header.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
|
|
51
|
+
if (!pair) return null;
|
|
52
|
+
const raw = pair.slice(name.length + 1);
|
|
53
|
+
if (!raw) return null;
|
|
54
|
+
try {
|
|
55
|
+
return decodeURIComponent(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function cookieScope(board) {
|
|
61
|
+
return board.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/server/session.ts
|
|
65
|
+
var SESSION_COOKIE_NAME = "__Host-cavuno_board_session";
|
|
66
|
+
function sessionCookieName(board) {
|
|
67
|
+
return board ? `${SESSION_COOKIE_NAME}_${cookieScope(board)}` : SESSION_COOKIE_NAME;
|
|
68
|
+
}
|
|
69
|
+
var COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
70
|
+
var EXPIRY_WINDOW_MS = 5 * 60 * 1e3;
|
|
71
|
+
function serializeSessionCookie(session, options) {
|
|
72
|
+
return buildCookie(
|
|
73
|
+
sessionCookieName(options?.board),
|
|
74
|
+
encodeURIComponent(JSON.stringify(session)),
|
|
75
|
+
COOKIE_MAX_AGE_SECONDS
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
function clearSessionCookie(options) {
|
|
79
|
+
return buildClearCookie(sessionCookieName(options?.board));
|
|
80
|
+
}
|
|
81
|
+
function parseSessionCookie(cookieHeader, options) {
|
|
82
|
+
const raw = readCookie(cookieHeader, sessionCookieName(options?.board));
|
|
83
|
+
if (!raw) return null;
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const session = parsed;
|
|
91
|
+
if (!session || typeof session.accessToken !== "string" || typeof session.refreshToken !== "string" || typeof session.expiresAt !== "number") {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
accessToken: session.accessToken,
|
|
96
|
+
refreshToken: session.refreshToken,
|
|
97
|
+
expiresAt: session.expiresAt
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function isExpiringSoon(session, now, windowMs = EXPIRY_WINDOW_MS) {
|
|
101
|
+
return session.expiresAt - now < windowMs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/server/board-access.ts
|
|
105
|
+
var BOARD_ACCESS_COOKIE_NAME = "__Host-cavuno_board_access";
|
|
106
|
+
function grantCookieName(board) {
|
|
107
|
+
return board ? `${BOARD_ACCESS_COOKIE_NAME}_${cookieScope(board)}` : BOARD_ACCESS_COOKIE_NAME;
|
|
108
|
+
}
|
|
109
|
+
var COOKIE_MAX_AGE_SECONDS2 = 24 * 60 * 60;
|
|
110
|
+
function serializeGrantCookie(token, options) {
|
|
111
|
+
return buildCookie(
|
|
112
|
+
grantCookieName(options?.board),
|
|
113
|
+
encodeURIComponent(token),
|
|
114
|
+
COOKIE_MAX_AGE_SECONDS2
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
function clearGrantCookie(options) {
|
|
118
|
+
return buildClearCookie(grantCookieName(options?.board));
|
|
119
|
+
}
|
|
120
|
+
function parseGrantCookie(cookieHeader, options) {
|
|
121
|
+
return readCookie(cookieHeader, grantCookieName(options?.board));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/server/redirect-guard.ts
|
|
125
|
+
function safeRedirectPath(path, defaultPath = "/") {
|
|
126
|
+
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
|
127
|
+
return defaultPath;
|
|
128
|
+
}
|
|
129
|
+
if (path.includes("://")) return defaultPath;
|
|
130
|
+
try {
|
|
131
|
+
const SENTINEL_ORIGIN = "https://redirect.invalid";
|
|
132
|
+
if (new URL(path, SENTINEL_ORIGIN).origin !== SENTINEL_ORIGIN) {
|
|
133
|
+
return defaultPath;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
return defaultPath;
|
|
137
|
+
}
|
|
138
|
+
return path;
|
|
139
|
+
}
|
|
140
|
+
function currentPathFromReferer(referer) {
|
|
141
|
+
if (!referer) return "/";
|
|
142
|
+
try {
|
|
143
|
+
const url = new URL(referer);
|
|
144
|
+
return safeRedirectPath(url.pathname + url.search);
|
|
145
|
+
} catch {
|
|
146
|
+
return "/";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/errors.ts
|
|
151
|
+
function isBoardApiError(e) {
|
|
152
|
+
return e instanceof Error && e.name === "BoardApiError";
|
|
153
|
+
}
|
|
154
|
+
function isUnauthorized(e) {
|
|
155
|
+
return isBoardApiError(e) && e.status === 401;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/storage.ts
|
|
159
|
+
var NOSTORE_BRAND = /* @__PURE__ */ Symbol.for("@cavuno/board:nostore");
|
|
160
|
+
function isNoStore(storage) {
|
|
161
|
+
return storage[NOSTORE_BRAND] === true;
|
|
162
|
+
}
|
|
163
|
+
var NOSTORE = {
|
|
164
|
+
[NOSTORE_BRAND]: true,
|
|
165
|
+
getItem: () => null,
|
|
166
|
+
setItem: () => {
|
|
167
|
+
},
|
|
168
|
+
removeItem: () => {
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/server/refresher.ts
|
|
173
|
+
function createSessionRefresher(board) {
|
|
174
|
+
if (typeof globalThis.document === "undefined" && !isNoStore(board.client.storage)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"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."
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
180
|
+
return async function refresh(session) {
|
|
181
|
+
const key = session.refreshToken;
|
|
182
|
+
const existing = inflight.get(key);
|
|
183
|
+
if (existing) return existing;
|
|
184
|
+
const attempt = board.auth.refresh({ refreshToken: key }).then(
|
|
185
|
+
(rotated) => ({
|
|
186
|
+
accessToken: rotated.accessToken,
|
|
187
|
+
refreshToken: rotated.refreshToken,
|
|
188
|
+
expiresAt: rotated.expiresAt
|
|
189
|
+
})
|
|
190
|
+
).catch((error) => {
|
|
191
|
+
if (isUnauthorized(error)) return null;
|
|
192
|
+
throw error;
|
|
193
|
+
}).finally(() => {
|
|
194
|
+
inflight.delete(key);
|
|
195
|
+
});
|
|
196
|
+
inflight.set(key, attempt);
|
|
197
|
+
return attempt;
|
|
198
|
+
};
|
|
199
|
+
}
|