@payloadcms/figma 0.0.1-alpha.63 → 0.0.1-alpha.65

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.
Files changed (48) hide show
  1. package/dist/auth/callback-server.d.ts +19 -7
  2. package/dist/auth/callback-server.js +72 -31
  3. package/dist/auth/crypto-utils.d.ts +11 -0
  4. package/dist/auth/crypto-utils.js +22 -1
  5. package/dist/auth/oauth-flow.d.ts +3 -1
  6. package/dist/auth/oauth-flow.js +14 -6
  7. package/dist/auth/project-token.d.ts +15 -10
  8. package/dist/auth/project-token.js +147 -64
  9. package/dist/auth/token-store-migration.js +2 -2
  10. package/dist/auth/token-store.d.ts +2 -0
  11. package/dist/auth/token-store.js +43 -3
  12. package/dist/auth/types.d.ts +11 -0
  13. package/dist/cli.js +15 -1
  14. package/dist/commands/bootstrap.d.ts +18 -0
  15. package/dist/commands/bootstrap.js +90 -0
  16. package/dist/commands/init.d.ts +4 -0
  17. package/dist/commands/init.js +76 -4
  18. package/dist/config/oauth.d.ts +2 -1
  19. package/dist/config/oauth.js +7 -1
  20. package/dist/db-content-api/generated/content-api-types.d.ts +6 -0
  21. package/dist/db-content-api/index.d.ts +2 -0
  22. package/dist/db-content-api/index.js +36 -74
  23. package/dist/lib/download-skill.d.ts +13 -0
  24. package/dist/lib/download-skill.js +79 -0
  25. package/dist/oauth/endpoints/getLoginEndpoint.js +26 -107
  26. package/dist/oauth/endpoints/getTokenLoginEndpoint.d.ts +17 -0
  27. package/dist/oauth/endpoints/getTokenLoginEndpoint.js +105 -0
  28. package/dist/oauth/index.js +8 -0
  29. package/dist/oauth/utilities/establishSession.d.ts +23 -0
  30. package/dist/oauth/utilities/establishSession.js +82 -0
  31. package/dist/oauth/utilities/exchangeCodeForAccessToken.d.ts +24 -0
  32. package/dist/oauth/utilities/exchangeCodeForAccessToken.js +28 -0
  33. package/dist/oauth/utilities/isAbsoluteURL.d.ts +2 -0
  34. package/dist/oauth/utilities/isAbsoluteURL.js +3 -0
  35. package/dist/plugin/build-config.js +65 -46
  36. package/dist/types.d.ts +2 -0
  37. package/dist/utils/download-template.d.ts +9 -1
  38. package/dist/utils/download-template.js +24 -19
  39. package/dist/utils/messages.js +9 -0
  40. package/dist/utils/parse-template-spec.d.ts +12 -0
  41. package/dist/utils/parse-template-spec.js +62 -0
  42. package/dist/utils/payload-config-modifier.js +96 -107
  43. package/dist/utils/payload-package-check.d.ts +21 -1
  44. package/dist/utils/payload-package-check.js +66 -26
  45. package/dist/utils/project.d.ts +2 -1
  46. package/dist/utils/project.js +2 -2
  47. package/package.json +9 -1
  48. package/dist/db-content-api/README.md +0 -98
@@ -7,6 +7,14 @@ export interface CallbackResult {
7
7
  /** State parameter for CSRF verification */
8
8
  state: string;
9
9
  }
10
+ export type CallbackServerOptions = {
11
+ /** Additional callback ports to try after port. */
12
+ fallbackPorts?: number[];
13
+ /** First callback port to try. Defaults to the configured callback port range. */
14
+ port?: number;
15
+ /** Callback timeout in milliseconds. */
16
+ timeoutMs?: number;
17
+ };
10
18
  /**
11
19
  * Error from OAuth callback
12
20
  */
