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

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 (35) hide show
  1. package/dist/auth/crypto-utils.d.ts +11 -0
  2. package/dist/auth/crypto-utils.js +51 -0
  3. package/dist/auth/oauth-flow.js +3 -3
  4. package/dist/auth/project-token.d.ts +17 -6
  5. package/dist/auth/project-token.js +37 -19
  6. package/dist/auth/token-store-migration.d.ts +58 -0
  7. package/dist/auth/token-store-migration.js +156 -0
  8. package/dist/auth/token-store.d.ts +51 -128
  9. package/dist/auth/token-store.js +326 -187
  10. package/dist/auth/types.d.ts +8 -1
  11. package/dist/cli.js +6 -0
  12. package/dist/commands/debug.js +16 -12
  13. package/dist/commands/dump-tokens.d.ts +12 -0
  14. package/dist/commands/dump-tokens.js +69 -0
  15. package/dist/commands/init.js +7 -3
  16. package/dist/commands/list-tokens.js +2 -2
  17. package/dist/commands/login.js +1 -1
  18. package/dist/commands/logout.js +23 -4
  19. package/dist/db-content-api/utilities/auth.js +4 -1
  20. package/dist/oauth/defaults.d.ts +3 -1
  21. package/dist/oauth/defaults.js +6 -5
  22. package/dist/oauth/endpoints/getLoginEndpoint.js +1 -1
  23. package/dist/oauth/index.js +1 -0
  24. package/dist/oauth/types.d.ts +10 -0
  25. package/dist/plugin/auth-preflight.d.ts +8 -0
  26. package/dist/plugin/auth-preflight.js +20 -0
  27. package/dist/plugin/bootstrap-preflight.d.ts +23 -0
  28. package/dist/plugin/bootstrap-preflight.js +45 -0
  29. package/dist/plugin/build-config.js +73 -12
  30. package/dist/plugin/dev-cookie-names.d.ts +14 -0
  31. package/dist/plugin/dev-cookie-names.js +19 -0
  32. package/dist/storage-content-api/client.js +4 -1
  33. package/dist/utils/token-display.d.ts +5 -1
  34. package/dist/utils/token-display.js +47 -26
  35. package/package.json +2 -1
@@ -1,18 +1,20 @@
1
1
  /* eslint-disable perfectionist/sort-classes */ import Conf from 'conf';
2
+ import envPaths from 'env-paths';
2
3
  import fsSync from 'node:fs';
4
+ import path from 'node:path';
3
5
  import { TOKEN_EXPIRY_BUFFER_SECONDS } from '../config/oauth.js';
4
6
  import { getInfraEnvironment } from '../constants.js';
7
+ import { getEnvVarSync } from '../utils/env-management.js';
5
8
  import * as log from '../utils/log.js';
6
9
  import { deriveEncryptionKey } from './crypto-utils.js';
10
+ import { migrateOAuth, migrateProjectData, needsMigration } from './token-store-migration.js';
7
11
  /**
8
- * Environment-keyed instances for singleton pattern
12
+ * Environment-keyed instances for singleton pattern.
9
13
  */ const instances = {};
