@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.
Files changed (212) hide show
  1. package/README.md +69 -0
  2. package/dist/chunk-2TKIQIOE.js +118 -0
  3. package/dist/chunk-2TKIQIOE.js.map +1 -0
  4. package/dist/chunk-53OQ5RXJ.js +912 -0
  5. package/dist/chunk-53OQ5RXJ.js.map +1 -0
  6. package/dist/chunk-56OMNQLH.js +1013 -0
  7. package/dist/chunk-56OMNQLH.js.map +1 -0
  8. package/dist/chunk-5AOOMQ7K.js +1031 -0
  9. package/dist/chunk-5AOOMQ7K.js.map +1 -0
  10. package/dist/chunk-5XRUCFDC.js +743 -0
  11. package/dist/chunk-5XRUCFDC.js.map +1 -0
  12. package/dist/chunk-62ZHCOLZ.js +25 -0
  13. package/dist/chunk-62ZHCOLZ.js.map +1 -0
  14. package/dist/chunk-6AZLRSYA.js +126 -0
  15. package/dist/chunk-6AZLRSYA.js.map +1 -0
  16. package/dist/chunk-6L3ZTCFB.js +14 -0
  17. package/dist/chunk-6L3ZTCFB.js.map +1 -0
  18. package/dist/chunk-6O7ZNNDT.js +697 -0
  19. package/dist/chunk-6O7ZNNDT.js.map +1 -0
  20. package/dist/chunk-6PQIXTMK.js +697 -0
  21. package/dist/chunk-6PQIXTMK.js.map +1 -0
  22. package/dist/chunk-7AW6M7QW.js +1013 -0
  23. package/dist/chunk-7AW6M7QW.js.map +1 -0
  24. package/dist/chunk-7E3XJLKX.js +73 -0
  25. package/dist/chunk-7E3XJLKX.js.map +1 -0
  26. package/dist/chunk-7LM54OTA.js +1031 -0
  27. package/dist/chunk-7LM54OTA.js.map +1 -0
  28. package/dist/chunk-7LMUFCWX.js +126 -0
  29. package/dist/chunk-7LMUFCWX.js.map +1 -0
  30. package/dist/chunk-A3OGL4OX.js +706 -0
  31. package/dist/chunk-A3OGL4OX.js.map +1 -0
  32. package/dist/chunk-AO4UAWOV.js +1030 -0
  33. package/dist/chunk-AO4UAWOV.js.map +1 -0
  34. package/dist/chunk-B4YYLS7Y.js +14 -0
  35. package/dist/chunk-B4YYLS7Y.js.map +1 -0
  36. package/dist/chunk-BDFVGXM2.js +697 -0
  37. package/dist/chunk-BDFVGXM2.js.map +1 -0
  38. package/dist/chunk-CXM7NZVX.js +1031 -0
  39. package/dist/chunk-CXM7NZVX.js.map +1 -0
  40. package/dist/chunk-DMLOBKYE.js +96 -0
  41. package/dist/chunk-DMLOBKYE.js.map +1 -0
  42. package/dist/chunk-DXJSYGS6.js +743 -0
  43. package/dist/chunk-DXJSYGS6.js.map +1 -0
  44. package/dist/chunk-EFUSAQZM.js +72 -0
  45. package/dist/chunk-EFUSAQZM.js.map +1 -0
  46. package/dist/chunk-F7KDGPBN.js +1013 -0
  47. package/dist/chunk-F7KDGPBN.js.map +1 -0
  48. package/dist/chunk-FBSQT45X.js +126 -0
  49. package/dist/chunk-FBSQT45X.js.map +1 -0
  50. package/dist/chunk-FEPDOMVH.js +697 -0
  51. package/dist/chunk-FEPDOMVH.js.map +1 -0
  52. package/dist/chunk-FQDOL2QW.js +25 -0
  53. package/dist/chunk-FQDOL2QW.js.map +1 -0
  54. package/dist/chunk-GB3LGQJM.js +109 -0
  55. package/dist/chunk-GB3LGQJM.js.map +1 -0
  56. package/dist/chunk-HCNP6R3P.js +736 -0
  57. package/dist/chunk-HCNP6R3P.js.map +1 -0
  58. package/dist/chunk-J6WEJDRI.js +743 -0
  59. package/dist/chunk-J6WEJDRI.js.map +1 -0
  60. package/dist/chunk-JHEAPNJ2.js +117 -0
  61. package/dist/chunk-JHEAPNJ2.js.map +1 -0
  62. package/dist/chunk-JOJDJ6GW.js +25 -0
  63. package/dist/chunk-JOJDJ6GW.js.map +1 -0
  64. package/dist/chunk-KTDV6WYB.js +202 -0
  65. package/dist/chunk-KTDV6WYB.js.map +1 -0
  66. package/dist/chunk-KZWJBKPC.js +734 -0
  67. package/dist/chunk-KZWJBKPC.js.map +1 -0
  68. package/dist/chunk-LEAQBGL6.js +109 -0
  69. package/dist/chunk-LEAQBGL6.js.map +1 -0
  70. package/dist/chunk-LGZJL2Q7.js +697 -0
  71. package/dist/chunk-LGZJL2Q7.js.map +1 -0
  72. package/dist/chunk-LQPBU46R.js +109 -0
  73. package/dist/chunk-LQPBU46R.js.map +1 -0
  74. package/dist/chunk-M55NNUTY.js +1013 -0
  75. package/dist/chunk-M55NNUTY.js.map +1 -0
  76. package/dist/chunk-M6NCYCC3.js +1013 -0
  77. package/dist/chunk-M6NCYCC3.js.map +1 -0
  78. package/dist/chunk-MGN7OZAU.js +109 -0
  79. package/dist/chunk-MGN7OZAU.js.map +1 -0
  80. package/dist/chunk-MGYZOX4K.js +1013 -0
  81. package/dist/chunk-MGYZOX4K.js.map +1 -0
  82. package/dist/chunk-MY4IEKJS.js +1031 -0
  83. package/dist/chunk-MY4IEKJS.js.map +1 -0
  84. package/dist/chunk-NCQRGAB4.js +705 -0
  85. package/dist/chunk-NCQRGAB4.js.map +1 -0
  86. package/dist/chunk-NI4NHXI4.js +697 -0
  87. package/dist/chunk-NI4NHXI4.js.map +1 -0
  88. package/dist/chunk-NPI5N7AU.js +14 -0
  89. package/dist/chunk-NPI5N7AU.js.map +1 -0
  90. package/dist/chunk-QN4CPMJF.js +126 -0
  91. package/dist/chunk-QN4CPMJF.js.map +1 -0
  92. package/dist/chunk-QYUVAKMC.js +1013 -0
  93. package/dist/chunk-QYUVAKMC.js.map +1 -0
  94. package/dist/chunk-RAAT6PDT.js +25 -0
  95. package/dist/chunk-RAAT6PDT.js.map +1 -0
  96. package/dist/chunk-RDPO5VPS.js +1013 -0
  97. package/dist/chunk-RDPO5VPS.js.map +1 -0
  98. package/dist/chunk-RQHIW2PF.js +25 -0
  99. package/dist/chunk-RQHIW2PF.js.map +1 -0
  100. package/dist/chunk-RREVHA52.js +722 -0
  101. package/dist/chunk-RREVHA52.js.map +1 -0
  102. package/dist/chunk-S7PT4PZN.js +25 -0
  103. package/dist/chunk-S7PT4PZN.js.map +1 -0
  104. package/dist/chunk-SUXV5M32.js +109 -0
  105. package/dist/chunk-SUXV5M32.js.map +1 -0
  106. package/dist/chunk-SZEORUY6.js +696 -0
  107. package/dist/chunk-SZEORUY6.js.map +1 -0
  108. package/dist/chunk-T7NGGWGY.js +25 -0
  109. package/dist/chunk-T7NGGWGY.js.map +1 -0
  110. package/dist/chunk-UEVWP5VE.js +696 -0
  111. package/dist/chunk-UEVWP5VE.js.map +1 -0
  112. package/dist/chunk-UJNKNP7U.js +748 -0
  113. package/dist/chunk-UJNKNP7U.js.map +1 -0
  114. package/dist/chunk-V4XVFWFI.js +1013 -0
  115. package/dist/chunk-V4XVFWFI.js.map +1 -0
  116. package/dist/chunk-VJU2CIKZ.js +729 -0
  117. package/dist/chunk-VJU2CIKZ.js.map +1 -0
  118. package/dist/chunk-VRQUP5L3.js +202 -0
  119. package/dist/chunk-VRQUP5L3.js.map +1 -0
  120. package/dist/chunk-WBJ5E7CL.js +742 -0
  121. package/dist/chunk-WBJ5E7CL.js.map +1 -0
  122. package/dist/chunk-XJPVYMZM.js +24 -0
  123. package/dist/chunk-XJPVYMZM.js.map +1 -0
  124. package/dist/chunk-Y3Q65C7R.js +14 -0
  125. package/dist/chunk-Y3Q65C7R.js.map +1 -0
  126. package/dist/chunk-YHEGFPTR.js +1030 -0
  127. package/dist/chunk-YHEGFPTR.js.map +1 -0
  128. package/dist/chunk-ZDLBN46C.js +697 -0
  129. package/dist/chunk-ZDLBN46C.js.map +1 -0
  130. package/dist/dev/index.d.ts +122 -0
  131. package/dist/dev/index.js +662 -0
  132. package/dist/dev/index.js.map +1 -0
  133. package/dist/fields/collections.d.ts +18 -0
  134. package/dist/fields/collections.js +37 -0
  135. package/dist/fields/collections.js.map +1 -0
  136. package/dist/index.d.ts +235 -0
  137. package/dist/index.js +956 -0
  138. package/dist/index.js.map +1 -0
  139. package/dist/middleware/index.d.ts +8 -0
  140. package/dist/middleware/index.js +13 -0
  141. package/dist/middleware/index.js.map +1 -0
  142. package/dist/preview-BEkLMD6N.d.ts +23 -0
  143. package/dist/preview-D9yewK8x.d.ts +24 -0
  144. package/dist/preview-DJEA4fj7.d.ts +23 -0
  145. package/dist/preview-DeD1Jn7U.d.ts +23 -0
  146. package/dist/toolbar/app.d.ts +28 -0
  147. package/dist/toolbar/app.js +1176 -0
  148. package/dist/toolbar/app.js.map +1 -0
  149. package/dist/types-15jfCQKb.d.ts +141 -0
  150. package/dist/types-BREdL9Gx.d.ts +140 -0
  151. package/dist/types-C6sUZNa2.d.ts +104 -0
  152. package/dist/types-CEYS5Fzc.d.ts +112 -0
  153. package/dist/types-DpeHpobR.d.ts +104 -0
  154. package/dist/types-NaKJe6Pg.d.ts +140 -0
  155. package/package.json +90 -0
  156. package/src/CmsHead.astro +25 -0
  157. package/src/CmsImage.astro +62 -0
  158. package/src/CmsVideo.astro +185 -0
  159. package/src/RichText.astro +32 -0
  160. package/src/client.test.ts +227 -0
  161. package/src/client.ts +164 -0
  162. package/src/dev/config.test.ts +258 -0
  163. package/src/dev/config.ts +319 -0
  164. package/src/dev/index.ts +5 -0
  165. package/src/dev/preview-token.ts +39 -0
  166. package/src/dev/routes.test.ts +354 -0
  167. package/src/dev/routes.ts +245 -0
  168. package/src/dev/token.test.ts +280 -0
  169. package/src/dev/token.ts +201 -0
  170. package/src/dev/verifier.test.ts +63 -0
  171. package/src/dev/verifier.ts +161 -0
  172. package/src/fields/attrs.ts +24 -0
  173. package/src/fields/collection.ts +72 -0
  174. package/src/fields/collections.ts +35 -0
  175. package/src/fields/entryScope.test.ts +62 -0
  176. package/src/fields/entryScope.ts +240 -0
  177. package/src/fields/group.ts +51 -0
  178. package/src/fields/helpers.test.ts +51 -0
  179. package/src/fields/helpers.ts +214 -0
  180. package/src/fields/index.ts +46 -0
  181. package/src/fields/scope.ts +21 -0
  182. package/src/fields/types.ts +119 -0
  183. package/src/head.test.ts +74 -0
  184. package/src/head.ts +123 -0
  185. package/src/image.test.ts +42 -0
  186. package/src/image.ts +64 -0
  187. package/src/index.test.ts +76 -0
  188. package/src/index.ts +13 -0
  189. package/src/integration.ts +218 -0
  190. package/src/jwt.ts +131 -0
  191. package/src/middleware/index.ts +4 -0
  192. package/src/middleware/preview.test.ts +184 -0
  193. package/src/middleware/preview.ts +206 -0
  194. package/src/preview/bridge-script.test.ts +683 -0
  195. package/src/preview/bridge-script.ts +763 -0
  196. package/src/preview/origins.ts +29 -0
  197. package/src/preview.ts +49 -0
  198. package/src/richtext/RichTextNode.astro +130 -0
  199. package/src/richtext/render.ts +248 -0
  200. package/src/richtext.test.ts +139 -0
  201. package/src/runtime/bundle.ts +28 -0
  202. package/src/runtime/store.ts +130 -0
  203. package/src/siteFiles.test.ts +93 -0
  204. package/src/siteFiles.ts +111 -0
  205. package/src/toolbar/app.test.ts +191 -0
  206. package/src/toolbar/app.ts +1091 -0
  207. package/src/toolbar/icon.ts +5 -0
  208. package/src/toolbar/widgets.test.ts +153 -0
  209. package/src/toolbar/widgets.ts +365 -0
  210. package/src/types.test.ts +154 -0
  211. package/src/types.ts +163 -0
  212. 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
+ }