@cavuno/board 1.26.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/filters.d.mts +1 -1
- package/dist/filters.d.ts +1 -1
- package/dist/format.d.mts +1 -1
- package/dist/format.d.ts +1 -1
- package/dist/index.d.mts +158 -255
- package/dist/index.d.ts +158 -255
- package/dist/index.js +60 -4
- package/dist/index.mjs +60 -4
- package/dist/{jobs-CM67_J6H.d.mts → jobs-DK5mPBgq.d.mts} +1 -1
- package/dist/{jobs-CM67_J6H.d.ts → jobs-DK5mPBgq.d.ts} +1 -1
- package/dist/salaries-CXt6Vkrp.d.ts +130 -0
- package/dist/salaries-CrJsaZe6.d.mts +130 -0
- package/dist/seo.d.mts +288 -0
- package/dist/seo.d.ts +288 -0
- package/dist/seo.js +1102 -0
- package/dist/seo.mjs +1079 -0
- 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/dist/sitemap.d.mts +63 -0
- package/dist/sitemap.d.ts +63 -0
- package/dist/sitemap.js +353 -0
- package/dist/sitemap.mjs +330 -0
- package/package.json +31 -1
- package/skills/cavuno-board-auth/SKILL.md +6 -5
- package/skills/cavuno-board-seo/SKILL.md +180 -0
- package/skills/cavuno-board-server/SKILL.md +229 -0
- package/skills/cavuno-board-sitemap/SKILL.md +147 -0
- package/skills/manifest.json +22 -1
|
@@ -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
|
+
}
|
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
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BoardSdk } from './index.mjs';
|
|
2
|
+
import './jobs-DK5mPBgq.mjs';
|
|
3
|
+
import './salaries-CrJsaZe6.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sitemap primitives — the pure XML + bucket-filename logic behind a board
|
|
7
|
+
* frontend's `/sitemap.xml` index and `/sitemap/:file` bucket routes.
|
|
8
|
+
* Mirrors the hosted board's 8-bucket model (`board-sitemap.loader.ts`): a
|
|
9
|
+
* sitemap index points at one file per content bucket, each an ordinary
|
|
10
|
+
* `<urlset>`.
|
|
11
|
+
*
|
|
12
|
+
* The XML byte layout transcribes the hosted serializers
|
|
13
|
+
* (`sitemap-xml.utils.ts` — `serializeBoardSitemap` /
|
|
14
|
+
* `serializeBoardSitemapIndex`) and is golden-tested byte-equal against
|
|
15
|
+
* them in-monorepo. `changefreq`/`priority` are deliberately not supported:
|
|
16
|
+
* the hosted builders never emit them.
|
|
17
|
+
*
|
|
18
|
+
* Network enumeration lives in `walker.ts`; this module is pure so the
|
|
19
|
+
* chunking + filename round-trip is unit-tested without a board.
|
|
20
|
+
*/
|
|
21
|
+
declare const SITEMAP_BUCKETS: readonly ["marketing", "jobs-categories", "jobs-skills", "jobs-locations", "jobs-details", "companies", "salaries", "blog"];
|
|
22
|
+
type SitemapBucket = (typeof SITEMAP_BUCKETS)[number];
|
|
23
|
+
/** Matches the hosted chunk size so the two corpora line up bucket-for-bucket. */
|
|
24
|
+
declare const SITEMAP_CHUNK_SIZE = 45000;
|
|
25
|
+
declare function xmlEscape(value: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Chunk 0 (or no chunk) → the bare bucket filename; chunks 1+ append `-2`,
|
|
28
|
+
* `-3`, … so the common single-chunk case has the cleanest URL. The scheme
|
|
29
|
+
* is the filename component of the hosted `getBoardSitemapBucketPath`.
|
|
30
|
+
*/
|
|
31
|
+
declare function bucketFilename(bucket: SitemapBucket, chunkIndex?: number): string;
|
|
32
|
+
/** Inverse of `bucketFilename`; returns null for unknown buckets / non-xml. */
|
|
33
|
+
declare function parseBucketFilename(filename: string): {
|
|
34
|
+
bucket: SitemapBucket;
|
|
35
|
+
chunkIndex: number;
|
|
36
|
+
} | null;
|
|
37
|
+
declare function chunk<T>(items: readonly T[], size: number): T[][];
|
|
38
|
+
/**
|
|
39
|
+
* One `<url>` entry. A bare string is shorthand for `{ url }` — the walker
|
|
40
|
+
* emits plain URL strings; hosted-shaped entries carry `lastModified` (and
|
|
41
|
+
* `images` on job-detail entries: the company logo).
|
|
42
|
+
*/
|
|
43
|
+
interface SitemapUrlEntry {
|
|
44
|
+
url: string;
|
|
45
|
+
/** A `Date` serializes to ISO 8601; a string passes through as-is. */
|
|
46
|
+
lastModified?: Date | string;
|
|
47
|
+
/** Google image-sitemap extension URLs (adds the `xmlns:image` namespace). */
|
|
48
|
+
images?: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
interface SitemapIndexEntry {
|
|
51
|
+
url: string;
|
|
52
|
+
lastModified?: Date | string;
|
|
53
|
+
}
|
|
54
|
+
declare function renderUrlset(entries: readonly (string | SitemapUrlEntry)[]): string;
|
|
55
|
+
declare function renderSitemapIndex(entries: readonly (string | SitemapIndexEntry)[]): string;
|
|
56
|
+
|
|
57
|
+
/** Matches the hosted thin-content floor: a listing page needs ≥5 jobs to index. */
|
|
58
|
+
declare const MIN_JOBS_PER_INDEXED_PAGE = 5;
|
|
59
|
+
/** Which buckets the index lists. Blog is gated by its feature; the rest emit an empty urlset when a board lacks that content (valid, just zero URLs). */
|
|
60
|
+
declare function listedBuckets(board: BoardSdk): Promise<SitemapBucket[]>;
|
|
61
|
+
declare function buildBucketUrls(board: BoardSdk, origin: string, bucket: SitemapBucket): Promise<string[]>;
|
|
62
|
+
|
|
63
|
+
export { MIN_JOBS_PER_INDEXED_PAGE, SITEMAP_BUCKETS, SITEMAP_CHUNK_SIZE, type SitemapBucket, type SitemapIndexEntry, type SitemapUrlEntry, bucketFilename, buildBucketUrls, chunk, listedBuckets, parseBucketFilename, renderSitemapIndex, renderUrlset, xmlEscape };
|