14
+ const OAUTH_STORE_SCHEMA_VERSION = 1;
15
+ const PROJECT_STORE_SCHEMA_VERSION = 1;
10
16
  /**
11
- * Get the singleton TokenStore instance for an environment
12
- * Use this for all production code to ensure a single shared instance per environment
13
- *
14
- * @param environment - The environment to get the token store for
15
- * @returns The singleton TokenStore instance for that environment
17
+ * Get the singleton TokenStore instance for an environment.
16
18
  */ export function getTokenStore(environment = getInfraEnvironment()) {
17
19
  if (!instances[environment]) {
18
20
  instances[environment] = new TokenStore({
@@ -22,110 +24,180 @@ import { deriveEncryptionKey } from './crypto-utils.js';
22
24
  return instances[environment];
23
25
  }
24
26
  /**
25
- * Secure storage manager for Figma OAuth2 tokens
26
- *
27
- * Uses the `conf` library to store tokens in an OS-specific secure location:
28
- * - macOS: ~/Library/Preferences/payloadcms-figma
29
- * - Linux: ~/.config/payloadcms-figma or $XDG_CONFIG_HOME/payloadcms-figma
30
- * - Windows: %APPDATA%/payloadcms-figma/Config
31
- *
32
- * Tokens are encrypted at rest with a machine-specific key and file permissions
33
- * are set to 0600 (owner read/write only)
27
+ * Secure storage manager for OAuth tokens and project-scoped bootstrap/token data.
34
28
  *
35
29
  * For production code, use getTokenStore() to get the singleton instance.
36
30
  * The constructor is still exported for test isolation.
37
31
  */ export class TokenStore {
38
- config;
32
+ baseConfigName;
33
+ encryptionKey;
39
34
  environment;
35
+ legacyRootDir;
36
+ oauthConfig;
37
+ projectName;
38
+ projectStoreConfigs = new Map();
40
39
  constructor(options){
41
40
  if (process.env.AWS_EXECUTION_ENV) {
42
41
  throw new Error('TokenStore cannot be used in AWS Lambda environments');
43
42
  }
44
43
  const environment = options?.environment ?? 'production';
45
- const projectName = environment === 'production' ? 'payloadcms-figma' : `payloadcms-figma-${environment}`;
44
+ const projectName = 'payloadcms-figma';
45
+ const encryptionKey = options?.encryptionKey ?? deriveEncryptionKey();
46
+ const baseConfigName = options?.configName || projectName;
47
+ this.baseConfigName = baseConfigName;
48
+ this.encryptionKey = encryptionKey;
46
49
  this.environment = environment;
47
- const confOptions = {
48
- clearInvalidConfig: false,
49
- configName: options?.configName || projectName,
50
- encryptionKey: options?.encryptionKey || deriveEncryptionKey(),
51
- projectName
52
- };
53
- try {
54
- this.config = new Conf(confOptions);
55
- } catch (error) {
56
- // Deserialization failed (e.g. encryption key changed).
57
- // Log diagnostics, clear the corrupted file, then create a fresh store.
58
- if (environment === 'staging') {
59
- log.warning(`Token store corrupted, resetting. ` + `Error: ${error instanceof Error ? error.message : 'Unknown'}`);
60
- }
61
- // Temporarily allow clearing so Conf can reinitialize
62
- this.config = new Conf({
63
- ...confOptions,
64
- clearInvalidConfig: true
50
+ this.projectName = projectName;
51
+ // Resolve the legacy root dir (matching Conf's env-paths convention)
52
+ // without instantiating Conf, which would read/parse the encrypted file
53
+ // and could wipe it under clearInvalidConfig.
54
+ this.legacyRootDir = envPaths(projectName).config;
55
+ const legacyFilePath = path.join(this.legacyRootDir, `${baseConfigName}.json`);
56
+ const newOAuthCwd = this.buildOAuthStoreCwd();
57
+ const newOAuthFilePath = path.join(newOAuthCwd, `${this.buildOAuthConfigName()}.json`);
58
+ const resolveProjectStorePath = (scope)=>({
59
+ configName: this.buildProjectStoreConfigName(scope),
60
+ cwd: this.buildProjectStoreCwd(scope)
61
+ });
62
+ if (needsMigration({
63
+ legacyFilePath,
64
+ newOAuthFilePath
65
+ })) {
66
+ migrateOAuth({
67
+ encryptionKey,
68
+ legacyConfigName: baseConfigName,
69
+ newOAuthConfigName: this.buildOAuthConfigName(),
70
+ newOAuthCwd,
71
+ projectName
65
72
  });
66
- // Force a write so the corrupt file is replaced with an empty store.
67
- // Without this, clearInvalidConfig only ignores bad reads in memory
68
- // and the corrupt file persists, triggering this fallback every time.
69
- this.config.clear();
70
73
  }
71
- }
72
- safeGet(key) {
73
- try {
74
- return this.config.get(key);
75
- } catch (error) {
76
- if (this.environment === 'staging') {
77
- const filePath = this.config.path;
78
- const fileExists = fsSync.existsSync(filePath);
79
- const fileSize = fileExists ? fsSync.statSync(filePath).size : 0;
80
- log.warning(`Token store read failed (key: ${key}). ` + `File: ${filePath}, exists: ${fileExists}, size: ${fileSize}B. ` + `Error: ${error instanceof Error ? error.message : 'Unknown'}`);
74
+ if (fsSync.existsSync(legacyFilePath)) {
75
+ migrateProjectData({
76
+ encryptionKey,
77
+ legacyConfigName: baseConfigName,
78
+ projectName,
79
+ resolveProjectStorePath
80
+ });
81
+ }
82
+ // Also check for legacy files in old environment-specific directories
83
+ // (e.g., payloadcms-figma-staging-nodejs/ for staging environment).
84
+ if (environment !== 'production') {
85
+ const oldEnvProjectName = `payloadcms-figma-${environment}`;
86
+ const oldEnvConfigName = options?.configName || oldEnvProjectName;
87
+ const oldEnvLegacyDir = envPaths(oldEnvProjectName).config;
88
+ const oldEnvLegacyFilePath = path.join(oldEnvLegacyDir, `${oldEnvConfigName}.json`);
89
+ if (needsMigration({
90
+ legacyFilePath: oldEnvLegacyFilePath,
91
+ newOAuthFilePath
92
+ })) {
93
+ migrateOAuth({
94
+ encryptionKey,
95
+ legacyConfigName: oldEnvConfigName,
96
+ legacyCwd: oldEnvLegacyDir,
97
+ newOAuthConfigName: this.buildOAuthConfigName(),
98
+ newOAuthCwd,
99
+ projectName: oldEnvProjectName
100
+ });
101
+ }
102
+ if (fsSync.existsSync(oldEnvLegacyFilePath)) {
103
+ migrateProjectData({
104
+ encryptionKey,
105
+ legacyConfigName: oldEnvConfigName,
106
+ legacyCwd: oldEnvLegacyDir,
107
+ projectName: oldEnvProjectName,
108
+ resolveProjectStorePath
109
+ });
81
110
  }
82
- return undefined;
83
111
  }
112
+ this.oauthConfig = this.createConfig({
113
+ configName: this.buildOAuthConfigName(),
114
+ cwd: this.buildOAuthStoreCwd(),
115
+ encryptionKey: this.encryptionKey,
116
+ storeType: 'oauth'
117
+ });
84
118
  }
85
- /**
86
- * Retrieve stored tokens
87
- * @returns FigmaTokens if stored, null otherwise
88
- */ getTokens() {
89
- const tokens = this.safeGet('tokens');
90
- return tokens || null;
119
+ getOauthInfo() {
120
+ const accessToken = this.safeGet({
121
+ config: this.oauthConfig,
122
+ key: 'accessToken'
123
+ });
124
+ const expiresAt = this.safeGet({
125
+ config: this.oauthConfig,
126
+ key: 'expiresAt'
127
+ });
128
+ const refreshToken = this.safeGet({
129
+ config: this.oauthConfig,
130
+ key: 'refreshToken'
131
+ });
132
+ if (typeof accessToken !== 'string' || typeof expiresAt !== 'number' || typeof refreshToken !== 'string') {
133
+ return null;
134
+ }
135
+ return {
136
+ accessToken,
137
+ expiresAt,
138
+ refreshToken,
139
+ scopes: this.safeGet({
140
+ config: this.oauthConfig,
141
+ key: 'scopes'
142
+ }),
143
+ tokenType: this.safeGet({
144
+ config: this.oauthConfig,
145
+ key: 'tokenType'
146
+ }),
147
+ userId: this.safeGet({
148
+ config: this.oauthConfig,
149
+ key: 'userId'
150
+ })
151
+ };
91
152
  }
92
- /**
93
- * Store OAuth2 tokens securely
94
- * @param tokens - Figma OAuth2 tokens to store
95
- */ setTokens(tokens) {
96
- this.config.set('tokens', tokens);
97
- // Verify the round-trip: read back what we just wrote
98
- const readBack = this.safeGet('tokens');
153
+ setTokens(tokens) {
154
+ this.oauthConfig.set('_schemaVersion', OAUTH_STORE_SCHEMA_VERSION);
155
+ this.oauthConfig.set('accessToken', tokens.accessToken);
156
+ this.oauthConfig.set('expiresAt', tokens.expiresAt);
157
+ this.oauthConfig.set('refreshToken', tokens.refreshToken);
158
+ if (tokens.scopes) {
159
+ this.oauthConfig.set('scopes', tokens.scopes);
160
+ } else {
161
+ this.oauthConfig.delete('scopes');
162
+ }
163
+ if (tokens.tokenType) {
164
+ this.oauthConfig.set('tokenType', tokens.tokenType);
165
+ } else {
166
+ this.oauthConfig.delete('tokenType');
167
+ }
168
+ if (tokens.userId) {
169
+ this.oauthConfig.set('userId', tokens.userId);
170
+ } else {
171
+ this.oauthConfig.delete('userId');
172
+ }
173
+ // Verify the round-trip: read back what we just wrote.
174
+ const readBack = this.getOauthInfo();
99
175
  if (!readBack || readBack.accessToken !== tokens.accessToken) {
100
176
  if (this.environment === 'staging') {
101
- const filePath = this.config.path;
177
+ const filePath = this.oauthConfig.path;
102
178
  const fileExists = fsSync.existsSync(filePath);
103
179
  const fileSize = fileExists ? fsSync.statSync(filePath).size : 0;
104
180
  log.warning(`Token verify-after-write FAILED. ` + `File: ${filePath}, exists: ${fileExists}, size: ${fileSize}B. ` + `Written accessToken starts: ${tokens.accessToken.substring(0, 10)}..., ` + `Read back: ${readBack ? readBack.accessToken.substring(0, 10) + '...' : 'null'}`);
105
181
  }
106
182
  }
107
183
  }
108
- /**
109
- * Clear all stored tokens (used for logout)
110
- */ clearTokens() {
111
- this.config.delete('tokens');
184
+ clearTokens() {
185
+ this.oauthConfig.delete('accessToken');
186
+ this.oauthConfig.delete('expiresAt');
187
+ this.oauthConfig.delete('refreshToken');
188
+ this.oauthConfig.delete('scopes');
189
+ this.oauthConfig.delete('tokenType');
190
+ this.oauthConfig.delete('userId');
112
191
  }
113
- /**
114
- * Check if valid (non-expired) tokens exist
115
- * @returns true if tokens exist and are not expired
116
- */ hasValidTokens() {
117
- const tokens = this.getTokens();
192
+ hasValidOauthToken() {
193
+ const tokens = this.getOauthInfo();
118
194
  if (!tokens) {
119
195
  return false;
120
196
  }
121
- return !this.isExpired();
197
+ return !this.isOauthExpired();
122
198
  }
123
- /**
124
- * Check if the current access token is expired
125
- * Includes a buffer time to avoid using tokens that are about to expire
126
- * @returns true if token is expired or will expire within buffer time
127
- */ isExpired() {
128
- const tokens = this.safeGet('tokens');
199
+ isOauthExpired() {
200
+ const tokens = this.getOauthInfo();
129
201
  if (!tokens) {
130
202
  return true;
131
203
  }
@@ -134,30 +206,19 @@ import { deriveEncryptionKey } from './crypto-utils.js';
134
206
  const expiryWithBuffer = tokens.expiresAt - bufferMs;
135
207
  return now >= expiryWithBuffer;
136
208
  }
137
- /**
138
- * Get the access token if it exists and is valid
139
- * @returns Access token string or null if expired/missing
140
- */ getAccessToken() {
141
- if (!this.hasValidTokens()) {
209
+ getCurrentOauthToken() {
210
+ if (!this.hasValidOauthToken()) {
142
211
  return null;
143
212
  }
144
- const tokens = this.getTokens();
213
+ const tokens = this.getOauthInfo();
145
214
  return tokens?.accessToken || null;
146
215
  }
147
- /**
148
- * Get the refresh token if it exists
149
- * @returns Refresh token string or null if missing
150
- */ getRefreshToken() {
151
- const tokens = this.getTokens();
216
+ getCurrentRefreshToken() {
217
+ const tokens = this.getOauthInfo();
152
218
  return tokens?.refreshToken || null;
153
219
  }
154
- /**
155
- * Update just the access token after a refresh
156
- * Preserves the existing refresh token and other metadata
157
- * @param accessToken - New access token
158
- * @param expiresIn - Expiration time in seconds
159
- */ updateAccessToken(accessToken, expiresIn) {
160
- const existingTokens = this.getTokens();
220
+ updateAccessToken(accessToken, expiresIn) {
221
+ const existingTokens = this.getOauthInfo();
161
222
  if (!existingTokens) {
162
223
  throw new Error('Cannot update access token: no existing tokens found');
163
224
  }
@@ -169,123 +230,201 @@ import { deriveEncryptionKey } from './crypto-utils.js';
169
230
  });
170
231
  }
171
232
  /**
172
- * Get the path to the config file (useful for debugging)
173
- * @returns Absolute path to the token storage file
233
+ * Returns the OAuth storage file path.
174
234
  */ getStoragePath() {
175
- return this.config.path;
235
+ return this.oauthConfig.path;
176
236
  }
177
- /**
178
- * Retrieve a project token for a specific tenant
179
- * Automatically removes expired tokens from storage
180
- * @param tenantId - The tenant/CMS ID
181
- * @returns ProjectToken if stored and valid, null otherwise
182
- */ getProjectToken(tenantId) {
183
- const projectTokens = this.safeGet('projectTokens') || {};
184
- const projectToken = projectTokens[tenantId];
237
+ getProjectToken({ projectInfo } = {}) {
238
+ const resolvedProjectInfo = projectInfo ?? this.getProjectInfoFromEnv();
239
+ if (!resolvedProjectInfo) {
240
+ return null;
241
+ }
242
+ const config = this.getProjectStore(resolvedProjectInfo);
243
+ const projectToken = this.safeGet({
244
+ config,
245
+ key: 'projectToken'
246
+ });
185
247
  if (!projectToken) {
186
248
  return null;
187
249
  }
188
- // Auto-cleanup expired project token
189
250
  if (this.isProjectTokenExpired(projectToken)) {
190
- this.clearProjectToken(tenantId);
251
+ this.clearProjectToken({
252
+ projectInfo: resolvedProjectInfo
253
+ });
191
254
  return null;
192
255
  }
193
256
  return projectToken;
194
257
  }
195
- /**
196
- * Store a project token for a specific tenant
197
- * @param tenantId - The tenant/CMS ID
198
- * @param projectToken - The project token to store
199
- */ setProjectToken(tenantId, projectToken) {
200
- const projectTokens = this.safeGet('projectTokens') || {};
201
- projectTokens[tenantId] = projectToken;
202
- this.config.set('projectTokens', projectTokens);
203
- }
204
- /**
205
- * Clear a project token for a specific tenant
206
- * @param tenantId - The tenant/CMS ID
207
- */ clearProjectToken(tenantId) {
208
- const projectTokens = this.safeGet('projectTokens') || {};
209
- delete projectTokens[tenantId];
210
- this.config.set('projectTokens', projectTokens);
258
+ setProjectToken({ projectInfo, token }) {
259
+ const resolvedProjectInfo = projectInfo ?? this.getProjectInfoFromEnv();
260
+ if (!resolvedProjectInfo) {
261
+ throw new Error('Cannot set project token: FIGMA_PROJECT_ID and FIGMA_ENVIRONMENT_NAME must be set');
262
+ }
263
+ const config = this.getProjectStore(resolvedProjectInfo);
264
+ config.set('_schemaVersion', PROJECT_STORE_SCHEMA_VERSION);
265
+ config.set('projectToken', token);
211
266
  }
212
- /**
213
- * Clear all project tokens
214
- */ clearAllProjectTokens() {
215
- this.config.delete('projectTokens');
267
+ clearProjectToken({ projectInfo } = {}) {
268
+ const resolvedProjectInfo = projectInfo ?? this.getProjectInfoFromEnv();
269
+ if (!resolvedProjectInfo) {
270
+ return; // Nothing to clear if we can't determine the project
271
+ }
272
+ const config = this.getProjectStore(resolvedProjectInfo);
273
+ config.delete('projectToken');
216
274
  }
217
- /**
218
- * Check if a project token is expired
219
- * Includes a buffer time to avoid using tokens that are about to expire
220
- * @param projectToken - The project token to check
221
- * @returns true if token is expired or will expire within buffer time
222
- */ isProjectTokenExpired(projectToken) {
275
+ isProjectTokenExpired(token) {
223
276
  const now = Date.now();
224
277
  const bufferMs = TOKEN_EXPIRY_BUFFER_SECONDS * 1000;
225
- const expiryWithBuffer = projectToken.expiresAt - bufferMs;
278
+ const expiryWithBuffer = token.expiresAt - bufferMs;
226
279
  return now >= expiryWithBuffer;
227
280
  }
228
- /**
229
- * Check if a valid (non-expired) project token exists for a tenant
230
- * @param tenantId - The tenant/CMS ID
231
- * @returns true if token exists and is not expired
232
- */ hasValidProjectToken(tenantId) {
233
- const projectToken = this.getProjectToken(tenantId);
281
+ hasValidProjectToken({ projectInfo } = {}) {
282
+ const projectToken = this.getProjectToken({
283
+ projectInfo
284
+ });
234
285
  if (!projectToken) {
235
286
  return false;
236
287
  }
237
288
  return !this.isProjectTokenExpired(projectToken);
238
289
  }
239
290
  /**
240
- * Get validated JWT claims from a project token
241
- * @param tenantId - The tenant/CMS ID
242
- * @returns JWT payload claims or null if token not found or has no claims
243
- */ getProjectTokenClaims(tenantId) {
244
- const projectToken = this.getProjectToken(tenantId);
245
- return projectToken?.claims || null;
291
+ * Get the tenantId (contentSystemId) for the project scope.
292
+ * Returns null if bootstrap data is not available.
293
+ */ getTenantId({ projectInfo } = {}) {
294
+ const resolvedProjectInfo = projectInfo ?? this.getProjectInfoFromEnv();
295
+ if (!resolvedProjectInfo) {
296
+ return null;
297
+ }
298
+ const bootstrapData = this.getBootstrapData(resolvedProjectInfo.projectId, resolvedProjectInfo.environmentName);
299
+ return bootstrapData?.contentSystemId ?? null;
246
300
  }
247
301
  /**
248
- * Get all tenant IDs that have stored project tokens
249
- * @returns Array of tenant IDs
250
- */ getAllProjectTokenTenantIds() {
251
- const projectTokens = this.safeGet('projectTokens') || {};
252
- return Object.keys(projectTokens);
302
+ * Derive project info from environment variables.
303
+ * Returns null if either FIGMA_PROJECT_ID or FIGMA_ENVIRONMENT_NAME is not set.
304
+ */ getProjectInfoFromEnv() {
305
+ const projectId = process.env.FIGMA_PROJECT_ID || getEnvVarSync(process.cwd(), 'FIGMA_PROJECT_ID');
306
+ const environmentName = process.env.FIGMA_ENVIRONMENT_NAME || getEnvVarSync(process.cwd(), 'FIGMA_ENVIRONMENT_NAME');
307
+ if (!projectId || !environmentName) {
308
+ return null;
309
+ }
310
+ return {
311
+ environmentName,
312
+ projectId
313
+ };
253
314
  }
254
- /**
255
- * Build the composite key for bootstrap data storage
256
- * @param projectId - The CMS resource/project ID
257
- * @param environmentName - The environment name (e.g. 'production', 'staging')
258
- * @returns Composite key string
259
- */ bootstrapKey(projectId, environmentName) {
260
- return `${projectId}:${environmentName}`;
315
+ getBootstrapData(projectId, environmentName) {
316
+ const config = this.getProjectStore({
317
+ environmentName,
318
+ projectId
319
+ });
320
+ const bootstrapData = this.safeGet({
321
+ config,
322
+ key: 'bootstrapData'
323
+ });
324
+ return bootstrapData || null;
261
325
  }
262
- /**
263
- * Retrieve bootstrap data for a project + environment
264
- * @param projectId - The CMS resource/project ID
265
- * @param environmentName - The environment name
266
- * @returns BootstrapData if stored, null otherwise
267
- */ getBootstrapData(projectId, environmentName) {
268
- const allData = this.safeGet('bootstrapData') || {};
269
- return allData[this.bootstrapKey(projectId, environmentName)] || null;
326
+ setBootstrapData(projectId, environmentName, data) {
327
+ const config = this.getProjectStore({
328
+ environmentName,
329
+ projectId
330
+ });
331
+ config.set('_schemaVersion', PROJECT_STORE_SCHEMA_VERSION);
332
+ config.set('bootstrapData', data);
270
333
  }
271
- /**
272
- * Store bootstrap data for a project + environment
273
- * @param projectId - The CMS resource/project ID
274
- * @param environmentName - The environment name
275
- * @param data - The bootstrap data to store
276
- */ setBootstrapData(projectId, environmentName, data) {
277
- const allData = this.safeGet('bootstrapData') || {};
278
- allData[this.bootstrapKey(projectId, environmentName)] = data;
279
- this.config.set('bootstrapData', allData);
334
+ clearBootstrapData(projectId, environmentName) {
335
+ const config = this.getProjectStore({
336
+ environmentName,
337
+ projectId
338
+ });
339
+ config.delete('bootstrapData');
280
340
  }
281
- /**
282
- * Clear bootstrap data for a project + environment
283
- * @param projectId - The CMS resource/project ID
284
- * @param environmentName - The environment name
285
- */ clearBootstrapData(projectId, environmentName) {
286
- const allData = this.safeGet('bootstrapData') || {};
287
- delete allData[this.bootstrapKey(projectId, environmentName)];
288
- this.config.set('bootstrapData', allData);
341
+ buildOAuthConfigName() {
342
+ // For test isolation: if using a custom config name, use it as-is with -oauth suffix
343
+ // Otherwise use the standard naming that includes environment
344
+ if (this.baseConfigName !== this.projectName) {
345
+ return `${this.baseConfigName}-oauth`;
346
+ }
347
+ return `payloadcms-figma-${this.environment}-oauth`;
348
+ }
349
+ buildOAuthStoreCwd() {
350
+ return path.join(this.getLegacyRootDir(), 'oauth');
351
+ }
352
+ buildProjectStoreConfigName(projectInfo) {
353
+ const env = this.sanitizeConfigSegment(projectInfo.environmentName);
354
+ if (this.baseConfigName !== this.projectName) {
355
+ return `${this.baseConfigName}-${env}-contentAPI`;
356
+ }
357
+ return `payloadcms-figma-${env}-contentAPI`;
358
+ }
359
+ buildProjectStoreCwd(projectInfo) {
360
+ return path.join(this.getLegacyRootDir(), 'projects', this.sanitizeConfigSegment(projectInfo.projectId));
361
+ }
362
+ sanitizeConfigSegment(value) {
363
+ return value.replace(/[^\w-]/g, '-').replace(/-+/g, '-');
364
+ }
365
+ projectInfoKey(projectInfo) {
366
+ return `${projectInfo.projectId}:${projectInfo.environmentName}`;
367
+ }
368
+ getLegacyRootDir() {
369
+ return this.legacyRootDir;
370
+ }
371
+ getProjectStore(projectInfo) {
372
+ const projectInfoKey = this.projectInfoKey(projectInfo);
373
+ const existing = this.projectStoreConfigs.get(projectInfoKey);
374
+ if (existing) {
375
+ return existing;
376
+ }
377
+ const config = this.createConfig({
378
+ configName: this.buildProjectStoreConfigName(projectInfo),
379
+ cwd: this.buildProjectStoreCwd(projectInfo),
380
+ encryptionKey: this.encryptionKey,
381
+ storeType: 'project'
382
+ });
383
+ this.projectStoreConfigs.set(projectInfoKey, config);
384
+ return config;
385
+ }
386
+ createConfig(params) {
387
+ const confOptions = {
388
+ clearInvalidConfig: false,
389
+ configName: params.configName,
390
+ cwd: params.cwd,
391
+ ...params.encryptionKey ? {
392
+ encryptionKey: params.encryptionKey
393
+ } : {},
394
+ projectName: this.projectName
395
+ };
396
+ try {
397
+ return new Conf(confOptions);
398
+ } catch (error) {
399
+ // Deserialization failed (e.g. encryption key changed).
400
+ // Log diagnostics, then clear and replace with a fresh store.
401
+ if (this.environment === 'staging') {
402
+ log.warning(`${params.storeType} token store corrupted, resetting. ` + `Error: ${error instanceof Error ? error.message : 'Unknown'}`);
403
+ }
404
+ const config = new Conf({
405
+ ...confOptions,
406
+ clearInvalidConfig: true
407
+ });
408
+ // Force a write so the corrupt file is replaced with an empty store.
409
+ // Without this, clearInvalidConfig only ignores bad reads in memory
410
+ // and the corrupt file persists, triggering this fallback every time.
411
+ config.clear();
412
+ return config;
413
+ }
414
+ }
415
+ safeGet(params) {
416
+ const { config, key } = params;
417
+ try {
418
+ return config.get(key);
419
+ } catch (error) {
420
+ if (this.environment === 'staging') {
421
+ const filePath = config.path;
422
+ const fileExists = fsSync.existsSync(filePath);
423
+ const fileSize = fileExists ? fsSync.statSync(filePath).size : 0;
424
+ log.warning(`Token store read failed (key: ${String(key)}). ` + `File: ${filePath}, exists: ${fileExists}, size: ${fileSize}B. ` + `Error: ${error instanceof Error ? error.message : 'Unknown'}`);
425
+ }
426
+ return undefined;
427
+ }
289
428
  }
290
429
  }
291
430
 
@@ -109,7 +109,7 @@ export interface OAuthTokenRefreshParams {
109
109
  export type TokenStoreConfig = {
110
110
  /** Config file name (for testing) */
111
111
  configName?: string;
112
- /** Encryption key (for testing) */
112
+ /** Optional override for storage encryption key (primarily for tests). */
113
113
  encryptionKey?: string;
114
114
  /** Environment for config file naming */
115
115
  environment?: Environment;
@@ -171,6 +171,13 @@ export type BootstrapData = {
171
171
  oauthClientSecret: string;
172
172
  tenantInstanceId: string;
173
173
  };
174
+ /**
175
+ * Scope information used for project/environment-specific token storage.
176
+ */
177
+ export type ProjectStoreScope = {
178
+ environmentName: string;
179
+ projectId: string;
180
+ };
174
181
  /**
175
182
  * JWT payload structure from Figma project tokens
176
183
  * Extends jose's standard JWTPayload with Figma-specific claims
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import pc from 'picocolors';
4
4
  import { buildLambdaZipCommand } from './commands/build-lambda-zip.js';
5
5
  import { debugCommand } from './commands/debug.js';
6
6
  import { deployCommand } from './commands/deploy.js';
7
+ import { dumpTokensCommand } from './commands/dump-tokens.js';
7
8
  import { envCommand } from './commands/env.js';
8
9
  import { initCommand } from './commands/init.js';
9
10
  import { listTokensCommand } from './commands/list-tokens.js';
@@ -101,6 +102,11 @@ class Main {
101
102
  await debugCommand();
102
103
  process.exit(0);
103
104
  }
105
+ // dump-tokens outputs plain text
106
+ if (subcommand === 'dump-tokens') {
107
+ await dumpTokensCommand();
108
+ process.exit(0);
109
+ }
104
110
  // eslint-disable-next-line no-console
105
111
  console.log('\n');
106
112
  p.intro(pc.bgCyan(pc.black(' @payloadcms/figma ')));