@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,258 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { type DevConfigResult, refreshDevConfigToken, resolveDevConfig } from './config';
|
|
3
|
+
|
|
4
|
+
function createMockJwt(payload: Record<string, unknown>): string {
|
|
5
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
6
|
+
const encode = (obj: unknown) =>
|
|
7
|
+
Buffer.from(JSON.stringify(obj))
|
|
8
|
+
.toString('base64')
|
|
9
|
+
.replace(/\+/g, '-')
|
|
10
|
+
.replace(/\//g, '_')
|
|
11
|
+
.replace(/=+$/, '');
|
|
12
|
+
return `${encode(header)}.${encode(payload)}.mocksignature`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('resolveDevConfig', () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllEnvs();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const mockFetchOk = vi.fn().mockImplementation(async (url: string) => {
|
|
21
|
+
if (url.includes('/sites/site-123')) {
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
status: 200,
|
|
25
|
+
json: async () => ({ id: 'site-123', name: 'Test Site' }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
status: 404,
|
|
31
|
+
statusText: 'Not Found',
|
|
32
|
+
json: async () => ({ error: 'Site not found' }),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('auto-mints dev JWT and connects when targeting local API without explicit CMS_DEV_TOKEN', async () => {
|
|
37
|
+
vi.stubEnv('CMS_DEV_TOKEN', '');
|
|
38
|
+
|
|
39
|
+
const result = await resolveDevConfig({
|
|
40
|
+
siteId: 'site-123',
|
|
41
|
+
apiUrl: 'http://127.0.0.1:8787',
|
|
42
|
+
fetchFn: mockFetchOk,
|
|
43
|
+
loadCredential: async () => null,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.status).toBe('ok');
|
|
47
|
+
expect(result.siteId).toBe('site-123');
|
|
48
|
+
expect(result.tokenSource).toBe('local-mint');
|
|
49
|
+
expect(typeof result.devToken).toBe('string');
|
|
50
|
+
expect(result.devToken?.split('.').length).toBe(3);
|
|
51
|
+
expect(result.identity?.sub).toBe('00000000-0000-0000-0000-000000000003');
|
|
52
|
+
expect(result.identity?.workspaceRole).toBe('viewer');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks unconfigured when targeting remote API without CMS_DEV_TOKEN and no stored CLI credential', async () => {
|
|
56
|
+
vi.stubEnv('CMS_DEV_TOKEN', '');
|
|
57
|
+
|
|
58
|
+
const result = await resolveDevConfig({
|
|
59
|
+
siteId: 'site-123',
|
|
60
|
+
apiUrl: 'https://api.cms.example.com',
|
|
61
|
+
fetchFn: mockFetchOk,
|
|
62
|
+
loadCredential: async () => null,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.status).toBe('unconfigured');
|
|
66
|
+
expect(result.reason).toContain('cms login');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses CLI credential when env var is absent', async () => {
|
|
70
|
+
vi.stubEnv('CMS_DEV_TOKEN', '');
|
|
71
|
+
|
|
72
|
+
const cliToken = createMockJwt({
|
|
73
|
+
role: 'authenticated',
|
|
74
|
+
sub: 'usr-cli',
|
|
75
|
+
email: 'cli@example.com',
|
|
76
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
77
|
+
app_metadata: {
|
|
78
|
+
role: 'viewer',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const loadCredential = vi.fn().mockResolvedValue(cliToken);
|
|
83
|
+
|
|
84
|
+
const result = await resolveDevConfig({
|
|
85
|
+
siteId: 'site-123',
|
|
86
|
+
apiUrl: 'https://api.cms.example.com',
|
|
87
|
+
fetchFn: mockFetchOk,
|
|
88
|
+
loadCredential,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.status).toBe('ok');
|
|
92
|
+
expect(result.devToken).toBe(cliToken);
|
|
93
|
+
expect(result.tokenSource).toBe('cli');
|
|
94
|
+
expect(result.identity?.email).toBe('cli@example.com');
|
|
95
|
+
expect(result.identity?.sub).toBe('usr-cli');
|
|
96
|
+
expect(loadCredential).toHaveBeenCalledWith('https://api.cms.example.com');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('env var wins over CLI credential', async () => {
|
|
100
|
+
const envToken = createMockJwt({
|
|
101
|
+
role: 'authenticated',
|
|
102
|
+
sub: 'usr-env',
|
|
103
|
+
email: 'env@example.com',
|
|
104
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
105
|
+
app_metadata: {
|
|
106
|
+
role: 'viewer',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const cliToken = createMockJwt({
|
|
111
|
+
role: 'authenticated',
|
|
112
|
+
sub: 'usr-cli',
|
|
113
|
+
email: 'cli@example.com',
|
|
114
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
115
|
+
app_metadata: {
|
|
116
|
+
role: 'viewer',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
vi.stubEnv('CMS_DEV_TOKEN', envToken);
|
|
121
|
+
const loadCredential = vi.fn().mockResolvedValue(cliToken);
|
|
122
|
+
|
|
123
|
+
const result = await resolveDevConfig({
|
|
124
|
+
siteId: 'site-123',
|
|
125
|
+
apiUrl: 'https://api.cms.example.com',
|
|
126
|
+
fetchFn: mockFetchOk,
|
|
127
|
+
loadCredential,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(result.status).toBe('ok');
|
|
131
|
+
expect(result.devToken).toBe(envToken);
|
|
132
|
+
expect(result.tokenSource).toBe('env');
|
|
133
|
+
expect(result.identity?.email).toBe('env@example.com');
|
|
134
|
+
expect(loadCredential).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('CLI rung accepts an owner/editor token', async () => {
|
|
138
|
+
vi.stubEnv('CMS_DEV_TOKEN', '');
|
|
139
|
+
|
|
140
|
+
const ownerToken = createMockJwt({
|
|
141
|
+
role: 'authenticated',
|
|
142
|
+
sub: 'usr-owner',
|
|
143
|
+
email: 'owner@example.com',
|
|
144
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
145
|
+
app_metadata: {
|
|
146
|
+
workspace_id: 'ws-1',
|
|
147
|
+
role: 'owner',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await resolveDevConfig({
|
|
152
|
+
siteId: 'site-123',
|
|
153
|
+
apiUrl: 'https://api.cms.example.com',
|
|
154
|
+
fetchFn: mockFetchOk,
|
|
155
|
+
loadCredential: async () => ownerToken,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.status).toBe('ok');
|
|
159
|
+
expect(result.tokenSource).toBe('cli');
|
|
160
|
+
expect(result.identity?.workspaceRole).toBe('owner');
|
|
161
|
+
expect(result.identity?.email).toBe('owner@example.com');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('env var refuses an owner/editor token', async () => {
|
|
165
|
+
const ownerToken = createMockJwt({
|
|
166
|
+
role: 'authenticated',
|
|
167
|
+
sub: 'usr-owner',
|
|
168
|
+
email: 'owner@example.com',
|
|
169
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
170
|
+
app_metadata: {
|
|
171
|
+
workspace_id: 'ws-1',
|
|
172
|
+
role: 'owner',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
vi.stubEnv('CMS_DEV_TOKEN', ownerToken);
|
|
177
|
+
|
|
178
|
+
const result = await resolveDevConfig({
|
|
179
|
+
siteId: 'site-123',
|
|
180
|
+
apiUrl: 'https://api.cms.example.com',
|
|
181
|
+
fetchFn: mockFetchOk,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.status).toBe('refused');
|
|
185
|
+
expect(result.tokenSource).toBe('env');
|
|
186
|
+
expect(result.reason).toContain("workspace 'owner' scope");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('refreshDevConfigToken', () => {
|
|
191
|
+
const baseCliConfig: DevConfigResult = {
|
|
192
|
+
status: 'ok',
|
|
193
|
+
siteId: 'site-123',
|
|
194
|
+
apiUrl: 'https://api.cms.example.com',
|
|
195
|
+
devToken: 'stale.mock.token',
|
|
196
|
+
identity: { sub: 'usr-cli', exp: Math.floor(Date.now() / 1000) - 5 },
|
|
197
|
+
mismatch: null,
|
|
198
|
+
tokenSource: 'cli',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
it('leaves non-CLI-rung tokens untouched', async () => {
|
|
202
|
+
const envConfig: DevConfigResult = { ...baseCliConfig, tokenSource: 'env' };
|
|
203
|
+
const loadCredential = vi.fn();
|
|
204
|
+
|
|
205
|
+
const result = await refreshDevConfigToken(envConfig, { loadCredential });
|
|
206
|
+
|
|
207
|
+
expect(loadCredential).not.toHaveBeenCalled();
|
|
208
|
+
expect(result.devToken).toBe('stale.mock.token');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('leaves a CLI token untouched when it is not near expiry', async () => {
|
|
212
|
+
const freshConfig: DevConfigResult = {
|
|
213
|
+
...baseCliConfig,
|
|
214
|
+
identity: { sub: 'usr-cli', exp: Math.floor(Date.now() / 1000) + 3600 },
|
|
215
|
+
};
|
|
216
|
+
const loadCredential = vi.fn();
|
|
217
|
+
|
|
218
|
+
const result = await refreshDevConfigToken(freshConfig, { loadCredential });
|
|
219
|
+
|
|
220
|
+
expect(loadCredential).not.toHaveBeenCalled();
|
|
221
|
+
expect(result.devToken).toBe('stale.mock.token');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('refreshes and mutates in place when a CLI token is near expiry', async () => {
|
|
225
|
+
const refreshedToken = createMockJwt({
|
|
226
|
+
role: 'authenticated',
|
|
227
|
+
sub: 'usr-cli',
|
|
228
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
229
|
+
});
|
|
230
|
+
const loadCredential = vi.fn().mockResolvedValue(refreshedToken);
|
|
231
|
+
const config = { ...baseCliConfig };
|
|
232
|
+
|
|
233
|
+
const result = await refreshDevConfigToken(config, { loadCredential });
|
|
234
|
+
|
|
235
|
+
expect(loadCredential).toHaveBeenCalledWith('https://api.cms.example.com');
|
|
236
|
+
expect(result).toBe(config);
|
|
237
|
+
expect(result.devToken).toBe(refreshedToken);
|
|
238
|
+
expect(result.identity?.sub).toBe('usr-cli');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('keeps the stale token when refresh fails', async () => {
|
|
242
|
+
const loadCredential = vi.fn().mockRejectedValue(new Error('refresh_token expired'));
|
|
243
|
+
const config = { ...baseCliConfig };
|
|
244
|
+
|
|
245
|
+
const result = await refreshDevConfigToken(config, { loadCredential });
|
|
246
|
+
|
|
247
|
+
expect(result.devToken).toBe('stale.mock.token');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('keeps the stale token when the refreshed credential fails validation', async () => {
|
|
251
|
+
const loadCredential = vi.fn().mockResolvedValue('not-a-jwt');
|
|
252
|
+
const config = { ...baseCliConfig };
|
|
253
|
+
|
|
254
|
+
const result = await refreshDevConfigToken(config, { loadCredential });
|
|
255
|
+
|
|
256
|
+
expect(result.devToken).toBe('stale.mock.token');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_DEV_API_URL } from '@407dev/config';
|
|
4
|
+
import { type DevTokenIdentity, signDevToken, validateDevToken } from './token';
|
|
5
|
+
|
|
6
|
+
export type DevConfigStatus = 'ok' | 'read-only' | 'refused' | 'unconfigured';
|
|
7
|
+
export type DevTokenSource = 'env' | 'cli' | 'local-mint';
|
|
8
|
+
export type LoadCredentialFn = (apiUrl: string) => Promise<string | null> | string | null;
|
|
9
|
+
|
|
10
|
+
export interface DevSiteMismatch {
|
|
11
|
+
source: string;
|
|
12
|
+
expected: string;
|
|
13
|
+
actual: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DevConfigResult {
|
|
17
|
+
status: DevConfigStatus;
|
|
18
|
+
siteId: string;
|
|
19
|
+
devSiteId?: string;
|
|
20
|
+
apiUrl: string;
|
|
21
|
+
devToken?: string;
|
|
22
|
+
identity?: DevTokenIdentity | null;
|
|
23
|
+
mismatch?: DevSiteMismatch | null;
|
|
24
|
+
reason?: string;
|
|
25
|
+
tokenSource?: DevTokenSource;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolveDevConfigOptions {
|
|
29
|
+
siteId?: string;
|
|
30
|
+
apiUrl?: string;
|
|
31
|
+
siteDir?: string;
|
|
32
|
+
fetchFn?: typeof fetch;
|
|
33
|
+
loadCredential?: LoadCredentialFn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function defaultLoadCredential(
|
|
37
|
+
apiUrl: string,
|
|
38
|
+
fetchFn?: typeof fetch,
|
|
39
|
+
): Promise<string | null> {
|
|
40
|
+
try {
|
|
41
|
+
const cliPkg = '@407dev/cli';
|
|
42
|
+
const cliModule = await import(/* @vite-ignore */ cliPkg);
|
|
43
|
+
if (!cliModule?.getStoredCredentials || !cliModule?.refreshCredentialsIfNeeded) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const creds = cliModule.getStoredCredentials(apiUrl);
|
|
47
|
+
if (!creds) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const refreshed = await cliModule.refreshCredentialsIfNeeded(apiUrl, creds, undefined, fetchFn);
|
|
51
|
+
return refreshed?.access_token || null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RefreshDevConfigTokenOptions {
|
|
58
|
+
loadCredential?: LoadCredentialFn;
|
|
59
|
+
fetchFn?: typeof fetch;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// resolveDevConfig runs once at startup, so the CLI rung's ~1h access_token needs refreshing on later requests; mutates devConfig in place since callers hold onto the single startup instance.
|
|
63
|
+
export async function refreshDevConfigToken(
|
|
64
|
+
devConfig: DevConfigResult,
|
|
65
|
+
options: RefreshDevConfigTokenOptions = {},
|
|
66
|
+
): Promise<DevConfigResult> {
|
|
67
|
+
if (devConfig.tokenSource !== 'cli') {
|
|
68
|
+
return devConfig;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const exp = devConfig.identity?.exp;
|
|
72
|
+
if (typeof exp === 'number' && exp - Math.floor(Date.now() / 1000) > 60) {
|
|
73
|
+
return devConfig;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const loadCredential =
|
|
77
|
+
options.loadCredential ?? ((url: string) => defaultLoadCredential(url, options.fetchFn));
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const refreshedToken = await loadCredential(devConfig.apiUrl);
|
|
81
|
+
if (!refreshedToken?.trim()) {
|
|
82
|
+
return devConfig;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const validation = validateDevToken(refreshedToken, { allowElevated: true });
|
|
86
|
+
if (!validation.ok) {
|
|
87
|
+
return devConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
devConfig.devToken = refreshedToken.trim();
|
|
91
|
+
devConfig.identity = validation.identity;
|
|
92
|
+
} catch {
|
|
93
|
+
// Keep the existing (possibly stale) token; the proxied upstream call surfaces the failure.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return devConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolves dev environment configuration, validates the user dev JWT, probes
|
|
101
|
+
* the write API for site accessibility, and verifies site identity consistency.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveDevConfig(
|
|
104
|
+
options: ResolveDevConfigOptions = {},
|
|
105
|
+
): Promise<DevConfigResult> {
|
|
106
|
+
const siteDir = options.siteDir ? path.resolve(options.siteDir) : process.cwd();
|
|
107
|
+
const fetchClient = options.fetchFn || fetch;
|
|
108
|
+
|
|
109
|
+
// Load environment variables via Vite's loadEnv to match .env files
|
|
110
|
+
let env: Record<string, string> = {};
|
|
111
|
+
try {
|
|
112
|
+
const vitePkg = 'vite';
|
|
113
|
+
const vite = await import(/* @vite-ignore */ vitePkg);
|
|
114
|
+
if (typeof vite.loadEnv === 'function') {
|
|
115
|
+
env = vite.loadEnv('development', siteDir, '');
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Fall back to process.env
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let devToken = env.CMS_DEV_TOKEN || process.env.CMS_DEV_TOKEN || '';
|
|
122
|
+
let tokenSource: DevTokenSource | undefined = devToken ? 'env' : undefined;
|
|
123
|
+
const devSiteId = env.CMS_DEV_SITE || process.env.CMS_DEV_SITE || '';
|
|
124
|
+
const apiUrl = (
|
|
125
|
+
options.apiUrl ||
|
|
126
|
+
env.CMS_API_URL ||
|
|
127
|
+
process.env.CMS_API_URL ||
|
|
128
|
+
DEFAULT_DEV_API_URL
|
|
129
|
+
).replace(/\/$/, '');
|
|
130
|
+
const siteId = options.siteId || env.CMS_SITE_ID || process.env.CMS_SITE_ID || '';
|
|
131
|
+
|
|
132
|
+
// 2nd rung: CLI session (stored credentials)
|
|
133
|
+
if (!devToken) {
|
|
134
|
+
const loadCredential =
|
|
135
|
+
options.loadCredential ?? ((url) => defaultLoadCredential(url, fetchClient));
|
|
136
|
+
try {
|
|
137
|
+
const cliToken = await loadCredential(apiUrl);
|
|
138
|
+
if (cliToken?.trim()) {
|
|
139
|
+
devToken = cliToken.trim();
|
|
140
|
+
tokenSource = 'cli';
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Swallow failure and fall through
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3rd rung: Auto-mint development JWT if running against local API and token is not explicitly configured
|
|
148
|
+
if (!devToken && (apiUrl.includes('127.0.0.1') || apiUrl.includes('localhost'))) {
|
|
149
|
+
const jwtSecret =
|
|
150
|
+
env.SUPABASE_JWT_SECRET ||
|
|
151
|
+
process.env.SUPABASE_JWT_SECRET ||
|
|
152
|
+
'super-secret-jwt-token-with-at-least-32-characters-long';
|
|
153
|
+
try {
|
|
154
|
+
devToken = signDevToken(jwtSecret, siteId || devSiteId || undefined);
|
|
155
|
+
tokenSource = 'local-mint';
|
|
156
|
+
} catch {
|
|
157
|
+
// Fall through to unconfigured check
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 1. Unconfigured check
|
|
162
|
+
if (!devToken || !devToken.trim()) {
|
|
163
|
+
return {
|
|
164
|
+
status: 'unconfigured',
|
|
165
|
+
siteId,
|
|
166
|
+
devSiteId: devSiteId || undefined,
|
|
167
|
+
apiUrl,
|
|
168
|
+
mismatch: null,
|
|
169
|
+
reason: 'Not logged in. Run `cms login` to enable dev-time editing.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. Token format and scope validation (CLI rung allows elevated roles)
|
|
174
|
+
const tokenValidation = validateDevToken(devToken, {
|
|
175
|
+
allowElevated: tokenSource === 'cli',
|
|
176
|
+
});
|
|
177
|
+
if (!tokenValidation.ok) {
|
|
178
|
+
return {
|
|
179
|
+
status: 'refused',
|
|
180
|
+
siteId,
|
|
181
|
+
devSiteId: devSiteId || undefined,
|
|
182
|
+
apiUrl,
|
|
183
|
+
mismatch: null,
|
|
184
|
+
reason: tokenValidation.reason,
|
|
185
|
+
tokenSource,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const identity = tokenValidation.identity;
|
|
190
|
+
|
|
191
|
+
// 3. Probe site accessibility on API via GET /sites/:id with token
|
|
192
|
+
const effectiveSiteId = siteId || devSiteId;
|
|
193
|
+
if (!effectiveSiteId) {
|
|
194
|
+
return {
|
|
195
|
+
status: 'refused',
|
|
196
|
+
siteId,
|
|
197
|
+
devSiteId: devSiteId || undefined,
|
|
198
|
+
apiUrl,
|
|
199
|
+
identity,
|
|
200
|
+
mismatch: null,
|
|
201
|
+
reason: 'No site ID provided (set CMS_SITE_ID or siteId option)',
|
|
202
|
+
tokenSource,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const probeRes = await fetchClient(`${apiUrl}/sites/${encodeURIComponent(effectiveSiteId)}`, {
|
|
208
|
+
method: 'GET',
|
|
209
|
+
headers: {
|
|
210
|
+
Authorization: `Bearer ${devToken}`,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (!probeRes.ok) {
|
|
215
|
+
let errorDetail = '';
|
|
216
|
+
try {
|
|
217
|
+
const body = (await probeRes.json()) as { error?: string };
|
|
218
|
+
errorDetail = body.error || '';
|
|
219
|
+
} catch {
|
|
220
|
+
errorDetail = probeRes.statusText || String(probeRes.status);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
status: 'refused',
|
|
224
|
+
siteId: effectiveSiteId,
|
|
225
|
+
devSiteId: devSiteId || undefined,
|
|
226
|
+
apiUrl,
|
|
227
|
+
identity,
|
|
228
|
+
mismatch: null,
|
|
229
|
+
reason: `Site '${effectiveSiteId}' inaccessible with dev token (HTTP ${probeRes.status}: ${errorDetail})`,
|
|
230
|
+
tokenSource,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
return {
|
|
236
|
+
status: 'refused',
|
|
237
|
+
siteId: effectiveSiteId,
|
|
238
|
+
devSiteId: devSiteId || undefined,
|
|
239
|
+
apiUrl,
|
|
240
|
+
identity,
|
|
241
|
+
mismatch: null,
|
|
242
|
+
reason: `Failed to connect to CMS API at ${apiUrl}: ${msg}`,
|
|
243
|
+
tokenSource,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 4. Site identity comparison (Wrong-site guard)
|
|
248
|
+
// Check against CMS_DEV_SITE, .cms/bundle.json, and .cms/state.json
|
|
249
|
+
let mismatch: DevSiteMismatch | null = null;
|
|
250
|
+
|
|
251
|
+
if (devSiteId && siteId && devSiteId !== siteId) {
|
|
252
|
+
mismatch = {
|
|
253
|
+
source: 'CMS_DEV_SITE',
|
|
254
|
+
expected: siteId,
|
|
255
|
+
actual: devSiteId,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!mismatch) {
|
|
260
|
+
const bundlePath = path.join(siteDir, '.cms', 'bundle.json');
|
|
261
|
+
if (fs.existsSync(bundlePath)) {
|
|
262
|
+
try {
|
|
263
|
+
const bundleContent = JSON.parse(fs.readFileSync(bundlePath, 'utf-8'));
|
|
264
|
+
if (bundleContent?.site_id && siteId && bundleContent.site_id !== siteId) {
|
|
265
|
+
mismatch = {
|
|
266
|
+
source: '.cms/bundle.json',
|
|
267
|
+
expected: siteId,
|
|
268
|
+
actual: bundleContent.site_id,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Ignore unreadable bundle.json
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!mismatch) {
|
|
278
|
+
const statePath = path.join(siteDir, '.cms', 'state.json');
|
|
279
|
+
if (fs.existsSync(statePath)) {
|
|
280
|
+
try {
|
|
281
|
+
const stateContent = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
282
|
+
const stateSiteKeys = stateContent?.sites ? Object.keys(stateContent.sites) : [];
|
|
283
|
+
if (stateSiteKeys.length > 0 && siteId && !stateSiteKeys.includes(siteId)) {
|
|
284
|
+
mismatch = {
|
|
285
|
+
source: '.cms/state.json',
|
|
286
|
+
expected: siteId,
|
|
287
|
+
actual: stateSiteKeys.join(', '),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Ignore unreadable state.json
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (mismatch) {
|
|
297
|
+
return {
|
|
298
|
+
status: 'read-only',
|
|
299
|
+
siteId: effectiveSiteId,
|
|
300
|
+
devSiteId: devSiteId || undefined,
|
|
301
|
+
apiUrl,
|
|
302
|
+
devToken,
|
|
303
|
+
identity,
|
|
304
|
+
mismatch,
|
|
305
|
+
tokenSource,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
status: 'ok',
|
|
311
|
+
siteId: effectiveSiteId,
|
|
312
|
+
devSiteId: devSiteId || effectiveSiteId,
|
|
313
|
+
apiUrl,
|
|
314
|
+
devToken,
|
|
315
|
+
identity,
|
|
316
|
+
mismatch: null,
|
|
317
|
+
tokenSource,
|
|
318
|
+
};
|
|
319
|
+
}
|
package/src/dev/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getJwtSecret } from '@407dev/config';
|
|
2
|
+
import { signPreviewToken } from '../jwt';
|
|
3
|
+
|
|
4
|
+
export const DEV_PREVIEW_TOKEN_TTL_SECONDS = 60 * 60 * 12;
|
|
5
|
+
|
|
6
|
+
/** Self-mints a preview token for dev-mode draft reads using the shared preview secret. */
|
|
7
|
+
export async function mintDevPreviewToken(
|
|
8
|
+
siteId: string,
|
|
9
|
+
previewSecret: string | undefined,
|
|
10
|
+
): Promise<string | null> {
|
|
11
|
+
const secret =
|
|
12
|
+
previewSecret ||
|
|
13
|
+
(typeof process !== 'undefined'
|
|
14
|
+
? process.env?.CMS_PREVIEW_SECRET || process.env?.JWT_SECRET
|
|
15
|
+
: undefined) ||
|
|
16
|
+
(() => {
|
|
17
|
+
try {
|
|
18
|
+
return getJwtSecret();
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
if (!secret || !siteId) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const now = Math.floor(Date.now() / 1000);
|
|
29
|
+
return signPreviewToken(
|
|
30
|
+
{
|
|
31
|
+
site_id: siteId,
|
|
32
|
+
sub: 'astro-dev',
|
|
33
|
+
scope: 'cms:preview',
|
|
34
|
+
iat: now,
|
|
35
|
+
exp: now + DEV_PREVIEW_TOKEN_TTL_SECONDS,
|
|
36
|
+
},
|
|
37
|
+
secret,
|
|
38
|
+
);
|
|
39
|
+
}
|