wuppycms 0.0.0-stage → 0.2.1

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 (58) hide show
  1. package/dist/admin-fo4l4lu2.mjs +59 -0
  2. package/dist/astro.d.mts +20 -0
  3. package/dist/astro.mjs +302 -0
  4. package/dist/cli.d.mts +1 -0
  5. package/dist/cli.mjs +210 -0
  6. package/dist/components-BrGAEPly.mjs +39 -0
  7. package/dist/config-CM8_-x_W.mjs +1242 -0
  8. package/dist/dist-D7fysV_5.mjs +9048 -0
  9. package/dist/frontmatter-DGaz9--t.mjs +24 -0
  10. package/dist/generate-iX4B_2U2.mjs +473 -0
  11. package/dist/i18n-CgLAHd9Q.mjs +79 -0
  12. package/dist/index-B6AHmgP9.d.mts +34 -0
  13. package/dist/index.d.mts +8636 -0
  14. package/dist/index.mjs +83 -0
  15. package/dist/live-runtime.d.mts +120 -0
  16. package/dist/live-runtime.mjs +207 -0
  17. package/dist/markdown.mjs +4 -0
  18. package/dist/mdx.d.mts +68 -0
  19. package/dist/mdx.mjs +4 -0
  20. package/dist/media-DGfJXgBA.mjs +356 -0
  21. package/dist/page-cache-middleware.d.mts +8 -0
  22. package/dist/page-cache-middleware.mjs +101 -0
  23. package/dist/publishing.d.mts +17 -0
  24. package/dist/publishing.mjs +42 -0
  25. package/dist/read-Bh4-a3Ap.mjs +18 -0
  26. package/dist/redirect-middleware.d.mts +6 -0
  27. package/dist/redirect-middleware.mjs +37 -0
  28. package/dist/render-DzH7Wil3.mjs +116 -0
  29. package/dist/routes/admin.d.mts +7 -0
  30. package/dist/routes/admin.mjs +10 -0
  31. package/dist/routes/api.d.mts +7 -0
  32. package/dist/routes/api.mjs +97 -0
  33. package/dist/routes/icons.d.mts +12 -0
  34. package/dist/routes/icons.mjs +36 -0
  35. package/dist/routes/robots.d.mts +7 -0
  36. package/dist/routes/robots.mjs +15 -0
  37. package/dist/routes/rss.d.mts +7 -0
  38. package/dist/routes/rss.mjs +20 -0
  39. package/dist/routes/sitemap.d.mts +7 -0
  40. package/dist/routes/sitemap.mjs +23 -0
  41. package/dist/routes/uploads.d.mts +7 -0
  42. package/dist/routes/uploads.mjs +11 -0
  43. package/dist/schemas-dHRJ1HeG.mjs +275 -0
  44. package/dist/seo.d.mts +125 -0
  45. package/dist/seo.mjs +130 -0
  46. package/dist/server-D92ryW2y.mjs +5883 -0
  47. package/dist/service-CgcvCG7l.mjs +353 -0
  48. package/dist/site-dF-xVPqe.mjs +148 -0
  49. package/dist/site.d.mts +28 -0
  50. package/dist/site.mjs +44 -0
  51. package/dist/sitemap-Ba0vil6a.mjs +46 -0
  52. package/dist/theme-pages-CRsBm1EX.mjs +246 -0
  53. package/dist/theme-seo-middleware.d.mts +7 -0
  54. package/dist/theme-seo-middleware.mjs +62 -0
  55. package/dist/visual-middleware.d.mts +7 -0
  56. package/dist/visual-middleware.mjs +71 -0
  57. package/package.json +72 -3
  58. package/README.md +0 -3
