@407dev/cms-astro 0.1.0
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/README.md +69 -0
- package/dist/chunk-2TKIQIOE.js +118 -0
- package/dist/chunk-2TKIQIOE.js.map +1 -0
- package/dist/chunk-53OQ5RXJ.js +912 -0
- package/dist/chunk-53OQ5RXJ.js.map +1 -0
- package/dist/chunk-56OMNQLH.js +1013 -0
- package/dist/chunk-56OMNQLH.js.map +1 -0
- package/dist/chunk-5AOOMQ7K.js +1031 -0
- package/dist/chunk-5AOOMQ7K.js.map +1 -0
- package/dist/chunk-5XRUCFDC.js +743 -0
- package/dist/chunk-5XRUCFDC.js.map +1 -0
- package/dist/chunk-62ZHCOLZ.js +25 -0
- package/dist/chunk-62ZHCOLZ.js.map +1 -0
- package/dist/chunk-6AZLRSYA.js +126 -0
- package/dist/chunk-6AZLRSYA.js.map +1 -0
- package/dist/chunk-6L3ZTCFB.js +14 -0
- package/dist/chunk-6L3ZTCFB.js.map +1 -0
- package/dist/chunk-6O7ZNNDT.js +697 -0
- package/dist/chunk-6O7ZNNDT.js.map +1 -0
- package/dist/chunk-6PQIXTMK.js +697 -0
- package/dist/chunk-6PQIXTMK.js.map +1 -0
- package/dist/chunk-7AW6M7QW.js +1013 -0
- package/dist/chunk-7AW6M7QW.js.map +1 -0
- package/dist/chunk-7E3XJLKX.js +73 -0
- package/dist/chunk-7E3XJLKX.js.map +1 -0
- package/dist/chunk-7LM54OTA.js +1031 -0
- package/dist/chunk-7LM54OTA.js.map +1 -0
- package/dist/chunk-7LMUFCWX.js +126 -0
- package/dist/chunk-7LMUFCWX.js.map +1 -0
- package/dist/chunk-A3OGL4OX.js +706 -0
- package/dist/chunk-A3OGL4OX.js.map +1 -0
- package/dist/chunk-AO4UAWOV.js +1030 -0
- package/dist/chunk-AO4UAWOV.js.map +1 -0
- package/dist/chunk-B4YYLS7Y.js +14 -0
- package/dist/chunk-B4YYLS7Y.js.map +1 -0
- package/dist/chunk-BDFVGXM2.js +697 -0
- package/dist/chunk-BDFVGXM2.js.map +1 -0
- package/dist/chunk-CXM7NZVX.js +1031 -0
- package/dist/chunk-CXM7NZVX.js.map +1 -0
- package/dist/chunk-DMLOBKYE.js +96 -0
- package/dist/chunk-DMLOBKYE.js.map +1 -0
- package/dist/chunk-DXJSYGS6.js +743 -0
- package/dist/chunk-DXJSYGS6.js.map +1 -0
- package/dist/chunk-EFUSAQZM.js +72 -0
- package/dist/chunk-EFUSAQZM.js.map +1 -0
- package/dist/chunk-F7KDGPBN.js +1013 -0
- package/dist/chunk-F7KDGPBN.js.map +1 -0
- package/dist/chunk-FBSQT45X.js +126 -0
- package/dist/chunk-FBSQT45X.js.map +1 -0
- package/dist/chunk-FEPDOMVH.js +697 -0
- package/dist/chunk-FEPDOMVH.js.map +1 -0
- package/dist/chunk-FQDOL2QW.js +25 -0
- package/dist/chunk-FQDOL2QW.js.map +1 -0
- package/dist/chunk-GB3LGQJM.js +109 -0
- package/dist/chunk-GB3LGQJM.js.map +1 -0
- package/dist/chunk-HCNP6R3P.js +736 -0
- package/dist/chunk-HCNP6R3P.js.map +1 -0
- package/dist/chunk-J6WEJDRI.js +743 -0
- package/dist/chunk-J6WEJDRI.js.map +1 -0
- package/dist/chunk-JHEAPNJ2.js +117 -0
- package/dist/chunk-JHEAPNJ2.js.map +1 -0
- package/dist/chunk-JOJDJ6GW.js +25 -0
- package/dist/chunk-JOJDJ6GW.js.map +1 -0
- package/dist/chunk-KTDV6WYB.js +202 -0
- package/dist/chunk-KTDV6WYB.js.map +1 -0
- package/dist/chunk-KZWJBKPC.js +734 -0
- package/dist/chunk-KZWJBKPC.js.map +1 -0
- package/dist/chunk-LEAQBGL6.js +109 -0
- package/dist/chunk-LEAQBGL6.js.map +1 -0
- package/dist/chunk-LGZJL2Q7.js +697 -0
- package/dist/chunk-LGZJL2Q7.js.map +1 -0
- package/dist/chunk-LQPBU46R.js +109 -0
- package/dist/chunk-LQPBU46R.js.map +1 -0
- package/dist/chunk-M55NNUTY.js +1013 -0
- package/dist/chunk-M55NNUTY.js.map +1 -0
- package/dist/chunk-M6NCYCC3.js +1013 -0
- package/dist/chunk-M6NCYCC3.js.map +1 -0
- package/dist/chunk-MGN7OZAU.js +109 -0
- package/dist/chunk-MGN7OZAU.js.map +1 -0
- package/dist/chunk-MGYZOX4K.js +1013 -0
- package/dist/chunk-MGYZOX4K.js.map +1 -0
- package/dist/chunk-MY4IEKJS.js +1031 -0
- package/dist/chunk-MY4IEKJS.js.map +1 -0
- package/dist/chunk-NCQRGAB4.js +705 -0
- package/dist/chunk-NCQRGAB4.js.map +1 -0
- package/dist/chunk-NI4NHXI4.js +697 -0
- package/dist/chunk-NI4NHXI4.js.map +1 -0
- package/dist/chunk-NPI5N7AU.js +14 -0
- package/dist/chunk-NPI5N7AU.js.map +1 -0
- package/dist/chunk-QN4CPMJF.js +126 -0
- package/dist/chunk-QN4CPMJF.js.map +1 -0
- package/dist/chunk-QYUVAKMC.js +1013 -0
- package/dist/chunk-QYUVAKMC.js.map +1 -0
- package/dist/chunk-RAAT6PDT.js +25 -0
- package/dist/chunk-RAAT6PDT.js.map +1 -0
- package/dist/chunk-RDPO5VPS.js +1013 -0
- package/dist/chunk-RDPO5VPS.js.map +1 -0
- package/dist/chunk-RQHIW2PF.js +25 -0
- package/dist/chunk-RQHIW2PF.js.map +1 -0
- package/dist/chunk-RREVHA52.js +722 -0
- package/dist/chunk-RREVHA52.js.map +1 -0
- package/dist/chunk-S7PT4PZN.js +25 -0
- package/dist/chunk-S7PT4PZN.js.map +1 -0
- package/dist/chunk-SUXV5M32.js +109 -0
- package/dist/chunk-SUXV5M32.js.map +1 -0
- package/dist/chunk-SZEORUY6.js +696 -0
- package/dist/chunk-SZEORUY6.js.map +1 -0
- package/dist/chunk-T7NGGWGY.js +25 -0
- package/dist/chunk-T7NGGWGY.js.map +1 -0
- package/dist/chunk-UEVWP5VE.js +696 -0
- package/dist/chunk-UEVWP5VE.js.map +1 -0
- package/dist/chunk-UJNKNP7U.js +748 -0
- package/dist/chunk-UJNKNP7U.js.map +1 -0
- package/dist/chunk-V4XVFWFI.js +1013 -0
- package/dist/chunk-V4XVFWFI.js.map +1 -0
- package/dist/chunk-VJU2CIKZ.js +729 -0
- package/dist/chunk-VJU2CIKZ.js.map +1 -0
- package/dist/chunk-VRQUP5L3.js +202 -0
- package/dist/chunk-VRQUP5L3.js.map +1 -0
- package/dist/chunk-WBJ5E7CL.js +742 -0
- package/dist/chunk-WBJ5E7CL.js.map +1 -0
- package/dist/chunk-XJPVYMZM.js +24 -0
- package/dist/chunk-XJPVYMZM.js.map +1 -0
- package/dist/chunk-Y3Q65C7R.js +14 -0
- package/dist/chunk-Y3Q65C7R.js.map +1 -0
- package/dist/chunk-YHEGFPTR.js +1030 -0
- package/dist/chunk-YHEGFPTR.js.map +1 -0
- package/dist/chunk-ZDLBN46C.js +697 -0
- package/dist/chunk-ZDLBN46C.js.map +1 -0
- package/dist/dev/index.d.ts +122 -0
- package/dist/dev/index.js +662 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/fields/collections.d.ts +18 -0
- package/dist/fields/collections.js +37 -0
- package/dist/fields/collections.js.map +1 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +956 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.d.ts +8 -0
- package/dist/middleware/index.js +13 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/preview-BEkLMD6N.d.ts +23 -0
- package/dist/preview-D9yewK8x.d.ts +24 -0
- package/dist/preview-DJEA4fj7.d.ts +23 -0
- package/dist/preview-DeD1Jn7U.d.ts +23 -0
- package/dist/toolbar/app.d.ts +28 -0
- package/dist/toolbar/app.js +1176 -0
- package/dist/toolbar/app.js.map +1 -0
- package/dist/types-15jfCQKb.d.ts +141 -0
- package/dist/types-BREdL9Gx.d.ts +140 -0
- package/dist/types-C6sUZNa2.d.ts +104 -0
- package/dist/types-CEYS5Fzc.d.ts +112 -0
- package/dist/types-DpeHpobR.d.ts +104 -0
- package/dist/types-NaKJe6Pg.d.ts +140 -0
- package/package.json +90 -0
- package/src/CmsHead.astro +25 -0
- package/src/CmsImage.astro +62 -0
- package/src/CmsVideo.astro +185 -0
- package/src/RichText.astro +32 -0
- package/src/client.test.ts +227 -0
- package/src/client.ts +164 -0
- package/src/dev/config.test.ts +258 -0
- package/src/dev/config.ts +319 -0
- package/src/dev/index.ts +5 -0
- package/src/dev/preview-token.ts +39 -0
- package/src/dev/routes.test.ts +354 -0
- package/src/dev/routes.ts +245 -0
- package/src/dev/token.test.ts +280 -0
- package/src/dev/token.ts +201 -0
- package/src/dev/verifier.test.ts +63 -0
- package/src/dev/verifier.ts +161 -0
- package/src/fields/attrs.ts +24 -0
- package/src/fields/collection.ts +72 -0
- package/src/fields/collections.ts +35 -0
- package/src/fields/entryScope.test.ts +62 -0
- package/src/fields/entryScope.ts +240 -0
- package/src/fields/group.ts +51 -0
- package/src/fields/helpers.test.ts +51 -0
- package/src/fields/helpers.ts +214 -0
- package/src/fields/index.ts +46 -0
- package/src/fields/scope.ts +21 -0
- package/src/fields/types.ts +119 -0
- package/src/head.test.ts +74 -0
- package/src/head.ts +123 -0
- package/src/image.test.ts +42 -0
- package/src/image.ts +64 -0
- package/src/index.test.ts +76 -0
- package/src/index.ts +13 -0
- package/src/integration.ts +218 -0
- package/src/jwt.ts +131 -0
- package/src/middleware/index.ts +4 -0
- package/src/middleware/preview.test.ts +184 -0
- package/src/middleware/preview.ts +206 -0
- package/src/preview/bridge-script.test.ts +683 -0
- package/src/preview/bridge-script.ts +763 -0
- package/src/preview/origins.ts +29 -0
- package/src/preview.ts +49 -0
- package/src/richtext/RichTextNode.astro +130 -0
- package/src/richtext/render.ts +248 -0
- package/src/richtext.test.ts +139 -0
- package/src/runtime/bundle.ts +28 -0
- package/src/runtime/store.ts +130 -0
- package/src/siteFiles.test.ts +93 -0
- package/src/siteFiles.ts +111 -0
- package/src/toolbar/app.test.ts +191 -0
- package/src/toolbar/app.ts +1091 -0
- package/src/toolbar/icon.ts +5 -0
- package/src/toolbar/widgets.test.ts +153 -0
- package/src/toolbar/widgets.ts +365 -0
- package/src/types.test.ts +154 -0
- package/src/types.ts +163 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { getActiveBundle, setBuildBundle } from '../runtime/store';
|
|
5
|
+
import type { DevConfigResult } from './config';
|
|
6
|
+
import { createDevApiMiddleware } from './routes';
|
|
7
|
+
|
|
8
|
+
function createMockJwt(payload: Record<string, unknown>): string {
|
|
9
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
10
|
+
const encode = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
11
|
+
return `${encode(header)}.${encode(payload)}.mocksignature`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createMockReq(options: {
|
|
15
|
+
url: string;
|
|
16
|
+
method: string;
|
|
17
|
+
remoteAddress?: string | null;
|
|
18
|
+
body?: unknown;
|
|
19
|
+
}): IncomingMessage {
|
|
20
|
+
const req = new EventEmitter() as unknown as IncomingMessage;
|
|
21
|
+
req.url = options.url;
|
|
22
|
+
req.method = options.method;
|
|
23
|
+
req.socket = {
|
|
24
|
+
remoteAddress:
|
|
25
|
+
options.remoteAddress !== undefined ? (options.remoteAddress as string) : '127.0.0.1',
|
|
26
|
+
} as unknown as IncomingMessage['socket'];
|
|
27
|
+
req.headers = { host: 'localhost:4321' };
|
|
28
|
+
|
|
29
|
+
process.nextTick(() => {
|
|
30
|
+
if (options.body !== undefined) {
|
|
31
|
+
req.emit('data', JSON.stringify(options.body));
|
|
32
|
+
}
|
|
33
|
+
req.emit('end');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return req;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createMockRes() {
|
|
40
|
+
let statusCode = 200;
|
|
41
|
+
const headers: Record<string, string> = {};
|
|
42
|
+
let body = '';
|
|
43
|
+
let resolvePromise: (val: {
|
|
44
|
+
status: number;
|
|
45
|
+
headers: Record<string, string>;
|
|
46
|
+
body: string;
|
|
47
|
+
json: Record<string, unknown>;
|
|
48
|
+
}) => void;
|
|
49
|
+
|
|
50
|
+
const promise = new Promise<{
|
|
51
|
+
status: number;
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
body: string;
|
|
54
|
+
json: Record<string, unknown>;
|
|
55
|
+
}>((resolve) => {
|
|
56
|
+
resolvePromise = resolve;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const res = {
|
|
60
|
+
get statusCode() {
|
|
61
|
+
return statusCode;
|
|
62
|
+
},
|
|
63
|
+
set statusCode(code: number) {
|
|
64
|
+
statusCode = code;
|
|
65
|
+
},
|
|
66
|
+
setHeader(name: string, value: string) {
|
|
67
|
+
headers[name.toLowerCase()] = value;
|
|
68
|
+
},
|
|
69
|
+
end(chunk?: unknown) {
|
|
70
|
+
if (chunk) body += String(chunk);
|
|
71
|
+
let json = {};
|
|
72
|
+
try {
|
|
73
|
+
json = JSON.parse(body);
|
|
74
|
+
} catch {
|
|
75
|
+
json = {};
|
|
76
|
+
}
|
|
77
|
+
resolvePromise({ status: statusCode, headers, body, json });
|
|
78
|
+
},
|
|
79
|
+
} as unknown as ServerResponse;
|
|
80
|
+
|
|
81
|
+
return { res, getResult: () => promise };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('cmsDevApiPlugin middleware routes', () => {
|
|
85
|
+
const baseOkConfig: DevConfigResult = {
|
|
86
|
+
status: 'ok',
|
|
87
|
+
siteId: 'site-123',
|
|
88
|
+
devSiteId: 'site-123',
|
|
89
|
+
apiUrl: 'http://127.0.0.1:8787',
|
|
90
|
+
devToken: 'valid.mock.token',
|
|
91
|
+
identity: { sub: 'user-1', email: 'user@example.com' },
|
|
92
|
+
mismatch: null,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
it('rejects non-loopback remoteAddress with 403 Forbidden', async () => {
|
|
96
|
+
const middleware = createDevApiMiddleware({
|
|
97
|
+
devConfig: baseOkConfig,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const req = createMockReq({
|
|
101
|
+
url: '/_cms/dev/status',
|
|
102
|
+
method: 'GET',
|
|
103
|
+
remoteAddress: '198.51.100.25', // External non-loopback IP
|
|
104
|
+
});
|
|
105
|
+
const { res, getResult } = createMockRes();
|
|
106
|
+
|
|
107
|
+
middleware(req, res, () => {});
|
|
108
|
+
const result = await getResult();
|
|
109
|
+
|
|
110
|
+
expect(result.status).toBe(403);
|
|
111
|
+
expect(result.json.error).toContain('loopback');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns 503 for all dev routes when status is refused', async () => {
|
|
115
|
+
const refusedConfig: DevConfigResult = {
|
|
116
|
+
status: 'refused',
|
|
117
|
+
siteId: 'site-123',
|
|
118
|
+
apiUrl: 'http://127.0.0.1:8787',
|
|
119
|
+
reason: 'CMS_DEV_TOKEN is expired',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const middleware = createDevApiMiddleware({
|
|
123
|
+
devConfig: refusedConfig,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
for (const path of ['status', 'bundle', 'value']) {
|
|
127
|
+
const method = path === 'value' ? 'PATCH' : 'GET';
|
|
128
|
+
const req = createMockReq({
|
|
129
|
+
url: `/_cms/dev/${path}`,
|
|
130
|
+
method,
|
|
131
|
+
remoteAddress: '127.0.0.1',
|
|
132
|
+
});
|
|
133
|
+
const { res, getResult } = createMockRes();
|
|
134
|
+
|
|
135
|
+
middleware(req, res, () => {});
|
|
136
|
+
const result = await getResult();
|
|
137
|
+
|
|
138
|
+
expect(result.status).toBe(503);
|
|
139
|
+
expect(result.json.status).toBe('refused');
|
|
140
|
+
expect(result.json.error).toBe('CMS_DEV_TOKEN is expired');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns status info on GET /_cms/dev/status', async () => {
|
|
145
|
+
const middleware = createDevApiMiddleware({
|
|
146
|
+
devConfig: baseOkConfig,
|
|
147
|
+
editorOrigins: ['http://localhost:5173'],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const req = createMockReq({
|
|
151
|
+
url: '/_cms/dev/status',
|
|
152
|
+
method: 'GET',
|
|
153
|
+
remoteAddress: '127.0.0.1',
|
|
154
|
+
});
|
|
155
|
+
const { res, getResult } = createMockRes();
|
|
156
|
+
|
|
157
|
+
middleware(req, res, () => {});
|
|
158
|
+
const result = await getResult();
|
|
159
|
+
|
|
160
|
+
expect(result.status).toBe(200);
|
|
161
|
+
expect(result.json.status).toBe('ok');
|
|
162
|
+
expect(result.json.siteId).toBe('site-123');
|
|
163
|
+
expect(result.json.identity).toEqual({ sub: 'user-1', email: 'user@example.com' });
|
|
164
|
+
expect(result.json.editorOrigins).toEqual(['http://localhost:5173']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns resolved default editorOrigins on GET /_cms/dev/status when omitted', async () => {
|
|
168
|
+
const middleware = createDevApiMiddleware({
|
|
169
|
+
devConfig: baseOkConfig,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const req = createMockReq({
|
|
173
|
+
url: '/_cms/dev/status',
|
|
174
|
+
method: 'GET',
|
|
175
|
+
remoteAddress: '127.0.0.1',
|
|
176
|
+
});
|
|
177
|
+
const { res, getResult } = createMockRes();
|
|
178
|
+
|
|
179
|
+
middleware(req, res, () => {});
|
|
180
|
+
const result = await getResult();
|
|
181
|
+
|
|
182
|
+
expect(result.status).toBe(200);
|
|
183
|
+
expect(result.json.editorOrigins).toEqual(['https://web.localhost', 'http://localhost:5173']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('refuses with 403 on PATCH /_cms/dev/value when status is read-only', async () => {
|
|
187
|
+
const readOnlyConfig: DevConfigResult = {
|
|
188
|
+
status: 'read-only',
|
|
189
|
+
siteId: 'site-123',
|
|
190
|
+
devSiteId: 'wrong-site-456',
|
|
191
|
+
apiUrl: 'http://127.0.0.1:8787',
|
|
192
|
+
devToken: 'valid.mock.token',
|
|
193
|
+
mismatch: {
|
|
194
|
+
source: 'CMS_DEV_SITE',
|
|
195
|
+
expected: 'site-123',
|
|
196
|
+
actual: 'wrong-site-456',
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const middleware = createDevApiMiddleware({
|
|
201
|
+
devConfig: readOnlyConfig,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const req = createMockReq({
|
|
205
|
+
url: '/_cms/dev/value',
|
|
206
|
+
method: 'PATCH',
|
|
207
|
+
remoteAddress: '127.0.0.1',
|
|
208
|
+
body: { key: 'home.hero.heading', value: 'New Heading' },
|
|
209
|
+
});
|
|
210
|
+
const { res, getResult } = createMockRes();
|
|
211
|
+
|
|
212
|
+
middleware(req, res, () => {});
|
|
213
|
+
const result = await getResult();
|
|
214
|
+
|
|
215
|
+
expect(result.status).toBe(403);
|
|
216
|
+
expect(result.json.error).toContain('read-only');
|
|
217
|
+
expect(result.json.mismatch).toEqual(readOnlyConfig.mismatch);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('forwards Authorization header and mirrors upstream 422 validation errors verbatim', async () => {
|
|
221
|
+
const stubbedFetch = vi.fn().mockResolvedValue({
|
|
222
|
+
status: 422,
|
|
223
|
+
text: async () =>
|
|
224
|
+
JSON.stringify({
|
|
225
|
+
error: 'Validation failed',
|
|
226
|
+
errors: [{ message: 'Heading exceeds maximum length of 50' }],
|
|
227
|
+
}),
|
|
228
|
+
} as unknown as Response);
|
|
229
|
+
|
|
230
|
+
const middleware = createDevApiMiddleware({
|
|
231
|
+
devConfig: baseOkConfig,
|
|
232
|
+
fetchFn: stubbedFetch as unknown as typeof fetch,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const req = createMockReq({
|
|
236
|
+
url: '/_cms/dev/value',
|
|
237
|
+
method: 'PATCH',
|
|
238
|
+
remoteAddress: '127.0.0.1',
|
|
239
|
+
body: { key: 'home.hero.heading', value: 'Very Long Value...' },
|
|
240
|
+
});
|
|
241
|
+
const { res, getResult } = createMockRes();
|
|
242
|
+
|
|
243
|
+
middleware(req, res, () => {});
|
|
244
|
+
const result = await getResult();
|
|
245
|
+
|
|
246
|
+
expect(stubbedFetch).toHaveBeenCalledWith(
|
|
247
|
+
'http://127.0.0.1:8787/sites/site-123/fields/home.hero.heading',
|
|
248
|
+
expect.objectContaining({
|
|
249
|
+
method: 'PATCH',
|
|
250
|
+
headers: {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
Authorization: 'Bearer valid.mock.token',
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({ value: 'Very Long Value...' }),
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(result.status).toBe(422);
|
|
259
|
+
expect(result.json.error).toBe('Validation failed');
|
|
260
|
+
expect(result.json.errors).toEqual([{ message: 'Heading exceeds maximum length of 50' }]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('refreshes a near-expired CLI-rung token before proxying a write', async () => {
|
|
264
|
+
const staleConfig: DevConfigResult = {
|
|
265
|
+
status: 'ok',
|
|
266
|
+
siteId: 'site-123',
|
|
267
|
+
devSiteId: 'site-123',
|
|
268
|
+
apiUrl: 'http://127.0.0.1:8787',
|
|
269
|
+
devToken: 'stale.mock.token',
|
|
270
|
+
identity: { sub: 'user-1', exp: Math.floor(Date.now() / 1000) - 5 },
|
|
271
|
+
mismatch: null,
|
|
272
|
+
tokenSource: 'cli',
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const refreshedToken = createMockJwt({
|
|
276
|
+
role: 'authenticated',
|
|
277
|
+
sub: 'user-1',
|
|
278
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
279
|
+
});
|
|
280
|
+
const loadCredential = vi.fn().mockResolvedValue(refreshedToken);
|
|
281
|
+
|
|
282
|
+
const stubbedFetch = vi.fn().mockResolvedValue({
|
|
283
|
+
ok: true,
|
|
284
|
+
status: 200,
|
|
285
|
+
text: async () =>
|
|
286
|
+
JSON.stringify({ ok: true, data: { key: 'home.hero.heading', value: 'Updated' } }),
|
|
287
|
+
} as unknown as Response);
|
|
288
|
+
|
|
289
|
+
const middleware = createDevApiMiddleware({
|
|
290
|
+
devConfig: staleConfig,
|
|
291
|
+
fetchFn: stubbedFetch as unknown as typeof fetch,
|
|
292
|
+
loadCredential,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const req = createMockReq({
|
|
296
|
+
url: '/_cms/dev/value',
|
|
297
|
+
method: 'PATCH',
|
|
298
|
+
remoteAddress: '127.0.0.1',
|
|
299
|
+
body: { key: 'home.hero.heading', value: 'Updated' },
|
|
300
|
+
});
|
|
301
|
+
const { res, getResult } = createMockRes();
|
|
302
|
+
|
|
303
|
+
middleware(req, res, () => {});
|
|
304
|
+
await getResult();
|
|
305
|
+
|
|
306
|
+
expect(loadCredential).toHaveBeenCalledWith('http://127.0.0.1:8787');
|
|
307
|
+
expect(stubbedFetch).toHaveBeenCalledWith(
|
|
308
|
+
'http://127.0.0.1:8787/sites/site-123/fields/home.hero.heading',
|
|
309
|
+
expect.objectContaining({
|
|
310
|
+
headers: expect.objectContaining({ Authorization: `Bearer ${refreshedToken}` }),
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('updates in-memory bundle when field patch succeeds', async () => {
|
|
316
|
+
setBuildBundle({
|
|
317
|
+
bundleVersion: 1,
|
|
318
|
+
site_id: 'site-123',
|
|
319
|
+
fields: { 'home.hero.heading': 'Initial Heading' },
|
|
320
|
+
collections: {},
|
|
321
|
+
entries: {},
|
|
322
|
+
routes: [],
|
|
323
|
+
redirects: [],
|
|
324
|
+
settings: { default_seo: {} },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const stubbedFetch = vi.fn().mockResolvedValue({
|
|
328
|
+
ok: true,
|
|
329
|
+
status: 200,
|
|
330
|
+
text: async () =>
|
|
331
|
+
JSON.stringify({ ok: true, data: { key: 'home.hero.heading', value: 'Updated Heading' } }),
|
|
332
|
+
} as unknown as Response);
|
|
333
|
+
|
|
334
|
+
const middleware = createDevApiMiddleware({
|
|
335
|
+
devConfig: baseOkConfig,
|
|
336
|
+
fetchFn: stubbedFetch as unknown as typeof fetch,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const req = createMockReq({
|
|
340
|
+
url: '/_cms/dev/value',
|
|
341
|
+
method: 'PATCH',
|
|
342
|
+
remoteAddress: '127.0.0.1',
|
|
343
|
+
body: { key: 'home.hero.heading', value: 'Updated Heading' },
|
|
344
|
+
});
|
|
345
|
+
const { res, getResult } = createMockRes();
|
|
346
|
+
|
|
347
|
+
middleware(req, res, () => {});
|
|
348
|
+
const result = await getResult();
|
|
349
|
+
|
|
350
|
+
expect(result.status).toBe(200);
|
|
351
|
+
const active = getActiveBundle();
|
|
352
|
+
expect(active?.fields['home.hero.heading']).toBe('Updated Heading');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
3
|
+
import { resolveEditorOrigins } from '../preview/origins';
|
|
4
|
+
import { updateBundleFieldValue } from '../runtime/store';
|
|
5
|
+
import type { CmsClient, ContentBundle } from '../types';
|
|
6
|
+
import { type DevConfigResult, type LoadCredentialFn, refreshDevConfigToken } from './config';
|
|
7
|
+
import { mintDevPreviewToken } from './preview-token';
|
|
8
|
+
import type { VerifierInstance } from './verifier';
|
|
9
|
+
|
|
10
|
+
export interface CmsDevApiPluginOptions {
|
|
11
|
+
devConfig: DevConfigResult;
|
|
12
|
+
client?: CmsClient | null;
|
|
13
|
+
verifier?: VerifierInstance;
|
|
14
|
+
previewSecret?: string;
|
|
15
|
+
editorOrigins?: string[];
|
|
16
|
+
fetchFn?: typeof fetch;
|
|
17
|
+
loadCredential?: LoadCredentialFn;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isLoopbackAddress(remoteAddress?: string | null): boolean {
|
|
21
|
+
if (!remoteAddress) return false;
|
|
22
|
+
const cleaned = remoteAddress.trim();
|
|
23
|
+
if (
|
|
24
|
+
cleaned === '127.0.0.1' ||
|
|
25
|
+
cleaned === '::1' ||
|
|
26
|
+
cleaned === '::ffff:127.0.0.1' ||
|
|
27
|
+
cleaned === 'localhost'
|
|
28
|
+
) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
// IPv6 loopback variants or IPv4 mapped loopback
|
|
32
|
+
if (cleaned.startsWith('::ffff:127.') || cleaned === '0:0:0:0:0:0:0:1') {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sendJson(res: ServerResponse, statusCode: number, data: unknown) {
|
|
39
|
+
res.statusCode = statusCode;
|
|
40
|
+
res.setHeader('Content-Type', 'application/json');
|
|
41
|
+
res.end(JSON.stringify(data));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readJsonBody<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let body = '';
|
|
47
|
+
req.on('data', (chunk) => {
|
|
48
|
+
body += chunk;
|
|
49
|
+
});
|
|
50
|
+
req.on('end', () => {
|
|
51
|
+
try {
|
|
52
|
+
resolve(body ? (JSON.parse(body) as T) : ({} as T));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
reject(err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
req.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createDevApiMiddleware(options: CmsDevApiPluginOptions) {
|
|
62
|
+
const {
|
|
63
|
+
devConfig,
|
|
64
|
+
client,
|
|
65
|
+
verifier,
|
|
66
|
+
previewSecret,
|
|
67
|
+
editorOrigins,
|
|
68
|
+
fetchFn = fetch,
|
|
69
|
+
loadCredential,
|
|
70
|
+
} = options;
|
|
71
|
+
|
|
72
|
+
return async function devApiMiddleware(
|
|
73
|
+
req: IncomingMessage,
|
|
74
|
+
res: ServerResponse,
|
|
75
|
+
next: () => void,
|
|
76
|
+
) {
|
|
77
|
+
const rawUrl = req.url || '';
|
|
78
|
+
const [pathname] = rawUrl.split('?');
|
|
79
|
+
|
|
80
|
+
if (!pathname.startsWith('/_cms/dev')) {
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 1. Loopback-only security gate
|
|
85
|
+
const remoteAddress = req.socket?.remoteAddress;
|
|
86
|
+
if (!isLoopbackAddress(remoteAddress)) {
|
|
87
|
+
return sendJson(res, 403, {
|
|
88
|
+
error: 'Forbidden: CMS dev routes accept loopback callers only',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await refreshDevConfigToken(devConfig, { loadCredential, fetchFn });
|
|
93
|
+
|
|
94
|
+
// 2. Refused or unconfigured gate
|
|
95
|
+
if (devConfig.status === 'refused' || devConfig.status === 'unconfigured') {
|
|
96
|
+
return sendJson(res, 503, {
|
|
97
|
+
error: devConfig.reason || 'CMS dev routes unconfigured',
|
|
98
|
+
status: devConfig.status,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. Dispatch routes
|
|
103
|
+
if (pathname === '/_cms/dev/status' && req.method === 'GET') {
|
|
104
|
+
const diag = verifier
|
|
105
|
+
? await verifier.getDiagnostics()
|
|
106
|
+
: {
|
|
107
|
+
available: false,
|
|
108
|
+
errors: [],
|
|
109
|
+
warnings: [],
|
|
110
|
+
localHash: null,
|
|
111
|
+
pushedHash: null,
|
|
112
|
+
fresh: true,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return sendJson(res, 200, {
|
|
116
|
+
status: devConfig.status,
|
|
117
|
+
siteId: devConfig.siteId,
|
|
118
|
+
devSiteId: devConfig.devSiteId,
|
|
119
|
+
apiUrl: devConfig.apiUrl,
|
|
120
|
+
env: { CMS_API_URL: devConfig.apiUrl },
|
|
121
|
+
identity: devConfig.identity || null,
|
|
122
|
+
mismatch: devConfig.mismatch || null,
|
|
123
|
+
manifest: {
|
|
124
|
+
fresh: diag.fresh,
|
|
125
|
+
localHash: diag.localHash,
|
|
126
|
+
pushedHash: diag.pushedHash,
|
|
127
|
+
},
|
|
128
|
+
verifier: {
|
|
129
|
+
available: diag.available,
|
|
130
|
+
errors: diag.errors,
|
|
131
|
+
warnings: diag.warnings,
|
|
132
|
+
},
|
|
133
|
+
editorOrigins: resolveEditorOrigins(editorOrigins),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (pathname === '/_cms/dev/bundle' && req.method === 'GET') {
|
|
138
|
+
try {
|
|
139
|
+
let bundle: ContentBundle | null = null;
|
|
140
|
+
if (client) {
|
|
141
|
+
try {
|
|
142
|
+
const previewToken = await mintDevPreviewToken(
|
|
143
|
+
devConfig.siteId,
|
|
144
|
+
previewSecret || client.previewSecret,
|
|
145
|
+
);
|
|
146
|
+
bundle = await client.getBundle(previewToken ? { previewToken } : undefined);
|
|
147
|
+
} catch {
|
|
148
|
+
bundle = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Fetch fields from write API
|
|
153
|
+
let fields: unknown[] = [];
|
|
154
|
+
try {
|
|
155
|
+
const fieldsRes = await fetchFn(
|
|
156
|
+
`${devConfig.apiUrl}/sites/${encodeURIComponent(devConfig.siteId)}/fields?include_orphaned=1`,
|
|
157
|
+
{
|
|
158
|
+
method: 'GET',
|
|
159
|
+
headers: {
|
|
160
|
+
Authorization: `Bearer ${devConfig.devToken}`,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
if (fieldsRes.ok) {
|
|
165
|
+
const fieldsJson = (await fieldsRes.json()) as { ok?: boolean; data?: unknown[] };
|
|
166
|
+
fields = Array.isArray(fieldsJson.data) ? fieldsJson.data : [];
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Keep empty fields on failure
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return sendJson(res, 200, {
|
|
173
|
+
bundle,
|
|
174
|
+
fields,
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
178
|
+
return sendJson(res, 500, { error: `Failed to load bundle: ${msg}` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (pathname === '/_cms/dev/value' && req.method === 'PATCH') {
|
|
183
|
+
if (devConfig.status === 'read-only') {
|
|
184
|
+
return sendJson(res, 403, {
|
|
185
|
+
error: 'Toolbar is in read-only mode due to site ID mismatch',
|
|
186
|
+
mismatch: devConfig.mismatch,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let body: { key?: string; value?: unknown };
|
|
191
|
+
try {
|
|
192
|
+
body = await readJsonBody<{ key?: string; value?: unknown }>(req);
|
|
193
|
+
} catch {
|
|
194
|
+
return sendJson(res, 400, { error: 'Invalid JSON request body' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!body.key || typeof body.key !== 'string') {
|
|
198
|
+
return sendJson(res, 400, { error: 'Field key is required' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const upstreamRes = await fetchFn(
|
|
203
|
+
`${devConfig.apiUrl}/sites/${encodeURIComponent(devConfig.siteId)}/fields/${encodeURIComponent(body.key)}`,
|
|
204
|
+
{
|
|
205
|
+
method: 'PATCH',
|
|
206
|
+
headers: {
|
|
207
|
+
'Content-Type': 'application/json',
|
|
208
|
+
Authorization: `Bearer ${devConfig.devToken}`,
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({ value: body.value }),
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const upstreamBodyText = await upstreamRes.text();
|
|
215
|
+
if (upstreamRes.ok) {
|
|
216
|
+
updateBundleFieldValue(body.key, body.value);
|
|
217
|
+
}
|
|
218
|
+
res.statusCode = upstreamRes.status;
|
|
219
|
+
res.setHeader('Content-Type', 'application/json');
|
|
220
|
+
res.end(upstreamBodyText);
|
|
221
|
+
return;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
return sendJson(res, 502, { error: `Upstream API request failed: ${msg}` });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Unmatched /_cms/dev path
|
|
229
|
+
return sendJson(res, 404, { error: 'Not Found' });
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function cmsDevApiPlugin(options: CmsDevApiPluginOptions): Plugin {
|
|
234
|
+
return {
|
|
235
|
+
name: 'cms-dev-api',
|
|
236
|
+
apply: 'serve',
|
|
237
|
+
enforce: 'pre',
|
|
238
|
+
configureServer(server: ViteDevServer) {
|
|
239
|
+
if (options.verifier) {
|
|
240
|
+
options.verifier.attachServer(server);
|
|
241
|
+
}
|
|
242
|
+
server.middlewares.use(createDevApiMiddleware(options));
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|