cf-service-sdk 0.1.18 → 0.1.19

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
@@ -1,11 +1,17 @@
1
1
  import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
2
+ import { TokenManager } from './tokenManager';
2
3
  export interface CloudForgeClientOptions {
3
4
  baseUrl: string;
4
5
  token?: string;
5
6
  onUnauthorized?: () => void | Promise<void>;
6
7
  onError?: (error: Error) => void;
7
8
  onValidationError?: (validationErrors: ValidationError[]) => void;
9
+ /** @deprecated Use the built-in TokenManager instead. Kept for backward compatibility. */
8
10
  refreshToken?: () => Promise<string | null>;
11
+ /** Called when token is refreshed (e.g. to persist to localStorage) */
12
+ onTokenRefreshed?: (token: string) => void;
13
+ /** Request credentials mode for HttpLink. Defaults to 'include' for cookie support. */
14
+ credentials?: RequestCredentials;
9
15
  }
10
16
  export interface ValidationError {
11
17
  field: string;
@@ -14,12 +20,37 @@ export interface ValidationError {
14
20
  export declare class CloudForgeClient {
15
21
  private apolloClient;
16
22
  private options;
17
- private isRefreshing;
23
+ readonly tokenManager: TokenManager;
24
+ private pendingRefreshQueue;
25
+ private isRefreshingLink;
18
26
  constructor(options: CloudForgeClientOptions);
19
27
  private createApolloClient;
28
+ private isAuthError;
29
+ private createErrorLink;
30
+ /**
31
+ * Retry a failed operation after refreshing the token.
32
+ * Uses a queue to ensure only ONE refresh HTTP call is made, even when
33
+ * multiple operations fail simultaneously (e.g. expired JWT).
34
+ * The first caller triggers the refresh; subsequent callers queue up
35
+ * and are retried (or errored) when the single refresh completes.
36
+ */
37
+ private retryWithRefresh;
38
+ /**
39
+ * Drain the pending refresh queue: retry all queued operations with
40
+ * the new token, or error them all if refresh failed.
41
+ */
42
+ private drainQueue;
43
+ /**
44
+ * Update the JWT token. Does NOT recreate the Apollo client -
45
+ * the auth link reads the token dynamically.
46
+ */
20
47
  setToken(token: string): void;
21
48
  getClient(): ApolloClient<NormalizedCacheObject>;
22
49
  getBaseUrl(): string;
50
+ /** @deprecated Use tokenManager.refresh() instead */
23
51
  refreshToken(): Promise<boolean>;
24
- logout(): void;
52
+ /**
53
+ * Logout: revoke refresh token server-side, clear Apollo cache, reset state.
54
+ */
55
+ logout(): Promise<void>;
25
56
  }
package/dist/client.js CHANGED
@@ -4,262 +4,239 @@ exports.CloudForgeClient = void 0;
4
4
  const client_1 = require("@apollo/client");
5
5
  const error_1 = require("@apollo/client/link/error");
6
6
  const context_1 = require("@apollo/client/link/context");
7
+ const tokenManager_1 = require("./tokenManager");
7
8
  const defaultOptions = {
8
9
  baseUrl: 'http://localhost:8000/api/graph/',
9
- };
10
- // Detect if we're in a React Native environment
11
- const isReactNative = () => {
12
- return typeof global.HermesInternal !== 'undefined' ||
13
- typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
10
+ credentials: 'include',
14
11
  };
15
12
  class CloudForgeClient {
16
13
  constructor(options) {
17
- this.isRefreshing = false;
14
+ this.pendingRefreshQueue = [];
15
+ this.isRefreshingLink = false;
18
16
  this.options = { ...defaultOptions, ...options };
17
+ this.tokenManager = new tokenManager_1.TokenManager({
18
+ baseUrl: this.options.baseUrl,
19
+ onTokenRefreshed: (token) => {
20
+ var _a, _b;
21
+ this.options.token = token;
22
+ (_b = (_a = this.options).onTokenRefreshed) === null || _b === void 0 ? void 0 : _b.call(_a, token);
23
+ },
24
+ onSessionExpired: () => {
25
+ var _a, _b;
26
+ (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
27
+ },
28
+ });
29
+ if (this.options.token) {
30
+ this.tokenManager.setToken(this.options.token);
31
+ }
19
32
  this.apolloClient = this.createApolloClient();
20
33
  }
21
34
  createApolloClient() {
22
- // Mobile device workaround: replace localhost with the device's network IP
23
- // This is needed because localhost on a mobile device refers to the device itself
24
- let apiUrl = this.options.baseUrl;
25
- // Note: We previously modified the URL here, but now we expect the URL to be
26
- // properly configured by the application that uses this SDK, especially for React Native
27
- // applications where localhost means different things on different platforms.
28
- // HTTP link
35
+ var _a;
29
36
  const httpLink = (0, client_1.createHttpLink)({
30
- uri: apiUrl,
37
+ uri: this.options.baseUrl,
38
+ credentials: (_a = this.options.credentials) !== null && _a !== void 0 ? _a : 'include',
31
39
  fetchOptions: {
32
- cache: 'no-store', // Disable browser cache
40
+ cache: 'no-store',
33
41
  },
34
42
  });
35
- // Auth link for adding the token to headers
43
+ // Auth link reads token dynamically - no need to recreate client on token change
36
44
  const authLink = (0, context_1.setContext)((_, { headers }) => {
37
- const token = this.options.token;
45
+ const token = this.tokenManager.getToken();
38
46
  return {
39
47
  headers: {
40
48
  ...headers,
41
- authorization: token ? `JWT ${token}` : '',
49
+ ...(token ? { authorization: `JWT ${token}` } : {}),
42
50
  },
43
51
  };
44
52
  });
45
- // Error handling link
46
- const errorLink = (0, error_1.onError)(({ graphQLErrors, networkError, operation, forward }) => {
47
- var _a, _b, _c, _d;
48
- console.log('==== ERROR LINK ====');
49
- console.log('==== GRAPHQL ERRORS ====');
50
- console.log(graphQLErrors);
51
- console.log('==== NETWORK ERROR ====');
52
- console.log(networkError);
53
- console.log('==== OPERATION ====');
54
- console.log(operation);
53
+ const errorLink = this.createErrorLink();
54
+ const link = client_1.ApolloLink.from([errorLink, authLink, httpLink]);
55
+ return new client_1.ApolloClient({
56
+ link,
57
+ cache: new client_1.InMemoryCache(),
58
+ defaultOptions: {
59
+ watchQuery: {
60
+ fetchPolicy: 'network-only',
61
+ errorPolicy: 'all',
62
+ },
63
+ query: {
64
+ fetchPolicy: 'network-only',
65
+ errorPolicy: 'all',
66
+ },
67
+ mutate: {
68
+ errorPolicy: 'all',
69
+ },
70
+ },
71
+ });
72
+ }
73
+ isAuthError(message) {
74
+ const msg = message.toLowerCase();
75
+ return (msg.includes('signature has expired') ||
76
+ msg.includes('error decoding signature') ||
77
+ (msg.includes('token') && msg.includes('expired')) ||
78
+ msg.includes('authentication'));
79
+ }
80
+ createErrorLink() {
81
+ return (0, error_1.onError)(({ graphQLErrors, networkError, operation, forward }) => {
82
+ var _a, _b, _c, _d, _e, _f, _g;
83
+ // Handle GraphQL-level auth errors
55
84
  if (graphQLErrors) {
56
85
  const validationErrors = [];
57
- graphQLErrors.forEach(({ message, locations, path }) => {
58
- var _a, _b;
59
- console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
60
- // Check for unauthorized errors
61
- if (message.includes('Authentication') ||
62
- message.includes('Error decoding signature') ||
63
- message.includes('JWT') ||
64
- message.toLowerCase().includes('token') ||
65
- message.includes('Signature has expired')) {
66
- console.log('==== JWT ERROR DETECTED ====', message);
67
- // Try to refresh the token if a refresh function is provided
68
- if (this.options.refreshToken && !this.isRefreshing) {
69
- this.isRefreshing = true;
70
- // Attempt to refresh the token
71
- this.options.refreshToken()
72
- .then(newToken => {
73
- var _a, _b;
74
- if (newToken) {
75
- console.log('==== TOKEN REFRESHED ====');
76
- // Set the new token
77
- this.setToken(newToken);
78
- // Retry the failed request
79
- const oldHeaders = operation.getContext().headers;
80
- operation.setContext({
81
- headers: {
82
- ...oldHeaders,
83
- authorization: `JWT ${newToken}`,
84
- },
85
- });
86
- // Retry the operation
87
- return forward(operation);
88
- }
89
- else {
90
- console.log('==== TOKEN REFRESH FAILED ====');
91
- // If refresh token failed, call onUnauthorized
92
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
93
- }
94
- })
95
- .catch(error => {
96
- var _a, _b;
97
- console.error('Token refresh failed:', error);
98
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
99
- })
100
- .finally(() => {
101
- this.isRefreshing = false;
102
- });
103
- }
104
- else {
105
- // If no refresh function is provided, just call onUnauthorized
106
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
107
- }
86
+ let hasAuthError = false;
87
+ graphQLErrors.forEach(({ message }) => {
88
+ if (this.isAuthError(message)) {
89
+ hasAuthError = true;
108
90
  }
109
- // Check for validation errors
110
- if (message.includes('got invalid value') || message.includes('Field') && message.includes('was not provided')) {
111
- // Extract field name from validation error
91
+ // Collect validation errors
92
+ if (message.includes('got invalid value') || (message.includes('Field') && message.includes('was not provided'))) {
112
93
  const fieldMatch = message.match(/Field '([^']+)'/);
113
- if (fieldMatch && fieldMatch[1]) {
114
- validationErrors.push({
115
- field: fieldMatch[1],
116
- message: message.trim()
117
- });
94
+ if (fieldMatch === null || fieldMatch === void 0 ? void 0 : fieldMatch[1]) {
95
+ validationErrors.push({ field: fieldMatch[1], message: message.trim() });
118
96
  }
119
97
  }
120
98
  });
121
- // If we found validation errors, call the validation error handler
122
- if (validationErrors.length > 0 && this.options.onValidationError) {
123
- this.options.onValidationError(validationErrors);
99
+ if (validationErrors.length > 0) {
100
+ (_b = (_a = this.options).onValidationError) === null || _b === void 0 ? void 0 : _b.call(_a, validationErrors);
101
+ }
102
+ if (hasAuthError) {
103
+ return this.retryWithRefresh(operation, forward);
124
104
  }
125
105
  }
106
+ // Handle network-level auth errors
126
107
  if (networkError) {
127
- console.error(`[Network error]: ${networkError}`);
128
- // Check if network error is related to expired signature
129
- if (networkError.message && networkError.message.includes('Signature has expired')) {
130
- console.log('==== JWT NETWORK ERROR DETECTED ====', networkError.message);
131
- // Try to refresh the token if a refresh function is provided
132
- if (this.options.refreshToken && !this.isRefreshing) {
133
- this.isRefreshing = true;
134
- // Attempt to refresh the token
135
- this.options.refreshToken()
136
- .then(newToken => {
137
- var _a, _b;
138
- if (newToken) {
139
- console.log('==== TOKEN REFRESHED (NETWORK) ====');
140
- // Set the new token
141
- this.setToken(newToken);
142
- // Retry the failed request
143
- const oldHeaders = operation.getContext().headers;
144
- operation.setContext({
145
- headers: {
146
- ...oldHeaders,
147
- authorization: `JWT ${newToken}`,
148
- },
149
- });
150
- // Retry the operation
151
- return forward(operation);
152
- }
153
- else {
154
- console.log('==== TOKEN REFRESH FAILED (NETWORK) ====');
155
- // If refresh token failed, call onUnauthorized
156
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
157
- }
158
- })
159
- .catch(error => {
160
- var _a, _b;
161
- console.error('Token refresh failed:', error);
162
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
163
- })
164
- .finally(() => {
165
- this.isRefreshing = false;
166
- });
167
- }
168
- else {
169
- // If no refresh function is provided, just call onUnauthorized
170
- (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
171
- }
108
+ if ((_c = networkError.message) === null || _c === void 0 ? void 0 : _c.includes('Signature has expired')) {
109
+ return this.retryWithRefresh(operation, forward);
172
110
  }
173
- // For server errors (400/500), try to extract more information
111
+ // Extract validation errors from server error responses
174
112
  const serverError = networkError;
175
- if (serverError.name === 'ServerError' &&
176
- serverError.result &&
177
- typeof serverError.result === 'object') {
178
- // Handle validation errors that might be in the response body
113
+ if (serverError.name === 'ServerError' && serverError.result && typeof serverError.result === 'object') {
179
114
  try {
180
115
  const errorResult = serverError.result;
181
116
  if (errorResult.errors && Array.isArray(errorResult.errors)) {
182
117
  const validationErrors = [];
183
118
  errorResult.errors.forEach((error) => {
184
- if (error.message && (error.message.includes('got invalid value') ||
185
- (error.message.includes('Field') && error.message.includes('was not provided')))) {
119
+ if (error.message && (error.message.includes('got invalid value') || (error.message.includes('Field') && error.message.includes('was not provided')))) {
186
120
  const fieldMatch = error.message.match(/Field '([^']+)'/);
187
- if (fieldMatch && fieldMatch[1]) {
188
- validationErrors.push({
189
- field: fieldMatch[1],
190
- message: error.message.trim()
191
- });
121
+ if (fieldMatch === null || fieldMatch === void 0 ? void 0 : fieldMatch[1]) {
122
+ validationErrors.push({ field: fieldMatch[1], message: error.message.trim() });
192
123
  }
193
124
  }
194
125
  });
195
- if (validationErrors.length > 0 && this.options.onValidationError) {
196
- this.options.onValidationError(validationErrors);
126
+ if (validationErrors.length > 0) {
127
+ (_e = (_d = this.options).onValidationError) === null || _e === void 0 ? void 0 : _e.call(_d, validationErrors);
197
128
  }
198
129
  }
199
130
  }
200
- catch (e) {
201
- console.error('Error parsing server error response:', e);
131
+ catch (_h) {
132
+ // Ignore parse errors
202
133
  }
203
134
  }
204
- (_d = (_c = this.options).onError) === null || _d === void 0 ? void 0 : _d.call(_c, networkError);
135
+ (_g = (_f = this.options).onError) === null || _g === void 0 ? void 0 : _g.call(_f, networkError);
205
136
  }
206
137
  });
207
- // Combine the links
208
- const link = client_1.ApolloLink.from([errorLink, authLink, httpLink]);
209
- // Create the Apollo Client
210
- return new client_1.ApolloClient({
211
- link,
212
- cache: new client_1.InMemoryCache(),
213
- defaultOptions: {
214
- watchQuery: {
215
- fetchPolicy: 'network-only',
216
- errorPolicy: 'all',
217
- },
218
- query: {
219
- fetchPolicy: 'network-only',
220
- errorPolicy: 'all',
221
- },
222
- mutate: {
223
- errorPolicy: 'all',
224
- },
225
- },
138
+ }
139
+ /**
140
+ * Retry a failed operation after refreshing the token.
141
+ * Uses a queue to ensure only ONE refresh HTTP call is made, even when
142
+ * multiple operations fail simultaneously (e.g. expired JWT).
143
+ * The first caller triggers the refresh; subsequent callers queue up
144
+ * and are retried (or errored) when the single refresh completes.
145
+ */
146
+ retryWithRefresh(operation, forward) {
147
+ return new client_1.Observable((observer) => {
148
+ this.pendingRefreshQueue.push({ operation, forward, observer });
149
+ // If a refresh is already in-flight, this operation is queued and
150
+ // will be retried when that refresh completes. Don't trigger another.
151
+ if (this.isRefreshingLink)
152
+ return;
153
+ this.isRefreshingLink = true;
154
+ const refreshFn = this.options.refreshToken
155
+ ? this.options.refreshToken
156
+ : () => this.tokenManager.refresh();
157
+ refreshFn()
158
+ .then((newToken) => {
159
+ var _a, _b, _c, _d;
160
+ if (!newToken) {
161
+ this.drainQueue(null, new Error('Token refresh failed'));
162
+ (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
163
+ return;
164
+ }
165
+ this.options.token = newToken;
166
+ this.tokenManager.setToken(newToken);
167
+ // Legacy refreshToken path bypasses TokenManager._doRefresh() which
168
+ // normally fires onTokenRefreshed. Call it here so consumers (e.g.
169
+ // cf-web persisting to localStorage) still get notified.
170
+ if (this.options.refreshToken) {
171
+ (_d = (_c = this.options).onTokenRefreshed) === null || _d === void 0 ? void 0 : _d.call(_c, newToken);
172
+ }
173
+ this.drainQueue(newToken, null);
174
+ })
175
+ .catch((error) => {
176
+ var _a, _b;
177
+ this.drainQueue(null, error);
178
+ (_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
179
+ })
180
+ .finally(() => {
181
+ this.isRefreshingLink = false;
182
+ });
183
+ });
184
+ }
185
+ /**
186
+ * Drain the pending refresh queue: retry all queued operations with
187
+ * the new token, or error them all if refresh failed.
188
+ */
189
+ drainQueue(newToken, error) {
190
+ const queue = [...this.pendingRefreshQueue];
191
+ this.pendingRefreshQueue = [];
192
+ if (error || !newToken) {
193
+ queue.forEach(({ observer: obs }) => {
194
+ obs.error(error !== null && error !== void 0 ? error : new Error('Token refresh failed'));
195
+ });
196
+ return;
197
+ }
198
+ queue.forEach(({ operation: op, forward: fwd, observer: obs }) => {
199
+ op.setContext(({ headers = {} }) => ({
200
+ headers: { ...headers, authorization: `JWT ${newToken}` },
201
+ }));
202
+ fwd(op).subscribe(obs);
226
203
  });
227
204
  }
228
- // Method to update the token
205
+ /**
206
+ * Update the JWT token. Does NOT recreate the Apollo client -
207
+ * the auth link reads the token dynamically.
208
+ */
229
209
  setToken(token) {
230
210
  this.options.token = token;
231
- // Recreate the client with the new token
232
- this.apolloClient = this.createApolloClient();
211
+ this.tokenManager.setToken(token);
233
212
  }
234
- // Get the Apollo client instance
235
213
  getClient() {
236
214
  return this.apolloClient;
237
215
  }
238
- // Get the base URL
239
216
  getBaseUrl() {
240
217
  return this.options.baseUrl;
241
218
  }
242
- // Refresh token - attempt to get a new token
219
+ /** @deprecated Use tokenManager.refresh() instead */
243
220
  async refreshToken() {
244
- if (this.options.refreshToken) {
245
- try {
246
- const newToken = await this.options.refreshToken();
247
- if (newToken) {
248
- this.setToken(newToken);
249
- return true;
250
- }
251
- }
252
- catch (error) {
253
- console.error('Failed to refresh token:', error);
254
- }
221
+ const newToken = await this.tokenManager.refresh();
222
+ if (newToken) {
223
+ this.setToken(newToken);
224
+ return true;
255
225
  }
256
226
  return false;
257
227
  }
258
- // Logout - clear the token
259
- logout() {
228
+ /**
229
+ * Logout: revoke refresh token server-side, clear Apollo cache, reset state.
230
+ */
231
+ async logout() {
232
+ await this.tokenManager.logout();
260
233
  this.options.token = undefined;
261
- this.apolloClient = this.createApolloClient();
262
- this.apolloClient.resetStore();
234
+ try {
235
+ await this.apolloClient.resetStore();
236
+ }
237
+ catch (_a) {
238
+ // resetStore can throw if there are active queries
239
+ }
263
240
  }
264
241
  }
265
242
  exports.CloudForgeClient = CloudForgeClient;
@@ -2462,7 +2462,6 @@ export type Login = {
2462
2462
  id?: Maybe<Scalars['ID']['output']>;
2463
2463
  isAdmin?: Maybe<Scalars['Boolean']['output']>;
2464
2464
  payload?: Maybe<Scalars['JSONString']['output']>;
2465
- refreshToken?: Maybe<Scalars['String']['output']>;
2466
2465
  token?: Maybe<Scalars['String']['output']>;
2467
2466
  };
2468
2467
  /** LoginWithGoogle - Login with Google */
@@ -10493,7 +10492,6 @@ export type LoginMutation = {
10493
10492
  isAdmin?: boolean | null;
10494
10493
  accountId?: string | null;
10495
10494
  payload?: any | null;
10496
- refreshToken?: string | null;
10497
10495
  account?: {
10498
10496
  __typename?: 'AccountType';
10499
10497
  id?: string | null;
@@ -6460,7 +6460,6 @@ exports.LoginDocument = (0, client_1.gql) `
6460
6460
  isAdmin
6461
6461
  accountId
6462
6462
  payload
6463
- refreshToken
6464
6463
  }
6465
6464
  }
6466
6465
  `;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { CloudForgeSDK } from "./sdk";
2
+ export { TokenManager } from "./tokenManager";
2
3
  export type { CloudForgeClientOptions } from "./client";
4
+ export type { TokenManagerOptions } from "./tokenManager";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CloudForgeSDK = void 0;
3
+ exports.TokenManager = exports.CloudForgeSDK = void 0;
4
4
  var sdk_1 = require("./sdk");
5
5
  Object.defineProperty(exports, "CloudForgeSDK", { enumerable: true, get: function () { return sdk_1.CloudForgeSDK; } });
6
+ var tokenManager_1 = require("./tokenManager");
7
+ Object.defineProperty(exports, "TokenManager", { enumerable: true, get: function () { return tokenManager_1.TokenManager; } });
package/dist/mutations.js CHANGED
@@ -6952,7 +6952,6 @@ mutation Login($email: String!, $password: String!) {
6952
6952
  isAdmin
6953
6953
  accountId
6954
6954
  payload
6955
- refreshToken
6956
6955
  }
6957
6956
  }`;
6958
6957
  exports.UPDATE_USER_PROFILE = (0, client_1.gql) `
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Central token lifecycle manager for CloudForge authentication.
3
+ *
4
+ * Handles:
5
+ * - Calling /api/auth/refresh/ with HttpOnly cookie credentials
6
+ * - Promise deduplication (concurrent 401s trigger only one refresh)
7
+ * - BroadcastChannel multi-tab synchronization
8
+ * - authFetch() wrapper for non-GraphQL requests
9
+ * - REST logout endpoint
10
+ */
11
+ export interface TokenManagerOptions {
12
+ /** GraphQL endpoint URL (e.g. https://api.cloudforgesoftware.com/api/graph/) */
13
+ baseUrl: string;
14
+ /** Called when a new access token is obtained via refresh or tab sync */
15
+ onTokenRefreshed?: (token: string) => void;
16
+ /** Called when auth is irrecoverable (refresh failed, reuse detected, etc.) */
17
+ onSessionExpired?: () => void | Promise<void>;
18
+ }
19
+ export declare class TokenManager {
20
+ private refreshPromise;
21
+ private channel;
22
+ private token;
23
+ private refreshUrl;
24
+ private logoutUrl;
25
+ private options;
26
+ /** Resolve handle to short-circuit an in-flight refresh when another tab broadcasts a token */
27
+ private _broadcastResolve;
28
+ /** AbortController to cancel an in-flight refresh fetch when another tab already refreshed */
29
+ private _refreshAbort;
30
+ constructor(options: TokenManagerOptions);
31
+ getToken(): string | null;
32
+ setToken(token: string): void;
33
+ /**
34
+ * Refresh the access token using the HttpOnly refresh cookie.
35
+ * Deduplicates concurrent calls - only one refresh request is ever in flight.
36
+ */
37
+ refresh(): Promise<string | null>;
38
+ private _doRefresh;
39
+ /**
40
+ * Revoke the refresh token server-side and clear local state.
41
+ */
42
+ logout(): Promise<void>;
43
+ /**
44
+ * Authenticated fetch with automatic token refresh on 401.
45
+ * Drop-in replacement for raw fetch() calls that need JWT auth.
46
+ */
47
+ authFetch(url: string, options?: RequestInit): Promise<Response>;
48
+ destroy(): void;
49
+ }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ /**
3
+ * Central token lifecycle manager for CloudForge authentication.
4
+ *
5
+ * Handles:
6
+ * - Calling /api/auth/refresh/ with HttpOnly cookie credentials
7
+ * - Promise deduplication (concurrent 401s trigger only one refresh)
8
+ * - BroadcastChannel multi-tab synchronization
9
+ * - authFetch() wrapper for non-GraphQL requests
10
+ * - REST logout endpoint
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.TokenManager = void 0;
14
+ class TokenManager {
15
+ constructor(options) {
16
+ this.refreshPromise = null;
17
+ this.channel = null;
18
+ this.token = null;
19
+ /** Resolve handle to short-circuit an in-flight refresh when another tab broadcasts a token */
20
+ this._broadcastResolve = null;
21
+ /** AbortController to cancel an in-flight refresh fetch when another tab already refreshed */
22
+ this._refreshAbort = null;
23
+ this.options = options;
24
+ // Derive auth endpoints from GraphQL base URL
25
+ const apiBase = options.baseUrl.replace(/\/api\/graph\/?$/, '');
26
+ this.refreshUrl = `${apiBase}/api/auth/refresh/`;
27
+ this.logoutUrl = `${apiBase}/api/auth/logout/`;
28
+ // Multi-tab token synchronization
29
+ if (typeof BroadcastChannel !== 'undefined') {
30
+ try {
31
+ this.channel = new BroadcastChannel('cf_auth');
32
+ this.channel.onmessage = (event) => {
33
+ var _a, _b, _c, _d, _e, _f, _g, _h;
34
+ if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) === 'TOKEN_REFRESHED' && event.data.token) {
35
+ this.token = event.data.token;
36
+ // Short-circuit any in-flight refresh: abort the HTTP request
37
+ // and resolve the racing promise so we use the broadcast token
38
+ (_b = this._refreshAbort) === null || _b === void 0 ? void 0 : _b.abort();
39
+ (_c = this._broadcastResolve) === null || _c === void 0 ? void 0 : _c.call(this, event.data.token);
40
+ (_e = (_d = this.options).onTokenRefreshed) === null || _e === void 0 ? void 0 : _e.call(_d, event.data.token);
41
+ }
42
+ else if (((_f = event.data) === null || _f === void 0 ? void 0 : _f.type) === 'LOGOUT') {
43
+ this.token = null;
44
+ (_h = (_g = this.options).onSessionExpired) === null || _h === void 0 ? void 0 : _h.call(_g);
45
+ }
46
+ };
47
+ }
48
+ catch (_a) {
49
+ // BroadcastChannel may throw in some environments (e.g. SSR)
50
+ }
51
+ }
52
+ }
53
+ getToken() {
54
+ return this.token;
55
+ }
56
+ setToken(token) {
57
+ this.token = token;
58
+ }
59
+ /**
60
+ * Refresh the access token using the HttpOnly refresh cookie.
61
+ * Deduplicates concurrent calls - only one refresh request is ever in flight.
62
+ */
63
+ async refresh() {
64
+ if (this.refreshPromise)
65
+ return this.refreshPromise;
66
+ // Race the actual HTTP refresh against a broadcast from another tab.
67
+ // If another tab refreshes first, its broadcast resolves the race
68
+ // immediately and aborts our in-flight fetch.
69
+ const broadcastPromise = new Promise((resolve) => {
70
+ this._broadcastResolve = resolve;
71
+ });
72
+ this.refreshPromise = Promise.race([
73
+ this._doRefresh(),
74
+ broadcastPromise,
75
+ ]);
76
+ try {
77
+ return await this.refreshPromise;
78
+ }
79
+ finally {
80
+ this.refreshPromise = null;
81
+ this._broadcastResolve = null;
82
+ }
83
+ }
84
+ async _doRefresh() {
85
+ var _a, _b, _c;
86
+ this._refreshAbort = new AbortController();
87
+ try {
88
+ const response = await fetch(this.refreshUrl, {
89
+ method: 'POST',
90
+ credentials: 'include',
91
+ signal: this._refreshAbort.signal,
92
+ });
93
+ if (!response.ok) {
94
+ console.error('[CloudForgeSDK] Token refresh failed:', response.status);
95
+ return null;
96
+ }
97
+ const data = await response.json();
98
+ const newToken = data.token;
99
+ if (!newToken || typeof newToken !== 'string') {
100
+ console.error('[CloudForgeSDK] Invalid token in refresh response');
101
+ return null;
102
+ }
103
+ this.token = newToken;
104
+ (_a = this.channel) === null || _a === void 0 ? void 0 : _a.postMessage({ type: 'TOKEN_REFRESHED', token: newToken });
105
+ (_c = (_b = this.options).onTokenRefreshed) === null || _c === void 0 ? void 0 : _c.call(_b, newToken);
106
+ return newToken;
107
+ }
108
+ catch (error) {
109
+ // Aborted because another tab already refreshed - not an error
110
+ if (error instanceof DOMException && error.name === 'AbortError') {
111
+ return this.token;
112
+ }
113
+ console.error('[CloudForgeSDK] Refresh error:', error);
114
+ return null;
115
+ }
116
+ finally {
117
+ this._refreshAbort = null;
118
+ }
119
+ }
120
+ /**
121
+ * Revoke the refresh token server-side and clear local state.
122
+ */
123
+ async logout() {
124
+ var _a, _b;
125
+ // Cancel any in-flight refresh so it can't re-set the token after logout
126
+ (_a = this._refreshAbort) === null || _a === void 0 ? void 0 : _a.abort();
127
+ this._refreshAbort = null;
128
+ this.refreshPromise = null;
129
+ this._broadcastResolve = null;
130
+ try {
131
+ await fetch(this.logoutUrl, {
132
+ method: 'POST',
133
+ credentials: 'include',
134
+ });
135
+ }
136
+ catch (_c) {
137
+ // Best-effort - clear local state regardless
138
+ }
139
+ this.token = null;
140
+ (_b = this.channel) === null || _b === void 0 ? void 0 : _b.postMessage({ type: 'LOGOUT' });
141
+ }
142
+ /**
143
+ * Authenticated fetch with automatic token refresh on 401.
144
+ * Drop-in replacement for raw fetch() calls that need JWT auth.
145
+ */
146
+ async authFetch(url, options = {}) {
147
+ var _a, _b;
148
+ const headers = new Headers(options.headers);
149
+ if (this.token) {
150
+ headers.set('authorization', `JWT ${this.token}`);
151
+ }
152
+ let response = await fetch(url, { ...options, headers });
153
+ if (response.status === 401) {
154
+ const newToken = await this.refresh();
155
+ if (!newToken) {
156
+ (_b = (_a = this.options).onSessionExpired) === null || _b === void 0 ? void 0 : _b.call(_a);
157
+ throw new Error('Session expired');
158
+ }
159
+ headers.set('authorization', `JWT ${newToken}`);
160
+ response = await fetch(url, { ...options, headers });
161
+ }
162
+ return response;
163
+ }
164
+ destroy() {
165
+ var _a, _b;
166
+ (_a = this._refreshAbort) === null || _a === void 0 ? void 0 : _a.abort();
167
+ this._refreshAbort = null;
168
+ this.refreshPromise = null;
169
+ this._broadcastResolve = null;
170
+ (_b = this.channel) === null || _b === void 0 ? void 0 : _b.close();
171
+ this.channel = null;
172
+ }
173
+ }
174
+ exports.TokenManager = TokenManager;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-service-sdk",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "commonjs",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",