@@ -0,0 +1,1242 @@
1
+ import { createRequire } from "node:module";
2
+ import { z } from "zod";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import { join, resolve, sep } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { createHash, createHmac, createPublicKey, verify } from "node:crypto";
7
+ import { lookup } from "node:dns/promises";
8
+ import { isIP } from "node:net";
9
+ import { canFetch, canReadContent, canReadSecret, canWriteContent, capabilityHash, manifestSchema } from "@wuppycms/plugin-types";
10
+ import { newQuickJSWASMModule } from "quickjs-emscripten";
11
+
12
+ //#region src/content-types/index.ts
13
+ const FIELD_TYPES = [
14
+ "string",
15
+ "text",
16
+ "date",
17
+ "image",
18
+ "boolean",
19
+ "number",
20
+ "list",
21
+ "byline",
22
+ "taxonomy",
23
+ "reference",
24
+ "section",
25
+ "select",
26
+ "json",
27
+ "richText",
28
+ "repeater",
29
+ "blocks"
30
+ ];
31
+ const fieldOptionsSchema = z.lazy(() => z.object({
32
+ type: z.enum(FIELD_TYPES),
33
+ collection: z.string().optional(),
34
+ taxonomy: z.string().optional(),
35
+ multiple: z.boolean().default(false),
36
+ required: z.boolean().default(false),
37
+ help: z.string().optional(),
38
+ default: z.unknown().optional(),
39
+ options: z.array(z.string()).optional(),
40
+ min: z.number().optional(),
41
+ max: z.number().optional(),
42
+ fields: z.record(z.string(), fieldTypeSchema).optional(),
43
+ blocks: z.record(z.string(), z.record(z.string(), fieldTypeSchema)).optional(),
44
+ showIf: z.object({
45
+ field: z.string(),
46
+ equals: z.unknown()
47
+ }).optional()
48
+ }));
49
+ const fieldTypeSchema = z.union([z.enum(FIELD_TYPES), fieldOptionsSchema]);
50
+ const collectionSchema = z.object({
51
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
52
+ dir: z.string().min(1),
53
+ format: z.enum(["md", "mdx"]).default("md"),
54
+ kind: z.enum(["posts", "pages"]).optional(),
55
+ map: z.record(z.string(), z.string()).optional(),
56
+ route: z.string().startsWith("/").includes("{slug}").optional(),
57
+ fields: z.record(z.string(), fieldTypeSchema).default({})
58
+ });
59
+ const BASE = {
60
+ string: z.string().max(500),
61
+ text: z.string(),
62
+ richText: z.string(),
63
+ image: z.string(),
64
+ boolean: z.boolean(),
65
+ list: z.array(z.string().max(100)).max(50),
66
+ number: z.number(),
67
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2})?)?$/, "Expected an ISO date"),
68
+ byline: z.union([z.string().max(200), z.array(z.union([z.string().max(200), z.object({
69
+ byline: z.string().max(200),
70
+ role: z.string().max(100).optional()
71
+ })])).max(50)]),
72
+ taxonomy: z.union([z.string().max(200), z.array(z.string().max(200)).max(50)]),
73
+ reference: z.union([z.string().max(200), z.array(z.string().max(200)).max(50)]),
74
+ section: z.array(z.string().max(200)).max(50),
75
+ select: z.string(),
76
+ json: z.json(),
77
+ repeater: z.array(z.unknown()),
78
+ blocks: z.array(z.unknown())
79
+ };
80
+ const normalize = (f) => typeof f === "string" ? {
81
+ type: f,
82
+ multiple: false,
83
+ required: false
84
+ } : f;
85
+ /** Zod schema of one field, including its bounds, options and nested fields. */
86
+ function fieldSchema(field) {
87
+ const f = normalize(field);
88
+ const { min, max } = f;
89
+ let schema = BASE[f.type];
90
+ if (f.type === "select" && f.options) {
91
+ const one = z.enum(f.options);
92
+ schema = f.multiple ? z.array(one) : one;
93
+ } else if (f.type === "number") {
94
+ let n = z.number();
95
+ if (min !== void 0) n = n.min(min);
96
+ if (max !== void 0) n = n.max(max);
97
+ schema = n;
98
+ } else if (f.type === "repeater") {
99
+ let a = z.array(objectSchema(f.fields ?? {}));
100
+ if (min !== void 0) a = a.min(min);
101
+ if (max !== void 0) a = a.max(max);
102
+ schema = a;
103
+ } else if (f.type === "blocks") {
104
+ const variants = Object.entries(f.blocks ?? {}).map(([name, fields]) => objectSchema(fields).extend({ type: z.literal(name) }));
105
+ const item = variants.length ? z.discriminatedUnion("type", variants) : z.unknown();
106
+ let a = z.array(item);
107
+ if (min !== void 0) a = a.min(min);
108
+ if (max !== void 0) a = a.max(max);
109
+ schema = a;
110
+ } else if ((f.type === "string" || f.type === "text") && (min !== void 0 || max !== void 0)) {
111
+ let t = z.string();
112
+ if (min !== void 0) t = t.min(min);
113
+ if (max !== void 0) t = t.max(max);
114
+ schema = t;
115
+ }
116
+ return f.required && !f.showIf ? schema : schema.optional();
117
+ }
118
+ function objectSchema(fields) {
119
+ return z.looseObject(Object.fromEntries(Object.entries(fields).map(([k, f]) => [k, fieldSchema(f)])));
120
+ }
121
+ /** Declared fields are type-checked; undeclared frontmatter passes through untouched. A field hidden by `showIf` is not required. */
122
+ const dataSchema = (c) => objectSchema(c.fields).superRefine((data, ctx) => {
123
+ for (const [name, field] of Object.entries(c.fields)) {
124
+ const f = normalize(field);
125
+ if (!f.required || !f.showIf || data[name] !== void 0) continue;
126
+ if (data[f.showIf.field] !== f.showIf.equals) continue;
127
+ ctx.addIssue({
128
+ code: "custom",
129
+ path: [name],
130
+ message: `${name} is required`
131
+ });
132
+ }
133
+ });
134
+ const fieldKind = (field) => normalize(field).type;
135
+
136
+ //#endregion
137
+ //#region src/markdown/index.ts
138
+ let satteri;
139
+ /**
140
+ * Astro's own Markdown/MDX parser, so content is checked exactly as the site will compile it. Loaded from Astro's
141
+ * dependency at runtime: it has a native binding, which cannot be bundled, and the site does not depend on it directly.
142
+ */
143
+ function loadSatteri(root = process.cwd()) {
144
+ satteri ??= (async () => {
145
+ return await import(
146
+ /* @vite-ignore */
147
+ pathToFileURL(createRequire(createRequire(createRequire(join(root, "package.json")).resolve("astro/package.json")).resolve("@astrojs/markdown-satteri")).resolve("satteri")).href
148
+ );
149
+ })();
150
+ return satteri;
151
+ }
152
+ const DANGEROUS_TAGS = new Set([
153
+ "script",
154
+ "iframe",
155
+ "frame",
156
+ "frameset",
157
+ "style",
158
+ "object",
159
+ "embed",
160
+ "applet",
161
+ "link",
162
+ "meta",
163
+ "base",
164
+ "form",
165
+ "noscript",
166
+ "template"
167
+ ]);
168
+ const URL_ATTRS = new Set([
169
+ "href",
170
+ "src",
171
+ "action",
172
+ "formaction",
173
+ "xlink:href",
174
+ "poster",
175
+ "data",
176
+ "srcset",
177
+ "background"
178
+ ]);
179
+ /** A URL scheme that runs code or smuggles a document: `javascript:`, `vbscript:`, `data:` (images excepted). */
180
+ function badUrl(url, image = false) {
181
+ const u = [...url].filter((c) => c.charCodeAt(0) > 32).join("").toLowerCase();
182
+ for (const scheme of [
183
+ "javascript:",
184
+ "vbscript:",
185
+ "data:"
186
+ ]) if (u.startsWith(scheme) && !(image && scheme === "data:" && /^data:image\/(png|jpe?g|gif|webp|avif);/.test(u))) return scheme;
187
+ return null;
188
+ }
189
+ /** `{/* a comment *\/}` is the only expression allowed on its own: anything else is JavaScript run at build time. */
190
+ const ONLY_COMMENT = /^\s*\/\*(?:(?!\*\/)[\s\S])*\*\/\s*$/;
191
+ /** JSON values (numbers, strings, booleans, arrays, objects) are data, not code: safe as component props. */
192
+ function isJson(source) {
193
+ try {
194
+ JSON.parse(source);
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+ /** Raw HTML in Markdown: dangerous tags, event handlers (`onerror=`) and code URLs in any attribute. */
201
+ function checkHtml(html, found) {
202
+ for (const m of html.matchAll(/<\s*\/?\s*([A-Za-z][\w:-]*)/g)) {
203
+ const tag = m[1];
204
+ if (DANGEROUS_TAGS.has(tag.toLowerCase())) found.add(tag.toLowerCase());
205
+ else if (/^[A-Z]/.test(tag)) found.add(tag);
206
+ }
207
+ for (const m of html.matchAll(/[\s"'/]((?:on|formaction|srcdoc)[\w-]*)\s*=/gi)) found.add(m[1].toLowerCase());
208
+ for (const m of html.matchAll(/=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)) {
209
+ const scheme = badUrl(m[1] ?? m[2] ?? m[3] ?? "");
210
+ if (scheme) found.add(scheme);
211
+ }
212
+ }
213
+ function checkJsx(node, allowedComponents, found) {
214
+ const name = node.name;
215
+ if (name) {
216
+ if (name.includes(".")) found.add(name);
217
+ else if (/^[A-Z]/.test(name)) {
218
+ if (!allowedComponents.includes(name)) found.add(name);
219
+ } else if (DANGEROUS_TAGS.has(name.toLowerCase())) found.add(name);
220
+ }
221
+ for (const attr of node.attributes ?? []) {
222
+ if (attr.type !== "mdxJsxAttribute") {
223
+ found.add("{...spread}");
224
+ continue;
225
+ }
226
+ const key = attr.name ?? "";
227
+ const lower = key.toLowerCase();
228
+ if (lower.startsWith("on") || key.includes(":") || lower === "dangerouslysetinnerhtml" || lower === "srcdoc") found.add(key);
229
+ const value = attr.value;
230
+ if (value && typeof value === "object") {
231
+ if (!isJson(String(value.value ?? ""))) found.add(`${key}={…}`);
232
+ } else if (typeof value === "string" && URL_ATTRS.has(lower)) {
233
+ const scheme = badUrl(value, lower === "src" && name === "img");
234
+ if (scheme) found.add(scheme);
235
+ }
236
+ }
237
+ }
238
+ function walk(node, allowedComponents, found) {
239
+ switch (node.type) {
240
+ case "mdxjsEsm":
241
+ found.add(/^\s*export\b/.test(String(node.value)) ? "export" : "import");
242
+ break;
243
+ case "mdxFlowExpression":
244
+ case "mdxTextExpression":
245
+ if (!ONLY_COMMENT.test(String(node.value ?? ""))) found.add("{expression}");
246
+ break;
247
+ case "mdxJsxFlowElement":
248
+ case "mdxJsxTextElement":
249
+ checkJsx(node, allowedComponents, found);
250
+ break;
251
+ case "html":
252
+ checkHtml(String(node.value ?? ""), found);
253
+ break;
254
+ case "link":
255
+ case "image":
256
+ case "definition": {
257
+ const scheme = badUrl(node.url ?? "", node.type === "image");
258
+ if (scheme) found.add(scheme);
259
+ break;
260
+ }
261
+ }
262
+ for (const child of node.children ?? []) walk(child, allowedComponents, found);
263
+ }
264
+ /**
265
+ * What an author may not put in a body: in MDX, imports/exports, JavaScript expressions (props may only be JSON),
266
+ * unlisted components, event handlers, Astro directives and code URLs; in Markdown, dangerous raw HTML, event
267
+ * handlers and code URLs. The body is parsed with the same parser Astro uses, so MDX that would break the build is
268
+ * reported too. Empty means the body is acceptable.
269
+ */
270
+ async function findDisallowed(body, allowedComponents, format = "mdx") {
271
+ const parser = await loadSatteri();
272
+ let tree;
273
+ try {
274
+ tree = format === "mdx" ? parser.mdxToMdast(body, { position: false }) : parser.markdownToMdast(body, { position: false });
275
+ } catch (e) {
276
+ return [`invalid MDX: ${e.message.split("\n")[0]}`];
277
+ }
278
+ const found = /* @__PURE__ */ new Set();
279
+ walk(tree, allowedComponents, found);
280
+ return [...found];
281
+ }
282
+ /**
283
+ * Theme sample entries often import their components explicitly. In the site, the generated components map already
284
+ * provides every theme component (and runtime MDX cannot resolve arbitrary imports), so component imports are removed
285
+ * when content enters Wuppy or is rendered live.
286
+ */
287
+ function stripComponentImports(body) {
288
+ return body.replace(/^[ \t]*import\s+[^;\n]*?\.astro["']\s*;?[ \t]*$/gm, "").replace(/\n{3,}/g, "\n\n").trimStart();
289
+ }
290
+ /** PascalCase JSX tags in a body, used to promote a Markdown entry to `.mdx` when a component is inserted. */
291
+ async function componentTags(body) {
292
+ const parser = await loadSatteri();
293
+ let tree;
294
+ try {
295
+ tree = parser.mdxToMdast(body, { position: false });
296
+ } catch {
297
+ const html = [];
298
+ const collect = (node) => {
299
+ if (node.type === "html") html.push(String(node.value ?? ""));
300
+ for (const child of node.children ?? []) collect(child);
301
+ };
302
+ collect(parser.markdownToMdast(body, { position: false }));
303
+ return [...new Set(Array.from(html.join("\n").matchAll(/<([A-Z][A-Za-z0-9]*)\b/g), (m) => m[1]))];
304
+ }
305
+ const found = /* @__PURE__ */ new Set();
306
+ const visit = (node) => {
307
+ if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && node.name) {
308
+ const name = node.name;
309
+ if (/^[A-Z]/.test(name) && !name.includes(".")) found.add(name);
310
+ }
311
+ for (const child of node.children ?? []) visit(child);
312
+ };
313
+ visit(tree);
314
+ return [...found];
315
+ }
316
+
317
+ //#endregion
318
+ //#region src/security/redact.ts
319
+ /** Masks credentials before text reaches logs or the audit trail. */
320
+ function redact(text, secrets = [process.env.WUPPY_SECRET]) {
321
+ let out = text.replace(/\bBearer\s+[\w.~+/-]+=*/gi, "Bearer [redacted]").replace(/\b(secret|token|password|api[_-]?key|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;&}]+/gi, "$1$2[redacted]").replace(/\bre_[A-Za-z0-9_]{10,}/g, "[redacted]");
322
+ for (const s of secrets) if (s && s.length >= 8) out = out.split(s).join("[redacted]");
323
+ return out;
324
+ }
325
+
326
+ //#endregion
327
+ //#region src/security/ssrf.ts
328
+ var UnsafeUrlError = class extends Error {};
329
+ /** Loopback, private, link-local (cloud metadata), CGNAT, multicast and unspecified addresses. */
330
+ function isPrivateAddress(ip) {
331
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
332
+ if (mapped) return isPrivateAddress(mapped[1]);
333
+ if (isIP(ip) === 4) {
334
+ const [a, b] = ip.split(".").map(Number);
335
+ return a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
336
+ }
337
+ const v6 = ip.toLowerCase();
338
+ return v6 === "::" || v6 === "::1" || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || v6.startsWith("ff");
339
+ }
340
+ const resolveAll = async (host) => (await lookup(host, { all: true })).map((r) => r.address);
341
+ /**
342
+ * Throws unless `url` is http(s) and every address its host resolves to is public.
343
+ * shortcut: resolving here and again inside fetch leaves a DNS-rebinding window; pin the address with a custom dispatcher if the URL is attacker-chosen.
344
+ */
345
+ async function assertPublicUrl(url, resolve = resolveAll) {
346
+ let parsed;
347
+ try {
348
+ parsed = new URL(url);
349
+ } catch {
350
+ throw new UnsafeUrlError("Invalid URL");
351
+ }
352
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UnsafeUrlError("Only http(s) URLs are allowed");
353
+ if (parsed.username || parsed.password) throw new UnsafeUrlError("Credentials in URLs are not allowed");
354
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
355
+ const addresses = isIP(host) ? [host] : await resolve(host);
356
+ if (addresses.length === 0 || addresses.some(isPrivateAddress)) throw new UnsafeUrlError("URL points to a private address");
357
+ return parsed;
358
+ }
359
+ /** GET a public URL with no redirects, a timeout and a hard byte cap. */
360
+ async function fetchPublic(url, { maxBytes = 10 * 1024 * 1024, timeoutMs = 1e4, resolve } = {}) {
361
+ const parsed = await assertPublicUrl(url, resolve);
362
+ const res = await fetch(parsed, {
363
+ redirect: "error",
364
+ signal: AbortSignal.timeout(timeoutMs)
365
+ });
366
+ if (!res.ok || !res.body) throw new Error(`Download failed (${res.status})`);
367
+ const chunks = [];
368
+ let size = 0;
369
+ for await (const chunk of res.body) {
370
+ size += chunk.byteLength;
371
+ if (size > maxBytes) throw new Error("Download exceeds the size limit");
372
+ chunks.push(chunk);
373
+ }
374
+ return {
375
+ bytes: Buffer.concat(chunks),
376
+ contentType: res.headers.get("content-type") ?? ""
377
+ };
378
+ }
379
+
380
+ //#endregion
381
+ //#region src/webhooks/index.ts
382
+ const EVENT_TYPES = [
383
+ "content.updated",
384
+ "content.deleted",
385
+ "content.reverted",
386
+ "media.uploaded"
387
+ ];
388
+ const webhooksSchema = z.array(z.object({
389
+ url: z.url({ protocol: /^https?$/ }),
390
+ secret: z.string().min(8).optional(),
391
+ events: z.array(z.enum(EVENT_TYPES)).optional()
392
+ })).default([]);
393
+ /** Deliveries are best-effort (no retries) and never throw into the code that caused the event. */
394
+ function createWebhookSender(hooks, { timeoutMs = 5e3, allowPrivate = false } = {}) {
395
+ return async (event) => {
396
+ const body = JSON.stringify(event);
397
+ await Promise.all(hooks.filter((h) => !h.events || h.events.includes(event.type)).map(async (h) => {
398
+ try {
399
+ if (!allowPrivate) await assertPublicUrl(h.url);
400
+ await fetch(h.url, {
401
+ method: "POST",
402
+ redirect: "error",
403
+ signal: AbortSignal.timeout(timeoutMs),
404
+ headers: {
405
+ "content-type": "application/json",
406
+ "x-wuppy-event": event.type,
407
+ ...h.secret ? { "x-wuppy-signature": `sha256=${createHmac("sha256", h.secret).update(body).digest("hex")}` } : {}
408
+ },
409
+ body
410
+ });
411
+ } catch (e) {
412
+ console.error(redact(`webhook ${h.url} failed: ${e.message}`));
413
+ }
414
+ }));
415
+ };
416
+ }
417
+
418
+ //#endregion
419
+ //#region src/redirects/index.ts
420
+ /** Same-site path, or an absolute http(s) URL. Rules out `//host` (open redirect) and `javascript:`. */
421
+ const target = z.string().refine((t) => t.startsWith("/") && !t.startsWith("//") || /^https?:\/\//i.test(t), "Target must be a /path or http(s) URL");
422
+ /** A regex that backtracks catastrophically on crafted paths would stall every request, so nested quantifiers are refused. */
423
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)[+*{]/;
424
+ function validRegex(source) {
425
+ if (source.length > 200 || NESTED_QUANTIFIER.test(source)) return false;
426
+ try {
427
+ return new RegExp(source) instanceof RegExp;
428
+ } catch {
429
+ return false;
430
+ }
431
+ }
432
+ const rule = z.object({
433
+ from: z.string().min(1).max(500),
434
+ to: target,
435
+ status: z.union([z.literal(301), z.literal(302)]).default(301),
436
+ match: z.enum([
437
+ "exact",
438
+ "wildcard",
439
+ "regex"
440
+ ]).default("exact"),
441
+ enabled: z.boolean().default(true),
442
+ preserveQuery: z.boolean().default(true),
443
+ note: z.string().max(200).optional()
444
+ }).superRefine((r, ctx) => {
445
+ if (r.match === "regex") {
446
+ if (!validRegex(r.from)) ctx.addIssue({
447
+ code: "custom",
448
+ path: ["from"],
449
+ message: "Invalid or unsafe regular expression"
450
+ });
451
+ } else if (!r.from.startsWith("/") || r.from.startsWith("//")) ctx.addIssue({
452
+ code: "custom",
453
+ path: ["from"],
454
+ message: "Invalid source"
455
+ });
456
+ else if (r.match === "wildcard" && !r.from.endsWith("/*")) ctx.addIssue({
457
+ code: "custom",
458
+ path: ["from"],
459
+ message: "A wildcard source must end in /*"
460
+ });
461
+ });
462
+ const norm = (p) => p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
463
+ const redirectsSchema = z.array(rule).superRefine((rules, ctx) => {
464
+ const map = /* @__PURE__ */ new Map();
465
+ for (const r of rules) {
466
+ if (r.match !== "exact") continue;
467
+ const from = norm(r.from);
468
+ if (map.has(from)) ctx.addIssue({
469
+ code: "custom",
470
+ message: `Duplicate redirect source: ${r.from}`
471
+ });
472
+ map.set(from, norm(r.to));
473
+ }
474
+ for (const start of map.keys()) {
475
+ const seen = new Set([start]);
476
+ for (let at = map.get(start); at !== void 0; at = map.get(at)) {
477
+ if (seen.has(at)) {
478
+ ctx.addIssue({
479
+ code: "custom",
480
+ message: `Redirect loop through ${start}`
481
+ });
482
+ break;
483
+ }
484
+ seen.add(at);
485
+ }
486
+ }
487
+ });
488
+ const fill = (to, groups) => to.replace(/\$(\d)/g, (_, n) => groups[Number(n) - 1] ?? "");
489
+ /**
490
+ * Priority: exact paths, then wildcards (longest prefix first), then regexes in the order listed.
491
+ * Rules from older files without the new fields behave as before (exact, enabled, query kept).
492
+ */
493
+ function resolveRedirect(rules, pathname, search = "") {
494
+ const p = norm(pathname);
495
+ const live = rules.filter((r) => r.enabled !== false);
496
+ const withQuery = (r, to) => search && r.preserveQuery !== false && !to.includes("?") ? `${to}${search.startsWith("?") ? search : `?${search}`}` : to;
497
+ const exact = live.find((r) => (r.match ?? "exact") === "exact" && norm(r.from) === p);
498
+ if (exact) return {
499
+ to: withQuery(exact, exact.to),
500
+ status: exact.status
501
+ };
502
+ const wild = live.filter((r) => r.match === "wildcard" && p.startsWith(r.from.slice(0, -1))).toSorted((a, b) => b.from.length - a.from.length)[0];
503
+ if (wild) return {
504
+ to: withQuery(wild, fill(wild.to, [p.slice(wild.from.length - 1)])),
505
+ status: wild.status
506
+ };
507
+ for (const r of live) {
508
+ if (r.match !== "regex") continue;
509
+ const m = new RegExp(r.from).exec(p);
510
+ if (m) return {
511
+ to: withQuery(r, fill(r.to, m.slice(1))),
512
+ status: r.status
513
+ };
514
+ }
515
+ return null;
516
+ }
517
+ const CSV_HEAD = [
518
+ "from",
519
+ "to",
520
+ "status",
521
+ "match",
522
+ "enabled",
523
+ "note"
524
+ ];
525
+ const cell = (v) => /[",\n]/.test(v) ? `"${v.replaceAll("\"", "\"\"")}"` : v;
526
+ function redirectsToCsv(rules) {
527
+ const rows = rules.map((r) => [
528
+ r.from,
529
+ r.to,
530
+ String(r.status),
531
+ r.match,
532
+ String(r.enabled),
533
+ r.note ?? ""
534
+ ].map(cell).join(","));
535
+ return `${[CSV_HEAD.join(","), ...rows].join("\n")}\n`;
536
+ }
537
+ /** Minimal RFC 4180 reader (quotes, doubled quotes, commas and newlines inside quotes). */
538
+ function parseCsv(text) {
539
+ const rows = [];
540
+ let row = [];
541
+ let field = "";
542
+ let quoted = false;
543
+ for (let i = 0; i < text.length; i++) {
544
+ const ch = text[i];
545
+ if (quoted) if (ch === "\"" && text[i + 1] === "\"") {
546
+ field += "\"";
547
+ i++;
548
+ } else if (ch === "\"") quoted = false;
549
+ else field += ch;
550
+ else if (ch === "\"") quoted = true;
551
+ else if (ch === ",") {
552
+ row.push(field);
553
+ field = "";
554
+ } else if (ch === "\n" || ch === "\r") {
555
+ if (ch === "\r" && text[i + 1] === "\n") i++;
556
+ row.push(field);
557
+ if (row.some((c) => c !== "")) rows.push(row);
558
+ row = [];
559
+ field = "";
560
+ } else field += ch;
561
+ }
562
+ row.push(field);
563
+ if (row.some((c) => c !== "")) rows.push(row);
564
+ return rows;
565
+ }
566
+ /** Rows become rules; the header row is optional. Throws with the line number of the first bad row. */
567
+ function parseRedirectsCsv(text) {
568
+ const rows = parseCsv(text);
569
+ if (rows[0]?.[0]?.toLowerCase() === "from") rows.shift();
570
+ return rows.map((cols, i) => {
571
+ const [from = "", to = "", status = "", match = "", enabled = "", note = ""] = cols.map((c) => c.trim());
572
+ const parsed = rule.safeParse({
573
+ from,
574
+ to,
575
+ ...status ? { status: Number(status) } : {},
576
+ ...match ? { match } : {},
577
+ ...enabled ? { enabled: enabled !== "false" } : {},
578
+ ...note ? { note } : {}
579
+ });
580
+ if (!parsed.success) throw new Error(`Row ${i + 1}: ${parsed.error.issues[0]?.message ?? "invalid"}`);
581
+ return parsed.data;
582
+ });
583
+ }
584
+
585
+ //#endregion
586
+ //#region src/plugins/errors.ts
587
+ /** A before-hook said no; `reason` is the plugin's plain-text explanation for the person who tried. */
588
+ var PluginRejectedError = class extends Error {
589
+ constructor(plugin, reason) {
590
+ super(`${plugin}: ${reason}`);
591
+ this.plugin = plugin;
592
+ this.reason = reason;
593
+ }
594
+ };
595
+ /** A before-hook failed, timed out or answered nonsense. The change is refused; details go to the plugin's log only. */
596
+ var PluginHookError = class extends Error {
597
+ constructor(plugin) {
598
+ super(`Plugin ${plugin} could not check this change`);
599
+ this.plugin = plugin;
600
+ }
601
+ };
602
+
603
+ //#endregion
604
+ //#region src/plugins/host.ts
605
+ /** A before-hook's answer larger than this is refused rather than parsed. */
606
+ const MAX_FILTER_RESULT = 4 * 1024 * 1024;
607
+ const PRELUDE = `
608
+ (() => {
609
+ const log = __hostLog, get = __hostContentGet, put = __hostContentPut, fetchFn = __hostFetch;
610
+ const secretFn = __hostSecret, mail = __hostEmail, settingsFn = __hostSettings;
611
+ for (const n of ["__hostLog", "__hostContentGet", "__hostContentPut", "__hostFetch", "__hostSecret", "__hostEmail", "__hostSettings"]) delete globalThis[n];
612
+ const ctx = Object.freeze({
613
+ log: (m) => log(String(m)),
614
+ content: Object.freeze({
615
+ get: async (c, s) => JSON.parse(await get(String(c), String(s))),
616
+ put: async (c, s, doc) => { await put(String(c), String(s), JSON.stringify(doc || {})); },
617
+ }),
618
+ fetch: async (u, init) => JSON.parse(await fetchFn(String(u), JSON.stringify(init || {}))),
619
+ secret: (name) => { const v = secretFn(String(name)); return v === "" ? undefined : v; },
620
+ email: Object.freeze({ send: async (m) => { await mail(JSON.stringify(m || {})); } }),
621
+ get settings() { return Object.freeze(JSON.parse(settingsFn())); },
622
+ });
623
+ globalThis.__run = async (hook, ev) => {
624
+ const fn = globalThis.__plugin && globalThis.__plugin[hook];
625
+ if (typeof fn !== "function") return "null";
626
+ const out = await fn(JSON.parse(ev), ctx);
627
+ return out === undefined ? "null" : JSON.stringify(out);
628
+ };
629
+ globalThis.__handle = async (method, path, body) => {
630
+ const fn = globalThis.__plugin && globalThis.__plugin.fetch;
631
+ if (typeof fn !== "function") return JSON.stringify({ status: 404, body: "" });
632
+ const res = await fn({ method: String(method), path: String(path), body: body ? JSON.parse(body) : undefined }, ctx);
633
+ return JSON.stringify(res || { status: 204, body: "" });
634
+ };
635
+ })();
636
+ `;
637
+ /**
638
+ * Runs untrusted plugin code in QuickJS compiled to WASM: no Node APIs, no ambient network or filesystem.
639
+ * The only way out is `ctx`, and every capability is checked against the manifest here, not in the plugin.
640
+ */
641
+ async function loadPlugin(manifest, code, deps) {
642
+ const runtime = (await newQuickJSWASMModule()).newRuntime();
643
+ runtime.setMemoryLimit(manifest.limits.memoryMb * 1024 * 1024);
644
+ runtime.setMaxStackSize(256 * 1024);
645
+ let deadline = Infinity;
646
+ runtime.setInterruptHandler(() => Date.now() > deadline);
647
+ const vm = runtime.newContext();
648
+ let dead = false;
649
+ let filtering = 0;
650
+ const dispose = () => {
651
+ if (dead) return;
652
+ dead = true;
653
+ try {
654
+ vm.dispose();
655
+ runtime.dispose();
656
+ } catch {}
657
+ };
658
+ const asyncFn = (name, impl) => {
659
+ const h = vm.newFunction(name, (...argHandles) => {
660
+ const args = argHandles.map((a) => vm.getString(a));
661
+ const promise = vm.newPromise();
662
+ impl(args).then((out) => vm.newString(out).consume((s) => promise.resolve(s)), (err) => vm.newError(err.message).consume((e) => promise.reject(e)));
663
+ promise.settled.then(() => !dead && runtime.executePendingJobs());
664
+ return promise.handle;
665
+ });
666
+ vm.setProp(vm.global, name, h);
667
+ h.dispose();
668
+ };
669
+ const syncFn = (name, impl) => {
670
+ const h = vm.newFunction(name, (...argHandles) => {
671
+ impl(argHandles.map((a) => vm.getString(a)));
672
+ });
673
+ vm.setProp(vm.global, name, h);
674
+ h.dispose();
675
+ };
676
+ /** Like `syncFn` but returns a string to the plugin. */
677
+ const valueFn = (name, impl) => {
678
+ const h = vm.newFunction(name, (...argHandles) => vm.newString(impl(argHandles.map((a) => vm.getString(a)))));
679
+ vm.setProp(vm.global, name, h);
680
+ h.dispose();
681
+ };
682
+ syncFn("__hostLog", ([m]) => deps.log(String(m).slice(0, 2e3)));
683
+ asyncFn("__hostContentGet", async ([collection = "", slug = ""]) => {
684
+ if (!canReadContent(manifest, collection)) throw new Error(`Content access denied: ${collection}`);
685
+ return JSON.stringify(await deps.readContent(collection, slug));
686
+ });
687
+ asyncFn("__hostContentPut", async ([collection = "", slug = "", raw = "{}"]) => {
688
+ if (!canWriteContent(manifest, collection)) throw new Error(`Content write denied: ${collection}`);
689
+ if (filtering > 0) throw new Error("content.put is not available in before-hooks; return the changed entry instead");
690
+ if (!deps.writeContent) throw new Error("Content writing is unavailable");
691
+ const doc = JSON.parse(raw);
692
+ if (typeof doc.body !== "string" || !doc.data || typeof doc.data !== "object" || Array.isArray(doc.data)) throw new Error("content.put needs { data: object, body: string }");
693
+ await deps.writeContent(manifest.id, collection, slug, {
694
+ data: doc.data,
695
+ body: doc.body
696
+ });
697
+ return "";
698
+ });
699
+ valueFn("__hostSecret", ([name = ""]) => {
700
+ if (!canReadSecret(manifest, name)) throw new Error(`Secret access denied: ${name}`);
701
+ return deps.secret?.(manifest.id, name) ?? "";
702
+ });
703
+ asyncFn("__hostEmail", async ([raw = "{}"]) => {
704
+ if (!manifest.capabilities.email) throw new Error("Email access denied");
705
+ if (!deps.sendEmail) throw new Error("Email is unavailable");
706
+ const m = JSON.parse(raw);
707
+ if (typeof m.to !== "string" || typeof m.subject !== "string" || typeof m.text !== "string") throw new Error("email.send needs { to, subject, text } strings");
708
+ await deps.sendEmail(manifest.id, {
709
+ to: m.to,
710
+ subject: m.subject.slice(0, 200),
711
+ text: m.text.slice(0, 5e3)
712
+ });
713
+ return "";
714
+ });
715
+ valueFn("__hostSettings", () => JSON.stringify(deps.settings?.(manifest.id) ?? {}));
716
+ asyncFn("__hostFetch", async ([url = "", rawInit = "{}"]) => {
717
+ const init = JSON.parse(rawInit);
718
+ const method = (init.method ?? "GET").toUpperCase();
719
+ if (!canFetch(manifest, url, method)) throw new Error(`Network access denied: ${method} ${url}`);
720
+ return JSON.stringify(await deps.fetch(url, {
721
+ method,
722
+ body: init.body,
723
+ headers: init.headers
724
+ }));
725
+ });
726
+ const evalOrThrow = (src, filename, what) => {
727
+ const r = vm.evalCode(src, filename);
728
+ if (r.error) {
729
+ const err = describe(vm, r.error);
730
+ dispose();
731
+ throw new Error(`Failed to load plugin (${what}): ${err}`);
732
+ }
733
+ r.value.dispose();
734
+ };
735
+ deadline = Date.now() + manifest.limits.timeoutMs;
736
+ evalOrThrow(PRELUDE, "prelude.js", "prelude");
737
+ evalOrThrow(code, `${manifest.id}.js`, "code");
738
+ const defined = vm.evalCode(`typeof globalThis.__plugin === "object" && globalThis.__plugin !== null`);
739
+ const ok = defined.error ? false : vm.dump(defined.value) === true;
740
+ (defined.error ?? defined.value).dispose();
741
+ if (!ok) {
742
+ dispose();
743
+ throw new Error("Failed to load plugin: it must define globalThis.__plugin");
744
+ }
745
+ return {
746
+ dispose,
747
+ async request(method, path, body) {
748
+ if (dead) return {
749
+ status: 503,
750
+ body: "Plugin was stopped"
751
+ };
752
+ deadline = Date.now() + manifest.limits.timeoutMs;
753
+ const call = vm.evalCode(`__handle(${JSON.stringify(method)}, ${JSON.stringify(path)}, ${JSON.stringify(body ?? "")})`);
754
+ if (call.error) return {
755
+ status: 500,
756
+ body: describe(vm, call.error)
757
+ };
758
+ let timer;
759
+ const timeout = new Promise((resolve) => timer = setTimeout(() => resolve("timeout"), manifest.limits.timeoutMs));
760
+ try {
761
+ const settled = vm.resolvePromise(call.value);
762
+ runtime.executePendingJobs();
763
+ const result = await Promise.race([settled, timeout]);
764
+ if (result === "timeout") {
765
+ dispose();
766
+ return {
767
+ status: 504,
768
+ body: "Plugin timed out"
769
+ };
770
+ }
771
+ if (result.error) return {
772
+ status: 500,
773
+ body: describe(vm, result.error)
774
+ };
775
+ const raw = vm.dump(result.value);
776
+ result.value.dispose();
777
+ try {
778
+ const parsed = JSON.parse(raw);
779
+ return {
780
+ status: typeof parsed.status === "number" ? parsed.status : 200,
781
+ headers: parsed.headers,
782
+ body: typeof parsed.body === "string" ? parsed.body : JSON.stringify(parsed.body ?? "")
783
+ };
784
+ } catch {
785
+ return {
786
+ status: 200,
787
+ body: raw
788
+ };
789
+ }
790
+ } finally {
791
+ clearTimeout(timer);
792
+ if (!dead) call.value.dispose();
793
+ }
794
+ },
795
+ async dispatch(hook, event) {
796
+ if (!manifest.hooks.includes(hook)) return {
797
+ ok: true,
798
+ skipped: true
799
+ };
800
+ const res = await run(hook, event);
801
+ return res.ok ? { ok: true } : res;
802
+ },
803
+ async filter(hook, event) {
804
+ if (!manifest.hooks.includes(hook)) return {
805
+ ok: true,
806
+ value: null
807
+ };
808
+ filtering++;
809
+ try {
810
+ return await run(hook, event);
811
+ } finally {
812
+ filtering--;
813
+ }
814
+ }
815
+ };
816
+ async function run(hook, event) {
817
+ if (dead) return {
818
+ ok: false,
819
+ error: "Plugin was stopped"
820
+ };
821
+ deadline = Date.now() + manifest.limits.timeoutMs;
822
+ const started = vm.evalCode(`__run(${JSON.stringify(hook)}, ${JSON.stringify(JSON.stringify(event))})`);
823
+ if (started.error) return fail(describe(vm, started.error));
824
+ let timer;
825
+ const timeout = new Promise((r) => timer = setTimeout(() => r("timeout"), manifest.limits.timeoutMs));
826
+ try {
827
+ const settled = vm.resolvePromise(started.value);
828
+ runtime.executePendingJobs();
829
+ const res = await Promise.race([settled, timeout]);
830
+ if (res === "timeout") return fail("Plugin timed out");
831
+ if (res.error) return fail(describe(vm, res.error));
832
+ const raw = vm.dump(res.value);
833
+ res.value.dispose();
834
+ if (typeof raw !== "string") return {
835
+ ok: true,
836
+ value: null
837
+ };
838
+ if (raw.length > MAX_FILTER_RESULT) return fail("Hook answer is too large");
839
+ return {
840
+ ok: true,
841
+ value: JSON.parse(raw)
842
+ };
843
+ } finally {
844
+ clearTimeout(timer);
845
+ if (!dead) started.value.dispose();
846
+ }
847
+ }
848
+ function fail(error) {
849
+ if (/timed out|interrupted|out of memory/i.test(error)) dispose();
850
+ return {
851
+ ok: false,
852
+ error
853
+ };
854
+ }
855
+ }
856
+ function describe(vm, handle) {
857
+ const dumped = vm.dump(handle);
858
+ handle.dispose();
859
+ return typeof dumped === "string" ? dumped : `${dumped.name ?? "Error"}: ${dumped.message ?? JSON.stringify(dumped)}`;
860
+ }
861
+
862
+ //#endregion
863
+ //#region src/plugins/loader.ts
864
+ /** Hash over the *parsed* capabilities, so defaults cannot hide a change. */
865
+ const capabilityHash$1 = (manifest) => capabilityHash(manifest);
866
+ /** Digest of exactly the files that run. Registries pin it; a lock file detects edits after install. */
867
+ const fileDigest = (manifestText, code) => createHash("sha256").update(manifestText).update("\n--\n").update(code).digest("hex");
868
+ const HOOK_FOR = {
869
+ "content.updated": "onContentUpdated",
870
+ "content.deleted": "onContentDeleted",
871
+ "media.uploaded": "onMediaUploaded",
872
+ "comment.created": "onCommentCreated"
873
+ };
874
+ const reasonOf = (answer) => {
875
+ if (!answer || typeof answer !== "object" || !("reject" in answer)) return void 0;
876
+ const raw = answer.reject;
877
+ const reason = typeof raw === "string" ? raw.replace(/[\p{Cc}\s]+/gu, " ").trim() : "";
878
+ return reason.length >= 1 && reason.length <= 500 ? reason : null;
879
+ };
880
+ const MAX_LOG_LINES = 100;
881
+ /** Fail closed per plugin: anything not approved, not parseable or outside the project is skipped and reported. */
882
+ async function loadPlugins(root, entries, deps, { approvals = {}, lock = {}, disabled = /* @__PURE__ */ new Set(), now = Date.now } = {}) {
883
+ const running = [];
884
+ const rows = [];
885
+ const errors = [];
886
+ const base = resolve(root);
887
+ for (const entry of entries) {
888
+ const fail = (message, extra = {}) => {
889
+ errors.push(message);
890
+ rows.push({
891
+ dir: entry.dir,
892
+ id: null,
893
+ version: null,
894
+ state: "error",
895
+ error: message,
896
+ ...extra
897
+ });
898
+ };
899
+ try {
900
+ const dir = resolve(base, entry.dir);
901
+ if (!dir.startsWith(base + sep)) throw new Error(`Plugin directory is outside the project: ${entry.dir}`);
902
+ const manifestText = await readFile(join(dir, "manifest.json"), "utf8");
903
+ const manifest = manifestSchema.parse(JSON.parse(manifestText));
904
+ const code = await readFile(join(dir, "plugin.js"), "utf8");
905
+ const pinned = lock[manifest.id];
906
+ if (pinned && pinned.sha256 !== fileDigest(manifestText, code)) throw new Error(`Plugin "${manifest.id}" files changed since they were installed; reinstall or update it`);
907
+ const hash = capabilityHash$1(manifest);
908
+ if (hash !== (entry.approve ?? approvals[entry.dir])) {
909
+ const message = `Plugin "${manifest.id}" capabilities are not approved. Review them, then approve ${hash} for ${entry.dir}`;
910
+ errors.push(message);
911
+ rows.push({
912
+ dir: entry.dir,
913
+ id: manifest.id,
914
+ version: manifest.version,
915
+ ...manifest.name ? { name: manifest.name } : {},
916
+ state: "needs-approval",
917
+ pendingHash: hash,
918
+ error: message,
919
+ manifest
920
+ });
921
+ continue;
922
+ }
923
+ const logs = [];
924
+ const instance = await loadPlugin(manifest, code, {
925
+ ...deps,
926
+ log: (m) => {
927
+ logs.push(`${new Date(now()).toISOString()} ${m}`);
928
+ if (logs.length > MAX_LOG_LINES) logs.shift();
929
+ deps.log(`[${manifest.id}] ${m}`);
930
+ }
931
+ });
932
+ running.push({
933
+ dir: entry.dir,
934
+ manifest,
935
+ instance,
936
+ enabled: !disabled.has(manifest.id),
937
+ calls: 0,
938
+ failures: 0,
939
+ totalMs: 0,
940
+ window: {
941
+ start: 0,
942
+ count: 0
943
+ },
944
+ logs
945
+ });
946
+ } catch (e) {
947
+ fail(e.message);
948
+ }
949
+ }
950
+ /** Counts a call against the plugin's per-minute quota. False means over quota. */
951
+ const admit = (p) => {
952
+ const t = now();
953
+ if (t - p.window.start >= 6e4) p.window = {
954
+ start: t,
955
+ count: 0
956
+ };
957
+ if (p.window.count >= p.manifest.limits.callsPerMinute) return false;
958
+ p.window.count++;
959
+ return true;
960
+ };
961
+ const record = (p, started, error) => {
962
+ p.calls++;
963
+ p.totalMs += now() - started;
964
+ if (error) {
965
+ p.failures++;
966
+ p.lastError = {
967
+ message: error.slice(0, 500),
968
+ at: new Date(now()).toISOString()
969
+ };
970
+ }
971
+ };
972
+ const find = (id) => running.find((p) => p.manifest.id === id);
973
+ return {
974
+ loaded: running.map((p) => p.manifest.id),
975
+ errors,
976
+ manifests: running.map((p) => p.manifest),
977
+ statuses() {
978
+ return [...running.map((p) => {
979
+ const t = now();
980
+ return {
981
+ dir: p.dir,
982
+ id: p.manifest.id,
983
+ version: p.manifest.version,
984
+ ...p.manifest.name ? { name: p.manifest.name } : {},
985
+ state: p.enabled ? "active" : "disabled",
986
+ manifest: p.manifest,
987
+ stats: {
988
+ calls: p.calls,
989
+ failures: p.failures,
990
+ callsThisMinute: t - p.window.start < 6e4 ? p.window.count : 0,
991
+ quota: p.manifest.limits.callsPerMinute,
992
+ totalMs: p.totalMs
993
+ },
994
+ ...p.lastError ? { lastError: p.lastError } : {},
995
+ logs: [...p.logs]
996
+ };
997
+ }), ...rows];
998
+ },
999
+ setEnabled(id, enabled) {
1000
+ const plugin = find(id);
1001
+ if (!plugin) return false;
1002
+ plugin.enabled = enabled;
1003
+ return true;
1004
+ },
1005
+ async request(id, method, path, body) {
1006
+ const plugin = find(id);
1007
+ if (!plugin) return null;
1008
+ if (!plugin.enabled) return {
1009
+ status: 503,
1010
+ body: "Plugin is disabled"
1011
+ };
1012
+ if (!admit(plugin)) return {
1013
+ status: 429,
1014
+ body: "Plugin call quota exceeded"
1015
+ };
1016
+ const started = now();
1017
+ const res = await plugin.instance.request(method, path, body);
1018
+ record(plugin, started, res.status >= 500 ? `HTTP ${res.status}: ${res.body}` : void 0);
1019
+ return res;
1020
+ },
1021
+ async dispatch(event) {
1022
+ const hook = HOOK_FOR[event.type];
1023
+ if (!hook) return;
1024
+ await Promise.all(running.map(async (p) => {
1025
+ if (!p.enabled || !p.manifest.hooks.includes(hook)) return;
1026
+ if (!admit(p)) {
1027
+ record(p, now(), `Dropped ${hook}: call quota of ${p.manifest.limits.callsPerMinute}/min exceeded`);
1028
+ return;
1029
+ }
1030
+ const started = now();
1031
+ const res = await p.instance.dispatch(hook, event);
1032
+ record(p, started, res.ok ? void 0 : res.error);
1033
+ if (!res.ok) deps.log(`plugin ${p.manifest.id} failed on ${hook}: ${res.error}`);
1034
+ }));
1035
+ },
1036
+ async filter(hook, event, { applies = () => true, apply } = {}) {
1037
+ let current = event;
1038
+ for (const p of running) {
1039
+ if (!p.enabled || !p.manifest.hooks.includes(hook) || !applies(p.manifest)) continue;
1040
+ admit(p);
1041
+ const started = now();
1042
+ const res = await p.instance.filter(hook, current);
1043
+ const refuse = (error) => {
1044
+ record(p, started, `${hook}: ${error}`);
1045
+ deps.log(`plugin ${p.manifest.id} failed on ${hook}: ${error}`);
1046
+ return new PluginHookError(p.manifest.id);
1047
+ };
1048
+ if (!res.ok) throw refuse(res.error);
1049
+ const reason = reasonOf(res.value);
1050
+ if (reason === null) throw refuse("a refusal needs a reason of 1-500 characters");
1051
+ if (reason !== void 0) {
1052
+ record(p, started);
1053
+ throw new PluginRejectedError(p.manifest.id, reason);
1054
+ }
1055
+ if (res.value !== null) {
1056
+ if (!apply) throw refuse("this hook can only refuse ({ reject }) or return nothing");
1057
+ try {
1058
+ current = apply(p.manifest, res.value, current);
1059
+ } catch (e) {
1060
+ throw refuse(e.message);
1061
+ }
1062
+ }
1063
+ record(p, started);
1064
+ }
1065
+ return current;
1066
+ },
1067
+ dispose: () => running.forEach((p) => p.instance.dispose())
1068
+ };
1069
+ }
1070
+
1071
+ //#endregion
1072
+ //#region src/plugins/registry.ts
1073
+ const registryConfigSchema = z.object({
1074
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
1075
+ url: z.url({ protocol: /^https?$/ }),
1076
+ publicKeys: z.array(z.string().min(20)).default([])
1077
+ });
1078
+ const indexSchema = z.object({ plugins: z.array(z.object({
1079
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/),
1080
+ name: z.string().max(100).optional(),
1081
+ description: z.string().max(500).optional(),
1082
+ author: z.string().max(200).optional(),
1083
+ version: z.string(),
1084
+ bundle: z.object({
1085
+ url: z.string(),
1086
+ sha256: z.string().length(64)
1087
+ }),
1088
+ signature: z.string().optional()
1089
+ })) });
1090
+ const bundleSchema = z.object({
1091
+ manifest: z.string(),
1092
+ code: z.string()
1093
+ });
1094
+ const defaultFetch = async (url) => new TextDecoder().decode((await fetchPublic(url, { maxBytes: 2 * 1024 * 1024 })).bytes);
1095
+ /** The message a publisher signs. */
1096
+ const signedMessage = (id, version, sha256) => `${id}@${version}:${sha256}`;
1097
+ function verifySignature(registry, entry) {
1098
+ if (!entry.signature || registry.publicKeys.length === 0) return false;
1099
+ const message = Buffer.from(signedMessage(entry.id, entry.version, entry.bundle.sha256));
1100
+ const signature = Buffer.from(entry.signature, "base64");
1101
+ return registry.publicKeys.some((key) => {
1102
+ try {
1103
+ return verify(null, message, createPublicKey({
1104
+ key: Buffer.from(key, "base64"),
1105
+ format: "der",
1106
+ type: "spki"
1107
+ }), signature);
1108
+ } catch {
1109
+ return false;
1110
+ }
1111
+ });
1112
+ }
1113
+ var RegistryClient = class {
1114
+ cache = /* @__PURE__ */ new Map();
1115
+ constructor(registries, fetchText = defaultFetch, ttlMs = 5 * 6e4, now = Date.now) {
1116
+ this.registries = registries;
1117
+ this.fetchText = fetchText;
1118
+ this.ttlMs = ttlMs;
1119
+ this.now = now;
1120
+ }
1121
+ async index(registry) {
1122
+ const hit = this.cache.get(registry.name);
1123
+ if (hit && this.now() - hit.at < this.ttlMs) return hit.entries;
1124
+ const entries = indexSchema.parse(JSON.parse(await this.fetchText(registry.url))).plugins;
1125
+ this.cache.set(registry.name, {
1126
+ at: this.now(),
1127
+ entries
1128
+ });
1129
+ return entries;
1130
+ }
1131
+ /** Matching packages from every registry. A registry that is down is reported, not fatal. */
1132
+ async search(q = "") {
1133
+ const needle = q.trim().toLowerCase();
1134
+ const results = [];
1135
+ const errors = [];
1136
+ for (const registry of this.registries) try {
1137
+ for (const entry of await this.index(registry)) {
1138
+ if (needle && !`${entry.id} ${entry.name ?? ""} ${entry.description ?? ""}`.toLowerCase().includes(needle)) continue;
1139
+ results.push({
1140
+ ...entry,
1141
+ registry: registry.name,
1142
+ verified: verifySignature(registry, entry)
1143
+ });
1144
+ }
1145
+ } catch (e) {
1146
+ errors.push(`${registry.name}: ${e.message}`);
1147
+ }
1148
+ return {
1149
+ results,
1150
+ errors
1151
+ };
1152
+ }
1153
+ /**
1154
+ * Downloads and checks a package: digest pinned by the index, signature when the registry has trusted keys,
1155
+ * and a manifest whose id/version match the listing. Nothing is written here.
1156
+ */
1157
+ async fetchPackage(registryName, id) {
1158
+ const registry = this.registries.find((r) => r.name === registryName);
1159
+ if (!registry) throw new Error(`Unknown registry: ${registryName}`);
1160
+ const entry = (await this.index(registry)).find((e) => e.id === id);
1161
+ if (!entry) throw new Error(`${registryName} has no plugin "${id}"`);
1162
+ const verified = verifySignature(registry, entry);
1163
+ if (registry.publicKeys.length > 0 && !verified) throw new Error(`"${id}" is not signed by a key this site trusts for ${registryName}`);
1164
+ const url = new URL(entry.bundle.url, registry.url).href;
1165
+ const bundle = bundleSchema.parse(JSON.parse(await this.fetchText(url)));
1166
+ const sha256 = fileDigest(bundle.manifest, bundle.code);
1167
+ if (sha256 !== entry.bundle.sha256) throw new Error(`"${id}" does not match the digest published in the index`);
1168
+ const manifest = manifestSchema.parse(JSON.parse(bundle.manifest));
1169
+ if (manifest.id !== entry.id || manifest.version !== entry.version) throw new Error(`"${id}" manifest says ${manifest.id}@${manifest.version}, the index says ${entry.id}@${entry.version}`);
1170
+ return {
1171
+ manifest,
1172
+ manifestText: bundle.manifest,
1173
+ code: bundle.code,
1174
+ sha256,
1175
+ verified,
1176
+ version: entry.version
1177
+ };
1178
+ }
1179
+ };
1180
+
1181
+ //#endregion
1182
+ //#region src/config/index.ts
1183
+ const safeDir = collectionSchema.shape.dir.refine((d) => !d.startsWith("/") && !d.split(/[\\/]/).includes(".."), "dir must be relative and stay inside the project");
1184
+ const configSchema = z.object({
1185
+ collections: z.array(collectionSchema.extend({ dir: safeDir })).default([]),
1186
+ site: z.object({
1187
+ url: z.url(),
1188
+ name: z.string().min(1)
1189
+ }).optional(),
1190
+ locales: z.array(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]+)*$/)).default([]),
1191
+ components: z.array(z.string().regex(/^[A-Z][A-Za-z0-9]*$/)).default([]),
1192
+ plugins: z.array(z.object({
1193
+ dir: z.string().min(1),
1194
+ approve: z.string().length(64)
1195
+ })).default([]),
1196
+ registries: z.array(registryConfigSchema).default([]),
1197
+ webhooks: webhooksSchema,
1198
+ redirects: redirectsSchema.default([]),
1199
+ git: z.boolean().default(true),
1200
+ admin: z.object({ path: z.string().startsWith("/").default("/admin") }).default({ path: "/admin" })
1201
+ }).superRefine((cfg, ctx) => {
1202
+ const seen = /* @__PURE__ */ new Set();
1203
+ for (const c of cfg.collections) {
1204
+ if (seen.has(c.name)) ctx.addIssue({
1205
+ code: "custom",
1206
+ message: `Duplicate collection: ${c.name}`,
1207
+ path: ["collections"]
1208
+ });
1209
+ seen.add(c.name);
1210
+ }
1211
+ });
1212
+ function defineConfig(input) {
1213
+ const r = configSchema.safeParse(input);
1214
+ if (!r.success) {
1215
+ const lines = r.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`);
1216
+ throw new Error(`Invalid wuppy.config:\n${lines.join("\n")}`);
1217
+ }
1218
+ return r.data;
1219
+ }
1220
+ const FILES = [
1221
+ "wuppy.config.ts",
1222
+ "wuppy.config.mjs",
1223
+ "wuppy.config.js"
1224
+ ];
1225
+ async function loadConfig(root) {
1226
+ for (const f of FILES) {
1227
+ const path = join(root, f);
1228
+ try {
1229
+ await access(path);
1230
+ } catch {
1231
+ continue;
1232
+ }
1233
+ return defineConfig((await import(
1234
+ /* @vite-ignore */
1235
+ pathToFileURL(path).href
1236
+ )).default ?? {});
1237
+ }
1238
+ throw new Error(`No wuppy.config found in ${root} (looked for ${FILES.join(", ")})`);
1239
+ }
1240
+
1241
+ //#endregion
1242
+ export { collectionSchema as C, stripComponentImports as S, fieldKind as T, isPrivateAddress as _, loadPlugins as a, findDisallowed as b, parseRedirectsCsv as c, resolveRedirect as d, createWebhookSender as f, fetchPublic as g, assertPublicUrl as h, capabilityHash$1 as i, redirectsSchema as l, UnsafeUrlError as m, loadConfig as n, PluginHookError as o, webhooksSchema as p, RegistryClient as r, PluginRejectedError as s, defineConfig as t, redirectsToCsv as u, redact as v, dataSchema as w, loadSatteri as x, componentTags as y };