quilltap 3.3.0-dev → 3.3.0-dev.39

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.
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Theme Bundle Validation (Standalone)
6
+ *
7
+ * Lightweight validation for .qtap-theme bundles that can run
8
+ * without the Next.js app or Zod dependency.
9
+ *
10
+ * @module quilltap/lib/theme-validation
11
+ */
12
+
13
+ const path = require('path');
14
+
15
+ // ============================================================================
16
+ // CONSTANTS (mirrored from lib/themes/bundle-loader.ts)
17
+ // ============================================================================
18
+
19
+ const MAX_BUNDLE_SIZE = 50 * 1024 * 1024; // 50MB total
20
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file
21
+ const MAX_FILE_COUNT = 200;
22
+
23
+ const ALLOWED_EXTENSIONS = new Set([
24
+ '.json', '.css',
25
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif',
26
+ '.woff', '.woff2', '.ttf', '.otf',
27
+ '.txt', '.md', '.license',
28
+ ]);
29
+
30
+ const BLOCKED_EXTENSIONS = new Set([
31
+ '.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx',
32
+ '.wasm', '.sh', '.bash', '.bat', '.cmd', '.ps1', '.exe',
33
+ '.dll', '.so', '.dylib', '.py', '.rb', '.php',
34
+ ]);
35
+
36
+ // Theme ID must be lowercase alphanumeric with hyphens
37
+ const THEME_ID_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
38
+
39
+ // Required color keys in a palette
40
+ const REQUIRED_COLOR_KEYS = [
41
+ 'background', 'foreground', 'primary', 'primaryForeground',
42
+ 'secondary', 'secondaryForeground', 'muted', 'mutedForeground',
43
+ 'accent', 'accentForeground', 'destructive', 'destructiveForeground',
44
+ 'card', 'cardForeground', 'popover', 'popoverForeground',
45
+ 'border', 'input', 'ring',
46
+ ];
47
+
48
+ // ============================================================================
49
+ // MANIFEST VALIDATION
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Validate a theme.json manifest object
54
+ * @param {Record<string, unknown>} manifest
55
+ * @returns {{ valid: boolean; errors: string[]; warnings: string[] }}
56
+ */
57
+ function validateManifest(manifest) {
58
+ const errors = [];
59
+ const warnings = [];
60
+
61
+ // Required fields
62
+ if (manifest.format !== 'qtap-theme') {
63
+ errors.push(`format must be "qtap-theme", got "${manifest.format}"`);
64
+ }
65
+ if (manifest.formatVersion !== 1) {
66
+ errors.push(`formatVersion must be 1, got ${manifest.formatVersion}`);
67
+ }
68
+ if (!manifest.id || typeof manifest.id !== 'string') {
69
+ errors.push('id is required and must be a string');
70
+ } else if (!THEME_ID_REGEX.test(manifest.id)) {
71
+ errors.push(`id "${manifest.id}" must be lowercase alphanumeric with hyphens`);
72
+ }
73
+ if (!manifest.name || typeof manifest.name !== 'string') {
74
+ errors.push('name is required and must be a string');
75
+ }
76
+ if (!manifest.version || typeof manifest.version !== 'string') {
77
+ errors.push('version is required and must be a string');
78
+ }
79
+ if (typeof manifest.supportsDarkMode !== 'boolean') {
80
+ errors.push('supportsDarkMode is required and must be a boolean');
81
+ }
82
+
83
+ // Must have tokens or tokensPath
84
+ if (!manifest.tokens && !manifest.tokensPath) {
85
+ errors.push('Either tokens (inline) or tokensPath (file reference) is required');
86
+ }
87
+
88
+ // Validate inline tokens if present
89
+ if (manifest.tokens) {
90
+ validateTokens(manifest.tokens, errors, warnings);
91
+ }
92
+
93
+ // Optional string fields
94
+ if (manifest.description !== undefined && typeof manifest.description !== 'string') {
95
+ errors.push('description must be a string');
96
+ }
97
+ if (manifest.author !== undefined && typeof manifest.author !== 'string') {
98
+ errors.push('author must be a string');
99
+ }
100
+
101
+ // Optional array fields
102
+ if (manifest.tags !== undefined) {
103
+ if (!Array.isArray(manifest.tags)) {
104
+ errors.push('tags must be an array of strings');
105
+ } else if (manifest.tags.some(t => typeof t !== 'string')) {
106
+ errors.push('tags must contain only strings');
107
+ }
108
+ }
109
+
110
+ // Fonts validation
111
+ if (manifest.fonts !== undefined) {
112
+ if (!Array.isArray(manifest.fonts)) {
113
+ errors.push('fonts must be an array');
114
+ } else {
115
+ for (let i = 0; i < manifest.fonts.length; i++) {
116
+ const font = manifest.fonts[i];
117
+ if (!font.family || typeof font.family !== 'string') {
118
+ errors.push(`fonts[${i}].family is required and must be a string`);
119
+ }
120
+ if (!font.src || typeof font.src !== 'string') {
121
+ errors.push(`fonts[${i}].src is required and must be a string`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ return { valid: errors.length === 0, errors, warnings };
128
+ }
129
+
130
+ /**
131
+ * Validate theme tokens object
132
+ */
133
+ function validateTokens(tokens, errors, warnings) {
134
+ if (!tokens || typeof tokens !== 'object') {
135
+ errors.push('tokens must be an object');
136
+ return;
137
+ }
138
+
139
+ // Colors are required
140
+ if (!tokens.colors || typeof tokens.colors !== 'object') {
141
+ errors.push('tokens.colors is required');
142
+ return;
143
+ }
144
+
145
+ // Validate light palette
146
+ if (!tokens.colors.light || typeof tokens.colors.light !== 'object') {
147
+ errors.push('tokens.colors.light is required');
148
+ } else {
149
+ for (const key of REQUIRED_COLOR_KEYS) {
150
+ if (!tokens.colors.light[key] || typeof tokens.colors.light[key] !== 'string') {
151
+ errors.push(`tokens.colors.light.${key} is required and must be a string`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // Validate dark palette
157
+ if (!tokens.colors.dark || typeof tokens.colors.dark !== 'object') {
158
+ errors.push('tokens.colors.dark is required');
159
+ } else {
160
+ for (const key of REQUIRED_COLOR_KEYS) {
161
+ if (!tokens.colors.dark[key] || typeof tokens.colors.dark[key] !== 'string') {
162
+ errors.push(`tokens.colors.dark.${key} is required and must be a string`);
163
+ }
164
+ }
165
+ }
166
+
167
+ // Typography, spacing, effects are optional
168
+ if (tokens.typography && typeof tokens.typography !== 'object') {
169
+ warnings.push('tokens.typography should be an object');
170
+ }
171
+ if (tokens.spacing && typeof tokens.spacing !== 'object') {
172
+ warnings.push('tokens.spacing should be an object');
173
+ }
174
+ if (tokens.effects && typeof tokens.effects !== 'object') {
175
+ warnings.push('tokens.effects should be an object');
176
+ }
177
+ }
178
+
179
+ // ============================================================================
180
+ // ZIP VALIDATION
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Validate a .qtap-theme zip file
185
+ * @param {string} zipPath - Path to the .qtap-theme file
186
+ * @returns {Promise<{ valid: boolean; manifest?: object; errors: string[]; warnings: string[]; fileCount: number; totalSize: number }>}
187
+ */
188
+ async function validateThemeBundle(zipPath) {
189
+ const fs = require('fs/promises');
190
+ const errors = [];
191
+ const warnings = [];
192
+ let fileCount = 0;
193
+ let totalSize = 0;
194
+
195
+ // Check file exists and size
196
+ try {
197
+ const stat = await fs.stat(zipPath);
198
+ if (stat.size > MAX_BUNDLE_SIZE) {
199
+ errors.push(`Bundle exceeds maximum size of ${MAX_BUNDLE_SIZE / 1024 / 1024}MB`);
200
+ return { valid: false, errors, warnings, fileCount: 0, totalSize: stat.size };
201
+ }
202
+ totalSize = stat.size;
203
+ } catch {
204
+ errors.push('Bundle file not found or not readable');
205
+ return { valid: false, errors, warnings, fileCount: 0, totalSize: 0 };
206
+ }
207
+
208
+ // Open and validate zip structure
209
+ let yauzl;
210
+ try {
211
+ yauzl = require('yauzl');
212
+ } catch {
213
+ errors.push('yauzl module not available - cannot validate zip files');
214
+ return { valid: false, errors, warnings, fileCount: 0, totalSize };
215
+ }
216
+
217
+ let zipFile;
218
+ try {
219
+ zipFile = await new Promise((resolve, reject) => {
220
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => {
221
+ if (err) reject(err);
222
+ else resolve(zf);
223
+ });
224
+ });
225
+ } catch (err) {
226
+ errors.push(`Invalid zip file: ${err.message}`);
227
+ return { valid: false, errors, warnings, fileCount: 0, totalSize };
228
+ }
229
+
230
+ try {
231
+ // Read all entries
232
+ const entries = await new Promise((resolve, reject) => {
233
+ const result = [];
234
+ zipFile.readEntry();
235
+ zipFile.on('entry', (entry) => {
236
+ result.push({
237
+ fileName: entry.fileName,
238
+ uncompressedSize: entry.uncompressedSize,
239
+ isDirectory: /\/$/.test(entry.fileName),
240
+ });
241
+ zipFile.readEntry();
242
+ });
243
+ zipFile.on('end', () => resolve(result));
244
+ zipFile.on('error', reject);
245
+ });
246
+
247
+ fileCount = entries.filter(e => !e.isDirectory).length;
248
+
249
+ if (fileCount > MAX_FILE_COUNT) {
250
+ errors.push(`Bundle contains ${fileCount} files, exceeding limit of ${MAX_FILE_COUNT}`);
251
+ return { valid: false, errors, warnings, fileCount, totalSize };
252
+ }
253
+
254
+ let hasThemeJson = false;
255
+ let totalUncompressed = 0;
256
+
257
+ for (const entry of entries) {
258
+ if (entry.isDirectory) continue;
259
+
260
+ // Path traversal check
261
+ if (entry.fileName.includes('..') || path.isAbsolute(entry.fileName)) {
262
+ errors.push(`Unsafe path detected: ${entry.fileName}`);
263
+ continue;
264
+ }
265
+
266
+ // File size check
267
+ if (entry.uncompressedSize > MAX_FILE_SIZE) {
268
+ errors.push(`File ${entry.fileName} exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`);
269
+ }
270
+ totalUncompressed += entry.uncompressedSize;
271
+
272
+ // Extension check
273
+ const ext = path.extname(entry.fileName).toLowerCase();
274
+ if (BLOCKED_EXTENSIONS.has(ext)) {
275
+ errors.push(`Blocked file type: ${entry.fileName} (${ext} files are not allowed)`);
276
+ } else if (ext && !ALLOWED_EXTENSIONS.has(ext)) {
277
+ warnings.push(`Unrecognized file type: ${entry.fileName} (${ext})`);
278
+ }
279
+
280
+ // Track theme.json
281
+ if (entry.fileName === 'theme.json' || entry.fileName.endsWith('/theme.json')) {
282
+ hasThemeJson = true;
283
+ }
284
+ }
285
+
286
+ // Zip bomb protection
287
+ if (totalUncompressed > MAX_BUNDLE_SIZE * 10) {
288
+ errors.push('Suspicious compression ratio detected (potential zip bomb)');
289
+ return { valid: false, errors, warnings, fileCount, totalSize };
290
+ }
291
+
292
+ if (!hasThemeJson) {
293
+ errors.push('Bundle must contain a theme.json file');
294
+ return { valid: false, errors, warnings, fileCount, totalSize };
295
+ }
296
+
297
+ if (errors.length > 0) {
298
+ return { valid: false, errors, warnings, fileCount, totalSize };
299
+ }
300
+
301
+ // Extract theme.json to validate manifest
302
+ const manifest = await extractAndValidateManifest(zipFile, zipPath, entries, errors, warnings);
303
+
304
+ return {
305
+ valid: errors.length === 0,
306
+ manifest: errors.length === 0 ? manifest : undefined,
307
+ errors,
308
+ warnings,
309
+ fileCount,
310
+ totalSize,
311
+ };
312
+ } finally {
313
+ zipFile.close();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Extract and validate theme.json from zip
319
+ */
320
+ async function extractAndValidateManifest(zipFile, zipPath, entries, errors, warnings) {
321
+ const yauzl = require('yauzl');
322
+ const themeJsonEntry = entries.find(
323
+ e => e.fileName === 'theme.json' || e.fileName.endsWith('/theme.json')
324
+ );
325
+ if (!themeJsonEntry) return null;
326
+
327
+ // Re-open to read specific entry
328
+ const zf = await new Promise((resolve, reject) => {
329
+ yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, z) => {
330
+ if (err) reject(err);
331
+ else resolve(z);
332
+ });
333
+ });
334
+
335
+ try {
336
+ const content = await new Promise((resolve, reject) => {
337
+ zf.readEntry();
338
+ zf.on('entry', (entry) => {
339
+ if (entry.fileName === themeJsonEntry.fileName) {
340
+ zf.openReadStream(entry, (err, stream) => {
341
+ if (err) { reject(err); return; }
342
+ const chunks = [];
343
+ stream.on('data', (chunk) => chunks.push(chunk));
344
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
345
+ stream.on('error', reject);
346
+ });
347
+ } else {
348
+ zf.readEntry();
349
+ }
350
+ });
351
+ zf.on('error', reject);
352
+ });
353
+
354
+ let manifestJson;
355
+ try {
356
+ manifestJson = JSON.parse(content);
357
+ } catch (err) {
358
+ errors.push(`Failed to parse theme.json: ${err.message}`);
359
+ return null;
360
+ }
361
+
362
+ const result = validateManifest(manifestJson);
363
+ errors.push(...result.errors);
364
+ warnings.push(...result.warnings);
365
+
366
+ return result.valid ? manifestJson : null;
367
+ } finally {
368
+ zf.close();
369
+ }
370
+ }
371
+
372
+ // ============================================================================
373
+ // EXPORTS
374
+ // ============================================================================
375
+
376
+ module.exports = {
377
+ validateThemeBundle,
378
+ validateManifest,
379
+ validateTokens,
380
+ MAX_BUNDLE_SIZE,
381
+ MAX_FILE_SIZE,
382
+ MAX_FILE_COUNT,
383
+ ALLOWED_EXTENSIONS,
384
+ BLOCKED_EXTENSIONS,
385
+ THEME_ID_REGEX,
386
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "3.3.0-dev",
3
+ "version": "3.3.0-dev.39",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -33,9 +33,10 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "better-sqlite3": "npm:better-sqlite3-multiple-ciphers@^12.6.2",
36
+ "better-sqlite3-multiple-ciphers": "^12.6.2",
37
37
  "sharp": "^0.34.5",
38
- "tar": "^7.4.3"
38
+ "tar": "^7.4.3",
39
+ "yauzl": "^3.2.0"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=18.0.0"