@payloadcms/figma 0.0.1-alpha.70 → 0.0.1-alpha.72
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/api/control-plane.js +9 -2
- package/dist/api/error-response.d.ts +37 -0
- package/dist/api/error-response.js +68 -0
- package/dist/api/figma-api.js +10 -15
- package/dist/auth/oauth-flow.js +75 -11
- package/dist/auth/paste-code.d.ts +34 -0
- package/dist/auth/paste-code.js +89 -0
- package/dist/cli.js +2 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +17 -4
- package/dist/oauth/components/LoginButton/index.js +11 -4
- package/dist/oauth/components/LogoutButton/index.js +1 -1
- package/dist/oauth/defaults.js +4 -1
- package/dist/oauth/endpoints/getLoginEndpoint.js +3 -1
- package/dist/oauth/hooks/refresh.js +2 -1
- package/dist/oauth/utilities/clearCookie.js +5 -1
- package/dist/oauth/utilities/createCookieOptions.js +9 -2
- package/dist/oauth/utilities/establishSession.js +4 -3
- package/dist/oauth/utilities/refreshTokens.js +2 -1
- package/dist/oauth/utilities/withPartitionedCookie.d.ts +20 -0
- package/dist/oauth/utilities/withPartitionedCookie.js +28 -0
- package/dist/utils/adapters/nextjs.d.ts +2 -2
- package/dist/utils/adapters/nextjs.js +1 -2
- package/dist/utils/adapters/nitro.d.ts +2 -2
- package/dist/utils/adapters/nitro.js +7 -2
- package/dist/utils/adapters/vite.d.ts +2 -2
- package/dist/utils/build-lambda-zip.d.ts +11 -1
- package/dist/utils/build-lambda-zip.js +14 -0
- package/dist/utils/deploy-adapter.d.ts +8 -2
- package/dist/utils/fs-utils.d.ts +7 -0
- package/dist/utils/fs-utils.js +30 -0
- package/dist/utils/messages.js +1 -0
- package/dist/utils/s3-upload.js +5 -1
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/ import { getAuthHeaders } from '../auth/credentials.js';
|
|
6
6
|
import { getEnvConfig } from '../constants.js';
|
|
7
7
|
import * as log from '../utils/log.js';
|
|
8
|
+
import { parseControlPlaneApiError } from './error-response.js';
|
|
8
9
|
/**
|
|
9
10
|
* Get the Control Plane API base URL based on environment
|
|
10
11
|
*/ function getControlPlaneBaseUrl() {
|
|
@@ -330,9 +331,15 @@ async function controlPlaneFetch(params) {
|
|
|
330
331
|
return response;
|
|
331
332
|
}
|
|
332
333
|
if (response.status < 500) {
|
|
333
|
-
throw new ControlPlaneError(
|
|
334
|
+
throw new ControlPlaneError(await parseControlPlaneApiError({
|
|
335
|
+
context,
|
|
336
|
+
response
|
|
337
|
+
}), response.status);
|
|
334
338
|
}
|
|
335
|
-
lastError = new ControlPlaneError(
|
|
339
|
+
lastError = new ControlPlaneError(await parseControlPlaneApiError({
|
|
340
|
+
context,
|
|
341
|
+
response
|
|
342
|
+
}), response.status);
|
|
336
343
|
} catch (error) {
|
|
337
344
|
if (error instanceof ControlPlaneError) {
|
|
338
345
|
throw error;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a human-readable error message from a non-ok control-plane API Response.
|
|
3
|
+
*
|
|
4
|
+
* Sinatra's error helpers (api_error, api_reject, api_not_found, api_needs_upgrade)
|
|
5
|
+
* all serialize the user-facing string under a `message` key. Surfacing it lets the
|
|
6
|
+
* CLI show the real reason (e.g. "Deployment package is too large...") instead of a
|
|
7
|
+
* bare status code.
|
|
8
|
+
*
|
|
9
|
+
* Scoped to JSON `{ message }` bodies. S3 upload errors arrive as XML and need a
|
|
10
|
+
* separate parser.
|
|
11
|
+
*
|
|
12
|
+
* Only called on the error path, immediately before throwing — the body is never read
|
|
13
|
+
* again, so consuming it here is safe (no clone needed). Never throws: any parse
|
|
14
|
+
* failure or missing message falls back to the HTTP status line.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseControlPlaneApiError(params: {
|
|
17
|
+
context: string;
|
|
18
|
+
response: Response;
|
|
19
|
+
}): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Extract a human-readable error message from a non-ok S3 upload Response.
|
|
22
|
+
*
|
|
23
|
+
* S3 reports failures (e.g. SignatureDoesNotMatch, AccessDenied / expired signatures)
|
|
24
|
+
* as an XML document whose first `<Code>` and `<Message>` carry the reason — not the
|
|
25
|
+
* JSON shape {@link parseControlPlaneApiError} expects. The document also includes
|
|
26
|
+
* sibling tags (StringToSign, CanonicalRequest, RequestId), so we extract only those
|
|
27
|
+
* two so the CLI can show the real reason instead of a bare status code.
|
|
28
|
+
*
|
|
29
|
+
* Only called on the error path, immediately before throwing — the body is never read
|
|
30
|
+
* again, so consuming it here is safe (no clone needed). Never throws: any parse
|
|
31
|
+
* failure or missing fields falls back to the HTTP status line.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseS3Error(params: {
|
|
34
|
+
context: string;
|
|
35
|
+
response: Response;
|
|
36
|
+
}): Promise<string>;
|
|
37
|
+
//# sourceMappingURL=error-response.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a human-readable error message from a non-ok control-plane API Response.
|
|
3
|
+
*
|
|
4
|
+
* Sinatra's error helpers (api_error, api_reject, api_not_found, api_needs_upgrade)
|
|
5
|
+
* all serialize the user-facing string under a `message` key. Surfacing it lets the
|
|
6
|
+
* CLI show the real reason (e.g. "Deployment package is too large...") instead of a
|
|
7
|
+
* bare status code.
|
|
8
|
+
*
|
|
9
|
+
* Scoped to JSON `{ message }` bodies. S3 upload errors arrive as XML and need a
|
|
10
|
+
* separate parser.
|
|
11
|
+
*
|
|
12
|
+
* Only called on the error path, immediately before throwing — the body is never read
|
|
13
|
+
* again, so consuming it here is safe (no clone needed). Never throws: any parse
|
|
14
|
+
* failure or missing message falls back to the HTTP status line.
|
|
15
|
+
*/ export async function parseControlPlaneApiError(params) {
|
|
16
|
+
const { context, response } = params;
|
|
17
|
+
const fallback = `Failed to ${context}: ${response.status} ${response.statusText}`;
|
|
18
|
+
try {
|
|
19
|
+
const body = await response.json();
|
|
20
|
+
if (typeof body.message === 'string' && body.message.trim().length > 0) {
|
|
21
|
+
return `Failed to ${context}: ${response.status} ${body.message.trim()}`;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Non-JSON body, empty body, or no .json() — fall back to the status line.
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract a human-readable error message from a non-ok S3 upload Response.
|
|
30
|
+
*
|
|
31
|
+
* S3 reports failures (e.g. SignatureDoesNotMatch, AccessDenied / expired signatures)
|
|
32
|
+
* as an XML document whose first `<Code>` and `<Message>` carry the reason — not the
|
|
33
|
+
* JSON shape {@link parseControlPlaneApiError} expects. The document also includes
|
|
34
|
+
* sibling tags (StringToSign, CanonicalRequest, RequestId), so we extract only those
|
|
35
|
+
* two so the CLI can show the real reason instead of a bare status code.
|
|
36
|
+
*
|
|
37
|
+
* Only called on the error path, immediately before throwing — the body is never read
|
|
38
|
+
* again, so consuming it here is safe (no clone needed). Never throws: any parse
|
|
39
|
+
* failure or missing fields falls back to the HTTP status line.
|
|
40
|
+
*/ export async function parseS3Error(params) {
|
|
41
|
+
const { context, response } = params;
|
|
42
|
+
const fallback = `Failed to ${context}: ${response.status} ${response.statusText}`;
|
|
43
|
+
try {
|
|
44
|
+
const body = await response.text();
|
|
45
|
+
const code = extractXmlTag(body, 'Code');
|
|
46
|
+
const message = extractXmlTag(body, 'Message');
|
|
47
|
+
if (message) {
|
|
48
|
+
return `Failed to ${context}: ${response.status} ${code ? `${code}: ` : ''}${message}`;
|
|
49
|
+
}
|
|
50
|
+
if (code) {
|
|
51
|
+
return `Failed to ${context}: ${response.status} ${code}`;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Non-XML body, empty body, or no .text() — fall back to the status line.
|
|
55
|
+
}
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pull the trimmed text content of a single non-nested XML tag (e.g. `<Code>`).
|
|
60
|
+
* Returns undefined when the tag is absent or empty. S3 error documents are flat,
|
|
61
|
+
* so a narrow regex is sufficient — no general XML parsing needed.
|
|
62
|
+
*/ function extractXmlTag(xml, tag) {
|
|
63
|
+
const match = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml);
|
|
64
|
+
const value = match?.[1]?.trim();
|
|
65
|
+
return value && value.length > 0 ? value : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=error-response.js.map
|
package/dist/api/figma-api.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/ import { getAuthHeaders } from '../auth/credentials.js';
|
|
7
7
|
import { getEnvConfig } from '../constants.js';
|
|
8
8
|
import * as log from '../utils/log.js';
|
|
9
|
+
import { parseControlPlaneApiError } from './error-response.js';
|
|
9
10
|
/**
|
|
10
11
|
* Error thrown when Figma API calls fail
|
|
11
12
|
*/ export class FigmaApiError extends Error {
|
|
@@ -92,20 +93,11 @@ import * as log from '../utils/log.js';
|
|
|
92
93
|
method: 'POST'
|
|
93
94
|
});
|
|
94
95
|
if (!response.ok) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (typeof errorBody.message === 'string') {
|
|
101
|
-
errorMessage = errorBody.message;
|
|
102
|
-
} else if (typeof errorBody.error === 'string') {
|
|
103
|
-
errorMessage = errorBody.error;
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Ignore JSON parse errors for error body
|
|
107
|
-
}
|
|
108
|
-
// Provide actionable error for common cases
|
|
96
|
+
let errorMessage = await parseControlPlaneApiError({
|
|
97
|
+
context: 'get project token',
|
|
98
|
+
response
|
|
99
|
+
});
|
|
100
|
+
// Provide an actionable error for the common misconfiguration case.
|
|
109
101
|
if (response.status === 404) {
|
|
110
102
|
errorMessage = `Content System ID "${tenantId}" not found. Ensure FIGMA_CONTENT_API_CONTENT_SYSTEM_ID is set to a valid content system ID in your .env file.`;
|
|
111
103
|
}
|
|
@@ -204,7 +196,10 @@ import * as log from '../utils/log.js';
|
|
|
204
196
|
headers: getAuthHeaders(credential)
|
|
205
197
|
});
|
|
206
198
|
if (!response.ok) {
|
|
207
|
-
throw new FigmaApiError(
|
|
199
|
+
throw new FigmaApiError(await parseControlPlaneApiError({
|
|
200
|
+
context: 'get user info',
|
|
201
|
+
response
|
|
202
|
+
}), response.status);
|
|
208
203
|
}
|
|
209
204
|
const data = await response.json();
|
|
210
205
|
if (!data.id || !data.email || typeof data.handle !== 'string') {
|
package/dist/auth/oauth-flow.js
CHANGED
|
@@ -3,7 +3,8 @@ import crypto from 'crypto';
|
|
|
3
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
|
-
import { CallbackServer } from './callback-server.js';
|
|
6
|
+
import { CallbackError, CallbackServer } from './callback-server.js';
|
|
7
|
+
import { parsePastedCode, validatePastedCode } from './paste-code.js';
|
|
7
8
|
import { generatePKCEPair } from './pkce.js';
|
|
8
9
|
import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './refresh.js';
|
|
9
10
|
/**
|
|
@@ -87,28 +88,36 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
|
|
|
87
88
|
// Prompt user to open browser
|
|
88
89
|
const shouldOpenBrowser = await p.confirm({
|
|
89
90
|
initialValue: true,
|
|
90
|
-
message: '
|
|
91
|
+
message: 'Open browser to authenticate with Figma?'
|
|
91
92
|
});
|
|
92
93
|
if (p.isCancel(shouldOpenBrowser)) {
|
|
93
94
|
throw new OAuthFlowError('Authentication cancelled by user');
|
|
94
95
|
}
|
|
96
|
+
let browserOpened = false;
|
|
95
97
|
if (shouldOpenBrowser) {
|
|
96
|
-
// Try to open browser, but don't fail if it doesn't work
|
|
97
98
|
try {
|
|
98
99
|
await openBrowser(authUrl);
|
|
100
|
+
browserOpened = true;
|
|
99
101
|
} catch (_error) {
|
|
100
|
-
// Browser opening failed, show URL for manual opening
|
|
101
102
|
log.warning('Could not open browser automatically.');
|
|
102
|
-
log.info(`Please open this URL in your browser:`);
|
|
103
|
-
log.info(` ${authUrl}`);
|
|
104
103
|
}
|
|
104
|
+
}
|
|
105
|
+
let code;
|
|
106
|
+
if (browserOpened) {
|
|
107
|
+
// Browser launched locally; the localhost listener receives the callback.
|
|
108
|
+
code = (await callbackPromise).code;
|
|
105
109
|
} else {
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
// No local browser (user declined, or open() failed — e.g. a remote devbox).
|
|
111
|
+
// Show the URL to open manually and accept the redirected code pasted back,
|
|
112
|
+
// while the localhost listener keeps running in case it can still receive it.
|
|
113
|
+
// p.log.step directly (not log.info) to avoid bolding the URL.
|
|
114
|
+
p.log.step(`Open this URL to authenticate:\n\n ${authUrl}`);
|
|
115
|
+
code = await waitForCallbackOrPastedCode({
|
|
116
|
+
callbackPromise,
|
|
117
|
+
callbackServer,
|
|
118
|
+
expectedState: state
|
|
119
|
+
});
|
|
109
120
|
}
|
|
110
|
-
// Wait for callback
|
|
111
|
-
const { code } = await callbackPromise;
|
|
112
121
|
// Exchange authorization code for tokens
|
|
113
122
|
let tokens;
|
|
114
123
|
try {
|
|
@@ -215,6 +224,61 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
|
|
|
215
224
|
return null;
|
|
216
225
|
}
|
|
217
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Race the localhost callback listener against a pasted-code prompt.
|
|
229
|
+
*
|
|
230
|
+
* Used when no local browser was opened (remote/headless environments). Whichever
|
|
231
|
+
* settles first wins: if the listener receives the callback, the paste prompt is
|
|
232
|
+
* aborted; if the user pastes first, the listener is stopped. The listener's own
|
|
233
|
+
* timeout is non-fatal here — the user may take a while approving in the browser.
|
|
234
|
+
*
|
|
235
|
+
* @returns The authorization code from whichever source won
|
|
236
|
+
* @throws OAuthFlowError if the user cancels the paste prompt or the pasted value is invalid
|
|
237
|
+
*/ async function waitForCallbackOrPastedCode({ callbackPromise, callbackServer, expectedState }) {
|
|
238
|
+
const abortController = new AbortController();
|
|
239
|
+
const listenerBranch = callbackPromise.then((result)=>{
|
|
240
|
+
// Listener won — tear down the paste prompt.
|
|
241
|
+
abortController.abort();
|
|
242
|
+
return result.code;
|
|
243
|
+
}, (error)=>{
|
|
244
|
+
// A timeout (or an unreachable listener on a remote box, which surfaces as a
|
|
245
|
+
// timeout) is non-fatal here — the user can still paste. Any other callback
|
|
246
|
+
// error means the browser actually reached the listener (denied consent, bad
|
|
247
|
+
// request, state mismatch), so surface it instead of hanging silently on the
|
|
248
|
+
// paste prompt.
|
|
249
|
+
if (error instanceof CallbackError && error.code === 'timeout') {
|
|
250
|
+
return new Promise(()=>{});
|
|
251
|
+
}
|
|
252
|
+
abortController.abort();
|
|
253
|
+
throw error;
|
|
254
|
+
});
|
|
255
|
+
const pasteBranch = (async ()=>{
|
|
256
|
+
// @clack/prompts TextOptions doesn't expose `signal` in its public type, but the
|
|
257
|
+
// underlying @clack/core PromptOptions does. Cast to reach the runtime capability.
|
|
258
|
+
const textOpts = {
|
|
259
|
+
message: 'Paste the redirect URL or code (optional):',
|
|
260
|
+
signal: abortController.signal,
|
|
261
|
+
validate: (value)=>validatePastedCode(value ?? '')
|
|
262
|
+
};
|
|
263
|
+
const pasted = await p.text(textOpts);
|
|
264
|
+
if (p.isCancel(pasted)) {
|
|
265
|
+
throw new OAuthFlowError('Authentication cancelled by user');
|
|
266
|
+
}
|
|
267
|
+
// Paste won — stop the listener before exchanging.
|
|
268
|
+
callbackServer.stop();
|
|
269
|
+
try {
|
|
270
|
+
return parsePastedCode(pasted, expectedState);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new OAuthFlowError(error instanceof Error ? error.message : 'Invalid pasted authorization code', error instanceof Error ? error : undefined);
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
// Avoid an unhandled rejection when the paste prompt is aborted after the listener wins.
|
|
276
|
+
pasteBranch.catch(()=>{});
|
|
277
|
+
return Promise.race([
|
|
278
|
+
listenerBranch,
|
|
279
|
+
pasteBranch
|
|
280
|
+
]);
|
|
281
|
+
}
|
|
218
282
|
function replaceRedirectUriPort(redirectUri, port) {
|
|
219
283
|
const url = new URL(redirectUri);
|
|
220
284
|
url.port = String(port);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error parsing a pasted authorization code or redirect URL.
|
|
3
|
+
*/
|
|
4
|
+
export declare class PastedCodeError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse a value the user pasted back after completing OAuth in their browser.
|
|
9
|
+
*
|
|
10
|
+
* Accepts either the full redirect URL (e.g.
|
|
11
|
+
* `http://localhost:34462/callback?code=...&state=...`) or a bare authorization
|
|
12
|
+
* code. When a URL is pasted, the `state` is validated against the value the CLI
|
|
13
|
+
* generated (CSRF protection); when a bare code is pasted, no state check is
|
|
14
|
+
* possible and PKCE alone gates the subsequent token exchange.
|
|
15
|
+
*
|
|
16
|
+
* @param input - Raw pasted text
|
|
17
|
+
* @param expectedState - The state value the CLI generated for this flow
|
|
18
|
+
* @returns The authorization code
|
|
19
|
+
* @throws PastedCodeError if the input is empty, is a URL without a code, or has a mismatched state
|
|
20
|
+
*/
|
|
21
|
+
export declare function parsePastedCode(input: string, expectedState: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Structural validation for use as a `@clack/prompts` `text` validator.
|
|
24
|
+
*
|
|
25
|
+
* Returns `undefined` when the input could yield a code (so the prompt accepts it),
|
|
26
|
+
* or a human-readable message to re-prompt. Intentionally does NOT validate `state`
|
|
27
|
+
* — a state mismatch is a hard failure handled after the prompt resolves, not a
|
|
28
|
+
* re-prompt condition.
|
|
29
|
+
*
|
|
30
|
+
* @param input - Current prompt value
|
|
31
|
+
* @returns Error message string, or undefined if structurally valid
|
|
32
|
+
*/
|
|
33
|
+
export declare function validatePastedCode(input: string): string | undefined;
|
|
34
|
+
//# sourceMappingURL=paste-code.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error parsing a pasted authorization code or redirect URL.
|
|
3
|
+
*/ export class PastedCodeError extends Error {
|
|
4
|
+
constructor(message){
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'PastedCodeError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const NO_CODE_MESSAGE = 'That URL has no "code" parameter. Paste the full redirect URL or just the code.';
|
|
10
|
+
function parsePasteInput(input) {
|
|
11
|
+
const trimmed = input.trim();
|
|
12
|
+
if (!trimmed) {
|
|
13
|
+
return {
|
|
14
|
+
kind: 'empty'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(trimmed);
|
|
20
|
+
} catch {
|
|
21
|
+
return {
|
|
22
|
+
code: trimmed,
|
|
23
|
+
kind: 'bare-code'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const code = url.searchParams.get('code');
|
|
27
|
+
if (!code) {
|
|
28
|
+
return {
|
|
29
|
+
kind: 'url-missing-code'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
code,
|
|
34
|
+
kind: 'url',
|
|
35
|
+
state: url.searchParams.get('state')
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse a value the user pasted back after completing OAuth in their browser.
|
|
40
|
+
*
|
|
41
|
+
* Accepts either the full redirect URL (e.g.
|
|
42
|
+
* `http://localhost:34462/callback?code=...&state=...`) or a bare authorization
|
|
43
|
+
* code. When a URL is pasted, the `state` is validated against the value the CLI
|
|
44
|
+
* generated (CSRF protection); when a bare code is pasted, no state check is
|
|
45
|
+
* possible and PKCE alone gates the subsequent token exchange.
|
|
46
|
+
*
|
|
47
|
+
* @param input - Raw pasted text
|
|
48
|
+
* @param expectedState - The state value the CLI generated for this flow
|
|
49
|
+
* @returns The authorization code
|
|
50
|
+
* @throws PastedCodeError if the input is empty, is a URL without a code, or has a mismatched state
|
|
51
|
+
*/ export function parsePastedCode(input, expectedState) {
|
|
52
|
+
const parsed = parsePasteInput(input);
|
|
53
|
+
switch(parsed.kind){
|
|
54
|
+
case 'empty':
|
|
55
|
+
throw new PastedCodeError('No value provided.');
|
|
56
|
+
case 'bare-code':
|
|
57
|
+
return parsed.code;
|
|
58
|
+
case 'url-missing-code':
|
|
59
|
+
throw new PastedCodeError(NO_CODE_MESSAGE);
|
|
60
|
+
case 'url':
|
|
61
|
+
if (parsed.state !== null && parsed.state !== expectedState) {
|
|
62
|
+
throw new PastedCodeError('State mismatch — possible CSRF. Run login again.');
|
|
63
|
+
}
|
|
64
|
+
return parsed.code;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Structural validation for use as a `@clack/prompts` `text` validator.
|
|
69
|
+
*
|
|
70
|
+
* Returns `undefined` when the input could yield a code (so the prompt accepts it),
|
|
71
|
+
* or a human-readable message to re-prompt. Intentionally does NOT validate `state`
|
|
72
|
+
* — a state mismatch is a hard failure handled after the prompt resolves, not a
|
|
73
|
+
* re-prompt condition.
|
|
74
|
+
*
|
|
75
|
+
* @param input - Current prompt value
|
|
76
|
+
* @returns Error message string, or undefined if structurally valid
|
|
77
|
+
*/ export function validatePastedCode(input) {
|
|
78
|
+
const parsed = parsePasteInput(input);
|
|
79
|
+
switch(parsed.kind){
|
|
80
|
+
case 'empty':
|
|
81
|
+
return 'Paste the redirect URL or the code.';
|
|
82
|
+
case 'url-missing-code':
|
|
83
|
+
return NO_CODE_MESSAGE;
|
|
84
|
+
default:
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//# sourceMappingURL=paste-code.js.map
|
package/dist/cli.js
CHANGED
|
@@ -49,6 +49,7 @@ class Main {
|
|
|
49
49
|
'--payload-version': String,
|
|
50
50
|
'--skip-auth': Boolean,
|
|
51
51
|
'--skip-build': Boolean,
|
|
52
|
+
'--skip-size-check': Boolean,
|
|
52
53
|
'--template': String,
|
|
53
54
|
'--version': Boolean,
|
|
54
55
|
'--yes': Boolean,
|
|
@@ -135,6 +136,7 @@ class Main {
|
|
|
135
136
|
debug: this.args['--debug'],
|
|
136
137
|
env: this.args['--env'],
|
|
137
138
|
skipBuild: this.args['--skip-build'],
|
|
139
|
+
skipSizeCheck: this.args['--skip-size-check'],
|
|
138
140
|
yes: this.args['--yes']
|
|
139
141
|
});
|
|
140
142
|
break;
|
|
@@ -13,6 +13,8 @@ export interface DeployCommandOptions {
|
|
|
13
13
|
id?: string;
|
|
14
14
|
/** Skip the build step (use existing build) */
|
|
15
15
|
skipBuild?: boolean;
|
|
16
|
+
/** Skip the client-side unzipped bundle size pre-flight check */
|
|
17
|
+
skipSizeCheck?: boolean;
|
|
16
18
|
/** Skip confirmation prompts */
|
|
17
19
|
yes?: boolean;
|
|
18
20
|
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getTokenStore } from '../auth/token-store.js';
|
|
|
7
7
|
import { getInfraEnvironment, getProjectNotFoundMessage } from '../constants.js';
|
|
8
8
|
import { getFileSize } from '../utils/asset-collection.js';
|
|
9
9
|
import { detectBuild, getBuildCommand } from '../utils/build-detection.js';
|
|
10
|
+
import { LAMBDA_MAX_UNZIPPED_BYTES } from '../utils/build-lambda-zip.js';
|
|
10
11
|
import { detectAdapter } from '../utils/deploy-adapter.js';
|
|
11
12
|
import { getEnvVar } from '../utils/env-management.js';
|
|
12
13
|
import * as log from '../utils/log.js';
|
|
@@ -201,12 +202,15 @@ import { loginCommand } from './login.js';
|
|
|
201
202
|
// Create deployment package (if server adapter)
|
|
202
203
|
let zipPath = null;
|
|
203
204
|
let zipSize = 0;
|
|
205
|
+
let unzippedBytes = 0;
|
|
204
206
|
spinner.start('Creating deployment package...');
|
|
205
207
|
try {
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
+
const bundle = await adapter.prepareLambdaBundle(projectPath);
|
|
209
|
+
if (bundle) {
|
|
210
|
+
zipPath = bundle.zipPath;
|
|
211
|
+
unzippedBytes = bundle.unzippedBytes;
|
|
208
212
|
zipSize = await getFileSize(zipPath);
|
|
209
|
-
spinner.stop(pc.green(`✓ Deployment package created (${formatBytes(zipSize)})`));
|
|
213
|
+
spinner.stop(pc.green(`✓ Deployment package created (${formatBytes(zipSize)} zipped, ${formatBytes(unzippedBytes)} unzipped)`));
|
|
210
214
|
} else {
|
|
211
215
|
spinner.stop(pc.green('✓ Static site — no server package needed'));
|
|
212
216
|
}
|
|
@@ -214,6 +218,15 @@ import { loginCommand } from './login.js';
|
|
|
214
218
|
spinner.stop(pc.red('✗ Failed to create deployment package'));
|
|
215
219
|
throw error;
|
|
216
220
|
}
|
|
221
|
+
// Pre-flight: fail fast before the long upload when the unzipped bundle exceeds
|
|
222
|
+
// AWS Lambda's limit. The server enforces this too (and now returns a clear
|
|
223
|
+
// message), so --skip-size-check is a safe escape hatch if this client-side
|
|
224
|
+
// value ever drifts below the real limit.
|
|
225
|
+
if (zipPath && !options.skipSizeCheck && unzippedBytes >= LAMBDA_MAX_UNZIPPED_BYTES) {
|
|
226
|
+
spinner.stop(pc.red(`✗ Unzipped deployment size (${formatBytes(unzippedBytes)}) is at or over the ${formatBytes(LAMBDA_MAX_UNZIPPED_BYTES)} limit.`));
|
|
227
|
+
p.note('Reduce your deployment size (remove unused dependencies or large assets) and try again.\n' + 'To bypass this client-side check, re-run with --skip-size-check.', 'Deployment too large');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
217
230
|
log.debug(`Adapter: ${adapter.name}`);
|
|
218
231
|
if (zipPath) {
|
|
219
232
|
log.debug(`Lambda zip size: ${formatBytes(zipSize)}`);
|
|
@@ -225,7 +238,7 @@ import { loginCommand } from './login.js';
|
|
|
225
238
|
p.note([
|
|
226
239
|
`${pc.cyan('Adapter:')} ${adapter.name}`,
|
|
227
240
|
`${pc.cyan('Build ID:')} ${buildInfo.buildId}`,
|
|
228
|
-
zipPath ? `${pc.cyan('Code size:')} ${formatBytes(zipSize)}` : `${pc.cyan('Code:')} No server`,
|
|
241
|
+
zipPath ? `${pc.cyan('Code size:')} ${formatBytes(zipSize)} zipped, ${formatBytes(unzippedBytes)} unzipped` : `${pc.cyan('Code:')} No server`,
|
|
229
242
|
`${pc.cyan('Pages:')} ${pages.routes.length} routes (${pages.uploadKeys.length} files)`,
|
|
230
243
|
`${pc.cyan('Assets:')} ${assets.uploadKeys.length} files`
|
|
231
244
|
].join('\n'), 'Details');
|
|
@@ -86,9 +86,13 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
|
|
|
86
86
|
// unspoofable) where supported; undefined on browsers that don't implement
|
|
87
87
|
// the non-standard Location.ancestorOrigins (older Firefox, pre-148).
|
|
88
88
|
const ancestorOrigins = window.location.ancestorOrigins;
|
|
89
|
+
// encodeURIComponent is required on Coder devbox: there a proxy hop rewrites
|
|
90
|
+
// an unencoded `serverURL=https://<host>` to http before it reaches the
|
|
91
|
+
// server, which then flows into state.serverURL / the OAuth redirect_uri and
|
|
92
|
+
// breaks login with an https→http navigation. Encoding the `://` avoids it.
|
|
89
93
|
const cleanup = setupIframeAutoLogin({
|
|
90
94
|
doFetch: window.fetch.bind(window),
|
|
91
|
-
metaUrl: `${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${window.location.origin}`,
|
|
95
|
+
metaUrl: `${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${encodeURIComponent(window.location.origin)}`,
|
|
92
96
|
navigate: (url)=>{
|
|
93
97
|
window.location.href = url;
|
|
94
98
|
},
|
|
@@ -115,8 +119,10 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
|
|
|
115
119
|
]);
|
|
116
120
|
useEffect(()=>{
|
|
117
121
|
if (payloadRedirect && !disabled) {
|
|
118
|
-
// set cookie to redirect to the original page
|
|
119
|
-
|
|
122
|
+
// set cookie to redirect to the original page. SameSite=None; Secure;
|
|
123
|
+
// Partitioned so it round-trips to /sso/login from the cross-site Make
|
|
124
|
+
// iframe (a Lax/unmarked cookie isn't sent, dropping the deep-link redirect).
|
|
125
|
+
document.cookie = `payloadRedirect=${payloadRedirect}; path=/; Secure; SameSite=None; Partitioned`;
|
|
120
126
|
}
|
|
121
127
|
}, [
|
|
122
128
|
payloadRedirect,
|
|
@@ -130,7 +136,8 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
|
|
|
130
136
|
return;
|
|
131
137
|
}
|
|
132
138
|
const getAuthorizeURL = async ()=>{
|
|
133
|
-
|
|
139
|
+
// Encoded for the same reason as the auto-login metaUrl above.
|
|
140
|
+
const serverURLFromWindow = encodeURIComponent(window.location.origin);
|
|
134
141
|
const data = await fetch(`${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${serverURLFromWindow}`).then((res)=>res.json());
|
|
135
142
|
setAuthorizeURL(data.authorizeURL);
|
|
136
143
|
};
|
|
@@ -45,7 +45,7 @@ export const LogoutButton = ({ disabled, endpointSlug })=>{
|
|
|
45
45
|
className: baseClass,
|
|
46
46
|
children: /*#__PURE__*/ _jsx("a", {
|
|
47
47
|
"aria-label": "Logout",
|
|
48
|
-
href: `${serverURL}${api}/${userSlug}/${endpointSlug}/logout?redirect=${redirect}`,
|
|
48
|
+
href: `${serverURL}${api}/${userSlug}/${endpointSlug}/logout?redirect=${encodeURIComponent(redirect)}`,
|
|
49
49
|
children: /*#__PURE__*/ _jsxs("svg", {
|
|
50
50
|
className: "icon icon--logout",
|
|
51
51
|
fill: "none",
|
package/dist/oauth/defaults.js
CHANGED
|
@@ -44,7 +44,10 @@ export const defaultVerify = ({ collection, strategyName, userInfoCookieName = D
|
|
|
44
44
|
// Clear the cookie after reading
|
|
45
45
|
if (figmaUserInfo) {
|
|
46
46
|
responseHeaders = new Headers();
|
|
47
|
-
|
|
47
|
+
// SameSite=None; Secure; Partitioned so the clear matches the attributes
|
|
48
|
+
// the cookie was set with (establishSession) and actually removes it in the
|
|
49
|
+
// cross-site Make iframe.
|
|
50
|
+
responseHeaders.append('Set-Cookie', `${userInfoCookieName}=; Max-Age=0; Path=/; Secure; SameSite=None; Partitioned`);
|
|
48
51
|
}
|
|
49
52
|
let user = null;
|
|
50
53
|
const depth = typeof collection.auth === 'object' ? collection.auth.depth : undefined;
|
|
@@ -198,7 +198,9 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
|
|
|
198
198
|
} else {
|
|
199
199
|
redirectToUse += payloadRedirect;
|
|
200
200
|
}
|
|
201
|
-
req.responseHeaders.append('Set-Cookie',
|
|
201
|
+
req.responseHeaders.append('Set-Cookie', // Match the attributes the cookie was set with so the clear removes it
|
|
202
|
+
// in the cross-site Make iframe.
|
|
203
|
+
`payloadRedirect=${redirectToUse}; Max-Age=0; Path=/; Secure; SameSite=None; Partitioned`);
|
|
202
204
|
}
|
|
203
205
|
await establishSession({
|
|
204
206
|
accessToken: access_token,
|
|
@@ -6,6 +6,7 @@ import { getCookieExpiration } from '../utilities/getCookieExpiration.js';
|
|
|
6
6
|
import { getTokenExp } from '../utilities/getTokenExp.js';
|
|
7
7
|
import { mergeHeaders } from '../utilities/mergeHeaders.js';
|
|
8
8
|
import { refreshTokens } from '../utilities/refreshTokens.js';
|
|
9
|
+
import { withPartitionedCookie } from '../utilities/withPartitionedCookie.js';
|
|
9
10
|
export const getRefreshHook = ({ pluginOptions, strategy })=>async ({ args, user })=>{
|
|
10
11
|
if (!args.req.responseHeaders) {
|
|
11
12
|
args.req.responseHeaders = new Headers();
|
|
@@ -47,7 +48,7 @@ export const getRefreshHook = ({ pluginOptions, strategy })=>async ({ args, user
|
|
|
47
48
|
}) ?? 31_556_952),
|
|
48
49
|
value: refreshedToken
|
|
49
50
|
});
|
|
50
|
-
args.req.responseHeaders.append('Set-Cookie', refreshCookie);
|
|
51
|
+
args.req.responseHeaders.append('Set-Cookie', withPartitionedCookie(refreshCookie));
|
|
51
52
|
const newDecoded = jwt.decode(refreshedToken);
|
|
52
53
|
strategy.debugLog('usePayloadJWT: true - Received new refresh token', {
|
|
53
54
|
newDecoded,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateCookie } from 'payload';
|
|
2
2
|
import { getCookieExpiration } from './getCookieExpiration.js';
|
|
3
|
+
import { withPartitionedCookie } from './withPartitionedCookie.js';
|
|
3
4
|
export const clearCookie = (args)=>{
|
|
4
5
|
const options = {
|
|
5
6
|
...args.cookieOptions,
|
|
@@ -15,7 +16,10 @@ export const clearCookie = (args)=>{
|
|
|
15
16
|
if (!args.req.responseHeaders) {
|
|
16
17
|
args.req.responseHeaders = new Headers();
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
// Match the Partitioned attribute the cookie was set with — a Max-Age=0 clear
|
|
20
|
+
// without it targets the unpartitioned jar and won't delete the partitioned
|
|
21
|
+
// cookie, so logout wouldn't clear the session in the cross-site Make iframe.
|
|
22
|
+
args.req.responseHeaders.append('Set-Cookie', withPartitionedCookie(clearedCookie));
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
//# sourceMappingURL=clearCookie.js.map
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
export const createCookieOptions = ({ collection, headers, pluginOptions })=>{
|
|
2
|
+
// Default to SameSite=None + Secure (and `Partitioned`, appended in
|
|
3
|
+
// establishSession via withPartitionedCookie) so the session cookies survive
|
|
4
|
+
// the cross-site Make-preview iframe — the admin panel runs on a different
|
|
5
|
+
// site from the top-level figma.com page, and a Lax/unmarked cookie is not
|
|
6
|
+
// sent back, looping the login. Mirrors the CSRF cookie (buildCsrfCookieHeader).
|
|
7
|
+
// Self-hosters on a same-site setup can override via collection.auth.cookies
|
|
8
|
+
// or pluginOptions.createCookieOptions below.
|
|
2
9
|
let cookieOptions = {
|
|
3
10
|
domain: undefined,
|
|
4
11
|
httpOnly: true,
|
|
5
12
|
path: '/',
|
|
6
|
-
sameSite:
|
|
7
|
-
secure:
|
|
13
|
+
sameSite: 'None',
|
|
14
|
+
secure: true
|
|
8
15
|
};
|
|
9
16
|
if (typeof collection.auth === 'object' && typeof collection.auth.cookies === 'object') {
|
|
10
17
|
const { domain, sameSite, secure } = collection.auth.cookies;
|
|
@@ -2,6 +2,7 @@ import { APIError, generateCookie } from 'payload';
|
|
|
2
2
|
import { getProjectToken, getUserInfo } from '../../api/figma-api.js';
|
|
3
3
|
import { createCookieOptions } from './createCookieOptions.js';
|
|
4
4
|
import { getCookieExpiration } from './getCookieExpiration.js';
|
|
5
|
+
import { withPartitionedCookie } from './withPartitionedCookie.js';
|
|
5
6
|
/**
|
|
6
7
|
* Given a valid OAuth access_token: fetch user info (non-fatal), mint a
|
|
7
8
|
* project token, and write the main + refresh + user-info cookies to
|
|
@@ -51,7 +52,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
|
|
|
51
52
|
returnCookieAsObject: false,
|
|
52
53
|
value: accessToken
|
|
53
54
|
});
|
|
54
|
-
req.responseHeaders.append('Set-Cookie', refreshCookie);
|
|
55
|
+
req.responseHeaders.append('Set-Cookie', withPartitionedCookie(refreshCookie));
|
|
55
56
|
}
|
|
56
57
|
const tokenExpires = cookieOptions?.expires ? getCookieExpiration(cookieOptions.expires) : new Date(projectToken.expiresAt);
|
|
57
58
|
const tokenCookie = generateCookie({
|
|
@@ -61,7 +62,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
|
|
|
61
62
|
returnCookieAsObject: false,
|
|
62
63
|
value: projectToken.token
|
|
63
64
|
});
|
|
64
|
-
req.responseHeaders.append('Set-Cookie', tokenCookie);
|
|
65
|
+
req.responseHeaders.append('Set-Cookie', withPartitionedCookie(tokenCookie));
|
|
65
66
|
// Short-lived handoff cookie consumed by defaultVerify on the next request.
|
|
66
67
|
if (figmaUserInfo) {
|
|
67
68
|
const userInfoValue = Buffer.from(JSON.stringify({
|
|
@@ -75,7 +76,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
|
|
|
75
76
|
returnCookieAsObject: false,
|
|
76
77
|
value: userInfoValue
|
|
77
78
|
});
|
|
78
|
-
req.responseHeaders.append('Set-Cookie', userInfoCookie);
|
|
79
|
+
req.responseHeaders.append('Set-Cookie', withPartitionedCookie(userInfoCookie));
|
|
79
80
|
}
|
|
80
81
|
};
|
|
81
82
|
|
|
@@ -5,6 +5,7 @@ import { getInfraEnvironment } from '../../constants.js';
|
|
|
5
5
|
import { createCookieOptions } from './createCookieOptions.js';
|
|
6
6
|
import { createDebugLogger } from './createDebugLogger.js';
|
|
7
7
|
import { getCookieExpiration } from './getCookieExpiration.js';
|
|
8
|
+
import { withPartitionedCookie } from './withPartitionedCookie.js';
|
|
8
9
|
export const refreshTokens = async ({ payload, refreshToken, strategy })=>{
|
|
9
10
|
const debugLogger = createDebugLogger(payload, strategy.debug);
|
|
10
11
|
debugLogger.info({
|
|
@@ -50,7 +51,7 @@ export const refreshTokens = async ({ payload, refreshToken, strategy })=>{
|
|
|
50
51
|
returnCookieAsObject: false,
|
|
51
52
|
value: token
|
|
52
53
|
});
|
|
53
|
-
headers.append('Set-Cookie', tokenCookie);
|
|
54
|
+
headers.append('Set-Cookie', withPartitionedCookie(tokenCookie));
|
|
54
55
|
const decodedOauthToken = await validateProjectToken(token, getInfraEnvironment());
|
|
55
56
|
return {
|
|
56
57
|
decodedOauthToken,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append the `Partitioned` (CHIPS) attribute to a `Set-Cookie` header string.
|
|
3
|
+
*
|
|
4
|
+
* Payload's `generateCookie` cannot emit `Partitioned`, but Chrome requires it
|
|
5
|
+
* for a `SameSite=None` cookie set inside the cross-site Make-preview iframe
|
|
6
|
+
* (top-level `figma.com`, app on a different site) to be stored and sent back.
|
|
7
|
+
* Without it the session cookies are dropped, `/api/users/me` sees no token,
|
|
8
|
+
* and the admin login loops forever. The CSRF cookie avoids this by
|
|
9
|
+
* hand-building its header (`buildCsrfCookieHeader`); the session cookies go
|
|
10
|
+
* through `generateCookie`, so we add the attribute here to match.
|
|
11
|
+
*
|
|
12
|
+
* Only applied to `Secure` cookies: `Partitioned` requires `Secure`, and a
|
|
13
|
+
* partitioned non-secure cookie is rejected outright. Idempotent. The presence
|
|
14
|
+
* check matches a bare `Secure` or `generateCookie`'s `Secure=true` form, but
|
|
15
|
+
* deliberately NOT `Secure=false` — `generateCookie` never emits that today
|
|
16
|
+
* (it only writes `Secure` when truthy), but the check stays defensive so a
|
|
17
|
+
* future/hand-built `Secure=false` can't get `Partitioned` (which requires Secure).
|
|
18
|
+
*/
|
|
19
|
+
export declare const withPartitionedCookie: (cookie: string) => string;
|
|
20
|
+
//# sourceMappingURL=withPartitionedCookie.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append the `Partitioned` (CHIPS) attribute to a `Set-Cookie` header string.
|
|
3
|
+
*
|
|
4
|
+
* Payload's `generateCookie` cannot emit `Partitioned`, but Chrome requires it
|
|
5
|
+
* for a `SameSite=None` cookie set inside the cross-site Make-preview iframe
|
|
6
|
+
* (top-level `figma.com`, app on a different site) to be stored and sent back.
|
|
7
|
+
* Without it the session cookies are dropped, `/api/users/me` sees no token,
|
|
8
|
+
* and the admin login loops forever. The CSRF cookie avoids this by
|
|
9
|
+
* hand-building its header (`buildCsrfCookieHeader`); the session cookies go
|
|
10
|
+
* through `generateCookie`, so we add the attribute here to match.
|
|
11
|
+
*
|
|
12
|
+
* Only applied to `Secure` cookies: `Partitioned` requires `Secure`, and a
|
|
13
|
+
* partitioned non-secure cookie is rejected outright. Idempotent. The presence
|
|
14
|
+
* check matches a bare `Secure` or `generateCookie`'s `Secure=true` form, but
|
|
15
|
+
* deliberately NOT `Secure=false` — `generateCookie` never emits that today
|
|
16
|
+
* (it only writes `Secure` when truthy), but the check stays defensive so a
|
|
17
|
+
* future/hand-built `Secure=false` can't get `Partitioned` (which requires Secure).
|
|
18
|
+
*/ export const withPartitionedCookie = (cookie)=>{
|
|
19
|
+
if (!/;\s*Secure(?:=true)?(?:;|$)/i.test(cookie)) {
|
|
20
|
+
return cookie;
|
|
21
|
+
}
|
|
22
|
+
if (/;\s*Partitioned(?:=|;|$)/i.test(cookie)) {
|
|
23
|
+
return cookie;
|
|
24
|
+
}
|
|
25
|
+
return `${cookie}; Partitioned`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
//# sourceMappingURL=withPartitionedCookie.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
|
|
1
|
+
import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
|
|
2
2
|
export declare class NextjsAdapter implements DeployAdapter {
|
|
3
3
|
name: "nextjs";
|
|
4
4
|
collectAssets(projectPath: string): Promise<AssetCollection>;
|
|
5
5
|
collectPages(projectPath: string): Promise<PageCollection>;
|
|
6
|
-
prepareLambdaBundle(projectPath: string): Promise<
|
|
6
|
+
prepareLambdaBundle(projectPath: string): Promise<LambdaBundle>;
|
|
7
7
|
}
|
|
8
8
|
export declare const nextjsAdapter: NextjsAdapter;
|
|
9
9
|
//# sourceMappingURL=nextjs.d.ts.map
|
|
@@ -55,8 +55,7 @@ export class NextjsAdapter {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
async prepareLambdaBundle(projectPath) {
|
|
58
|
-
|
|
59
|
-
return path.join(projectPath, 'lambda.zip');
|
|
58
|
+
return buildLambdaZip(projectPath);
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
export const nextjsAdapter = new NextjsAdapter();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
|
|
1
|
+
import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
|
|
2
2
|
export declare class NitroAdapter implements DeployAdapter {
|
|
3
3
|
name: "nitro";
|
|
4
4
|
collectAssets(projectPath: string): Promise<AssetCollection>;
|
|
5
5
|
collectPages(projectPath: string): Promise<PageCollection>;
|
|
6
|
-
prepareLambdaBundle(projectPath: string): Promise<
|
|
6
|
+
prepareLambdaBundle(projectPath: string): Promise<LambdaBundle>;
|
|
7
7
|
}
|
|
8
8
|
export declare const nitroAdapter: NitroAdapter;
|
|
9
9
|
//# sourceMappingURL=nitro.d.ts.map
|
|
@@ -2,7 +2,7 @@ import archiver from 'archiver';
|
|
|
2
2
|
import { createWriteStream, statSync } from 'fs';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { collectFilesRecursive } from '../fs-utils.js';
|
|
5
|
+
import { collectFilesRecursive, getDirectorySize } from '../fs-utils.js';
|
|
6
6
|
const RUN_SCRIPT = `#!/bin/bash -x
|
|
7
7
|
[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache
|
|
8
8
|
NODE_ENV=production exec node server/index.mjs
|
|
@@ -99,11 +99,16 @@ export class NitroAdapter {
|
|
|
99
99
|
source: runShPath
|
|
100
100
|
}
|
|
101
101
|
]);
|
|
102
|
+
const runShStat = await fs.stat(runShPath);
|
|
103
|
+
const unzippedBytes = await getDirectorySize(serverDir) + await getDirectorySize(publicDir) + runShStat.size;
|
|
102
104
|
// Clean up temp run.sh
|
|
103
105
|
await fs.rm(runShPath, {
|
|
104
106
|
force: true
|
|
105
107
|
});
|
|
106
|
-
return
|
|
108
|
+
return {
|
|
109
|
+
unzippedBytes,
|
|
110
|
+
zipPath
|
|
111
|
+
};
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
export const nitroAdapter = new NitroAdapter();
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
|
|
1
|
+
import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
|
|
2
2
|
export declare class ViteAdapter implements DeployAdapter {
|
|
3
3
|
fallback: string;
|
|
4
4
|
name: "vite";
|
|
5
5
|
collectAssets(projectPath: string): Promise<AssetCollection>;
|
|
6
6
|
collectPages(): Promise<PageCollection>;
|
|
7
|
-
prepareLambdaBundle(): Promise<null>;
|
|
7
|
+
prepareLambdaBundle(): Promise<LambdaBundle | null>;
|
|
8
8
|
}
|
|
9
9
|
export declare const viteAdapter: ViteAdapter;
|
|
10
10
|
//# sourceMappingURL=vite.d.ts.map
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import type { LambdaBundle } from './deploy-adapter.js';
|
|
2
|
+
/**
|
|
3
|
+
* AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
|
|
4
|
+
* total must be strictly less than this value, so a bundle exactly equal to it is
|
|
5
|
+
* rejected. The server enforces this too and now returns a clear message, so a
|
|
6
|
+
* client-side check against this value is an early-exit convenience, not the
|
|
7
|
+
* source of truth.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LAMBDA_MAX_UNZIPPED_BYTES: number;
|
|
1
10
|
/**
|
|
2
11
|
* Build Lambda deployment zip using pure Node.js
|
|
3
12
|
*
|
|
@@ -10,7 +19,8 @@
|
|
|
10
19
|
* as static assets and served via CDN, not from the Lambda function.
|
|
11
20
|
*
|
|
12
21
|
* @param projectPath - Path to project root
|
|
22
|
+
* @returns The built bundle: its unzipped byte size and the absolute zip path.
|
|
13
23
|
* @throws Error if standalone directory missing or zip creation fails
|
|
14
24
|
*/
|
|
15
|
-
export declare function buildLambdaZip(projectPath: string): Promise<
|
|
25
|
+
export declare function buildLambdaZip(projectPath: string): Promise<LambdaBundle>;
|
|
16
26
|
//# sourceMappingURL=build-lambda-zip.d.ts.map
|
|
@@ -2,6 +2,14 @@ import archiver from 'archiver';
|
|
|
2
2
|
import { createWriteStream } from 'fs';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import { getDirectorySize } from './fs-utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
|
|
8
|
+
* total must be strictly less than this value, so a bundle exactly equal to it is
|
|
9
|
+
* rejected. The server enforces this too and now returns a clear message, so a
|
|
10
|
+
* client-side check against this value is an early-exit convenience, not the
|
|
11
|
+
* source of truth.
|
|
12
|
+
*/ export const LAMBDA_MAX_UNZIPPED_BYTES = 250 * 1024 * 1024;
|
|
5
13
|
/**
|
|
6
14
|
* Build Lambda deployment zip using pure Node.js
|
|
7
15
|
*
|
|
@@ -14,6 +22,7 @@ import path from 'path';
|
|
|
14
22
|
* as static assets and served via CDN, not from the Lambda function.
|
|
15
23
|
*
|
|
16
24
|
* @param projectPath - Path to project root
|
|
25
|
+
* @returns The built bundle: its unzipped byte size and the absolute zip path.
|
|
17
26
|
* @throws Error if standalone directory missing or zip creation fails
|
|
18
27
|
*/ export async function buildLambdaZip(projectPath) {
|
|
19
28
|
const standalonePath = path.join(projectPath, '.next', 'standalone');
|
|
@@ -32,7 +41,12 @@ import path from 'path';
|
|
|
32
41
|
} catch {
|
|
33
42
|
// run.sh may not exist in all setups
|
|
34
43
|
}
|
|
44
|
+
const unzippedBytes = await getDirectorySize(standalonePath);
|
|
35
45
|
await createZip(standalonePath, zipPath);
|
|
46
|
+
return {
|
|
47
|
+
unzippedBytes,
|
|
48
|
+
zipPath
|
|
49
|
+
};
|
|
36
50
|
}
|
|
37
51
|
async function isDirectory(dirPath) {
|
|
38
52
|
try {
|
|
@@ -17,14 +17,20 @@ export interface AssetCollection {
|
|
|
17
17
|
uploadKeys: string[];
|
|
18
18
|
}
|
|
19
19
|
export type AdapterName = 'nextjs' | 'nitro' | 'vite';
|
|
20
|
+
export interface LambdaBundle {
|
|
21
|
+
/** Total size in bytes of the bundle contents before compression. */
|
|
22
|
+
unzippedBytes: number;
|
|
23
|
+
/** Absolute path to the built lambda.zip. */
|
|
24
|
+
zipPath: string;
|
|
25
|
+
}
|
|
20
26
|
export interface DeployAdapter {
|
|
21
27
|
collectAssets(projectPath: string): Promise<AssetCollection>;
|
|
22
28
|
collectPages(projectPath: string): Promise<PageCollection>;
|
|
23
29
|
/** SPA fallback path (e.g., "/index.html"). Undefined for SSR/SSG apps. */
|
|
24
30
|
fallback?: string;
|
|
25
31
|
name: AdapterName;
|
|
26
|
-
/** Assembles and zips the server bundle. Returns
|
|
27
|
-
prepareLambdaBundle(projectPath: string): Promise<
|
|
32
|
+
/** Assembles and zips the server bundle. Returns the bundle, or null if no server (e.g., Vite SPA). */
|
|
33
|
+
prepareLambdaBundle(projectPath: string): Promise<LambdaBundle | null>;
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
30
36
|
* Auto-detect the framework adapter based on build output.
|
package/dist/utils/fs-utils.d.ts
CHANGED
|
@@ -3,4 +3,11 @@
|
|
|
3
3
|
* returned as forward-slash-separated paths relative to baseDir.
|
|
4
4
|
*/
|
|
5
5
|
export declare function collectFilesRecursive(dir: string, baseDir: string): Promise<string[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Recursively sum the byte size of all files under a directory.
|
|
8
|
+
* Returns 0 for a missing or unreadable directory. Symlinks are not
|
|
9
|
+
* followed. Files that disappear or become unreadable between readdir
|
|
10
|
+
* and stat are skipped (treated as 0 bytes).
|
|
11
|
+
*/
|
|
12
|
+
export declare function getDirectorySize(dir: string): Promise<number>;
|
|
6
13
|
//# sourceMappingURL=fs-utils.d.ts.map
|
package/dist/utils/fs-utils.js
CHANGED
|
@@ -23,5 +23,35 @@ import path from 'path';
|
|
|
23
23
|
}
|
|
24
24
|
return files;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Recursively sum the byte size of all files under a directory.
|
|
28
|
+
* Returns 0 for a missing or unreadable directory. Symlinks are not
|
|
29
|
+
* followed. Files that disappear or become unreadable between readdir
|
|
30
|
+
* and stat are skipped (treated as 0 bytes).
|
|
31
|
+
*/ export async function getDirectorySize(dir) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await fs.readdir(dir, {
|
|
35
|
+
withFileTypes: true
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (const entry of entries){
|
|
42
|
+
const fullPath = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
total += await getDirectorySize(fullPath);
|
|
45
|
+
} else if (entry.isFile()) {
|
|
46
|
+
try {
|
|
47
|
+
const stats = await fs.stat(fullPath);
|
|
48
|
+
total += stats.size;
|
|
49
|
+
} catch {
|
|
50
|
+
// File vanished or is unreadable between readdir and stat — skip it.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return total;
|
|
55
|
+
}
|
|
26
56
|
|
|
27
57
|
//# sourceMappingURL=fs-utils.js.map
|
package/dist/utils/messages.js
CHANGED
|
@@ -61,6 +61,7 @@ export function helpMessage() {
|
|
|
61
61
|
${pc.dim('--env <environment>')} Override FIGMA_ENVIRONMENT_NAME
|
|
62
62
|
${pc.dim('--yes, -y')} Skip confirmation prompts
|
|
63
63
|
${pc.dim('--skip-build')} Skip building and use existing build
|
|
64
|
+
${pc.dim('--skip-size-check')} Skip the client-side bundle size check
|
|
64
65
|
|
|
65
66
|
${pc.bold('UPGRADE COMMAND')}
|
|
66
67
|
|
package/dist/utils/s3-upload.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { parseS3Error } from '../api/error-response.js';
|
|
3
4
|
import * as log from './log.js';
|
|
4
5
|
/**
|
|
5
6
|
* Retry configuration
|
|
@@ -87,7 +88,10 @@ const INITIAL_RETRY_DELAY = 1000 // 1 second
|
|
|
87
88
|
if (response.ok) {
|
|
88
89
|
return; // Success
|
|
89
90
|
}
|
|
90
|
-
lastError = new Error(
|
|
91
|
+
lastError = new Error(await parseS3Error({
|
|
92
|
+
context: `upload ${path.basename(filePath)}`,
|
|
93
|
+
response
|
|
94
|
+
}));
|
|
91
95
|
} catch (error) {
|
|
92
96
|
lastError = error instanceof Error ? error : new Error('Unknown error');
|
|
93
97
|
}
|