@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.
@@ -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 };
@@ -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
+ }
@@ -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 };