@markwharton/pwa-core 3.5.0 → 4.0.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/client.d.ts CHANGED
@@ -2,9 +2,17 @@
2
2
  * pwa-core/client - Browser-side API client utilities
3
3
  *
4
4
  * This module has NO Node.js dependencies and is designed for browser use.
5
+ *
6
+ * Supports two auth strategies via a single API surface:
7
+ * - Token (Bearer): initApiClient() — sends Authorization header
8
+ * - Session (Cookie): initSessionApiClient() — sends credentials: 'include'
9
+ *
10
+ * All api* functions (apiCall, apiGet, apiPost, etc.) work with whichever
11
+ * strategy was initialized. The sessionApi* names are deprecated aliases.
5
12
  */
6
13
  /**
7
- * API response wrapper for safe calls
14
+ * API response wrapper for safe calls.
15
+ * @deprecated Use ApiResponse — will be removed in next major version.
8
16
  */
9
17
  export interface ApiResponse<T> {
10
18
  ok: boolean;
@@ -13,7 +21,7 @@ export interface ApiResponse<T> {
13
21
  error?: string;
14
22
  }
15
23
  /**
16
- * Configuration for initApiClient
24
+ * Configuration for initApiClient (Bearer token auth)
17
25
  */
18
26
  export interface ApiClientConfig {
19
27
  /** Function that returns the current auth token (or null) */
@@ -24,7 +32,7 @@ export interface ApiClientConfig {
24
32
  timeout?: number;
25
33
  }
26
34
  /**
27
- * Configuration for initSessionApiClient
35
+ * Configuration for initSessionApiClient (cookie auth)
28
36
  */
29
37
  export interface SessionApiClientConfig {
30
38
  /** Optional callback for 401 responses (e.g., redirect to login) */
@@ -81,7 +89,7 @@ export declare class ApiError extends Error {
81
89
  isServerError(): boolean;
82
90
  }
83
91
  /**
84
- * Initializes the API client with token retrieval and optional configuration.
92
+ * Initializes the API client with Bearer token authentication.
85
93
  * Call once at application startup.
86
94
  * @param config - Client configuration
87
95
  * @example
@@ -92,6 +100,18 @@ export declare class ApiError extends Error {
92
100
  * });
93
101
  */
94
102
  export declare function initApiClient(config: ApiClientConfig): void;
103
+ /**
104
+ * Initializes the session-based API client.
105
+ * Uses cookies (credentials: 'include') instead of Bearer tokens.
106
+ * After calling this, use apiCall/apiGet/apiPost etc. (same functions as token auth).
107
+ * @param config - Client configuration
108
+ * @example
109
+ * initSessionApiClient({
110
+ * onUnauthenticated: () => window.location.href = '/login',
111
+ * timeout: 60000
112
+ * });
113
+ */
114
+ export declare function initSessionApiClient(config: SessionApiClientConfig): void;
95
115
  /**
96
116
  * Extract a user-friendly error message from an API error.
97
117
  * Use in catch blocks to convert errors to displayable strings.
@@ -108,6 +128,7 @@ export declare function initApiClient(config: ApiClientConfig): void;
108
128
  export declare function getApiErrorMessage(error: unknown, fallback: string): string;
109
129
  /**
110
130
  * Makes an authenticated API call. Throws ApiError on non-2xx responses.
131
+ * Works with both token (Bearer) and session (cookie) auth strategies.
111
132
  * @typeParam T - The expected response data type
112
133
  * @param url - The API endpoint URL
113
134
  * @param options - Optional fetch options (method, body, headers)
@@ -198,88 +219,6 @@ export declare function apiCallVoid(url: string, options?: RequestInit): Promise
198
219
  * }
199
220
  */
200
221
  export declare function apiCallSafe<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>>;
201
- /**
202
- * Initializes the session-based API client.
203
- * Uses cookies (credentials: 'include') instead of Bearer tokens.
204
- * @param config - Client configuration
205
- * @example
206
- * initSessionApiClient({
207
- * onUnauthenticated: () => window.location.href = '/login',
208
- * timeout: 60000
209
- * });
210
- */
211
- export declare function initSessionApiClient(config: SessionApiClientConfig): void;
212
- /**
213
- * Makes a cookie-authenticated API call. Throws ApiError on non-2xx responses.
214
- * Uses credentials: 'include' to send session cookies.
215
- * @typeParam T - The expected response data type
216
- * @param url - The API endpoint URL
217
- * @param options - Optional fetch options (method, body, headers)
218
- * @returns The parsed JSON response
219
- * @throws ApiError on non-2xx HTTP status or timeout
220
- * @example
221
- * const user = await sessionApiCall<User>('/api/auth/me');
222
- */
223
- export declare function sessionApiCall<T>(url: string, options?: RequestInit): Promise<T>;
224
- /**
225
- * Makes a cookie-authenticated GET request.
226
- * @typeParam T - The expected response data type
227
- * @param url - The API endpoint URL
228
- * @returns The parsed JSON response
229
- * @throws ApiError on non-2xx HTTP status
230
- */
231
- export declare function sessionApiGet<T>(url: string): Promise<T>;
232
- /**
233
- * Makes a cookie-authenticated POST request.
234
- * @typeParam T - The expected response data type
235
- * @param url - The API endpoint URL
236
- * @param body - Optional request body (will be JSON stringified)
237
- * @returns The parsed JSON response
238
- * @throws ApiError on non-2xx HTTP status
239
- */
240
- export declare function sessionApiPost<T>(url: string, body?: unknown): Promise<T>;
241
- /**
242
- * Makes a cookie-authenticated PUT request.
243
- * @typeParam T - The expected response data type
244
- * @param url - The API endpoint URL
245
- * @param body - Optional request body (will be JSON stringified)
246
- * @returns The parsed JSON response
247
- * @throws ApiError on non-2xx HTTP status
248
- */
249
- export declare function sessionApiPut<T>(url: string, body?: unknown): Promise<T>;
250
- /**
251
- * Makes a cookie-authenticated PATCH request.
252
- * @typeParam T - The expected response data type
253
- * @param url - The API endpoint URL
254
- * @param body - Optional request body (will be JSON stringified)
255
- * @returns The parsed JSON response
256
- * @throws ApiError on non-2xx HTTP status
257
- */
258
- export declare function sessionApiPatch<T>(url: string, body?: unknown): Promise<T>;
259
- /**
260
- * Makes a cookie-authenticated DELETE request.
261
- * @typeParam T - The expected response data type
262
- * @param url - The API endpoint URL
263
- * @returns The parsed JSON response
264
- * @throws ApiError on non-2xx HTTP status
265
- */
266
- export declare function sessionApiDelete<T>(url: string): Promise<T>;
267
- /**
268
- * Makes a cookie-authenticated API call with Result-style error handling.
269
- * Unlike sessionApiCall, this never throws - errors are returned in the response.
270
- * @typeParam T - The expected response data type
271
- * @param url - The API endpoint URL
272
- * @param options - Optional fetch options (method, body, headers)
273
- * @returns ApiResponse with ok, status, data (on success), or error (on failure)
274
- */
275
- export declare function sessionApiCallSafe<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>>;
276
- /**
277
- * Makes a cookie-authenticated API call expecting no response body.
278
- * @param url - The API endpoint URL
279
- * @param options - Optional fetch options (method, body, headers)
280
- * @throws ApiError on non-2xx HTTP status or timeout
281
- */
282
- export declare function sessionApiCallVoid(url: string, options?: RequestInit): Promise<void>;
283
222
  /**
284
223
  * Checks if an HTTP status code indicates a retryable error.
285
224
  * @param status - The HTTP status code (or undefined for network errors)
@@ -289,8 +228,9 @@ export declare function sessionApiCallVoid(url: string, options?: RequestInit):
289
228
  */
290
229
  export declare function isRetryableStatus(status: number | undefined): boolean;
291
230
  /**
292
- * Makes a cookie-authenticated API call with exponential backoff retry.
231
+ * Makes an authenticated API call with exponential backoff retry.
293
232
  * Retries on retryable status codes (408, 425, 429, 5xx, network errors).
233
+ * Works with both token and session auth strategies.
294
234
  * @typeParam T - The expected response data type
295
235
  * @param url - The API endpoint URL
296
236
  * @param options - Optional fetch options
@@ -299,9 +239,27 @@ export declare function isRetryableStatus(status: number | undefined): boolean;
299
239
  * @returns The parsed JSON response
300
240
  * @throws ApiError if all retries fail
301
241
  * @example
302
- * const result = await sessionApiCallWithRetry<AuthResponse>('/api/auth/verify', {
242
+ * const result = await apiCallWithRetry<AuthResponse>('/api/auth/verify', {
303
243
  * method: 'POST',
304
244
  * body: JSON.stringify({ token })
305
245
  * });
306
246
  */
307
- export declare function sessionApiCallWithRetry<T>(url: string, options?: RequestInit, maxRetries?: number, initialDelayMs?: number): Promise<T>;
247
+ export declare function apiCallWithRetry<T>(url: string, options?: RequestInit, maxRetries?: number, initialDelayMs?: number): Promise<T>;
248
+ /** @deprecated Use apiCall after initSessionApiClient */
249
+ export declare const sessionApiCall: typeof apiCall;
250
+ /** @deprecated Use apiGet after initSessionApiClient */
251
+ export declare const sessionApiGet: typeof apiGet;
252
+ /** @deprecated Use apiPost after initSessionApiClient */
253
+ export declare const sessionApiPost: typeof apiPost;
254
+ /** @deprecated Use apiPut after initSessionApiClient */
255
+ export declare const sessionApiPut: typeof apiPut;
256
+ /** @deprecated Use apiPatch after initSessionApiClient */
257
+ export declare const sessionApiPatch: typeof apiPatch;
258
+ /** @deprecated Use apiDelete after initSessionApiClient */
259
+ export declare const sessionApiDelete: typeof apiDelete;
260
+ /** @deprecated Use apiCallSafe after initSessionApiClient */
261
+ export declare const sessionApiCallSafe: typeof apiCallSafe;
262
+ /** @deprecated Use apiCallVoid after initSessionApiClient */
263
+ export declare const sessionApiCallVoid: typeof apiCallVoid;
264
+ /** @deprecated Use apiCallWithRetry after initSessionApiClient */
265
+ export declare const sessionApiCallWithRetry: typeof apiCallWithRetry;
package/dist/client.js CHANGED
@@ -3,10 +3,18 @@
3
3
  * pwa-core/client - Browser-side API client utilities
4
4
  *
5
5
  * This module has NO Node.js dependencies and is designed for browser use.
6
+ *
7
+ * Supports two auth strategies via a single API surface:
8
+ * - Token (Bearer): initApiClient() — sends Authorization header
9
+ * - Session (Cookie): initSessionApiClient() — sends credentials: 'include'
10
+ *
11
+ * All api* functions (apiCall, apiGet, apiPost, etc.) work with whichever
12
+ * strategy was initialized. The sessionApi* names are deprecated aliases.
6
13
  */
7
14
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ApiError = void 0;
15
+ exports.sessionApiCallWithRetry = exports.sessionApiCallVoid = exports.sessionApiCallSafe = exports.sessionApiDelete = exports.sessionApiPatch = exports.sessionApiPut = exports.sessionApiPost = exports.sessionApiGet = exports.sessionApiCall = exports.ApiError = void 0;
9
16
  exports.initApiClient = initApiClient;
17
+ exports.initSessionApiClient = initSessionApiClient;
10
18
  exports.getApiErrorMessage = getApiErrorMessage;
11
19
  exports.apiCall = apiCall;
12
20
  exports.apiGet = apiGet;
@@ -16,17 +24,8 @@ exports.apiPatch = apiPatch;
16
24
  exports.apiDelete = apiDelete;
17
25
  exports.apiCallVoid = apiCallVoid;
18
26
  exports.apiCallSafe = apiCallSafe;
19
- exports.initSessionApiClient = initSessionApiClient;
20
- exports.sessionApiCall = sessionApiCall;
21
- exports.sessionApiGet = sessionApiGet;
22
- exports.sessionApiPost = sessionApiPost;
23
- exports.sessionApiPut = sessionApiPut;
24
- exports.sessionApiPatch = sessionApiPatch;
25
- exports.sessionApiDelete = sessionApiDelete;
26
- exports.sessionApiCallSafe = sessionApiCallSafe;
27
- exports.sessionApiCallVoid = sessionApiCallVoid;
28
27
  exports.isRetryableStatus = isRetryableStatus;
29
- exports.sessionApiCallWithRetry = sessionApiCallWithRetry;
28
+ exports.apiCallWithRetry = apiCallWithRetry;
30
29
  // =============================================================================
31
30
  // ApiError Class
32
31
  // =============================================================================
@@ -92,29 +91,35 @@ class ApiError extends Error {
92
91
  }
93
92
  }
94
93
  exports.ApiError = ApiError;
95
- // =============================================================================
96
- // Module State
97
- // =============================================================================
98
- // Token getter function - set by consuming app
99
- let getToken = null;
100
- // Callback for 401 responses (e.g., redirect to login)
101
- let onUnauthorized = null;
102
- // Request timeout in milliseconds (default: 30 seconds)
103
- let requestTimeout = 30000;
94
+ let clientState = null;
104
95
  // =============================================================================
105
96
  // Internal Helpers
106
97
  // =============================================================================
107
- /** Prepare headers with optional auth token */
108
- function prepareHeaders(options) {
109
- const token = getToken?.();
98
+ /** Get the active client state or throw */
99
+ function getState() {
100
+ if (!clientState) {
101
+ throw new Error('API client not initialized. Call initApiClient() or initSessionApiClient() first.');
102
+ }
103
+ return clientState;
104
+ }
105
+ /** Prepare headers and fetch options based on auth strategy */
106
+ function prepareFetchOptions(options) {
107
+ const state = getState();
110
108
  const headers = {
111
109
  'Content-Type': 'application/json',
112
110
  ...options.headers
113
111
  };
114
- if (token) {
115
- headers['Authorization'] = `Bearer ${token}`;
112
+ const fetchOptions = { ...options };
113
+ if (state.strategy === 'token') {
114
+ const token = state.getToken?.();
115
+ if (token) {
116
+ headers['Authorization'] = `Bearer ${token}`;
117
+ }
116
118
  }
117
- return headers;
119
+ else {
120
+ fetchOptions.credentials = 'include';
121
+ }
122
+ return { headers, fetchOptions };
118
123
  }
119
124
  /** Extract error message from failed response */
120
125
  async function extractErrorMessage(response) {
@@ -126,10 +131,10 @@ async function extractErrorMessage(response) {
126
131
  return 'Request failed';
127
132
  }
128
133
  }
129
- /** Handle 401 unauthorized callback */
134
+ /** Handle 401 unauthorized/unauthenticated callback */
130
135
  function handleUnauthorized(status) {
131
- if (status === 401 && onUnauthorized) {
132
- onUnauthorized();
136
+ if (status === 401 && clientState?.onUnauthorized) {
137
+ clientState.onUnauthorized();
133
138
  }
134
139
  }
135
140
  /** Handle catch block for throwing API functions */
@@ -143,10 +148,10 @@ function handleApiError(error) {
143
148
  throw error;
144
149
  }
145
150
  // =============================================================================
146
- // Public API
151
+ // Initialization
147
152
  // =============================================================================
148
153
  /**
149
- * Initializes the API client with token retrieval and optional configuration.
154
+ * Initializes the API client with Bearer token authentication.
150
155
  * Call once at application startup.
151
156
  * @param config - Client configuration
152
157
  * @example
@@ -157,10 +162,34 @@ function handleApiError(error) {
157
162
  * });
158
163
  */
159
164
  function initApiClient(config) {
160
- getToken = config.getToken;
161
- onUnauthorized = config.onUnauthorized ?? null;
162
- requestTimeout = config.timeout ?? 30000;
165
+ clientState = {
166
+ strategy: 'token',
167
+ getToken: config.getToken,
168
+ onUnauthorized: config.onUnauthorized,
169
+ timeout: config.timeout ?? 30000,
170
+ };
163
171
  }
172
+ /**
173
+ * Initializes the session-based API client.
174
+ * Uses cookies (credentials: 'include') instead of Bearer tokens.
175
+ * After calling this, use apiCall/apiGet/apiPost etc. (same functions as token auth).
176
+ * @param config - Client configuration
177
+ * @example
178
+ * initSessionApiClient({
179
+ * onUnauthenticated: () => window.location.href = '/login',
180
+ * timeout: 60000
181
+ * });
182
+ */
183
+ function initSessionApiClient(config) {
184
+ clientState = {
185
+ strategy: 'session',
186
+ onUnauthorized: config.onUnauthenticated,
187
+ timeout: config.timeout ?? 30000,
188
+ };
189
+ }
190
+ // =============================================================================
191
+ // Public API — Works with both token and session strategies
192
+ // =============================================================================
164
193
  /**
165
194
  * Extract a user-friendly error message from an API error.
166
195
  * Use in catch blocks to convert errors to displayable strings.
@@ -185,6 +214,7 @@ function getApiErrorMessage(error, fallback) {
185
214
  }
186
215
  /**
187
216
  * Makes an authenticated API call. Throws ApiError on non-2xx responses.
217
+ * Works with both token (Bearer) and session (cookie) auth strategies.
188
218
  * @typeParam T - The expected response data type
189
219
  * @param url - The API endpoint URL
190
220
  * @param options - Optional fetch options (method, body, headers)
@@ -194,12 +224,13 @@ function getApiErrorMessage(error, fallback) {
194
224
  * const user = await apiCall<User>('/api/users/123');
195
225
  */
196
226
  async function apiCall(url, options = {}) {
197
- const headers = prepareHeaders(options);
227
+ const state = getState();
228
+ const { headers, fetchOptions } = prepareFetchOptions(options);
198
229
  const controller = new AbortController();
199
- const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
230
+ const timeoutId = setTimeout(() => controller.abort(), state.timeout);
200
231
  try {
201
232
  const response = await fetch(url, {
202
- ...options,
233
+ ...fetchOptions,
203
234
  headers,
204
235
  signal: controller.signal
205
236
  });
@@ -304,12 +335,13 @@ async function apiDelete(url) {
304
335
  * await apiCallVoid('/api/action', { method: 'POST' });
305
336
  */
306
337
  async function apiCallVoid(url, options = {}) {
307
- const headers = prepareHeaders(options);
338
+ const state = getState();
339
+ const { headers, fetchOptions } = prepareFetchOptions(options);
308
340
  const controller = new AbortController();
309
- const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
341
+ const timeoutId = setTimeout(() => controller.abort(), state.timeout);
310
342
  try {
311
343
  const response = await fetch(url, {
312
- ...options,
344
+ ...fetchOptions,
313
345
  headers,
314
346
  signal: controller.signal
315
347
  });
@@ -343,12 +375,13 @@ async function apiCallVoid(url, options = {}) {
343
375
  * }
344
376
  */
345
377
  async function apiCallSafe(url, options = {}) {
346
- const headers = prepareHeaders(options);
378
+ const state = getState();
379
+ const { headers, fetchOptions } = prepareFetchOptions(options);
347
380
  const controller = new AbortController();
348
- const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
381
+ const timeoutId = setTimeout(() => controller.abort(), state.timeout);
349
382
  try {
350
383
  const response = await fetch(url, {
351
- ...options,
384
+ ...fetchOptions,
352
385
  headers,
353
386
  signal: controller.signal
354
387
  });
@@ -372,211 +405,6 @@ async function apiCallSafe(url, options = {}) {
372
405
  }
373
406
  }
374
407
  // =============================================================================
375
- // Session API Client (cookie-based auth)
376
- // =============================================================================
377
- // Session API client state
378
- let sessionOnUnauthenticated = null;
379
- let sessionRequestTimeout = 30000;
380
- /**
381
- * Initializes the session-based API client.
382
- * Uses cookies (credentials: 'include') instead of Bearer tokens.
383
- * @param config - Client configuration
384
- * @example
385
- * initSessionApiClient({
386
- * onUnauthenticated: () => window.location.href = '/login',
387
- * timeout: 60000
388
- * });
389
- */
390
- function initSessionApiClient(config) {
391
- sessionOnUnauthenticated = config.onUnauthenticated ?? null;
392
- sessionRequestTimeout = config.timeout ?? 30000;
393
- }
394
- /** Handle 401 unauthenticated callback for session API */
395
- function handleUnauthenticated(status) {
396
- if (status === 401 && sessionOnUnauthenticated) {
397
- sessionOnUnauthenticated();
398
- }
399
- }
400
- /**
401
- * Makes a cookie-authenticated API call. Throws ApiError on non-2xx responses.
402
- * Uses credentials: 'include' to send session cookies.
403
- * @typeParam T - The expected response data type
404
- * @param url - The API endpoint URL
405
- * @param options - Optional fetch options (method, body, headers)
406
- * @returns The parsed JSON response
407
- * @throws ApiError on non-2xx HTTP status or timeout
408
- * @example
409
- * const user = await sessionApiCall<User>('/api/auth/me');
410
- */
411
- async function sessionApiCall(url, options = {}) {
412
- const headers = {
413
- 'Content-Type': 'application/json',
414
- ...options.headers
415
- };
416
- const controller = new AbortController();
417
- const timeoutId = setTimeout(() => controller.abort(), sessionRequestTimeout);
418
- try {
419
- const response = await fetch(url, {
420
- ...options,
421
- headers,
422
- credentials: 'include',
423
- signal: controller.signal
424
- });
425
- if (!response.ok) {
426
- handleUnauthenticated(response.status);
427
- const errorMessage = await extractErrorMessage(response);
428
- throw new ApiError(response.status, errorMessage);
429
- }
430
- const text = await response.text();
431
- if (!text) {
432
- throw new ApiError(response.status, 'Empty response body');
433
- }
434
- return JSON.parse(text);
435
- }
436
- catch (error) {
437
- handleApiError(error);
438
- }
439
- finally {
440
- clearTimeout(timeoutId);
441
- }
442
- }
443
- /**
444
- * Makes a cookie-authenticated GET request.
445
- * @typeParam T - The expected response data type
446
- * @param url - The API endpoint URL
447
- * @returns The parsed JSON response
448
- * @throws ApiError on non-2xx HTTP status
449
- */
450
- async function sessionApiGet(url) {
451
- return sessionApiCall(url, { method: 'GET' });
452
- }
453
- /**
454
- * Makes a cookie-authenticated POST request.
455
- * @typeParam T - The expected response data type
456
- * @param url - The API endpoint URL
457
- * @param body - Optional request body (will be JSON stringified)
458
- * @returns The parsed JSON response
459
- * @throws ApiError on non-2xx HTTP status
460
- */
461
- async function sessionApiPost(url, body) {
462
- return sessionApiCall(url, {
463
- method: 'POST',
464
- body: body ? JSON.stringify(body) : undefined
465
- });
466
- }
467
- /**
468
- * Makes a cookie-authenticated PUT request.
469
- * @typeParam T - The expected response data type
470
- * @param url - The API endpoint URL
471
- * @param body - Optional request body (will be JSON stringified)
472
- * @returns The parsed JSON response
473
- * @throws ApiError on non-2xx HTTP status
474
- */
475
- async function sessionApiPut(url, body) {
476
- return sessionApiCall(url, {
477
- method: 'PUT',
478
- body: body ? JSON.stringify(body) : undefined
479
- });
480
- }
481
- /**
482
- * Makes a cookie-authenticated PATCH request.
483
- * @typeParam T - The expected response data type
484
- * @param url - The API endpoint URL
485
- * @param body - Optional request body (will be JSON stringified)
486
- * @returns The parsed JSON response
487
- * @throws ApiError on non-2xx HTTP status
488
- */
489
- async function sessionApiPatch(url, body) {
490
- return sessionApiCall(url, {
491
- method: 'PATCH',
492
- body: body ? JSON.stringify(body) : undefined
493
- });
494
- }
495
- /**
496
- * Makes a cookie-authenticated DELETE request.
497
- * @typeParam T - The expected response data type
498
- * @param url - The API endpoint URL
499
- * @returns The parsed JSON response
500
- * @throws ApiError on non-2xx HTTP status
501
- */
502
- async function sessionApiDelete(url) {
503
- return sessionApiCall(url, { method: 'DELETE' });
504
- }
505
- /**
506
- * Makes a cookie-authenticated API call with Result-style error handling.
507
- * Unlike sessionApiCall, this never throws - errors are returned in the response.
508
- * @typeParam T - The expected response data type
509
- * @param url - The API endpoint URL
510
- * @param options - Optional fetch options (method, body, headers)
511
- * @returns ApiResponse with ok, status, data (on success), or error (on failure)
512
- */
513
- async function sessionApiCallSafe(url, options = {}) {
514
- const headers = {
515
- 'Content-Type': 'application/json',
516
- ...options.headers
517
- };
518
- const controller = new AbortController();
519
- const timeoutId = setTimeout(() => controller.abort(), sessionRequestTimeout);
520
- try {
521
- const response = await fetch(url, {
522
- ...options,
523
- headers,
524
- credentials: 'include',
525
- signal: controller.signal
526
- });
527
- if (!response.ok) {
528
- handleUnauthenticated(response.status);
529
- const errorMessage = await extractErrorMessage(response);
530
- return { ok: false, status: response.status, error: errorMessage };
531
- }
532
- const text = await response.text();
533
- const data = text ? JSON.parse(text) : undefined;
534
- return { ok: true, status: response.status, data };
535
- }
536
- catch (error) {
537
- if (error instanceof Error && error.name === 'AbortError') {
538
- return { ok: false, status: 0, error: 'Request timeout' };
539
- }
540
- return { ok: false, status: 0, error: 'Network error' };
541
- }
542
- finally {
543
- clearTimeout(timeoutId);
544
- }
545
- }
546
- /**
547
- * Makes a cookie-authenticated API call expecting no response body.
548
- * @param url - The API endpoint URL
549
- * @param options - Optional fetch options (method, body, headers)
550
- * @throws ApiError on non-2xx HTTP status or timeout
551
- */
552
- async function sessionApiCallVoid(url, options = {}) {
553
- const headers = {
554
- 'Content-Type': 'application/json',
555
- ...options.headers
556
- };
557
- const controller = new AbortController();
558
- const timeoutId = setTimeout(() => controller.abort(), sessionRequestTimeout);
559
- try {
560
- const response = await fetch(url, {
561
- ...options,
562
- headers,
563
- credentials: 'include',
564
- signal: controller.signal
565
- });
566
- if (!response.ok) {
567
- handleUnauthenticated(response.status);
568
- const errorMessage = await extractErrorMessage(response);
569
- throw new ApiError(response.status, errorMessage);
570
- }
571
- }
572
- catch (error) {
573
- handleApiError(error);
574
- }
575
- finally {
576
- clearTimeout(timeoutId);
577
- }
578
- }
579
- // =============================================================================
580
408
  // Retryable Request Helper
581
409
  // =============================================================================
582
410
  /**
@@ -596,8 +424,9 @@ function isRetryableStatus(status) {
596
424
  return false;
597
425
  }
598
426
  /**
599
- * Makes a cookie-authenticated API call with exponential backoff retry.
427
+ * Makes an authenticated API call with exponential backoff retry.
600
428
  * Retries on retryable status codes (408, 425, 429, 5xx, network errors).
429
+ * Works with both token and session auth strategies.
601
430
  * @typeParam T - The expected response data type
602
431
  * @param url - The API endpoint URL
603
432
  * @param options - Optional fetch options
@@ -606,16 +435,16 @@ function isRetryableStatus(status) {
606
435
  * @returns The parsed JSON response
607
436
  * @throws ApiError if all retries fail
608
437
  * @example
609
- * const result = await sessionApiCallWithRetry<AuthResponse>('/api/auth/verify', {
438
+ * const result = await apiCallWithRetry<AuthResponse>('/api/auth/verify', {
610
439
  * method: 'POST',
611
440
  * body: JSON.stringify({ token })
612
441
  * });
613
442
  */
614
- async function sessionApiCallWithRetry(url, options, maxRetries = 3, initialDelayMs = 1000) {
443
+ async function apiCallWithRetry(url, options, maxRetries = 3, initialDelayMs = 1000) {
615
444
  let lastError;
616
445
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
617
446
  try {
618
- return await sessionApiCall(url, options);
447
+ return await apiCall(url, options);
619
448
  }
620
449
  catch (error) {
621
450
  lastError = error;
@@ -630,3 +459,24 @@ async function sessionApiCallWithRetry(url, options, maxRetries = 3, initialDela
630
459
  }
631
460
  throw lastError;
632
461
  }
462
+ // =============================================================================
463
+ // Deprecated Aliases — Use apiCall/apiGet/apiPost etc. after initSessionApiClient
464
+ // =============================================================================
465
+ /** @deprecated Use apiCall after initSessionApiClient */
466
+ exports.sessionApiCall = apiCall;
467
+ /** @deprecated Use apiGet after initSessionApiClient */
468
+ exports.sessionApiGet = apiGet;
469
+ /** @deprecated Use apiPost after initSessionApiClient */
470
+ exports.sessionApiPost = apiPost;
471
+ /** @deprecated Use apiPut after initSessionApiClient */
472
+ exports.sessionApiPut = apiPut;
473
+ /** @deprecated Use apiPatch after initSessionApiClient */
474
+ exports.sessionApiPatch = apiPatch;
475
+ /** @deprecated Use apiDelete after initSessionApiClient */
476
+ exports.sessionApiDelete = apiDelete;
477
+ /** @deprecated Use apiCallSafe after initSessionApiClient */
478
+ exports.sessionApiCallSafe = apiCallSafe;
479
+ /** @deprecated Use apiCallVoid after initSessionApiClient */
480
+ exports.sessionApiCallVoid = apiCallVoid;
481
+ /** @deprecated Use apiCallWithRetry after initSessionApiClient */
482
+ exports.sessionApiCallWithRetry = apiCallWithRetry;
package/dist/server.d.ts CHANGED
@@ -23,12 +23,11 @@ export declare function initAuth(config: {
23
23
  /**
24
24
  * Initializes JWT authentication from environment variables.
25
25
  * Reads JWT_SECRET from process.env.
26
- * @param minLength - Minimum required secret length (default: 32)
27
26
  * @throws Error if JWT_SECRET is missing or too short
28
27
  * @example
29
28
  * initAuthFromEnv(); // Uses process.env.JWT_SECRET
30
29
  */
31
- export declare function initAuthFromEnv(minLength?: number): void;
30
+ export declare function initAuthFromEnv(): void;
32
31
  /**
33
32
  * Gets the configured JWT secret.
34
33
  * @returns The JWT secret string
@@ -75,50 +74,31 @@ export declare function generateToken<T extends object>(payload: T, expiresIn?:
75
74
  * const apiToken = generateLongLivedToken({ machineId: 'server-1' });
76
75
  */
77
76
  export declare function generateLongLivedToken<T extends object>(payload: T, expiresInDays?: number): string;
78
- /**
79
- * Successful auth result with typed payload.
80
- */
81
- export interface AuthSuccess<T extends BaseJwtPayload> {
82
- authorized: true;
83
- payload: T;
84
- }
85
- /**
86
- * Failed auth result with HTTP response.
87
- */
88
- export interface AuthFailure {
89
- authorized: false;
90
- response: HttpResponseInit;
91
- }
92
- /**
93
- * Discriminated union for auth results.
94
- * Use `auth.authorized` to narrow the type.
95
- */
96
- export type AuthResult<T extends BaseJwtPayload> = AuthSuccess<T> | AuthFailure;
97
77
  /** Default token expiry string for generateToken (7 days) */
98
78
  export declare const DEFAULT_TOKEN_EXPIRY = "7d";
99
79
  /** Default token expiry in seconds (7 days = 604800 seconds) */
100
80
  export declare const DEFAULT_TOKEN_EXPIRY_SECONDS: number;
101
81
  /**
102
- * Validates auth header and returns typed payload or error response.
82
+ * Validates auth header and returns typed payload or error result.
103
83
  * @typeParam T - The expected payload type (extends BaseJwtPayload)
104
84
  * @param authHeader - The Authorization header value
105
- * @returns AuthResult with payload on success, or HTTP response on failure
85
+ * @returns Result with payload on success, or error with status on failure
106
86
  * @example
107
87
  * const auth = requireAuth<UsernameTokenPayload>(request.headers.get('Authorization'));
108
- * if (!auth.authorized) return auth.response;
109
- * console.log(auth.payload.username);
88
+ * if (!auth.ok) return resultToResponse(auth);
89
+ * console.log(auth.data.username);
110
90
  */
111
- export declare function requireAuth<T extends BaseJwtPayload>(authHeader: string | null): AuthResult<T>;
91
+ export declare function requireAuth<T extends BaseJwtPayload>(authHeader: string | null): Result<T>;
112
92
  /**
113
93
  * Requires admin role. Use with RoleTokenPayload.
114
94
  * @param authHeader - The Authorization header value
115
- * @returns AuthResult with RoleTokenPayload on success, or HTTP response on failure
95
+ * @returns Result with RoleTokenPayload on success, or error with status on failure
116
96
  * @example
117
97
  * const auth = requireAdmin(request.headers.get('Authorization'));
118
- * if (!auth.authorized) return auth.response;
98
+ * if (!auth.ok) return resultToResponse(auth);
119
99
  * // User is admin
120
100
  */
121
- export declare function requireAdmin(authHeader: string | null): AuthResult<RoleTokenPayload>;
101
+ export declare function requireAdmin(authHeader: string | null): Result<RoleTokenPayload>;
122
102
  /**
123
103
  * Extracts API key from the X-API-Key header.
124
104
  * @param request - Request object with headers.get() method
@@ -259,12 +239,14 @@ export declare function initErrorHandling(config?: {
259
239
  * Initializes error handling from environment variables.
260
240
  * Currently a no-op but provides consistent API for future error config
261
241
  * (e.g., ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK).
262
- * @param callback - Optional callback invoked when handleFunctionError is called (fire-and-forget)
242
+ * @param config - Optional config with onError callback invoked when handleFunctionError is called (fire-and-forget)
263
243
  * @example
264
244
  * initErrorHandlingFromEnv(); // Uses process.env automatically
265
- * initErrorHandlingFromEnv((op, msg) => sendAlert(op, msg));
245
+ * initErrorHandlingFromEnv({ onError: (op, msg) => sendAlert(op, msg) });
266
246
  */
267
- export declare function initErrorHandlingFromEnv(callback?: ErrorCallback): void;
247
+ export declare function initErrorHandlingFromEnv(config?: {
248
+ onError?: ErrorCallback;
249
+ }): void;
268
250
  /**
269
251
  * Handles unexpected errors safely by logging details and returning a generic message.
270
252
  * Use in catch blocks to avoid exposing internal error details to clients.
@@ -397,15 +379,16 @@ export declare function upsertEntity<T extends {
397
379
  }>(client: TableClient, entity: T): Promise<void>;
398
380
  /**
399
381
  * Deletes an entity from Azure Table Storage.
400
- * Returns true on success, false on error (swallows errors).
382
+ * Returns ok(true) if deleted, ok(false) if not found (not an error), err() on real failures.
401
383
  * @param client - The TableClient instance
402
384
  * @param partitionKey - The partition key
403
385
  * @param rowKey - The row key
404
- * @returns True if deleted successfully, false on error
386
+ * @returns Result with true if deleted, false if not found, or error on failure
405
387
  * @example
406
- * const deleted = await deleteEntity(client, 'session', sessionId);
388
+ * const result = await deleteEntity(client, 'session', sessionId);
389
+ * if (!result.ok) console.error(result.error);
407
390
  */
408
- export declare function deleteEntity(client: TableClient, partitionKey: string, rowKey: string): Promise<boolean>;
391
+ export declare function deleteEntity(client: TableClient, partitionKey: string, rowKey: string): Promise<Result<boolean>>;
409
392
  /**
410
393
  * Lists all entities in a partition.
411
394
  * @typeParam T - The entity type
@@ -458,7 +441,8 @@ export interface SessionAuthConfig {
458
441
  allowedDomain?: string;
459
442
  /** Emails that get isAdmin=true */
460
443
  adminEmails?: string[];
461
- /** Custom email validation callback (overrides default allowedEmails/allowedDomain check). Supports async for database lookups. */
444
+ /** Additional email validation. Called after built-in domain/admin checks reject.
445
+ * Can only widen access, never narrow it. Supports async for database lookups. */
462
446
  isEmailAllowed?: (email: string) => boolean | Promise<boolean>;
463
447
  /** Base URL for magic links and SWA preview URL validation */
464
448
  appBaseUrl?: string;
@@ -480,16 +464,25 @@ export declare function initSessionAuth(config: SessionAuthConfig): void;
480
464
  /**
481
465
  * Initializes session auth from environment variables.
482
466
  * Reads: SESSION_COOKIE_NAME, APP_BASE_URL, ALLOWED_EMAILS, ALLOWED_DOMAIN, ADMIN_EMAILS.
483
- * @param sendEmail - Required callback to send magic link emails
484
- * @param overrides - Optional config overrides (e.g., isEmailAllowed callback)
467
+ * Only callbacks go in the config object data comes from env vars.
468
+ * Use initSessionAuth() directly for full control.
469
+ * @param config - Required config with sendEmail callback and optional isEmailAllowed
485
470
  * @throws Error if sendEmail is not provided
486
471
  * @example
487
- * initSessionAuthFromEnv(async (to, magicLink) => {
488
- * await resend.emails.send({ to, html: `<a href="${magicLink}">Sign In</a>` });
489
- * return true;
490
- * }, { isEmailAllowed: async (email) => lookupInDatabase(email) });
472
+ * initSessionAuthFromEnv({
473
+ * sendEmail: async (to, magicLink) => {
474
+ * await resend.emails.send({ to, html: `<a href="${magicLink}">Sign In</a>` });
475
+ * return true;
476
+ * },
477
+ * isEmailAllowed: async (email) => lookupInDatabase(email),
478
+ * });
491
479
  */
492
- export declare function initSessionAuthFromEnv(sendEmail: (to: string, magicLink: string) => Promise<boolean>, overrides?: Partial<Omit<SessionAuthConfig, 'sendEmail'>>): void;
480
+ export declare function initSessionAuthFromEnv(config: {
481
+ sendEmail: (to: string, magicLink: string) => Promise<boolean>;
482
+ /** Additional email validation. Called after built-in domain/admin checks reject.
483
+ * Can only widen access, never narrow it. */
484
+ isEmailAllowed?: (email: string) => boolean | Promise<boolean>;
485
+ }): void;
493
486
  /**
494
487
  * Parses cookies from a request's Cookie header.
495
488
  * @param request - Request object with headers.get() method
@@ -582,37 +575,37 @@ export declare function verifyMagicLink(token: string): Promise<Result<{
582
575
  * Validates a session cookie and returns the user and session info.
583
576
  * Performs sliding window refresh if the session is close to expiry.
584
577
  * @param request - Request object with headers.get() method
585
- * @returns User, session, and optional refreshed cookie, or null if invalid
578
+ * @returns Result with user, session, and optional refreshed cookie
586
579
  * @example
587
580
  * const result = await validateSession(request);
588
- * if (!result) return unauthorizedResponse();
581
+ * if (!result.ok) return unauthorizedResponse();
589
582
  */
590
583
  export declare function validateSession(request: {
591
584
  headers: {
592
585
  get(name: string): string | null;
593
586
  };
594
- }): Promise<{
587
+ }): Promise<Result<{
595
588
  user: SessionUser;
596
589
  session: SessionInfo;
597
590
  refreshedCookie?: string;
598
- } | null>;
591
+ }>>;
599
592
  /**
600
593
  * Convenience function: validates session and returns user with optional refresh headers.
601
594
  * @param request - Request object with headers.get() method
602
- * @returns User and optional Set-Cookie headers, or null if not authenticated
595
+ * @returns Result with user and optional Set-Cookie headers
603
596
  * @example
604
597
  * const result = await getSessionUser(request);
605
- * if (!result) return unauthorizedResponse();
606
- * return { headers: result.headers, jsonBody: { user: result.user } };
598
+ * if (!result.ok) return resultToResponse(result);
599
+ * const { user, headers } = result.data!;
607
600
  */
608
601
  export declare function getSessionUser(request: {
609
602
  headers: {
610
603
  get(name: string): string | null;
611
604
  };
612
- }): Promise<{
605
+ }): Promise<Result<{
613
606
  user: SessionUser;
614
607
  headers?: Record<string, string>;
615
- } | null>;
608
+ }>>;
616
609
  /**
617
610
  * Destroys the current session and returns a logout cookie string.
618
611
  * @param request - Request object with headers.get() method
@@ -703,4 +696,14 @@ export declare function hasKeyVaultReferences(): boolean;
703
696
  * }
704
697
  */
705
698
  export declare function resolveKeyVaultReferences(): Promise<number>;
699
+ /**
700
+ * Convert a failed Result to an HttpResponseInit.
701
+ * Use after checking `!result.ok` to return the error as an HTTP response.
702
+ * @param result - A failed Result (ok=false)
703
+ * @returns HttpResponseInit with the error status and message
704
+ * @example
705
+ * const auth = requireAuth<UsernameTokenPayload>(authHeader);
706
+ * if (!auth.ok) return resultToResponse(auth);
707
+ */
708
+ export declare function resultToResponse(result: Result<unknown>): HttpResponseInit;
706
709
  export { Result, ok, okVoid, err, getErrorMessage, BaseJwtPayload, UserTokenPayload, UsernameTokenPayload, RoleTokenPayload, hasUsername, hasRole, isAdmin, HTTP_STATUS, HttpStatus, ErrorResponse, SessionUser, SessionInfo, MagicLinkRequest, SessionAuthResponse } from './shared';
package/dist/server.js CHANGED
@@ -101,6 +101,7 @@ exports.deleteExpiredSessions = deleteExpiredSessions;
101
101
  exports.deleteExpiredMagicLinks = deleteExpiredMagicLinks;
102
102
  exports.hasKeyVaultReferences = hasKeyVaultReferences;
103
103
  exports.resolveKeyVaultReferences = resolveKeyVaultReferences;
104
+ exports.resultToResponse = resultToResponse;
104
105
  const crypto_1 = require("crypto");
105
106
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
106
107
  const data_tables_1 = require("@azure/data-tables");
@@ -130,15 +131,13 @@ function initAuth(config) {
130
131
  /**
131
132
  * Initializes JWT authentication from environment variables.
132
133
  * Reads JWT_SECRET from process.env.
133
- * @param minLength - Minimum required secret length (default: 32)
134
134
  * @throws Error if JWT_SECRET is missing or too short
135
135
  * @example
136
136
  * initAuthFromEnv(); // Uses process.env.JWT_SECRET
137
137
  */
138
- function initAuthFromEnv(minLength = 32) {
138
+ function initAuthFromEnv() {
139
139
  initAuth({
140
140
  secret: process.env.JWT_SECRET,
141
- minLength
142
141
  });
143
142
  }
144
143
  /**
@@ -225,41 +224,41 @@ exports.DEFAULT_TOKEN_EXPIRY_SECONDS = DEFAULT_TOKEN_EXPIRY_DAYS * 24 * 60 * 60;
225
224
  // Auth Helpers
226
225
  // =============================================================================
227
226
  /**
228
- * Validates auth header and returns typed payload or error response.
227
+ * Validates auth header and returns typed payload or error result.
229
228
  * @typeParam T - The expected payload type (extends BaseJwtPayload)
230
229
  * @param authHeader - The Authorization header value
231
- * @returns AuthResult with payload on success, or HTTP response on failure
230
+ * @returns Result with payload on success, or error with status on failure
232
231
  * @example
233
232
  * const auth = requireAuth<UsernameTokenPayload>(request.headers.get('Authorization'));
234
- * if (!auth.authorized) return auth.response;
235
- * console.log(auth.payload.username);
233
+ * if (!auth.ok) return resultToResponse(auth);
234
+ * console.log(auth.data.username);
236
235
  */
237
236
  function requireAuth(authHeader) {
238
237
  const token = extractToken(authHeader);
239
238
  if (!token) {
240
- return { authorized: false, response: unauthorizedResponse() };
239
+ return (0, shared_1.err)('Unauthorized', shared_1.HTTP_STATUS.UNAUTHORIZED);
241
240
  }
242
241
  const result = validateToken(token);
243
242
  if (!result.ok) {
244
- return { authorized: false, response: unauthorizedResponse(result.error) };
243
+ return (0, shared_1.err)(result.error ?? 'Unauthorized', shared_1.HTTP_STATUS.UNAUTHORIZED);
245
244
  }
246
- return { authorized: true, payload: result.data };
245
+ return (0, shared_1.ok)(result.data);
247
246
  }
248
247
  /**
249
248
  * Requires admin role. Use with RoleTokenPayload.
250
249
  * @param authHeader - The Authorization header value
251
- * @returns AuthResult with RoleTokenPayload on success, or HTTP response on failure
250
+ * @returns Result with RoleTokenPayload on success, or error with status on failure
252
251
  * @example
253
252
  * const auth = requireAdmin(request.headers.get('Authorization'));
254
- * if (!auth.authorized) return auth.response;
253
+ * if (!auth.ok) return resultToResponse(auth);
255
254
  * // User is admin
256
255
  */
257
256
  function requireAdmin(authHeader) {
258
257
  const auth = requireAuth(authHeader);
259
- if (!auth.authorized)
258
+ if (!auth.ok)
260
259
  return auth;
261
- if (!(0, shared_1.isAdmin)(auth.payload)) {
262
- return { authorized: false, response: forbiddenResponse('Admin access required') };
260
+ if (!(0, shared_1.isAdmin)(auth.data)) {
261
+ return (0, shared_1.err)('Admin access required', shared_1.HTTP_STATUS.FORBIDDEN);
263
262
  }
264
263
  return auth;
265
264
  }
@@ -459,14 +458,14 @@ function initErrorHandling(config = {}) {
459
458
  * Initializes error handling from environment variables.
460
459
  * Currently a no-op but provides consistent API for future error config
461
460
  * (e.g., ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK).
462
- * @param callback - Optional callback invoked when handleFunctionError is called (fire-and-forget)
461
+ * @param config - Optional config with onError callback invoked when handleFunctionError is called (fire-and-forget)
463
462
  * @example
464
463
  * initErrorHandlingFromEnv(); // Uses process.env automatically
465
- * initErrorHandlingFromEnv((op, msg) => sendAlert(op, msg));
464
+ * initErrorHandlingFromEnv({ onError: (op, msg) => sendAlert(op, msg) });
466
465
  */
467
- function initErrorHandlingFromEnv(callback) {
466
+ function initErrorHandlingFromEnv(config) {
468
467
  // Future: read ERROR_LOG_LEVEL, ERROR_INCLUDE_STACK, etc. from process.env
469
- initErrorHandling({ callback });
468
+ initErrorHandling({ callback: config?.onError });
470
469
  }
471
470
  /**
472
471
  * Handles unexpected errors safely by logging details and returning a generic message.
@@ -695,21 +694,25 @@ async function upsertEntity(client, entity) {
695
694
  }
696
695
  /**
697
696
  * Deletes an entity from Azure Table Storage.
698
- * Returns true on success, false on error (swallows errors).
697
+ * Returns ok(true) if deleted, ok(false) if not found (not an error), err() on real failures.
699
698
  * @param client - The TableClient instance
700
699
  * @param partitionKey - The partition key
701
700
  * @param rowKey - The row key
702
- * @returns True if deleted successfully, false on error
701
+ * @returns Result with true if deleted, false if not found, or error on failure
703
702
  * @example
704
- * const deleted = await deleteEntity(client, 'session', sessionId);
703
+ * const result = await deleteEntity(client, 'session', sessionId);
704
+ * if (!result.ok) console.error(result.error);
705
705
  */
706
706
  async function deleteEntity(client, partitionKey, rowKey) {
707
707
  try {
708
708
  await client.deleteEntity(partitionKey, rowKey);
709
- return true;
709
+ return (0, shared_1.ok)(true);
710
710
  }
711
- catch {
712
- return false;
711
+ catch (error) {
712
+ if (isNotFoundError(error)) {
713
+ return (0, shared_1.ok)(false);
714
+ }
715
+ return (0, shared_1.err)((0, shared_1.getErrorMessage)(error, 'Delete failed'));
713
716
  }
714
717
  }
715
718
  /**
@@ -788,16 +791,20 @@ function initSessionAuth(config) {
788
791
  /**
789
792
  * Initializes session auth from environment variables.
790
793
  * Reads: SESSION_COOKIE_NAME, APP_BASE_URL, ALLOWED_EMAILS, ALLOWED_DOMAIN, ADMIN_EMAILS.
791
- * @param sendEmail - Required callback to send magic link emails
792
- * @param overrides - Optional config overrides (e.g., isEmailAllowed callback)
794
+ * Only callbacks go in the config object data comes from env vars.
795
+ * Use initSessionAuth() directly for full control.
796
+ * @param config - Required config with sendEmail callback and optional isEmailAllowed
793
797
  * @throws Error if sendEmail is not provided
794
798
  * @example
795
- * initSessionAuthFromEnv(async (to, magicLink) => {
796
- * await resend.emails.send({ to, html: `<a href="${magicLink}">Sign In</a>` });
797
- * return true;
798
- * }, { isEmailAllowed: async (email) => lookupInDatabase(email) });
799
+ * initSessionAuthFromEnv({
800
+ * sendEmail: async (to, magicLink) => {
801
+ * await resend.emails.send({ to, html: `<a href="${magicLink}">Sign In</a>` });
802
+ * return true;
803
+ * },
804
+ * isEmailAllowed: async (email) => lookupInDatabase(email),
805
+ * });
799
806
  */
800
- function initSessionAuthFromEnv(sendEmail, overrides) {
807
+ function initSessionAuthFromEnv(config) {
801
808
  const allowedEmailsStr = process.env.ALLOWED_EMAILS;
802
809
  const adminEmailsStr = process.env.ADMIN_EMAILS;
803
810
  initSessionAuth({
@@ -810,8 +817,8 @@ function initSessionAuthFromEnv(sendEmail, overrides) {
810
817
  adminEmails: adminEmailsStr
811
818
  ? adminEmailsStr.split(',').map(e => e.trim().toLowerCase())
812
819
  : undefined,
813
- ...overrides,
814
- sendEmail
820
+ isEmailAllowed: config.isEmailAllowed,
821
+ sendEmail: config.sendEmail,
815
822
  });
816
823
  }
817
824
  /**
@@ -1007,10 +1014,11 @@ async function createMagicLink(email, request) {
1007
1014
  if (!isValidEmail(normalizedEmail)) {
1008
1015
  return (0, shared_1.err)('Valid email required', shared_1.HTTP_STATUS.BAD_REQUEST);
1009
1016
  }
1010
- // Check allowlist (custom callback overrides default)
1011
- const emailAllowed = config.isEmailAllowed
1012
- ? await config.isEmailAllowed(normalizedEmail)
1013
- : isEmailAllowed(normalizedEmail);
1017
+ // Check allowlist (built-in first, custom extends can only widen access)
1018
+ const emailAllowed = isEmailAllowed(normalizedEmail)
1019
+ || (config.isEmailAllowed
1020
+ ? await config.isEmailAllowed(normalizedEmail)
1021
+ : false);
1014
1022
  if (!emailAllowed) {
1015
1023
  return (0, shared_1.err)('Email not allowed', shared_1.HTTP_STATUS.FORBIDDEN);
1016
1024
  }
@@ -1123,10 +1131,10 @@ async function verifyMagicLink(token) {
1123
1131
  * Validates a session cookie and returns the user and session info.
1124
1132
  * Performs sliding window refresh if the session is close to expiry.
1125
1133
  * @param request - Request object with headers.get() method
1126
- * @returns User, session, and optional refreshed cookie, or null if invalid
1134
+ * @returns Result with user, session, and optional refreshed cookie
1127
1135
  * @example
1128
1136
  * const result = await validateSession(request);
1129
- * if (!result) return unauthorizedResponse();
1137
+ * if (!result.ok) return unauthorizedResponse();
1130
1138
  */
1131
1139
  async function validateSession(request) {
1132
1140
  const config = getSessionAuthConfig();
@@ -1134,20 +1142,20 @@ async function validateSession(request) {
1134
1142
  const cookies = parseCookies(request);
1135
1143
  const sessionId = cookies[cookieName];
1136
1144
  if (!sessionId)
1137
- return null;
1145
+ return (0, shared_1.err)('No session cookie', shared_1.HTTP_STATUS.UNAUTHORIZED);
1138
1146
  try {
1139
1147
  const sessionsClient = await getTableClient(SESSIONS_TABLE);
1140
1148
  const sessionEntity = await getEntityIfExists(sessionsClient, SESSION_PARTITION, sessionId);
1141
1149
  if (!sessionEntity)
1142
- return null;
1150
+ return (0, shared_1.err)('Invalid session', shared_1.HTTP_STATUS.UNAUTHORIZED);
1143
1151
  // Check expiry
1144
1152
  if (new Date(sessionEntity.expiresAt) < new Date())
1145
- return null;
1153
+ return (0, shared_1.err)('Session expired', shared_1.HTTP_STATUS.UNAUTHORIZED);
1146
1154
  // Get user
1147
1155
  const usersClient = await getTableClient(USERS_TABLE);
1148
1156
  const userEntity = await getEntityIfExists(usersClient, USER_PARTITION, sessionEntity.email.toLowerCase());
1149
1157
  if (!userEntity)
1150
- return null;
1158
+ return (0, shared_1.err)('User not found', shared_1.HTTP_STATUS.UNAUTHORIZED);
1151
1159
  const user = {
1152
1160
  id: userEntity.id,
1153
1161
  email: userEntity.email,
@@ -1179,30 +1187,30 @@ async function validateSession(request) {
1179
1187
  await upsertEntity(sessionsClient, updatedSession);
1180
1188
  refreshedCookie = createSessionCookie(sessionId);
1181
1189
  }
1182
- return { user, session, refreshedCookie };
1190
+ return (0, shared_1.ok)({ user, session, refreshedCookie });
1183
1191
  }
1184
1192
  catch {
1185
- return null;
1193
+ return (0, shared_1.err)('Session validation failed', shared_1.HTTP_STATUS.UNAUTHORIZED);
1186
1194
  }
1187
1195
  }
1188
1196
  // --- Session Operations ---
1189
1197
  /**
1190
1198
  * Convenience function: validates session and returns user with optional refresh headers.
1191
1199
  * @param request - Request object with headers.get() method
1192
- * @returns User and optional Set-Cookie headers, or null if not authenticated
1200
+ * @returns Result with user and optional Set-Cookie headers
1193
1201
  * @example
1194
1202
  * const result = await getSessionUser(request);
1195
- * if (!result) return unauthorizedResponse();
1196
- * return { headers: result.headers, jsonBody: { user: result.user } };
1203
+ * if (!result.ok) return resultToResponse(result);
1204
+ * const { user, headers } = result.data!;
1197
1205
  */
1198
1206
  async function getSessionUser(request) {
1199
1207
  const result = await validateSession(request);
1200
- if (!result)
1201
- return null;
1202
- const headers = result.refreshedCookie
1203
- ? { 'Set-Cookie': result.refreshedCookie }
1208
+ if (!result.ok)
1209
+ return result;
1210
+ const headers = result.data.refreshedCookie
1211
+ ? { 'Set-Cookie': result.data.refreshedCookie }
1204
1212
  : undefined;
1205
- return { user: result.user, headers };
1213
+ return (0, shared_1.ok)({ user: result.data.user, headers });
1206
1214
  }
1207
1215
  /**
1208
1216
  * Destroys the current session and returns a logout cookie string.
@@ -1239,13 +1247,13 @@ async function destroySession(request) {
1239
1247
  function withSessionAuth(handler) {
1240
1248
  return async (request, context) => {
1241
1249
  const result = await getSessionUser(request);
1242
- if (!result) {
1250
+ if (!result.ok) {
1243
1251
  return unauthorizedResponse('Not authenticated');
1244
1252
  }
1245
- const response = await handler(request, context, result);
1253
+ const response = await handler(request, context, result.data);
1246
1254
  // Merge refresh cookie headers
1247
- if (result.headers) {
1248
- response.headers = { ...result.headers, ...response.headers };
1255
+ if (result.data.headers) {
1256
+ response.headers = { ...result.data.headers, ...response.headers };
1249
1257
  }
1250
1258
  return response;
1251
1259
  };
@@ -1265,16 +1273,16 @@ function withSessionAuth(handler) {
1265
1273
  function withSessionAdminAuth(handler) {
1266
1274
  return async (request, context) => {
1267
1275
  const result = await getSessionUser(request);
1268
- if (!result) {
1276
+ if (!result.ok) {
1269
1277
  return unauthorizedResponse('Not authenticated');
1270
1278
  }
1271
- if (!result.user.isAdmin) {
1279
+ if (!result.data.user.isAdmin) {
1272
1280
  return forbiddenResponse('Admin access required');
1273
1281
  }
1274
- const response = await handler(request, context, result);
1282
+ const response = await handler(request, context, result.data);
1275
1283
  // Merge refresh cookie headers
1276
- if (result.headers) {
1277
- response.headers = { ...result.headers, ...response.headers };
1284
+ if (result.data.headers) {
1285
+ response.headers = { ...result.data.headers, ...response.headers };
1278
1286
  }
1279
1287
  return response;
1280
1288
  };
@@ -1295,8 +1303,8 @@ async function deleteExpiredSessions() {
1295
1303
  let deleted = 0;
1296
1304
  for (const session of sessions) {
1297
1305
  if (new Date(session.expiresAt) < now) {
1298
- const success = await deleteEntity(client, SESSION_PARTITION, session.rowKey);
1299
- if (success)
1306
+ const result = await deleteEntity(client, SESSION_PARTITION, session.rowKey);
1307
+ if (result.ok && result.data)
1300
1308
  deleted++;
1301
1309
  }
1302
1310
  }
@@ -1317,8 +1325,8 @@ async function deleteExpiredMagicLinks() {
1317
1325
  let deleted = 0;
1318
1326
  for (const link of links) {
1319
1327
  if (new Date(link.expiresAt) < now || link.used) {
1320
- const success = await deleteEntity(client, MAGICLINK_PARTITION, link.rowKey);
1321
- if (success)
1328
+ const result = await deleteEntity(client, MAGICLINK_PARTITION, link.rowKey);
1329
+ if (result.ok && result.data)
1322
1330
  deleted++;
1323
1331
  }
1324
1332
  }
@@ -1432,6 +1440,24 @@ async function resolveKeyVaultReferences() {
1432
1440
  return resolved;
1433
1441
  }
1434
1442
  // =============================================================================
1443
+ // Result Conversion Helper
1444
+ // =============================================================================
1445
+ /**
1446
+ * Convert a failed Result to an HttpResponseInit.
1447
+ * Use after checking `!result.ok` to return the error as an HTTP response.
1448
+ * @param result - A failed Result (ok=false)
1449
+ * @returns HttpResponseInit with the error status and message
1450
+ * @example
1451
+ * const auth = requireAuth<UsernameTokenPayload>(authHeader);
1452
+ * if (!auth.ok) return resultToResponse(auth);
1453
+ */
1454
+ function resultToResponse(result) {
1455
+ return {
1456
+ status: result.status ?? shared_1.HTTP_STATUS.INTERNAL_ERROR,
1457
+ jsonBody: { error: result.error ?? 'Unknown error' }
1458
+ };
1459
+ }
1460
+ // =============================================================================
1435
1461
  // Re-exports from shared (for convenience)
1436
1462
  // =============================================================================
1437
1463
  var shared_2 = require("./shared");
package/dist/shared.d.ts CHANGED
@@ -15,13 +15,13 @@
15
15
  * { ok: false, error: 'Token expired' }
16
16
  *
17
17
  * // With status code (HTTP/push operations)
18
- * { ok: false, error: 'Subscription expired', statusCode: 410 }
18
+ * { ok: false, error: 'Subscription expired', status: 410 }
19
19
  */
20
20
  export interface Result<T> {
21
21
  ok: boolean;
22
22
  data?: T;
23
23
  error?: string;
24
- statusCode?: number;
24
+ status?: number;
25
25
  }
26
26
  /**
27
27
  * Creates a success result with data.
@@ -41,13 +41,13 @@ export declare function okVoid(): Result<void>;
41
41
  /**
42
42
  * Creates a failure result with an error message.
43
43
  * @param error - The error message
44
- * @param statusCode - Optional HTTP status code for API/push operations
44
+ * @param status - Optional HTTP status code for API/push operations
45
45
  * @returns A Result with ok=false and the error details
46
46
  * @example
47
47
  * return err('Token expired');
48
48
  * return err('Subscription gone', 410);
49
49
  */
50
- export declare function err<T>(error: string, statusCode?: number): Result<T>;
50
+ export declare function err<T = never>(error: string, status?: number): Result<T>;
51
51
  /**
52
52
  * Base JWT payload - all tokens include these fields
53
53
  * Projects extend this with their specific fields
package/dist/shared.js CHANGED
@@ -35,15 +35,15 @@ function okVoid() {
35
35
  /**
36
36
  * Creates a failure result with an error message.
37
37
  * @param error - The error message
38
- * @param statusCode - Optional HTTP status code for API/push operations
38
+ * @param status - Optional HTTP status code for API/push operations
39
39
  * @returns A Result with ok=false and the error details
40
40
  * @example
41
41
  * return err('Token expired');
42
42
  * return err('Subscription gone', 410);
43
43
  */
44
- function err(error, statusCode) {
45
- return statusCode !== undefined
46
- ? { ok: false, error, statusCode }
44
+ function err(error, status) {
45
+ return status !== undefined
46
+ ? { ok: false, error, status }
47
47
  : { ok: false, error };
48
48
  }
49
49
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/pwa-core",
3
- "version": "3.5.0",
3
+ "version": "4.0.0",
4
4
  "description": "Shared patterns for Azure PWA projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",