@@ -20,18 +28,18 @@ export declare class CallbackError extends Error {
20
28
  * Starts a temporary server on localhost to receive the OAuth authorization code.
21
29
  * The server automatically shuts down after receiving the callback or on timeout.
22
30
  *
23
- * Implements dynamic port allocation: tries preferred port first, falls back to
24
- * OS-assigned port if unavailable.
31
+ * Tries the registered localhost callback ports in order. It does not use an
32
+ * OS-assigned random port because OAuth redirect URIs must be registered.
25
33
  */
26
34
  export declare class CallbackServer {
27
35
  private actualPort?;
28
- private preferredPort;
36
+ private callbackPorts;
37
+ private readyPromise;
38
+ private rejectReady?;
39
+ private resolveReady?;
29
40
  private server;
30
41
  private timeoutMs;
31
- constructor(options?: {
32
- port?: number;
33
- timeoutMs?: number;
34
- });
42
+ constructor(options?: CallbackServerOptions);
35
43
  /**
36
44
  * Render error page shown in browser when auth fails
37
45
  */
@@ -57,5 +65,9 @@ export declare class CallbackServer {
57
65
  * @throws CallbackError if callback fails or times out
58
66
  */
59
67
  waitForCallback(expectedState: string): Promise<CallbackResult>;
68
+ /**
69
+ * Wait until the callback server is listening.
70
+ */
71
+ waitUntilReady(): Promise<number>;
60
72
  }
61
73
  //# sourceMappingURL=callback-server.d.ts.map
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { createServer } from 'http';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
- import { DEFAULT_CALLBACK_PORT } from '../config/oauth.js';
5
+ import { DEFAULT_CALLBACK_PORT, DEFAULT_CALLBACK_PORTS } from '../config/oauth.js';
6
6
  const filename = fileURLToPath(import.meta.url);
7
7
  const dirname = path.dirname(filename);
8
8
  /**
@@ -20,17 +20,19 @@ const dirname = path.dirname(filename);
20
20
  * Starts a temporary server on localhost to receive the OAuth authorization code.
21
21
  * The server automatically shuts down after receiving the callback or on timeout.
22
22
  *
23
- * Implements dynamic port allocation: tries preferred port first, falls back to
24
- * OS-assigned port if unavailable.
23
+ * Tries the registered localhost callback ports in order. It does not use an
24
+ * OS-assigned random port because OAuth redirect URIs must be registered.
25
25
  */ export class CallbackServer {
26
26
  actualPort;
27
- preferredPort;
27
+ callbackPorts;
28
+ readyPromise = null;
29
+ rejectReady;
30
+ resolveReady;
28
31
  server = null;
29
32
  timeoutMs;
30
33
  constructor(options = {}){
31
- this.preferredPort = options.port || DEFAULT_CALLBACK_PORT;
34
+ this.callbackPorts = resolveCallbackPorts(options);
32
35
  this.timeoutMs = options.timeoutMs || 120000; // 2 minutes default
33
- this.server = createServer();
34
36
  }
35
37
  /**
36
38
  * Render error page shown in browser when auth fails
@@ -47,7 +49,7 @@ const dirname = path.dirname(filename);
47
49
  * Get the actual port the server is listening on
48
50
  * @returns Port number (may differ from preferred if that was in use)
49
51
  */ getActualPort() {
50
- return this.actualPort || this.preferredPort;
52
+ return this.actualPort || this.callbackPorts[0];
51
53
  }
52
54
  /**
53
55
  * Stop the server if running
@@ -65,6 +67,17 @@ const dirname = path.dirname(filename);
65
67
  * @throws CallbackError if callback fails or times out
66
68
  */ async waitForCallback(expectedState) {
67
69
  return new Promise((resolve, reject)=>{
70
+ if (this.server) {
71
+ reject(new CallbackError('Callback server is already running', 'server_error'));
72
+ return;
73
+ }
74
+ this.readyPromise = new Promise((resolveReady, rejectReady)=>{
75
+ this.resolveReady = resolveReady;
76
+ this.rejectReady = rejectReady;
77
+ });
78
+ this.readyPromise.catch(()=>{
79
+ // The caller may only await waitForCallback(); avoid an unhandled rejection.
80
+ });
68
81
  const cleanup = (handle)=>{
69
82
  clearTimeout(handle);
70
83
  if (this.server) {
@@ -77,8 +90,7 @@ const dirname = path.dirname(filename);
77
90
  cleanup(timeoutHandle);
78
91
  reject(new CallbackError('OAuth authorization timed out. Please try again.', 'timeout'));
79
92
  }, this.timeoutMs);
80
- // Create HTTP server
81
- this.server = createServer((req, res)=>{
93
+ const handleRequest = (req, res)=>{
82
94
  const url = new URL(req.url || '', `http://localhost:${this.getActualPort()}`);
83
95
  // Only handle /callback path
84
96
  if (url.pathname !== '/callback') {
@@ -134,36 +146,65 @@ const dirname = path.dirname(filename);
134
146
  code,
135
147
  state
136
148
  });
137
- });
138
- // Try to start server with dynamic port allocation
139
- const tryListen = (port, isRetry = false)=>{
140
- if (!this.server) {
141
- reject(new CallbackError('Server instance is not available', 'server_error'));
142
- return;
143
- }
144
- this.server.listen(port, '127.0.0.1', ()=>{
149
+ };
150
+ // Try registered callback ports in order.
151
+ const tryListen = (portIndex)=>{
152
+ const port = this.callbackPorts[portIndex];
153
+ const server = createServer(handleRequest);
154
+ this.server = server;
155
+ server.once('error', (err)=>{
156
+ if (this.server === server) {
157
+ this.server = null;
158
+ }
159
+ if (err.code === 'EADDRINUSE' && portIndex < this.callbackPorts.length - 1) {
160
+ tryListen(portIndex + 1);
161
+ return;
162
+ }
163
+ cleanup(timeoutHandle);
164
+ const callbackError = err.code === 'EADDRINUSE' ? new CallbackError(`OAuth callback ports are all in use: ${this.callbackPorts.join(', ')}. Close the process using one of these ports and run login again.`, 'ports_in_use') : new CallbackError(`Failed to start callback server: ${err.message}`, 'server_error');
165
+ this.rejectReady?.(callbackError);
166
+ reject(callbackError);
167
+ });
168
+ server.listen(port, '127.0.0.1', ()=>{
169
+ if (this.server !== server) {
170
+ return;
171
+ }
145
172
  // Capture the actual port that was assigned
146
- const addr = this.server.address();
173
+ const addr = server.address();
147
174
  if (addr && typeof addr === 'object') {
148
175
  this.actualPort = addr.port;
149
- }
150
- });
151
- // Handle server errors (port in use, etc)
152
- this.server.once('error', (err)=>{
153
- if (err.code === 'EADDRINUSE' && !isRetry) {
154
- // Port in use, try with OS-assigned port (0 = dynamic)
155
- this.server.removeAllListeners();
156
- tryListen(0, true);
157
- } else {
158
- cleanup(timeoutHandle);
159
- reject(new CallbackError(`Failed to start callback server: ${err.message}${!isRetry ? ' (tried fallback port)' : ''}`));
176
+ this.resolveReady?.(addr.port);
160
177
  }
161
178
  });
162
179
  };
163
- // Start with preferred port
164
- tryListen(this.preferredPort);
180
+ // Start with first configured port
181
+ tryListen(0);
165
182
  });
166
183
  }
184
+ /**
185
+ * Wait until the callback server is listening.
186
+ */ waitUntilReady() {
187
+ if (!this.readyPromise) {
188
+ return Promise.reject(new CallbackError('Callback server has not started', 'server_error'));
189
+ }
190
+ return this.readyPromise;
191
+ }
192
+ }
193
+ function resolveCallbackPorts(options) {
194
+ if (!options.port && !options.fallbackPorts) {
195
+ return DEFAULT_CALLBACK_PORTS;
196
+ }
197
+ const primaryPort = options.port || DEFAULT_CALLBACK_PORT;
198
+ const ports = uniquePorts([
199
+ primaryPort,
200
+ ...options.fallbackPorts || []
201
+ ]).slice(0, DEFAULT_CALLBACK_PORTS.length);
202
+ return ports.length > 0 ? ports : DEFAULT_CALLBACK_PORTS;
203
+ }
204
+ function uniquePorts(ports) {
205
+ return [
206
+ ...new Set(ports)
207
+ ];
167
208
  }
168
209
 
169
210
  //# sourceMappingURL=callback-server.js.map
@@ -30,6 +30,17 @@
30
30
  * @throws Error if unable to derive key due to missing machine information
31
31
  */
32
32
  export declare function deriveEncryptionKey(): string;
33
+ /**
34
+ * @deprecated Exists solely to read token stores written by older versions of
35
+ * this CLI that mixed `os.hostname()` into the encryption-key derivation. macOS
36
+ * rewrites the kernel hostname across DHCP/VPN/sleep events, which made those
37
+ * stores periodically un-decryptable. Used only by the transparent-migration
38
+ * path in `token-store.ts` and should be removed in a future release once
39
+ * upgraded clients are sufficiently common.
40
+ *
41
+ * @returns 64-character hex string (32-byte key) suitable for AES-256
42
+ */
43
+ export declare function deriveLegacyEncryptionKey(): string;
33
44
  /**
34
45
  * Get a short hash of the encryption key for diagnostics.
35
46
  * Returns first 8 chars of SHA-256 hash — enough to compare across invocations
@@ -36,7 +36,6 @@ import path from 'path';
36
36
  */ function gatherMachineEntropy() {
37
37
  try {
38
38
  const entropy = [
39
- os.hostname(),
40
39
  os.homedir(),
41
40
  os.userInfo().username,
42
41
  os.platform(),
@@ -88,6 +87,28 @@ import path from 'path';
88
87
  const key = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256');
89
88
  return key.toString('hex');
90
89
  }
90
+ /**
91
+ * @deprecated Exists solely to read token stores written by older versions of
92
+ * this CLI that mixed `os.hostname()` into the encryption-key derivation. macOS
93
+ * rewrites the kernel hostname across DHCP/VPN/sleep events, which made those
94
+ * stores periodically un-decryptable. Used only by the transparent-migration
95
+ * path in `token-store.ts` and should be removed in a future release once
96
+ * upgraded clients are sufficiently common.
97
+ *
98
+ * @returns 64-character hex string (32-byte key) suitable for AES-256
99
+ */ export function deriveLegacyEncryptionKey() {
100
+ const entropy = [
101
+ os.hostname(),
102
+ os.homedir(),
103
+ os.userInfo().username,
104
+ os.platform(),
105
+ os.arch()
106
+ ];
107
+ const machineId = entropy.join('::');
108
+ const salt = getOrCreateSalt();
109
+ const key = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256');
110
+ return key.toString('hex');
111
+ }
91
112
  /**
92
113
  * Get a short hash of the encryption key for diagnostics.
93
114
  * Returns first 8 chars of SHA-256 hash — enough to compare across invocations
@@ -14,7 +14,9 @@ export declare class OAuthFlowError extends Error {
14
14
  export interface OAuthFlowOptions {
15
15
  /** OAuth2 client ID (defaults to config) */
16
16
  clientId?: string;
17
- /** Callback server port (defaults to DEFAULT_CALLBACK_PORT) */
17
+ /** Callback server fallback ports to try after port */
18
+ fallbackPorts?: number[];
19
+ /** First callback server port to try */
18
20
  port?: number;
19
21
  /** Redirect URI (defaults to config) */
20
22
  redirectUri?: string;
@@ -1,6 +1,6 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import crypto from 'crypto';
3
- import { DEFAULT_CALLBACK_PORT, getOAuthConfig } from '../config/oauth.js';
3
+ import { getOAuthConfig } from '../config/oauth.js';
4
4
  import * as log from '../utils/log.js';
5
5
  import { buildAuthorizationUrl, openBrowser } from './browser.js';
6
6
  import { CallbackServer } from './callback-server.js';
@@ -53,25 +53,27 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
53
53
  };
54
54
  }
55
55
  // REAL OAUTH IMPLEMENTATION
56
- const { clientId = oauthConfig.clientId, port = DEFAULT_CALLBACK_PORT, redirectUri = oauthConfig.redirectUri, scopes = oauthConfig.scopes, timeoutMs = 120000 } = options;
56
+ const { clientId = oauthConfig.clientId, fallbackPorts, port, redirectUri = oauthConfig.redirectUri, scopes = oauthConfig.scopes, timeoutMs = 120000 } = options;
57
57
  // Generate PKCE pair for enhanced security
58
58
  const { codeChallenge, codeChallengeMethod, codeVerifier } = generatePKCEPair();
59
59
  // Generate random state for CSRF protection
60
60
  const state = crypto.randomBytes(32).toString('base64url');
61
61
  // Create callback server
62
62
  const callbackServer = new CallbackServer({
63
+ fallbackPorts,
63
64
  port,
64
65
  timeoutMs
65
66
  });
66
67
  try {
67
68
  // Start waiting for callback (this starts the server and captures actual port)
68
69
  const callbackPromise = callbackServer.waitForCallback(state);
69
- // Wait a bit for server to start and determine actual port
70
- await new Promise((resolve)=>setTimeout(resolve, 100));
70
+ callbackPromise.catch(()=>{
71
+ // The callback promise is awaited below; avoid an unhandled rejection if startup fails early.
72
+ });
71
73
  // Get the actual port (may differ from preferred if that was in use)
72
- const actualPort = callbackServer.getActualPort();
74
+ const actualPort = await callbackServer.waitUntilReady();
73
75
  // Build redirect URI with actual port
74
- const actualRedirectUri = redirectUri.replace(/:\d+/, `:${actualPort}`);
76
+ const actualRedirectUri = replaceRedirectUriPort(redirectUri, actualPort);
75
77
  // Build authorization URL
76
78
  const authUrl = buildAuthorizationUrl({
77
79
  authorizationUrl: oauthConfig.authorizationUrl,
@@ -195,6 +197,7 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
195
197
  */ export async function getValidCredential(tokenStore) {
196
198
  const envFigmaAccessToken = process.env.FIGMA_ACCESS_TOKEN;
197
199
  if (envFigmaAccessToken) {
200
+ log.debug('Using FIGMA_ACCESS_TOKEN env var override; skipping stored OAuth tokens');
198
201
  return {
199
202
  type: 'plan_access_token',
200
203
  token: envFigmaAccessToken
@@ -212,5 +215,10 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
212
215
  return null;
213
216
  }
214
217
  }
218
+ function replaceRedirectUriPort(redirectUri, port) {
219
+ const url = new URL(redirectUri);
220
+ url.port = String(port);
221
+ return url.toString();
222
+ }
215
223
 
216
224
  //# sourceMappingURL=oauth-flow.js.map
@@ -14,15 +14,18 @@ export declare class ProjectTokenError extends Error {
14
14
  cause?: Error | undefined;
15
15
  constructor(message: string, cause?: Error | undefined);
16
16
  }
17
+ /** Test-only: clears the in-memory token cache and any in-flight refresh promises. */
18
+ export declare function __resetProjectTokenCacheForTests(): void;
17
19
  /**
18
- * Get a valid project token for a tenant, refreshing if necessary
20
+ * Get a valid project token for a tenant, refreshing if necessary.
19
21
  *
20
- * This function:
21
- * 1. Checks if a stored project token exists and is valid (not expired)
22
- * 2. If expired or missing, gets a valid user access token
23
- * 3. Calls the Figma API to generate a new project token
24
- * 4. Stores the new project token in encrypted storage
25
- * 5. Returns the JWT string
22
+ * Lookup order:
23
+ * 1. Module-level in-memory cache (no I/O on hits).
24
+ * 2. In-flight refresh map concurrent callers with no cached entry share a single
25
+ * refresh promise, so the project-token endpoint is only hit once per cache key.
26
+ * 3. Persisted (`conf`-backed) token store picks up tokens minted by sibling processes.
27
+ * 4. Figma API — fetch + JWT validation, then write to both the persisted store and
28
+ * the in-memory cache.
26
29
  *
27
30
  * The project token is used by local Payload instances to authenticate
28
31
  * requests to the Content API without requiring a round-trip to Sinatra.
@@ -39,10 +42,12 @@ export declare function getValidProjectToken({ projectInfo, tenantId, tokenStore
39
42
  tokenStore: TokenStore;
40
43
  }): Promise<null | string>;
41
44
  /**
42
- * Refresh a project token for a tenant
45
+ * Force-refresh a project token, bypassing all cache layers.
43
46
  *
44
- * Forces a refresh of the project token even if the current one is still valid.
45
- * Useful for testing or when you want to ensure you have the freshest token.
47
+ * Invalidates the in-memory cache entry, the in-flight refresh promise (so a
48
+ * concurrently-running refresh that may already be returning a soon-to-be-stale
49
+ * token is dropped from the dedup map), and the persisted token store. Then
50
+ * delegates to {@link getValidProjectToken} to mint a fresh token.
46
51
  *
47
52
  * @param params.tokenStore - Token store for persisting tokens
48
53
  * @param params.tenantId - Optional tenant/CMS ID; resolved from stored bootstrap data when omitted
@@ -5,6 +5,7 @@
5
5
  * to the Content API. Project tokens are scoped to a specific tenant/CMS
6
6
  * and have a shorter lifespan (15-30 minutes) than OAuth tokens.
7
7
  */ import { getProjectToken as fetchProjectToken } from '../api/figma-api.js';
8
+ import { TOKEN_EXPIRY_BUFFER_SECONDS } from '../config/oauth.js';
8
9
  import { getInfraEnvironment } from '../constants.js';
9
10
  import * as log from '../utils/log.js';
10
11
  import { JWTValidationError, validateProjectToken } from './jwt-validator.js';
@@ -18,6 +19,21 @@ import { getValidCredential } from './oauth-flow.js';
18
19
  this.name = 'ProjectTokenError';
19
20
  }
20
21
  }
22
+ const projectTokenCache = new Map();
23
+ const inFlightRefreshes = new Map();
24
+ function buildCacheKey(params) {
25
+ const projectId = params.projectInfo?.projectId ?? '';
26
+ const environmentName = params.projectInfo?.environmentName ?? '';
27
+ return `${params.tenantId}::${projectId}::${environmentName}`;
28
+ }
29
+ function isCachedTokenValid(entry) {
30
+ const bufferMs = TOKEN_EXPIRY_BUFFER_SECONDS * 1000;
31
+ return Date.now() < entry.expiresAt - bufferMs;
32
+ }
33
+ /** Test-only: clears the in-memory token cache and any in-flight refresh promises. */ export function __resetProjectTokenCacheForTests() {
34
+ projectTokenCache.clear();
35
+ inFlightRefreshes.clear();
36
+ }
21
37
  /**
22
38
  * Parse JWT claims from token without validation
23
39
  * Used for mock tokens where signature validation is not needed
@@ -43,14 +59,15 @@ import { getValidCredential } from './oauth-flow.js';
43
59
  }
44
60
  }
45
61
  /**
46
- * Get a valid project token for a tenant, refreshing if necessary
62
+ * Get a valid project token for a tenant, refreshing if necessary.
47
63
  *
48
- * This function:
49
- * 1. Checks if a stored project token exists and is valid (not expired)
50
- * 2. If expired or missing, gets a valid user access token
51
- * 3. Calls the Figma API to generate a new project token
52
- * 4. Stores the new project token in encrypted storage
53
- * 5. Returns the JWT string
64
+ * Lookup order:
65
+ * 1. Module-level in-memory cache (no I/O on hits).
66
+ * 2. In-flight refresh map concurrent callers with no cached entry share a single
67
+ * refresh promise, so the project-token endpoint is only hit once per cache key.
68
+ * 3. Persisted (`conf`-backed) token store picks up tokens minted by sibling processes.
69
+ * 4. Figma API — fetch + JWT validation, then write to both the persisted store and
70
+ * the in-memory cache.
54
71
  *
55
72
  * The project token is used by local Payload instances to authenticate
56
73
  * requests to the Content API without requiring a round-trip to Sinatra.
@@ -71,70 +88,125 @@ import { getValidCredential } from './oauth-flow.js';
71
88
  if (!resolvedTenantId) {
72
89
  throw new ProjectTokenError('tenantId was not provided and could not be resolved from bootstrap data');
73
90
  }
74
- // Check for existing valid project token
75
- if (tokenStore.hasValidProjectToken({
76
- projectInfo
77
- })) {
78
- const projectToken = tokenStore.getProjectToken({
79
- projectInfo
80
- });
81
- return projectToken?.token || null;
82
- }
83
- // We need to refresh the project token
84
- // An oauth access token is required for the call
85
- let credential;
86
- try {
87
- credential = await getValidCredential(tokenStore);
88
- } catch (error) {
89
- throw new ProjectTokenError('Failed to get valid access token for project token refresh', error instanceof Error ? error : undefined);
91
+ const cacheKey = buildCacheKey({
92
+ projectInfo,
93
+ tenantId: resolvedTenantId
94
+ });
95
+ const cached = projectTokenCache.get(cacheKey);
96
+ if (cached && isCachedTokenValid(cached)) {
97
+ return cached.token;
90
98
  }
91
- if (!credential) {
92
- // No valid user tokens available - user needs to authenticate
93
- return null;
99
+ const inFlight = inFlightRefreshes.get(cacheKey);
100
+ if (inFlight) {
101
+ return inFlight;
94
102
  }
95
- // Fetch a new project token from the Figma API
96
- const fetchedToken = await fetchProjectToken(credential, resolvedTenantId);
97
- // Validate the JWT signature and claims (skip for mocks)
98
- const hasEnvToken = !!process.env.FIGMA_MOCK_PROJECT_TOKEN_VALUE;
99
- const shouldMock = process.env.FIGMA_MOCK_PROJECT_TOKEN !== 'false';
100
- let validatedClaims;
101
- if (hasEnvToken || shouldMock) {
102
- // MOCK: Skip JWT validation and parse claims directly
103
- log.debug('Skipping JWT validation for mock/env project token');
104
- validatedClaims = parseJWTClaims(fetchedToken.token);
105
- } else {
106
- // REAL: Validate JWT signature against JWKS
103
+ // The IIFE references this holder so it can check whether it is still the
104
+ // active refresh before writing back to the caches. Without this guard a
105
+ // stale in-flight refresh could clobber a fresh token written by a later
106
+ // refresh (e.g. one triggered by `refreshProjectToken`).
107
+ const handle = {};
108
+ handle.promise = (async ()=>{
109
+ if (tokenStore.hasValidProjectToken({
110
+ projectInfo
111
+ })) {
112
+ const stored = tokenStore.getProjectToken({
113
+ projectInfo
114
+ });
115
+ if (stored?.token && inFlightRefreshes.get(cacheKey) === handle.promise) {
116
+ projectTokenCache.set(cacheKey, {
117
+ expiresAt: stored.expiresAt,
118
+ token: stored.token
119
+ });
120
+ return stored.token;
121
+ }
122
+ if (stored?.token) {
123
+ return stored.token;
124
+ }
125
+ // hasValidProjectToken said yes but getProjectToken returned no token —
126
+ // store was likely cleared by another process between calls. Fall through
127
+ // to fetch so the caller still gets a token.
128
+ log.warning(`Stored project token vanished between hasValidProjectToken and getProjectToken (cacheKey=${cacheKey}); re-fetching`);
129
+ }
130
+ let credential;
131
+ try {
132
+ credential = await getValidCredential(tokenStore);
133
+ } catch (error) {
134
+ throw new ProjectTokenError('Failed to get valid access token for project token refresh', error instanceof Error ? error : undefined);
135
+ }
136
+ if (!credential) {
137
+ return null;
138
+ }
139
+ let fetchedToken;
107
140
  try {
108
- validatedClaims = await validateProjectToken(fetchedToken.token, getInfraEnvironment());
109
- } catch (validationError) {
110
- // Handle validation errors
111
- if (validationError instanceof JWTValidationError) {
112
- throw new ProjectTokenError(`Project token validation failed: ${validationError.message}`, validationError);
141
+ fetchedToken = await fetchProjectToken(credential, resolvedTenantId);
142
+ } catch (error) {
143
+ // Let API errors (e.g. FigmaApiError) propagate raw so callers can
144
+ // branch on status codes — but log context for debugging.
145
+ log.debug(`fetchProjectToken failed (cacheKey=${cacheKey}, tenantId=${resolvedTenantId}): ${error instanceof Error ? error.message : 'Unknown error'}`);
146
+ throw error;
147
+ }
148
+ const hasEnvToken = !!process.env.FIGMA_MOCK_PROJECT_TOKEN_VALUE;
149
+ const shouldMock = process.env.FIGMA_MOCK_PROJECT_TOKEN !== 'false';
150
+ let validatedClaims;
151
+ if (hasEnvToken || shouldMock) {
152
+ log.debug('Skipping JWT validation for mock/env project token');
153
+ validatedClaims = parseJWTClaims(fetchedToken.token);
154
+ } else {
155
+ try {
156
+ validatedClaims = await validateProjectToken(fetchedToken.token, getInfraEnvironment());
157
+ } catch (validationError) {
158
+ if (validationError instanceof JWTValidationError) {
159
+ throw new ProjectTokenError(`Project token validation failed: ${validationError.message}`, validationError);
160
+ }
161
+ throw validationError;
113
162
  }
114
- throw validationError;
115
163
  }
116
- }
117
- // Ensure exp claim is present
118
- if (!validatedClaims.exp) {
119
- throw new ProjectTokenError('Project token missing expiration (exp) claim');
120
- }
121
- // Store the project token with validated expiration and claims
122
- tokenStore.setProjectToken({
123
- projectInfo,
124
- token: {
125
- claims: validatedClaims,
126
- expiresAt: validatedClaims.exp * 1000,
127
- tenantId: resolvedTenantId,
164
+ if (typeof validatedClaims.exp !== 'number' || !Number.isFinite(validatedClaims.exp)) {
165
+ throw new ProjectTokenError('Project token missing or invalid expiration (exp) claim');
166
+ }
167
+ const expiresAt = validatedClaims.exp * 1000;
168
+ if (inFlightRefreshes.get(cacheKey) !== handle.promise) {
169
+ log.debug(`Project token refresh superseded; not persisting (cacheKey=${cacheKey})`);
170
+ return fetchedToken.token;
171
+ }
172
+ try {
173
+ tokenStore.setProjectToken({
174
+ projectInfo,
175
+ token: {
176
+ claims: validatedClaims,
177
+ expiresAt,
178
+ tenantId: resolvedTenantId,
179
+ token: fetchedToken.token
180
+ }
181
+ });
182
+ } catch (error) {
183
+ // Persistence is best-effort: a failure here (disk full, permissions,
184
+ // corrupt conf entry) shouldn't lose the freshly-minted token. Log and
185
+ // populate the in-memory cache so this process can still use it.
186
+ log.error(`Failed to persist project token to store (cacheKey=${cacheKey}): ${error instanceof Error ? error.message : 'Unknown error'}`);
187
+ }
188
+ projectTokenCache.set(cacheKey, {
189
+ expiresAt,
128
190
  token: fetchedToken.token
191
+ });
192
+ return fetchedToken.token;
193
+ })();
194
+ inFlightRefreshes.set(cacheKey, handle.promise);
195
+ try {
196
+ return await handle.promise;
197
+ } finally{
198
+ if (inFlightRefreshes.get(cacheKey) === handle.promise) {
199
+ inFlightRefreshes.delete(cacheKey);
129
200
  }
130
- });
131
- return fetchedToken.token;
201
+ }
132
202
  }
133
203
  /**
134
- * Refresh a project token for a tenant
204
+ * Force-refresh a project token, bypassing all cache layers.
135
205
  *
136
- * Forces a refresh of the project token even if the current one is still valid.
137
- * Useful for testing or when you want to ensure you have the freshest token.
206
+ * Invalidates the in-memory cache entry, the in-flight refresh promise (so a
207
+ * concurrently-running refresh that may already be returning a soon-to-be-stale
208
+ * token is dropped from the dedup map), and the persisted token store. Then
209
+ * delegates to {@link getValidProjectToken} to mint a fresh token.
138
210
  *
139
211
  * @param params.tokenStore - Token store for persisting tokens
140
212
  * @param params.tenantId - Optional tenant/CMS ID; resolved from stored bootstrap data when omitted
@@ -142,12 +214,23 @@ import { getValidCredential } from './oauth-flow.js';
142
214
  * @returns Promise resolving to JWT token string or null if failed
143
215
  * @throws {ProjectTokenError} If token refresh fails
144
216
  */ export async function refreshProjectToken(params) {
145
- const { projectInfo, tokenStore } = params;
146
- // Clear the existing project token to force a refresh
217
+ const { projectInfo, tenantId, tokenStore } = params;
218
+ const resolvedTenantId = tenantId ?? tokenStore.getTenantId({
219
+ projectInfo
220
+ });
221
+ if (resolvedTenantId) {
222
+ const cacheKey = buildCacheKey({
223
+ projectInfo,
224
+ tenantId: resolvedTenantId
225
+ });
226
+ projectTokenCache.delete(cacheKey);
227
+ // Drop any in-flight refresh so getValidProjectToken below cannot return
228
+ // a soon-to-be-stale token via the dedup map.
229
+ inFlightRefreshes.delete(cacheKey);
230
+ }
147
231
  tokenStore.clearProjectToken({
148
232
  projectInfo
149
233
  });
150
- // Get a new token (which will fetch from API since we just cleared it)
151
234
  return getValidProjectToken(params);
152
235
  }
153
236