@tinacms/app 0.0.0-059f480-20260324232913 → 0.0.0-05c21ca-20260715013235
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/package.json +19 -5
- package/src/index.css +2 -2
- package/src/lib/errors.tsx +12 -9
- package/src/lib/expand-query.ts +22 -3
- package/src/lib/graphql-reducer.ts +111 -27
- package/src/lib/preview-origin.test.ts +147 -0
- package/src/lib/preview-origin.ts +61 -0
- package/src/lib/util.ts +4 -3
- package/CHANGELOG.md +0 -1419
package/package.json
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinacms/app",
|
|
3
|
-
"version": "0.0.0-
|
|
3
|
+
"version": "0.0.0-05c21ca-20260715013235",
|
|
4
4
|
"main": "src/main.tsx",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"index.html",
|
|
8
|
+
"favicon.svg",
|
|
9
|
+
"tsconfig.json",
|
|
10
|
+
"tsconfig.node.json"
|
|
11
|
+
],
|
|
5
12
|
"license": "Apache-2.0",
|
|
6
13
|
"devDependencies": {
|
|
7
14
|
"@types/react": "^18.3.18",
|
|
8
15
|
"@types/react-dom": "^18.3.5",
|
|
9
|
-
"
|
|
16
|
+
"happy-dom": "15.10.2",
|
|
17
|
+
"typescript": "^5.7.3",
|
|
18
|
+
"vite": "^5.4.14",
|
|
19
|
+
"vitest": "^2.1.9"
|
|
10
20
|
},
|
|
11
21
|
"peerDependencies": {
|
|
12
22
|
"react": ">=18.3.1 <20.0.0",
|
|
@@ -23,14 +33,18 @@
|
|
|
23
33
|
"monaco-editor": "0.31.0",
|
|
24
34
|
"react": "^18.3.1",
|
|
25
35
|
"react-dom": "^18.3.1",
|
|
26
|
-
"react-router-dom": "6.3
|
|
36
|
+
"react-router-dom": "^6.30.3",
|
|
27
37
|
"typescript": "^5.7.3",
|
|
28
38
|
"zod": "^3.24.2",
|
|
29
|
-
"
|
|
30
|
-
"tinacms": "0.0.0-
|
|
39
|
+
"tinacms": "^0.0.0-05c21ca-20260715013235",
|
|
40
|
+
"@tinacms/mdx": "^0.0.0-05c21ca-20260715013235"
|
|
31
41
|
},
|
|
32
42
|
"repository": {
|
|
33
43
|
"url": "https://github.com/tinacms/tinacms.git",
|
|
34
44
|
"directory": "packages/@tinacms/app"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test-watch": "vitest"
|
|
35
49
|
}
|
|
36
50
|
}
|
package/src/index.css
CHANGED
package/src/lib/errors.tsx
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { TinaCMS } from 'tinacms';
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
|
|
4
|
+
const TROUBLESHOOTING_URL = 'https://tina.io/docs/tinacloud/troubleshooting';
|
|
5
|
+
|
|
6
|
+
const ErrorModalContent = (props: { title: string; message: string }) => {
|
|
7
|
+
const { title, message } = props;
|
|
6
8
|
return (
|
|
7
9
|
<>
|
|
8
10
|
<div>{title}</div>
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
</ul>
|
|
11
|
+
<p>{message}</p>
|
|
12
|
+
<a href={TROUBLESHOOTING_URL} target='_blank' rel='noopener noreferrer'>
|
|
13
|
+
Try these troubleshooting tips
|
|
14
|
+
</a>
|
|
14
15
|
</>
|
|
15
16
|
);
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
export const showErrorModal = (
|
|
19
20
|
title: string,
|
|
20
|
-
|
|
21
|
+
message: string,
|
|
21
22
|
cms: TinaCMS
|
|
22
23
|
) => {
|
|
23
|
-
cms.alerts.
|
|
24
|
+
if (cms.alerts.all.some((a: { level: string }) => a.level === 'error'))
|
|
25
|
+
return;
|
|
26
|
+
cms.alerts.error(() => <ErrorModalContent title={title} message={message} />);
|
|
24
27
|
};
|
package/src/lib/expand-query.ts
CHANGED
|
@@ -3,14 +3,17 @@ import * as G from 'graphql';
|
|
|
3
3
|
export const expandQuery = ({
|
|
4
4
|
schema,
|
|
5
5
|
documentNode,
|
|
6
|
+
includeNodeMetadata,
|
|
6
7
|
}: {
|
|
7
8
|
schema: G.GraphQLSchema;
|
|
8
9
|
documentNode: G.DocumentNode;
|
|
10
|
+
includeNodeMetadata?: boolean;
|
|
9
11
|
}): G.DocumentNode => {
|
|
10
12
|
const documentNodeWithTypenames = addTypenameToDocument(documentNode);
|
|
11
13
|
return addMetaFieldsToQuery(
|
|
12
14
|
documentNodeWithTypenames,
|
|
13
|
-
new G.TypeInfo(schema)
|
|
15
|
+
new G.TypeInfo(schema),
|
|
16
|
+
includeNodeMetadata
|
|
14
17
|
);
|
|
15
18
|
};
|
|
16
19
|
|
|
@@ -114,7 +117,8 @@ const addMetadataField = (
|
|
|
114
117
|
|
|
115
118
|
const addMetaFieldsToQuery = (
|
|
116
119
|
documentNode: G.DocumentNode,
|
|
117
|
-
typeInfo: G.TypeInfo
|
|
120
|
+
typeInfo: G.TypeInfo,
|
|
121
|
+
includeNodeMetadata?: boolean
|
|
118
122
|
) => {
|
|
119
123
|
const addMetaFields: G.VisitFn<G.ASTNode, G.FieldNode> = (
|
|
120
124
|
node: G.FieldNode
|
|
@@ -126,7 +130,11 @@ const addMetaFieldsToQuery = (
|
|
|
126
130
|
kind: 'SelectionSet',
|
|
127
131
|
selections: [],
|
|
128
132
|
}),
|
|
129
|
-
selections: [
|
|
133
|
+
selections: [
|
|
134
|
+
...(node.selectionSet?.selections || []),
|
|
135
|
+
...metaFields,
|
|
136
|
+
...(includeNodeMetadata ? nodeMetadataFields : []),
|
|
137
|
+
],
|
|
130
138
|
},
|
|
131
139
|
};
|
|
132
140
|
};
|
|
@@ -218,6 +226,17 @@ const metaFields: G.SelectionNode[] =
|
|
|
218
226
|
// @ts-ignore
|
|
219
227
|
node.definitions[0].selectionSet.selections;
|
|
220
228
|
|
|
229
|
+
const nodeMetadataFragment = G.parse(`
|
|
230
|
+
query Sample {
|
|
231
|
+
...on Document {
|
|
232
|
+
_tina_metadata
|
|
233
|
+
_content_source
|
|
234
|
+
}
|
|
235
|
+
}`);
|
|
236
|
+
const nodeMetadataFields: G.SelectionNode[] =
|
|
237
|
+
// @ts-ignore
|
|
238
|
+
nodeMetadataFragment.definitions[0].selectionSet.selections;
|
|
239
|
+
|
|
221
240
|
export const isNodeType = (type: G.GraphQLOutputType) => {
|
|
222
241
|
const namedType = G.getNamedType(type);
|
|
223
242
|
if (G.isInterfaceType(namedType)) {
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
ErrorDialog,
|
|
11
11
|
Form,
|
|
12
12
|
FormOptions,
|
|
13
|
-
GlobalFormPlugin,
|
|
14
13
|
NAMER,
|
|
15
14
|
Template,
|
|
16
15
|
TinaCMS,
|
|
@@ -24,6 +23,11 @@ import { z } from 'zod';
|
|
|
24
23
|
import { FormifyCallback, createForm, createGlobalForm } from './build-form';
|
|
25
24
|
import { showErrorModal } from './errors';
|
|
26
25
|
import { expandQuery, isConnectionType, isNodeType } from './expand-query';
|
|
26
|
+
import {
|
|
27
|
+
getExpectedPreviewOrigin,
|
|
28
|
+
isFromTrustedPreviewOrigin,
|
|
29
|
+
postMessageToPreview,
|
|
30
|
+
} from './preview-origin';
|
|
27
31
|
import type {
|
|
28
32
|
Payload,
|
|
29
33
|
PostMessage,
|
|
@@ -190,9 +194,23 @@ export const useGraphQLReducer = (
|
|
|
190
194
|
ResolvedDocument[]
|
|
191
195
|
>([]);
|
|
192
196
|
const [operationIndex, setOperationIndex] = React.useState(0);
|
|
197
|
+
// Pending `user-select-form` request whose form hasn't been built yet.
|
|
198
|
+
// Set when the bridge's `open` is still in flight (the bridge retries
|
|
199
|
+
// user-select-form but stops once it sees `updateData`, so we can't
|
|
200
|
+
// rely on a future message landing after `forms:add` resolves).
|
|
201
|
+
const [pendingPrimaryId, setPendingPrimaryId] = React.useState<string | null>(
|
|
202
|
+
null
|
|
203
|
+
);
|
|
193
204
|
|
|
194
205
|
const activeField = searchParams.get('active-field');
|
|
195
206
|
|
|
207
|
+
// Origin of the preview document we load in the iframe. Used to validate
|
|
208
|
+
// inbound messages and as the explicit `targetOrigin` for outbound ones.
|
|
209
|
+
const expectedOrigin = React.useMemo(
|
|
210
|
+
() => getExpectedPreviewOrigin(url),
|
|
211
|
+
[url]
|
|
212
|
+
);
|
|
213
|
+
|
|
196
214
|
React.useEffect(() => {
|
|
197
215
|
const run = async () => {
|
|
198
216
|
return Promise.all(
|
|
@@ -476,11 +494,15 @@ export const useGraphQLReducer = (
|
|
|
476
494
|
});
|
|
477
495
|
}
|
|
478
496
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
497
|
+
postMessageToPreview(
|
|
498
|
+
iframe.current?.contentWindow,
|
|
499
|
+
{
|
|
500
|
+
type: 'updateData',
|
|
501
|
+
id: payload.id,
|
|
502
|
+
data: result.data,
|
|
503
|
+
},
|
|
504
|
+
expectedOrigin
|
|
505
|
+
);
|
|
484
506
|
}
|
|
485
507
|
cms.dispatch({
|
|
486
508
|
type: 'form-lists:add',
|
|
@@ -495,16 +517,47 @@ export const useGraphQLReducer = (
|
|
|
495
517
|
[
|
|
496
518
|
resolvedDocuments.map((doc) => doc._internalSys.path).join('.'),
|
|
497
519
|
activeField,
|
|
520
|
+
expectedOrigin,
|
|
498
521
|
]
|
|
499
522
|
);
|
|
500
523
|
|
|
524
|
+
// The bridge sends `formId` keyed by query id (hashFromQuery output), but
|
|
525
|
+
// `state.forms` is keyed by document path. Match by either so callers using
|
|
526
|
+
// `useTina`'s `experimental___selectFormByFormId` (which usually returns a
|
|
527
|
+
// path) keep working too. Returns false when the form isn't built yet.
|
|
528
|
+
const activateFormByWireId = React.useCallback(
|
|
529
|
+
(wireId: string): boolean => {
|
|
530
|
+
const match = cms.state.forms.find(
|
|
531
|
+
({ tinaForm }) =>
|
|
532
|
+
tinaForm.id === wireId || tinaForm.queries.includes(wireId)
|
|
533
|
+
);
|
|
534
|
+
if (!match) return false;
|
|
535
|
+
cms.dispatch({
|
|
536
|
+
type: 'forms:set-active-form-id',
|
|
537
|
+
value: match.tinaForm.id,
|
|
538
|
+
});
|
|
539
|
+
return true;
|
|
540
|
+
},
|
|
541
|
+
[cms]
|
|
542
|
+
);
|
|
543
|
+
|
|
501
544
|
const handleMessage = React.useCallback(
|
|
502
545
|
(event: MessageEvent<PostMessage>) => {
|
|
546
|
+
// Validate the sender before reading `event.data`: only the preview
|
|
547
|
+
// iframe we loaded is trusted to drive the reducer.
|
|
548
|
+
if (
|
|
549
|
+
!isFromTrustedPreviewOrigin({
|
|
550
|
+
event,
|
|
551
|
+
expectedOrigin,
|
|
552
|
+
peerWindow: iframe.current?.contentWindow,
|
|
553
|
+
})
|
|
554
|
+
) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
503
557
|
if (event.data.type === 'user-select-form') {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
});
|
|
558
|
+
const incoming = event.data.formId;
|
|
559
|
+
// Buffer until `forms:add` resolves it if the form isn't built yet.
|
|
560
|
+
setPendingPrimaryId(activateFormByWireId(incoming) ? null : incoming);
|
|
508
561
|
}
|
|
509
562
|
|
|
510
563
|
if (event?.data?.type === 'quick-edit') {
|
|
@@ -512,15 +565,23 @@ export const useGraphQLReducer = (
|
|
|
512
565
|
type: 'set-quick-editing-supported',
|
|
513
566
|
value: event.data.value,
|
|
514
567
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
568
|
+
postMessageToPreview(
|
|
569
|
+
iframe.current?.contentWindow,
|
|
570
|
+
{
|
|
571
|
+
type: 'quickEditEnabled',
|
|
572
|
+
value: cms.state.sidebarDisplayState === 'open',
|
|
573
|
+
},
|
|
574
|
+
expectedOrigin
|
|
575
|
+
);
|
|
519
576
|
}
|
|
520
577
|
if (event?.data?.type === 'isEditMode') {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
578
|
+
postMessageToPreview(
|
|
579
|
+
iframe.current?.contentWindow,
|
|
580
|
+
{
|
|
581
|
+
type: 'tina:editMode',
|
|
582
|
+
},
|
|
583
|
+
expectedOrigin
|
|
584
|
+
);
|
|
524
585
|
}
|
|
525
586
|
if (event.data.type === 'field:selected') {
|
|
526
587
|
const [queryId, eventFieldName] = event.data.fieldName.split('---');
|
|
@@ -571,7 +632,7 @@ export const useGraphQLReducer = (
|
|
|
571
632
|
// });
|
|
572
633
|
// }
|
|
573
634
|
},
|
|
574
|
-
[cms, JSON.stringify(results)]
|
|
635
|
+
[cms, JSON.stringify(results), expectedOrigin]
|
|
575
636
|
);
|
|
576
637
|
|
|
577
638
|
React.useEffect(() => {
|
|
@@ -586,17 +647,30 @@ export const useGraphQLReducer = (
|
|
|
586
647
|
return () => {
|
|
587
648
|
setPayloads([]);
|
|
588
649
|
setResults([]);
|
|
650
|
+
setPendingPrimaryId(null);
|
|
589
651
|
cms.removeAllForms();
|
|
590
652
|
cms.dispatch({ type: 'form-lists:clear' });
|
|
591
653
|
};
|
|
592
654
|
}, [url]);
|
|
593
655
|
|
|
656
|
+
// Drain a buffered `user-select-form` once the matching form lands in
|
|
657
|
+
// `state.forms`. Watches `forms.length` rather than the array identity
|
|
658
|
+
// so the effect doesn't re-run on unrelated form mutations.
|
|
594
659
|
React.useEffect(() => {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
660
|
+
if (!pendingPrimaryId) return;
|
|
661
|
+
if (activateFormByWireId(pendingPrimaryId)) setPendingPrimaryId(null);
|
|
662
|
+
}, [cms.state.forms.length, pendingPrimaryId, activateFormByWireId]);
|
|
663
|
+
|
|
664
|
+
React.useEffect(() => {
|
|
665
|
+
postMessageToPreview(
|
|
666
|
+
iframe.current?.contentWindow,
|
|
667
|
+
{
|
|
668
|
+
type: 'quickEditEnabled',
|
|
669
|
+
value: cms.state.sidebarDisplayState === 'open',
|
|
670
|
+
},
|
|
671
|
+
expectedOrigin
|
|
672
|
+
);
|
|
673
|
+
}, [cms.state.sidebarDisplayState, expectedOrigin]);
|
|
600
674
|
|
|
601
675
|
React.useEffect(() => {
|
|
602
676
|
cms.dispatch({ type: 'set-edit-mode', value: 'visual' });
|
|
@@ -613,7 +687,12 @@ export const useGraphQLReducer = (
|
|
|
613
687
|
|
|
614
688
|
React.useEffect(() => {
|
|
615
689
|
if (requestErrors.length) {
|
|
616
|
-
|
|
690
|
+
console.error('Unexpected error querying content:', requestErrors);
|
|
691
|
+
showErrorModal(
|
|
692
|
+
'Unexpected Error',
|
|
693
|
+
'We ran into an unexpected error while fetching your content. If after refreshing the issue persists, reach out to us on Discord.',
|
|
694
|
+
cms
|
|
695
|
+
);
|
|
617
696
|
}
|
|
618
697
|
}, [requestErrors]);
|
|
619
698
|
};
|
|
@@ -902,6 +981,7 @@ const expandPayload = async (
|
|
|
902
981
|
const expandedDocumentNodeForResolver = expandQuery({
|
|
903
982
|
schema: schemaForResolver,
|
|
904
983
|
documentNode,
|
|
984
|
+
includeNodeMetadata: true,
|
|
905
985
|
});
|
|
906
986
|
const expandedQueryForResolver = G.print(expandedDocumentNodeForResolver);
|
|
907
987
|
return { ...payload, expandedQuery, expandedData, expandedQueryForResolver };
|
|
@@ -1000,10 +1080,14 @@ const buildForm = ({
|
|
|
1000
1080
|
}
|
|
1001
1081
|
}
|
|
1002
1082
|
if (form) {
|
|
1083
|
+
// Track the payload (query) id on the form so wire-side lookups —
|
|
1084
|
+
// notably `user-select-form`, which the bridge sends keyed by query
|
|
1085
|
+
// id — can resolve to the document-path-keyed `form.id` used in
|
|
1086
|
+
// `state.forms`. `addQuery` is otherwise only called on the second
|
|
1087
|
+
// open for the same document (see the `existingForm` branch above),
|
|
1088
|
+
// so without this the very first open leaves `form.queries` empty.
|
|
1089
|
+
form.addQuery(payloadId);
|
|
1003
1090
|
if (shouldRegisterForm) {
|
|
1004
|
-
if (collection.ui?.global) {
|
|
1005
|
-
cms.plugins.add(new GlobalFormPlugin(form));
|
|
1006
|
-
}
|
|
1007
1091
|
cms.dispatch({ type: 'forms:add', value: form });
|
|
1008
1092
|
}
|
|
1009
1093
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getExpectedPreviewOrigin,
|
|
4
|
+
isFromTrustedPreviewOrigin,
|
|
5
|
+
postMessageToPreview,
|
|
6
|
+
} from './preview-origin';
|
|
7
|
+
|
|
8
|
+
const ADMIN = 'https://admin.example';
|
|
9
|
+
const PREVIEW = 'https://preview.example';
|
|
10
|
+
|
|
11
|
+
// A stand-in for the iframe's content window; we only need an identity to
|
|
12
|
+
// compare `event.source` against.
|
|
13
|
+
const makePeerWindow = () =>
|
|
14
|
+
({ postMessage: vi.fn() }) as unknown as Window & {
|
|
15
|
+
postMessage: ReturnType<typeof vi.fn>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('getExpectedPreviewOrigin', () => {
|
|
19
|
+
it('resolves a relative admin URL to the admin origin', () => {
|
|
20
|
+
expect(getExpectedPreviewOrigin('/posts/hello', ADMIN)).toBe(ADMIN);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps the origin of an absolute preview URL', () => {
|
|
24
|
+
expect(getExpectedPreviewOrigin(`${PREVIEW}/posts/hello`, ADMIN)).toBe(
|
|
25
|
+
PREVIEW
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('isFromTrustedPreviewOrigin', () => {
|
|
31
|
+
it('ignores messages from an untrusted origin', () => {
|
|
32
|
+
const peerWindow = makePeerWindow();
|
|
33
|
+
const event = {
|
|
34
|
+
origin: 'https://evil.example',
|
|
35
|
+
source: peerWindow,
|
|
36
|
+
data: { type: 'open' },
|
|
37
|
+
} as unknown as MessageEvent;
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
isFromTrustedPreviewOrigin({
|
|
41
|
+
event,
|
|
42
|
+
expectedOrigin: PREVIEW,
|
|
43
|
+
peerWindow,
|
|
44
|
+
})
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts messages from the trusted origin and peer window', () => {
|
|
49
|
+
const peerWindow = makePeerWindow();
|
|
50
|
+
const event = {
|
|
51
|
+
origin: PREVIEW,
|
|
52
|
+
source: peerWindow,
|
|
53
|
+
data: { type: 'open' },
|
|
54
|
+
} as unknown as MessageEvent;
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
isFromTrustedPreviewOrigin({
|
|
58
|
+
event,
|
|
59
|
+
expectedOrigin: PREVIEW,
|
|
60
|
+
peerWindow,
|
|
61
|
+
})
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores the trusted origin from the wrong source window', () => {
|
|
66
|
+
const peerWindow = makePeerWindow();
|
|
67
|
+
const otherFrame = makePeerWindow();
|
|
68
|
+
const event = {
|
|
69
|
+
origin: PREVIEW,
|
|
70
|
+
source: otherFrame,
|
|
71
|
+
data: { type: 'open' },
|
|
72
|
+
} as unknown as MessageEvent;
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
isFromTrustedPreviewOrigin({
|
|
76
|
+
event,
|
|
77
|
+
expectedOrigin: PREVIEW,
|
|
78
|
+
peerWindow,
|
|
79
|
+
})
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not read event.data before validating the origin', () => {
|
|
84
|
+
const peerWindow = makePeerWindow();
|
|
85
|
+
const event = {
|
|
86
|
+
origin: 'https://evil.example',
|
|
87
|
+
source: peerWindow,
|
|
88
|
+
// Reading `data` would throw — proves the guard never touches the
|
|
89
|
+
// payload when the origin is untrusted.
|
|
90
|
+
get data(): unknown {
|
|
91
|
+
throw new Error('event.data must not be read before origin validation');
|
|
92
|
+
},
|
|
93
|
+
} as unknown as MessageEvent;
|
|
94
|
+
|
|
95
|
+
expect(() =>
|
|
96
|
+
isFromTrustedPreviewOrigin({
|
|
97
|
+
event,
|
|
98
|
+
expectedOrigin: PREVIEW,
|
|
99
|
+
peerWindow,
|
|
100
|
+
})
|
|
101
|
+
).not.toThrow();
|
|
102
|
+
expect(
|
|
103
|
+
isFromTrustedPreviewOrigin({
|
|
104
|
+
event,
|
|
105
|
+
expectedOrigin: PREVIEW,
|
|
106
|
+
peerWindow,
|
|
107
|
+
})
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('falls back to origin-only when no peer window handle is available', () => {
|
|
112
|
+
const event = {
|
|
113
|
+
origin: PREVIEW,
|
|
114
|
+
source: makePeerWindow(),
|
|
115
|
+
data: { type: 'open' },
|
|
116
|
+
} as unknown as MessageEvent;
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
isFromTrustedPreviewOrigin({
|
|
120
|
+
event,
|
|
121
|
+
expectedOrigin: PREVIEW,
|
|
122
|
+
peerWindow: null,
|
|
123
|
+
})
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('postMessageToPreview', () => {
|
|
129
|
+
it('posts with the exact expected targetOrigin, not a wildcard', () => {
|
|
130
|
+
const peerWindow = makePeerWindow();
|
|
131
|
+
const message = { type: 'updateData', id: 'q1', data: {} };
|
|
132
|
+
|
|
133
|
+
postMessageToPreview(peerWindow, message, PREVIEW);
|
|
134
|
+
|
|
135
|
+
expect(peerWindow.postMessage).toHaveBeenCalledWith(message, PREVIEW);
|
|
136
|
+
expect(peerWindow.postMessage).not.toHaveBeenCalledWith(
|
|
137
|
+
expect.anything(),
|
|
138
|
+
'*'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('no-ops when there is no peer window', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
postMessageToPreview(null, { type: 'updateData' }, PREVIEW)
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for securing the admin <-> preview iframe postMessage channel.
|
|
3
|
+
*
|
|
4
|
+
* The trusted peer is the preview iframe loaded by the admin. Messages are only
|
|
5
|
+
* trusted when they match the expected preview origin and, when available, the
|
|
6
|
+
* iframe's exact `contentWindow`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the origin expected for the preview iframe.
|
|
11
|
+
*
|
|
12
|
+
* Relative URLs resolve against `baseOrigin`; absolute URLs keep their own
|
|
13
|
+
* origin.
|
|
14
|
+
*/
|
|
15
|
+
export const getExpectedPreviewOrigin = (
|
|
16
|
+
url: string,
|
|
17
|
+
baseOrigin: string = typeof window !== 'undefined'
|
|
18
|
+
? window.location.origin
|
|
19
|
+
: ''
|
|
20
|
+
): string => {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url, baseOrigin || undefined).origin;
|
|
23
|
+
} catch {
|
|
24
|
+
return baseOrigin;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether a MessageEvent came from the trusted preview iframe.
|
|
30
|
+
*
|
|
31
|
+
* `event.data` is intentionally not read here, so callers can run this check
|
|
32
|
+
* before trusting the payload.
|
|
33
|
+
*/
|
|
34
|
+
export const isFromTrustedPreviewOrigin = ({
|
|
35
|
+
event,
|
|
36
|
+
expectedOrigin,
|
|
37
|
+
peerWindow,
|
|
38
|
+
}: {
|
|
39
|
+
event: MessageEvent;
|
|
40
|
+
expectedOrigin: string;
|
|
41
|
+
peerWindow: Window | null | undefined;
|
|
42
|
+
}): boolean => {
|
|
43
|
+
if (!expectedOrigin) return false;
|
|
44
|
+
if (event.origin !== expectedOrigin) return false;
|
|
45
|
+
if (peerWindow && event.source !== peerWindow) return false;
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sends a message to the preview iframe with a strict target origin.
|
|
51
|
+
*
|
|
52
|
+
* No-ops when the iframe window or origin is missing, and never uses `"*"`.
|
|
53
|
+
*/
|
|
54
|
+
export const postMessageToPreview = (
|
|
55
|
+
peerWindow: Window | null | undefined,
|
|
56
|
+
message: unknown,
|
|
57
|
+
expectedOrigin: string
|
|
58
|
+
): void => {
|
|
59
|
+
if (!peerWindow || !expectedOrigin) return;
|
|
60
|
+
peerWindow.postMessage(message, expectedOrigin);
|
|
61
|
+
};
|
package/src/lib/util.ts
CHANGED
|
@@ -93,9 +93,10 @@ export const getDeepestMetadata = (state: Object, complexKey: string): any => {
|
|
|
93
93
|
}
|
|
94
94
|
const value = current[key];
|
|
95
95
|
if (value?._tina_metadata) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (complexKey === value._tina_metadata?.prefix && metadata) {
|
|
96
|
+
if (value._tina_metadata.id === null) {
|
|
97
|
+
// Placeholder metadata from connection/list types — skip it
|
|
98
|
+
} else if (complexKey === value._tina_metadata?.prefix && metadata) {
|
|
99
|
+
// Reference field — keep the parent form's metadata
|
|
99
100
|
} else {
|
|
100
101
|
metadata = value._tina_metadata;
|
|
101
102
|
}
|