@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.
- package/dist/auth/callback-server.d.ts +19 -7
- package/dist/auth/callback-server.js +72 -31
- package/dist/auth/crypto-utils.d.ts +11 -0
- package/dist/auth/crypto-utils.js +22 -1
- package/dist/auth/oauth-flow.d.ts +3 -1
- package/dist/auth/oauth-flow.js +14 -6
- package/dist/auth/project-token.d.ts +15 -10
- package/dist/auth/project-token.js +147 -64
- package/dist/auth/token-store-migration.js +2 -2
- package/dist/auth/token-store.d.ts +2 -0
- package/dist/auth/token-store.js +43 -3
- package/dist/auth/types.d.ts +11 -0
- package/dist/cli.js +15 -1
- package/dist/commands/bootstrap.d.ts +18 -0
- package/dist/commands/bootstrap.js +90 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +76 -4
- package/dist/config/oauth.d.ts +2 -1
- package/dist/config/oauth.js +7 -1
- package/dist/db-content-api/generated/content-api-types.d.ts +6 -0
- package/dist/db-content-api/index.d.ts +2 -0
- package/dist/db-content-api/index.js +36 -74
- package/dist/lib/download-skill.d.ts +13 -0
- package/dist/lib/download-skill.js +79 -0
- package/dist/oauth/endpoints/getLoginEndpoint.js +26 -107
- package/dist/oauth/endpoints/getTokenLoginEndpoint.d.ts +17 -0
- package/dist/oauth/endpoints/getTokenLoginEndpoint.js +105 -0
- package/dist/oauth/index.js +8 -0
- package/dist/oauth/utilities/establishSession.d.ts +23 -0
- package/dist/oauth/utilities/establishSession.js +82 -0
- package/dist/oauth/utilities/exchangeCodeForAccessToken.d.ts +24 -0
- package/dist/oauth/utilities/exchangeCodeForAccessToken.js +28 -0
- package/dist/oauth/utilities/isAbsoluteURL.d.ts +2 -0
- package/dist/oauth/utilities/isAbsoluteURL.js +3 -0
- package/dist/plugin/build-config.js +65 -46
- package/dist/types.d.ts +2 -0
- package/dist/utils/download-template.d.ts +9 -1
- package/dist/utils/download-template.js +24 -19
- package/dist/utils/messages.js +9 -0
- package/dist/utils/parse-template-spec.d.ts +12 -0
- package/dist/utils/parse-template-spec.js +62 -0
- package/dist/utils/payload-config-modifier.js +96 -107
- package/dist/utils/payload-package-check.d.ts +21 -1
- package/dist/utils/payload-package-check.js +66 -26
- package/dist/utils/project.d.ts +2 -1
- package/dist/utils/project.js +2 -2
- package/package.json +9 -1
- 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
|
-
*
|
|
24
|
-
* OS-assigned port
|
|
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
|
|
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
|
-
*
|
|
24
|
-
* OS-assigned port
|
|
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
|
-
|
|
27
|
+
callbackPorts;
|
|
28
|
+
readyPromise = null;
|
|
29
|
+
rejectReady;
|
|
30
|
+
resolveReady;
|
|
28
31
|
server = null;
|
|
29
32
|
timeoutMs;
|
|
30
33
|
constructor(options = {}){
|
|
31
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
|
139
|
-
const tryListen = (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 =
|
|
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
|
|
164
|
-
tryListen(
|
|
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
|
|
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;
|
package/dist/auth/oauth-flow.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
74
|
+
const actualPort = await callbackServer.waitUntilReady();
|
|
73
75
|
// Build redirect URI with actual port
|
|
74
|
-
const actualRedirectUri = redirectUri
|
|
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
|
-
*
|
|
21
|
-
* 1.
|
|
22
|
-
* 2.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
45
|
+
* Force-refresh a project token, bypassing all cache layers.
|
|
43
46
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
49
|
-
* 1.
|
|
50
|
-
* 2.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
99
|
+
const inFlight = inFlightRefreshes.get(cacheKey);
|
|
100
|
+
if (inFlight) {
|
|
101
|
+
return inFlight;
|
|
94
102
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
} catch (
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
*
|
|
204
|
+
* Force-refresh a project token, bypassing all cache layers.
|
|
135
205
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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
|
|