@payloadcms/figma 0.0.1-alpha.68 → 0.0.1-alpha.69

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.
@@ -1,4 +1,4 @@
1
- import type { BaseDatabaseAdapter, DatabaseAdapterObj } from 'payload';
1
+ import type { BaseDatabaseAdapter, CreateArgs, DatabaseAdapterObj } from 'payload';
2
2
  import createClient from 'openapi-fetch';
3
3
  import type { paths } from './generated/content-api-types.js';
4
4
  import { type AuthMode } from './utilities/auth.js';
@@ -7,8 +7,21 @@ type ContentAPIOptions = {
7
7
  auth: AuthMode;
8
8
  contentSystemId: string;
9
9
  environmentName?: string;
10
+ notifyLocalJobQueue: boolean;
10
11
  url: string;
11
12
  };
13
+ /**
14
+ * Heads-up logged locally whenever a job is queued. Queued jobs are executed by Figma's deployed runner, so
15
+ * if a deployed environment exists its runner can pick up anything in the queue. The developer
16
+ * needs to know that a job they just created locally may run on their deployed app instead of their machine.
17
+ */
18
+ export declare const LOCAL_JOB_QUEUE_NOTICE: string;
19
+ /**
20
+ * Logs {@link LOCAL_JOB_QUEUE_NOTICE} for every job queued locally: if a deployed
21
+ * environment exists, its runner can pick up anything in the queue. No-op when
22
+ * `notifyLocalJobQueue` is false (i.e. not a local runtime).
23
+ */
24
+ export declare function maybeNotifyLocalJobQueue(adapter: ContentAPIAdapter, args: CreateArgs): void;
12
25
  export type ContentAPIAdapter = {
13
26
  auth: AuthMode;
14
27
  clearDatabase: () => Promise<void>;
@@ -16,6 +29,7 @@ export type ContentAPIAdapter = {
16
29
  contentSystemId: string;
17
30
  environmentName?: string;
18
31
  idType: 'uuid';
32
+ notifyLocalJobQueue: boolean;
19
33
  url: string;
20
34
  } & BaseDatabaseAdapter;
21
35
  export declare const contentAPIAdapter: (opts: ContentAPIOptions) => DatabaseAdapterObj;
@@ -14,6 +14,24 @@ import { addFallbackLocale } from './utilities/locale/index.js';
14
14
  import { buildMeta } from './utilities/meta/buildMeta.js';
15
15
  import { normalizeLocaleInWhere } from './utilities/meta/normalizeLocaleInWhere.js';
16
16
  import { convertPayloadWhereToContentAPI } from './utilities/where.js';
17
+ /**
18
+ * Heads-up logged locally whenever a job is queued. Queued jobs are executed by Figma's deployed runner, so
19
+ * if a deployed environment exists its runner can pick up anything in the queue. The developer
20
+ * needs to know that a job they just created locally may run on their deployed app instead of their machine.
21
+ */ export const LOCAL_JOB_QUEUE_NOTICE = "A job was queued locally. Queued jobs may be run by Figma's deployed runner, not your local " + 'dev server. If this app has a deployed environment, your deployed app may pick this ' + 'job up and run it against deployed code. If this app has no deployed ' + 'environment, the job will not run automatically. To test job logic against your local ' + 'code, call the function your task runs directly instead of queueing a job. ';
22
+ /**
23
+ * Logs {@link LOCAL_JOB_QUEUE_NOTICE} for every job queued locally: if a deployed
24
+ * environment exists, its runner can pick up anything in the queue. No-op when
25
+ * `notifyLocalJobQueue` is false (i.e. not a local runtime).
26
+ */ export function maybeNotifyLocalJobQueue(adapter, args) {
27
+ if (!adapter.notifyLocalJobQueue) {
28
+ return;
29
+ }
30
+ if (args.collection !== 'payload-jobs') {
31
+ return;
32
+ }
33
+ adapter.payload.logger.warn(LOCAL_JOB_QUEUE_NOTICE);
34
+ }
17
35
  async function syncCollections() {
18
36
  let existingIds;
19
37
  try {
@@ -544,6 +562,7 @@ async function create(args) {
544
562
  if (!response) {
545
563
  throw new Error('No response from create');
546
564
  }
565
+ maybeNotifyLocalJobQueue(this, args);
547
566
  return unwrapDocument({
548
567
  collectionSlug: args.collection,
549
568
  doc: response.result,
@@ -878,6 +897,7 @@ export const contentAPIAdapter = (opts)=>({
878
897
  findVersions: findVersions,
879
898
  idType: 'uuid',
880
899
  init,
900
+ notifyLocalJobQueue: opts.notifyLocalJobQueue,
881
901
  packageName: '@payloadcms/db-content-api',
882
902
  payload,
883
903
  queryDrafts: queryDrafts,
@@ -9,15 +9,30 @@ export interface CmsOAuthCodeMessage {
9
9
  * value the iframe sent on its outbound `mint-cms-oauth-code` request.
10
10
  */
11
11
  export declare const parseCmsOAuthCodeMessage: (data: unknown) => CmsOAuthCodeMessage | null;
12
+ export interface ParentOriginSignals {
13
+ /**
14
+ * `ancestorOrigins[0]`: immediate embedder origin. `undefined` where the
15
+ * browser lacks `Location.ancestorOrigins`.
16
+ */
17
+ ancestorOrigin: null | string | undefined;
18
+ /** `window.location.origin` — this iframe's own origin. */
19
+ ownOrigin: null | string | undefined;
20
+ /** `document.referrer`. */
21
+ referrer: null | string | undefined;
22
+ }
12
23
  /**
13
- * Derive a tight `targetOrigin` for `window.parent.postMessage` from the
14
- * iframe's `document.referrer`. If the referrer is a Figma origin we return
15
- * it verbatim; otherwise (stripped by Referrer-Policy, malformed, or hostile)
16
- * we fall back to '*'. The outbound payload contains the server-signed
17
- * `state` from /meta no secrets to leak — so '*' is acceptable; the
18
- * inbound origin check is the load-bearing security control.
24
+ * Resolve the exact origin for `window.parent.postMessage`, or `null` if we
25
+ * can't establish a trusted embedder (caller must then NOT post). We never
26
+ * target `'*'`: the payload embeds PKCE material in `state`.
27
+ *
28
+ * 1. `ancestorOrigin` present (browser-set, unspoofable): trust iff Figma,
29
+ * else `null` no fall-through to weaker signals.
30
+ * 2. No `ancestorOrigins`: fall back to `referrer`, but only if
31
+ * it's a Figma origin and not our own. (Payload's `/admin` -> `/admin/login`
32
+ * 307 leaves `referrer` as our own origin, never the embedder.)
33
+ * 3. Otherwise `null`.
19
34
  */
20
- export declare const pickParentTargetOrigin: (referrer: null | string | undefined) => string;
35
+ export declare const resolveParentTargetOrigin: ({ ancestorOrigin, ownOrigin, referrer, }: ParentOriginSignals) => null | string;
21
36
  export interface IframeAutoLoginDeps {
22
37
  doFetch: typeof fetch;
23
38
  /**
@@ -36,7 +51,11 @@ export interface IframeAutoLoginDeps {
36
51
  * the host is expected to apply its own timeout.
37
52
  */
38
53
  onFailed?: () => void;
39
- parentReferrer: null | string | undefined;
54
+ /**
55
+ * Exact origin for `postMessage`, from `resolveParentTargetOrigin`. `null`
56
+ * means no trusted embedder: don't post, trigger `onFailed`. Never `'*'`.
57
+ */
58
+ parentTargetOrigin: null | string;
40
59
  postMessageToParent: (message: unknown, targetOrigin: string) => void;
41
60
  win: Pick<Window, 'addEventListener' | 'removeEventListener'>;
42
61
  }
@@ -24,22 +24,31 @@ import { isFigmaOrigin } from '../../utilities/figmaHostnames.js';
24
24
  };
25
25
  };
26
26
  /**
27
- * Derive a tight `targetOrigin` for `window.parent.postMessage` from the
28
- * iframe's `document.referrer`. If the referrer is a Figma origin we return
29
- * it verbatim; otherwise (stripped by Referrer-Policy, malformed, or hostile)
30
- * we fall back to '*'. The outbound payload contains the server-signed
31
- * `state` from /meta no secrets to leak — so '*' is acceptable; the
32
- * inbound origin check is the load-bearing security control.
33
- */ export const pickParentTargetOrigin = (referrer)=>{
34
- if (!referrer) {
35
- return '*';
27
+ * Resolve the exact origin for `window.parent.postMessage`, or `null` if we
28
+ * can't establish a trusted embedder (caller must then NOT post). We never
29
+ * target `'*'`: the payload embeds PKCE material in `state`.
30
+ *
31
+ * 1. `ancestorOrigin` present (browser-set, unspoofable): trust iff Figma,
32
+ * else `null` no fall-through to weaker signals.
33
+ * 2. No `ancestorOrigins`: fall back to `referrer`, but only if
34
+ * it's a Figma origin and not our own. (Payload's `/admin` -> `/admin/login`
35
+ * 307 leaves `referrer` as our own origin, never the embedder.)
36
+ * 3. Otherwise `null`.
37
+ */ export const resolveParentTargetOrigin = ({ ancestorOrigin, ownOrigin, referrer })=>{
38
+ if (ancestorOrigin != null) {
39
+ return isFigmaOrigin(ancestorOrigin) ? ancestorOrigin : null;
36
40
  }
37
- try {
38
- const candidate = new URL(referrer).origin;
39
- return isFigmaOrigin(candidate) ? candidate : '*';
40
- } catch {
41
- return '*';
41
+ if (referrer) {
42
+ try {
43
+ const candidate = new URL(referrer).origin;
44
+ if (candidate !== ownOrigin && isFigmaOrigin(candidate)) {
45
+ return candidate;
46
+ }
47
+ } catch {
48
+ // unparseable referrer — fall through
49
+ }
42
50
  }
51
+ return null;
43
52
  };
44
53
  /**
45
54
  * Wire up the iframe auto-login flow:
@@ -122,6 +131,14 @@ import { isFigmaOrigin } from '../../utilities/figmaHostnames.js';
122
131
  if (abortController.signal.aborted) {
123
132
  return;
124
133
  }
134
+ // No trusted embedder origin: refuse to post (don't broadcast PKCE-bearing
135
+ // `state` to '*'). See `resolveParentTargetOrigin`.
136
+ if (deps.parentTargetOrigin === null) {
137
+ // eslint-disable-next-line no-console -- surface auto-login failures to dev tools
138
+ console.warn('[payload iframe auto-login] could not determine a trusted parent origin — falling back to manual login');
139
+ deps.onFailed?.();
140
+ return;
141
+ }
125
142
  flow = {
126
143
  redirectUri: redirect,
127
144
  state
@@ -134,7 +151,7 @@ import { isFigmaOrigin } from '../../utilities/figmaHostnames.js';
134
151
  redirect_uri: redirect,
135
152
  scope,
136
153
  state
137
- }, pickParentTargetOrigin(deps.parentReferrer));
154
+ }, deps.parentTargetOrigin);
138
155
  } catch (err) {
139
156
  if (abortController.signal.aborted) {
140
157
  return;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useConfig } from '@payloadcms/ui';
4
4
  import { useSearchParams } from 'next/navigation.js';
5
5
  import React, { useEffect, useState } from 'react';
6
- import { setupIframeAutoLogin } from './iframeAutoLogin.js';
6
+ import { resolveParentTargetOrigin, setupIframeAutoLogin } from './iframeAutoLogin.js';
7
7
  import './index.scss';
8
8
  const baseClass = 'oauth-login';
9
9
  // Max time we wait for the parent (figma.com) to respond with a code after
@@ -82,6 +82,10 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
82
82
  console.warn(`[payload iframe auto-login] parent did not reply with cms-oauth-code within ${AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS}ms — falling back to manual login`);
83
83
  setAutoLoginFailed(true);
84
84
  }, AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS);
85
+ // ancestorOrigins[0] is the immediate embedder's origin (browser-set,
86
+ // unspoofable) where supported; undefined on browsers that don't implement
87
+ // the non-standard Location.ancestorOrigins (older Firefox, pre-148).
88
+ const ancestorOrigins = window.location.ancestorOrigins;
85
89
  const cleanup = setupIframeAutoLogin({
86
90
  doFetch: window.fetch.bind(window),
87
91
  metaUrl: `${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${window.location.origin}`,
@@ -89,7 +93,11 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
89
93
  window.location.href = url;
90
94
  },
91
95
  onFailed: ()=>setAutoLoginFailed(true),
92
- parentReferrer: document.referrer,
96
+ parentTargetOrigin: resolveParentTargetOrigin({
97
+ ancestorOrigin: ancestorOrigins?.[0],
98
+ ownOrigin: window.location.origin,
99
+ referrer: document.referrer
100
+ }),
93
101
  postMessageToParent: (msg, target)=>window.parent.postMessage(msg, target),
94
102
  win: window
95
103
  });
@@ -213,6 +213,10 @@ export async function buildFigmaConfig(config) {
213
213
  }
214
214
  const url = process.env.FIGMA_CONTENT_API_URL || envConfig.contentApiUrl;
215
215
  const isProduction = process.env.NODE_ENV === 'production';
216
+ // The deployed Lambda runs all queued jobs; locally there is no runner, so the db adapter logs
217
+ // a heads-up whenever a job is queued. Deployed environments set DISABLE_LOCAL_JOB_QUEUE_NOTIFICATIONS=true
218
+ // (injected by the control plane); end-users can also set it locally to silence the notice.
219
+ const notifyLocalJobQueue = process.env.DISABLE_LOCAL_JOB_QUEUE_NOTIFICATIONS !== 'true';
216
220
  const usesTokenStoreAuth = !process.env.FIGMA_CONTENT_API_ACCESS_KEY && process.env.FIGMA_DEV_JWT !== 'true';
217
221
  if (usesTokenStoreAuth) {
218
222
  logMissingCliAuth(getTokenStore());
@@ -229,6 +233,7 @@ export async function buildFigmaConfig(config) {
229
233
  },
230
234
  contentSystemId,
231
235
  environmentName: process.env.FIGMA_ENVIRONMENT_NAME,
236
+ notifyLocalJobQueue,
232
237
  url
233
238
  });
234
239
  } else if (process.env.FIGMA_DEV_JWT === 'true') {
@@ -238,6 +243,7 @@ export async function buildFigmaConfig(config) {
238
243
  },
239
244
  contentSystemId,
240
245
  environmentName: process.env.FIGMA_ENVIRONMENT_NAME,
246
+ notifyLocalJobQueue,
241
247
  url
242
248
  });
243
249
  } else {
@@ -248,6 +254,7 @@ export async function buildFigmaConfig(config) {
248
254
  },
249
255
  contentSystemId,
250
256
  environmentName: process.env.FIGMA_ENVIRONMENT_NAME,
257
+ notifyLocalJobQueue,
251
258
  url
252
259
  });
253
260
  }
@@ -34,6 +34,7 @@ export const ContentApiClientUploadHandler = createClientUploadHandler({
34
34
  return {
35
35
  clientUploaded: true,
36
36
  filename: uploadFilename,
37
+ mimeType: file.type,
37
38
  prefix
38
39
  };
39
40
  }
@@ -1,16 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { parseClientUploadContext } from './utilities/index.js';
3
4
  const PART_SIZE = 64 * 1024 * 1024 // 64MB per part
4
5
  ;
5
6
  const MAX_FILE_SIZE_BEFORE_MULTIPART = 100 * 1024 * 1024 // 100MB
6
7
  ;
7
8
  export const getHandleUpload = ({ client, collection, prefix = '' })=>{
8
9
  return async ({ clientUploadContext, data, file })=>{
9
- // Skip if client already uploaded THIS SPECIFIC file
10
- // clientUploadContext.filename contains the original filename that was client-uploaded
11
- // Resized versions (different filename) should still be uploaded
12
- const clientUploadedFilename = clientUploadContext && typeof clientUploadContext === 'object' && 'filename' in clientUploadContext ? clientUploadContext.filename : undefined;
13
- const isOriginalClientUploaded = clientUploadContext && typeof clientUploadContext === 'object' && 'clientUploaded' in clientUploadContext && clientUploadContext.clientUploaded && file.filename === clientUploadedFilename;
10
+ // Skip if the client already uploaded THIS SPECIFIC file. Resized versions
11
+ // (different filename) should still be uploaded.
12
+ const context = parseClientUploadContext(clientUploadContext);
13
+ const isOriginalClientUploaded = context.clientUploaded === true && file.filename === context.filename;
14
14
  if (isOriginalClientUploaded) {
15
15
  return data;
16
16
  }
@@ -1,5 +1,7 @@
1
1
  import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities';
2
2
  import path from 'path';
3
+ import { isImage } from 'payload/shared';
4
+ import { parseClientUploadContext } from './utilities/index.js';
3
5
  export const getHandler = ({ client, collection })=>{
4
6
  return async (req, { params: { clientUploadContext, filename } })=>{
5
7
  try {
@@ -23,6 +25,28 @@ export const getHandler = ({ client, collection })=>{
23
25
  status: 500
24
26
  });
25
27
  }
28
+ // On a client-upload fetch-back, core reads this response body into req.file.data to
29
+ // size the image; a 302's empty body crashes getImageSize. Return real bytes for
30
+ // images (and unknown mimetypes, to be safe), but keep the redirect otherwise so we
31
+ // don't download large non-images server-side.
32
+ if (clientUploadContext) {
33
+ const { mimeType } = parseClientUploadContext(clientUploadContext);
34
+ if (!mimeType || isImage(mimeType)) {
35
+ const fileResponse = await fetch(data.url);
36
+ if (!fileResponse.ok) {
37
+ req.payload.logger.error(`Failed to fetch file for client-upload buffer fetch-back: ${fileResponse.status}`);
38
+ return new Response('Internal Server Error', {
39
+ status: 500
40
+ });
41
+ }
42
+ const contentType = fileResponse.headers.get('Content-Type') ?? mimeType ?? 'application/octet-stream';
43
+ return new Response(await fileResponse.arrayBuffer(), {
44
+ headers: {
45
+ 'Content-Type': contentType
46
+ }
47
+ });
48
+ }
49
+ }
26
50
  return Response.redirect(data.url, 302);
27
51
  } catch (err) {
28
52
  req.payload.logger.error({
@@ -1,2 +1,3 @@
1
1
  export { getSafeFilename } from './getSafeFilename.js';
2
+ export { type ContentApiClientUploadContext, parseClientUploadContext, } from './parseClientUploadContext.js';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,4 @@
1
1
  export { getSafeFilename } from './getSafeFilename.js';
2
+ export { parseClientUploadContext } from './parseClientUploadContext.js';
2
3
 
3
4
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The shape of the `clientUploadContext` produced by this adapter's client upload
3
+ * handler. The plugin's `StaticHandler`/`HandleUpload` types expose it as `unknown`
4
+ * (it is deserialized from a JSON form field and is adapter-specific), so it must be
5
+ * narrowed at runtime before use.
6
+ */
7
+ export type ContentApiClientUploadContext = {
8
+ clientUploaded?: boolean;
9
+ filename?: string;
10
+ mimeType?: string;
11
+ prefix?: string;
12
+ };
13
+ /**
14
+ * Safely narrows an `unknown` clientUploadContext into the adapter's known shape,
15
+ * keeping only properties that are actually present with the expected type. Returns
16
+ * an empty object for any non-object input.
17
+ */
18
+ export declare const parseClientUploadContext: (context: unknown) => ContentApiClientUploadContext;
19
+ //# sourceMappingURL=parseClientUploadContext.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The shape of the `clientUploadContext` produced by this adapter's client upload
3
+ * handler. The plugin's `StaticHandler`/`HandleUpload` types expose it as `unknown`
4
+ * (it is deserialized from a JSON form field and is adapter-specific), so it must be
5
+ * narrowed at runtime before use.
6
+ */ /**
7
+ * Safely narrows an `unknown` clientUploadContext into the adapter's known shape,
8
+ * keeping only properties that are actually present with the expected type. Returns
9
+ * an empty object for any non-object input.
10
+ */ export const parseClientUploadContext = (context)=>{
11
+ if (!context || typeof context !== 'object') {
12
+ return {};
13
+ }
14
+ const result = {};
15
+ if ('clientUploaded' in context && typeof context.clientUploaded === 'boolean') {
16
+ result.clientUploaded = context.clientUploaded;
17
+ }
18
+ if ('filename' in context && typeof context.filename === 'string') {
19
+ result.filename = context.filename;
20
+ }
21
+ if ('mimeType' in context && typeof context.mimeType === 'string') {
22
+ result.mimeType = context.mimeType;
23
+ }
24
+ if ('prefix' in context && typeof context.prefix === 'string') {
25
+ result.prefix = context.prefix;
26
+ }
27
+ return result;
28
+ };
29
+
30
+ //# sourceMappingURL=parseClientUploadContext.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.0.1-alpha.68",
3
+ "version": "0.0.1-alpha.69",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {