zelune 1.4.0 → 1.5.2
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/CHANGELOG.md +29 -0
- package/README.md +8 -15
- package/bin/cli.mjs +215 -31
- package/mcp/server.ts +57 -1
- package/package.json +1 -1
- package/src/canvas/CanvasApp.tsx +89 -4
- package/src/canvas/ScreenArtboard.tsx +1008 -43
- package/src/server/apiMiddleware.ts +185 -12
- package/src/server/astEngine.ts +428 -19
- package/vite.config.ts +91 -24
|
@@ -4,47 +4,197 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
4
4
|
import type { Plugin } from 'vite';
|
|
5
5
|
import {
|
|
6
6
|
discoverScreens,
|
|
7
|
+
getActiveProjectRoot,
|
|
7
8
|
inspectElement,
|
|
8
9
|
patchElement,
|
|
10
|
+
reorderElement,
|
|
9
11
|
undoLastPatch,
|
|
10
12
|
redoLastPatch,
|
|
11
13
|
getHistoryStatus,
|
|
12
14
|
type PatchPayload,
|
|
15
|
+
type ReorderPayload,
|
|
13
16
|
} from './astEngine';
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
19
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
20
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
21
|
+
|
|
22
|
+
export function isLoopbackHostHeader(hostHeader: string | undefined): boolean {
|
|
23
|
+
if (!hostHeader) return false;
|
|
24
|
+
const trimmed = hostHeader.trim().toLowerCase();
|
|
25
|
+
if (trimmed.startsWith('[')) {
|
|
26
|
+
const closeIdx = trimmed.indexOf(']');
|
|
27
|
+
if (closeIdx === -1) return false;
|
|
28
|
+
const ipv6Host = trimmed.slice(0, closeIdx + 1);
|
|
29
|
+
return LOOPBACK_HOSTS.has(ipv6Host);
|
|
30
|
+
}
|
|
31
|
+
const hostname = trimmed.split(':')[0];
|
|
32
|
+
return LOOPBACK_HOSTS.has(hostname);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function verifyApiRequestSecurity(req: {
|
|
36
|
+
method?: string;
|
|
37
|
+
headers: Record<string, string | string[] | undefined>;
|
|
38
|
+
}): { allowed: boolean; reason?: string } {
|
|
39
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
|
|
40
|
+
if (!isLoopbackHostHeader(host)) {
|
|
41
|
+
return {
|
|
42
|
+
allowed: false,
|
|
43
|
+
reason: 'Forbidden Host header: Local API only accepts loopback connections (127.0.0.1, localhost, [::1])',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const secFetchSite =
|
|
48
|
+
typeof req.headers['sec-fetch-site'] === 'string'
|
|
49
|
+
? req.headers['sec-fetch-site'].toLowerCase()
|
|
50
|
+
: undefined;
|
|
51
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
|
|
52
|
+
return {
|
|
53
|
+
allowed: false,
|
|
54
|
+
reason: `Forbidden cross-site request (Sec-Fetch-Site: ${secFetchSite})`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
|
|
59
|
+
if (origin) {
|
|
60
|
+
try {
|
|
61
|
+
const parsedOrigin = new URL(origin);
|
|
62
|
+
if (!LOOPBACK_HOSTS.has(parsedOrigin.hostname.toLowerCase())) {
|
|
63
|
+
return {
|
|
64
|
+
allowed: false,
|
|
65
|
+
reason: `Forbidden cross-origin request from ${origin}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
return {
|
|
70
|
+
allowed: false,
|
|
71
|
+
reason: 'Malformed Origin header',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (req.method === 'POST') {
|
|
77
|
+
const contentType =
|
|
78
|
+
typeof req.headers['content-type'] === 'string'
|
|
79
|
+
? req.headers['content-type'].toLowerCase()
|
|
80
|
+
: '';
|
|
81
|
+
if (!contentType.startsWith('application/json')) {
|
|
82
|
+
return {
|
|
83
|
+
allowed: false,
|
|
84
|
+
reason: 'POST requests to /api/* must specify Content-Type: application/json',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { allowed: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sanitizeLayoutPayload(
|
|
93
|
+
input: unknown
|
|
94
|
+
): Record<string, { x: number; y: number; width: number; height: number }> {
|
|
95
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
96
|
+
throw new Error('Invalid layout payload: expected an object map of screen coordinates');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sanitized: Record<string, { x: number; y: number; width: number; height: number }> =
|
|
100
|
+
Object.create(null);
|
|
101
|
+
const entries = Object.entries(input as Record<string, unknown>);
|
|
102
|
+
if (entries.length > 200) {
|
|
103
|
+
throw new Error('Invalid layout payload: too many screen entries');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [screenId, rawBox] of entries) {
|
|
107
|
+
if (!screenId || FORBIDDEN_KEYS.has(screenId) || screenId.length > 120) {
|
|
108
|
+
throw new Error(`Invalid screen key in layout payload: "${screenId}"`);
|
|
109
|
+
}
|
|
110
|
+
if (!rawBox || typeof rawBox !== 'object' || Array.isArray(rawBox)) {
|
|
111
|
+
throw new Error(`Invalid layout coordinates for screen "${screenId}"`);
|
|
112
|
+
}
|
|
113
|
+
const box = rawBox as Record<string, unknown>;
|
|
114
|
+
const x = Number(box.x);
|
|
115
|
+
const y = Number(box.y);
|
|
116
|
+
const width = Number(box.width);
|
|
117
|
+
const height = Number(box.height);
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
!Number.isFinite(x) ||
|
|
121
|
+
!Number.isFinite(y) ||
|
|
122
|
+
!Number.isFinite(width) ||
|
|
123
|
+
!Number.isFinite(height) ||
|
|
124
|
+
Math.abs(x) > 100_000 ||
|
|
125
|
+
Math.abs(y) > 100_000 ||
|
|
126
|
+
width < 100 ||
|
|
127
|
+
width > 5_000 ||
|
|
128
|
+
height < 100 ||
|
|
129
|
+
height > 5_000
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(`Out-of-bounds layout coordinates for screen "${screenId}"`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
sanitized[screenId] = {
|
|
135
|
+
x: Math.round(x),
|
|
136
|
+
y: Math.round(y),
|
|
137
|
+
width: Math.round(width),
|
|
138
|
+
height: Math.round(height),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return sanitized;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
16
146
|
return new Promise((resolve, reject) => {
|
|
17
147
|
let raw = '';
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
148
|
+
let byteCount = 0;
|
|
149
|
+
let aborted = false;
|
|
150
|
+
|
|
151
|
+
req.on('data', (chunk: Buffer | string) => {
|
|
152
|
+
if (aborted) return;
|
|
153
|
+
const chunkBytes =
|
|
154
|
+
typeof chunk === 'string' ? Buffer.byteLength(chunk, 'utf-8') : chunk.length;
|
|
155
|
+
byteCount += chunkBytes;
|
|
156
|
+
|
|
157
|
+
if (byteCount > MAX_BODY_BYTES) {
|
|
158
|
+
aborted = true;
|
|
159
|
+
req.destroy();
|
|
21
160
|
reject(new Error('Request payload too large'));
|
|
161
|
+
return;
|
|
22
162
|
}
|
|
163
|
+
|
|
164
|
+
raw += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
|
23
165
|
});
|
|
166
|
+
|
|
24
167
|
req.on('end', () => {
|
|
168
|
+
if (aborted) return;
|
|
25
169
|
try {
|
|
26
170
|
resolve(raw ? (JSON.parse(raw) as T) : ({} as T));
|
|
27
171
|
} catch (err) {
|
|
28
172
|
reject(err);
|
|
29
173
|
}
|
|
30
174
|
});
|
|
31
|
-
|
|
175
|
+
|
|
176
|
+
req.on('error', (err) => {
|
|
177
|
+
if (!aborted) {
|
|
178
|
+
aborted = true;
|
|
179
|
+
reject(err);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
32
182
|
});
|
|
33
183
|
}
|
|
34
184
|
|
|
35
185
|
function sendJson(res: ServerResponse, status: number, payload: unknown) {
|
|
36
186
|
res.statusCode = status;
|
|
37
187
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
188
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
38
189
|
res.end(JSON.stringify(payload));
|
|
39
190
|
}
|
|
40
191
|
|
|
41
192
|
export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
42
|
-
const layoutFilePath = path.resolve(workspaceRoot, '.canvas-layout.json');
|
|
43
|
-
|
|
44
193
|
async function resolveConnectedProjectName(): Promise<string> {
|
|
45
|
-
const
|
|
194
|
+
const activeRoot = getActiveProjectRoot(workspaceRoot);
|
|
195
|
+
const dirName = path.basename(activeRoot);
|
|
46
196
|
try {
|
|
47
|
-
const appJsonRaw = await fs.readFile(path.join(
|
|
197
|
+
const appJsonRaw = await fs.readFile(path.join(activeRoot, 'app.json'), 'utf-8');
|
|
48
198
|
const appJson = JSON.parse(appJsonRaw) as {
|
|
49
199
|
expo?: { name?: string };
|
|
50
200
|
name?: string;
|
|
@@ -55,7 +205,7 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
|
55
205
|
// No app.json present
|
|
56
206
|
}
|
|
57
207
|
try {
|
|
58
|
-
const pkgRaw = await fs.readFile(path.join(
|
|
208
|
+
const pkgRaw = await fs.readFile(path.join(activeRoot, 'package.json'), 'utf-8');
|
|
59
209
|
const pkg = JSON.parse(pkgRaw) as { name?: string };
|
|
60
210
|
if (pkg?.name && pkg.name.toLowerCase() !== 'zelune') {
|
|
61
211
|
return pkg.name;
|
|
@@ -74,6 +224,13 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
|
74
224
|
return next();
|
|
75
225
|
}
|
|
76
226
|
|
|
227
|
+
const securityCheck = verifyApiRequestSecurity(req);
|
|
228
|
+
if (!securityCheck.allowed) {
|
|
229
|
+
return sendJson(res, 403, { ok: false, error: securityCheck.reason });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const activeRoot = getActiveProjectRoot(workspaceRoot);
|
|
233
|
+
const layoutFilePath = path.resolve(activeRoot, '.canvas-layout.json');
|
|
77
234
|
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
78
235
|
const pathname = urlObj.pathname;
|
|
79
236
|
|
|
@@ -114,6 +271,20 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
|
114
271
|
});
|
|
115
272
|
}
|
|
116
273
|
|
|
274
|
+
// POST /api/reorder
|
|
275
|
+
if (req.method === 'POST' && pathname === '/api/reorder') {
|
|
276
|
+
const body = await readJsonBody<ReorderPayload>(req);
|
|
277
|
+
const result = await reorderElement(workspaceRoot, body);
|
|
278
|
+
return sendJson(res, 200, {
|
|
279
|
+
ok: true,
|
|
280
|
+
element: result.element,
|
|
281
|
+
history: {
|
|
282
|
+
undoAvailable: result.undoAvailable,
|
|
283
|
+
redoAvailable: result.redoAvailable,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
117
288
|
// POST /api/undo
|
|
118
289
|
if (req.method === 'POST' && pathname === '/api/undo') {
|
|
119
290
|
const result = await undoLastPatch(workspaceRoot);
|
|
@@ -130,7 +301,8 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
|
130
301
|
if (req.method === 'GET' && pathname === '/api/layout') {
|
|
131
302
|
try {
|
|
132
303
|
const raw = await fs.readFile(layoutFilePath, 'utf-8');
|
|
133
|
-
|
|
304
|
+
const validated = sanitizeLayoutPayload(JSON.parse(raw));
|
|
305
|
+
return sendJson(res, 200, { ok: true, layout: validated });
|
|
134
306
|
} catch {
|
|
135
307
|
return sendJson(res, 200, { ok: true, layout: null });
|
|
136
308
|
}
|
|
@@ -139,7 +311,8 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
|
|
|
139
311
|
// POST /api/layout
|
|
140
312
|
if (req.method === 'POST' && pathname === '/api/layout') {
|
|
141
313
|
const body = await readJsonBody<Record<string, unknown>>(req);
|
|
142
|
-
|
|
314
|
+
const validated = sanitizeLayoutPayload(body);
|
|
315
|
+
await fs.writeFile(layoutFilePath, JSON.stringify(validated, null, 2), 'utf-8');
|
|
143
316
|
return sendJson(res, 200, { ok: true });
|
|
144
317
|
}
|
|
145
318
|
|