@aeptus/aexeo-emdash 0.8.17 → 0.8.19

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.
@@ -1,13 +1,40 @@
1
1
  // src/adapter.ts
2
- function adaptContentItem(content) {
3
- const route = deriveRoute(content);
2
+ async function adaptContentItemWithContext(content, contentApi, defaultLocale) {
3
+ const publicUrl = content.status === "published" ? await contentApi.getPublicUrl(content.type, content.id) : null;
4
+ const route = publicUrl === null ? deriveRoute(content) : routeFromPublicUrl(publicUrl);
5
+ const adapted = adaptContentItem(content, {
6
+ route,
7
+ publicUrl,
8
+ ...defaultLocale === void 0 ? {} : { defaultLocale }
9
+ });
10
+ const translationSet = await contentApi.getTranslations(
11
+ content.type,
12
+ content.id
13
+ );
14
+ const alternates = await Promise.all(
15
+ translationSet.translations.filter(
16
+ (translation) => translation.status === "published" && typeof translation.locale === "string" && translation.locale.length > 0
17
+ ).map(async (translation) => {
18
+ const href = translation.id === content.id ? publicUrl : await contentApi.getPublicUrl(content.type, translation.id);
19
+ if (href === null) return null;
20
+ return { lang: translation.locale, href };
21
+ })
22
+ );
23
+ const uniqueAlternates = /* @__PURE__ */ new Map();
24
+ for (const alternate of alternates) {
25
+ if (alternate !== null) uniqueAlternates.set(alternate.lang, alternate);
26
+ }
27
+ if (uniqueAlternates.size > 0) {
28
+ adapted.document.alternates = [...uniqueAlternates.values()];
29
+ }
30
+ return adapted;
31
+ }
32
+ function adaptContentItem(content, options = {}) {
33
+ const route = options.route ?? deriveRoute(content);
4
34
  const title = stringOrEmpty(
5
35
  content.seo?.title ?? content.data["title"] ?? content.slug ?? content.id
6
36
  );
7
- const document = {
8
- route,
9
- title
10
- };
37
+ const document = { route, title };
11
38
  const description = content.seo?.description ?? content.data["description"];
12
39
  if (typeof description === "string" && description.length > 0) {
13
40
  document.description = description;
@@ -16,13 +43,12 @@ function adaptContentItem(content) {
16
43
  if (typeof canonical === "string" && canonical.length > 0) {
17
44
  document.canonical = canonical;
18
45
  }
19
- if (content.locale !== null) {
20
- document.lang = content.locale;
46
+ const locale = content.locale || options.defaultLocale;
47
+ if (typeof locale === "string" && locale.length > 0) {
48
+ document.lang = locale;
21
49
  }
22
50
  const body = extractPortableText(content.data);
23
- if (body !== null) {
24
- document.body = body;
25
- }
51
+ if (body !== null) document.body = body;
26
52
  return {
27
53
  document,
28
54
  meta: {
@@ -30,48 +56,226 @@ function adaptContentItem(content) {
30
56
  collection: content.type,
31
57
  slug: content.slug,
32
58
  status: content.status,
33
- title
59
+ title,
60
+ publicUrl: options.publicUrl ?? null
34
61
  }
35
62
  };
36
63
  }
37
64
  function deriveRoute(content) {
38
- if (typeof content.slug === "string" && content.slug.length > 0) {
39
- return content.slug.startsWith("/") ? content.slug : `/${content.slug}`;
65
+ return `/${encodeURIComponent(content.type)}/${encodeURIComponent(content.id)}`;
66
+ }
67
+ function routeFromPublicUrl(publicUrl) {
68
+ try {
69
+ return new URL(publicUrl).pathname || "/";
70
+ } catch (cause) {
71
+ throw new Error(`EmDash returned an invalid public URL: ${publicUrl}`, {
72
+ cause
73
+ });
40
74
  }
41
- return `/${content.type}/${content.id}`;
42
75
  }
43
76
  function stringOrEmpty(value) {
44
77
  return typeof value === "string" ? value : "";
45
78
  }
46
79
  function extractPortableText(data) {
47
- const candidates = ["body", "content"];
48
- for (const slug of candidates) {
80
+ for (const slug of ["body", "content"]) {
49
81
  const value = data[slug];
50
- if (isPortableTextArray(value)) {
51
- return value;
82
+ if (isPortableTextArray(value)) return value;
83
+ if (isEmdashBlocksArray(value, slug)) return blocksToPortableText(value);
84
+ if (typeof value === "string" && value.trim().length > 0) {
85
+ return [portableTextBlock(value.trim(), `${slug}-text`)];
52
86
  }
53
87
  }
54
- for (const value of Object.values(data)) {
55
- if (isPortableTextArray(value)) {
56
- return value;
57
- }
88
+ const blocks = data["blocks"];
89
+ if (isPortableTextArray(blocks)) return blocks;
90
+ if (isEmdashBlocksArray(blocks, "blocks")) return blocksToPortableText(blocks);
91
+ for (const [field, value] of Object.entries(data)) {
92
+ if (field === "body" || field === "content" || field === "blocks") continue;
93
+ if (isPortableTextArray(value)) return value;
94
+ if (isEmdashBlocksArray(value, field)) return blocksToPortableText(value);
58
95
  }
59
96
  return null;
60
97
  }
61
98
  function isPortableTextArray(value) {
62
- if (!Array.isArray(value) || value.length === 0) {
63
- return false;
99
+ return Array.isArray(value) && value.length > 0 && value.every(
100
+ (item) => isRecord(item) && item["_type"] === "block" && Array.isArray(item["children"]) && item["children"].every(
101
+ (child) => isRecord(child) && typeof child["text"] === "string"
102
+ )
103
+ );
104
+ }
105
+ function isEmdashBlocksArray(value, field) {
106
+ if (!Array.isArray(value) || value.length === 0) return false;
107
+ return value.every(
108
+ (item) => isRecord(item) && typeof item["_type"] === "string" && typeof item["_key"] === "string" && (typeof item["_version"] === "number" || field === "blocks")
109
+ );
110
+ }
111
+ function blocksToPortableText(blocks) {
112
+ const paragraphs = [];
113
+ for (const block of blocks) {
114
+ const segments = [];
115
+ collectText(block, "", segments);
116
+ const text = segments.map((segment) => segment.trim()).filter(Boolean).join(" ");
117
+ if (text.length > 0) {
118
+ paragraphs.push(portableTextBlock(text, stringOrEmpty(block["_key"])));
119
+ }
120
+ }
121
+ return paragraphs;
122
+ }
123
+ function collectText(value, key, out) {
124
+ if (typeof value === "string") {
125
+ if (!isStructuralField(key) && value.trim().length > 0) out.push(value);
126
+ return;
127
+ }
128
+ if (Array.isArray(value)) {
129
+ for (const item of value) collectText(item, key, out);
130
+ return;
131
+ }
132
+ if (!isRecord(value)) return;
133
+ for (const [childKey, childValue] of Object.entries(value)) {
134
+ if (isStructuralField(childKey)) continue;
135
+ collectText(childValue, childKey, out);
64
136
  }
65
- const first = value[0];
66
- return typeof first === "object" && first !== null && "_type" in first && typeof first._type === "string";
137
+ }
138
+ var STRUCTURAL_FIELDS = /* @__PURE__ */ new Set([
139
+ "_key",
140
+ "_ref",
141
+ "_type",
142
+ "_version",
143
+ "asset",
144
+ "align",
145
+ "alignment",
146
+ "autoplay",
147
+ "color",
148
+ "controls",
149
+ "focalpoint",
150
+ "height",
151
+ "href",
152
+ "id",
153
+ "layout",
154
+ "level",
155
+ "loop",
156
+ "markdefs",
157
+ "marks",
158
+ "mime_type",
159
+ "mimetype",
160
+ "position",
161
+ "rel",
162
+ "size",
163
+ "src",
164
+ "style",
165
+ "target",
166
+ "theme",
167
+ "tone",
168
+ "url",
169
+ "variant",
170
+ "width"
171
+ ]);
172
+ function isStructuralField(key) {
173
+ const normalized = key.toLowerCase();
174
+ return normalized.startsWith("_") || STRUCTURAL_FIELDS.has(normalized);
175
+ }
176
+ function portableTextBlock(text, key) {
177
+ return {
178
+ _type: "block",
179
+ ...key.length === 0 ? {} : { _key: key },
180
+ children: [
181
+ {
182
+ _type: "span",
183
+ ...key.length === 0 ? {} : { _key: `${key}-text` },
184
+ text,
185
+ marks: []
186
+ }
187
+ ]
188
+ };
189
+ }
190
+ function isRecord(value) {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+
194
+ // src/sidecar.ts
195
+ async function evaluateViaSidecar(http, config, documents) {
196
+ const body = {
197
+ documents
198
+ };
199
+ if (config.configJson !== void 0) {
200
+ body.configJson = config.configJson;
201
+ }
202
+ let response;
203
+ try {
204
+ response = await http.fetch(`${trimTrailingSlash(config.url)}/evaluate`, {
205
+ method: "POST",
206
+ headers: {
207
+ authorization: `Bearer ${config.authToken}`,
208
+ "content-type": "application/json"
209
+ },
210
+ body: JSON.stringify(body)
211
+ });
212
+ } catch (err) {
213
+ const detail = err instanceof Error ? err.message : String(err);
214
+ return { ok: false, reason: "network_error", detail };
215
+ }
216
+ if (response.status === 401 || response.status === 403) {
217
+ return {
218
+ ok: false,
219
+ reason: "auth_error",
220
+ detail: `sidecar rejected token (HTTP ${response.status})`
221
+ };
222
+ }
223
+ if (!response.ok) {
224
+ let detail = `sidecar HTTP ${response.status}`;
225
+ try {
226
+ detail = `${detail}: ${await response.text()}`;
227
+ } catch {
228
+ }
229
+ return { ok: false, reason: "server_error", detail };
230
+ }
231
+ let raw;
232
+ try {
233
+ raw = await response.text();
234
+ } catch (err) {
235
+ const detail = err instanceof Error ? err.message : String(err);
236
+ return { ok: false, reason: "invalid_response", detail };
237
+ }
238
+ try {
239
+ const findings = JSON.parse(raw);
240
+ if (!Array.isArray(findings)) {
241
+ return {
242
+ ok: false,
243
+ reason: "invalid_response",
244
+ detail: "sidecar returned non-array body"
245
+ };
246
+ }
247
+ return { ok: true, findings };
248
+ } catch (err) {
249
+ const detail = err instanceof Error ? err.message : String(err);
250
+ return { ok: false, reason: "invalid_response", detail };
251
+ }
252
+ }
253
+ function trimTrailingSlash(url) {
254
+ return url.endsWith("/") ? url.slice(0, -1) : url;
67
255
  }
68
256
 
69
257
  // src/plugin.ts
258
+ function buildAllowedHosts(evaluatorHost) {
259
+ const host = normalizeHost(evaluatorHost);
260
+ return host === null ? [] : [host];
261
+ }
262
+ function normalizeHost(evaluatorHost) {
263
+ const host = evaluatorHost?.trim() ?? "";
264
+ return host.length === 0 ? null : host;
265
+ }
70
266
  var CONFIG_URL_KEY = "config:evaluator_url";
71
267
  var CONFIG_TOKEN_KEY = "config:eval_token";
72
- async function readSidecarConfig(kv) {
73
- const url = await kv.get(CONFIG_URL_KEY);
74
- const token = await kv.get(CONFIG_TOKEN_KEY);
268
+ var SETTINGS_TOKEN_KEY = "eval_token";
269
+ var PLUGIN_SETTINGS_SCHEMA = {
270
+ [SETTINGS_TOKEN_KEY]: {
271
+ type: "secret",
272
+ label: "EVAL_TOKEN",
273
+ description: "Bearer token for your aexeo-crawl-worker Worker. Encrypted at rest by the emdash host."
274
+ }
275
+ };
276
+ async function readSidecarConfig(store) {
277
+ const url = await store.kv.get(CONFIG_URL_KEY);
278
+ const token = await readStoredToken(store);
75
279
  if (typeof url !== "string" || typeof token !== "string") {
76
280
  return null;
77
281
  }
@@ -80,11 +284,96 @@ async function readSidecarConfig(kv) {
80
284
  }
81
285
  return { url, token };
82
286
  }
83
- async function writeSidecarConfig(kv, config) {
84
- await kv.set(CONFIG_URL_KEY, config.url);
85
- await kv.set(CONFIG_TOKEN_KEY, config.token);
287
+ async function writeSidecarConfig(store, config) {
288
+ await store.kv.set(CONFIG_URL_KEY, config.url);
289
+ await writeStoredToken(store, config.token);
290
+ }
291
+ async function readStoredToken(store) {
292
+ const { settings } = store;
293
+ if (settings !== void 0) {
294
+ try {
295
+ const stored = await settings.get(SETTINGS_TOKEN_KEY);
296
+ if (typeof stored === "string" && stored.length > 0) {
297
+ return stored;
298
+ }
299
+ } catch (err) {
300
+ store.log?.warn?.(
301
+ `aexeo: could not read the encrypted EVAL_TOKEN from plugin settings, falling back to KV: ${describeError(err, "")}`
302
+ );
303
+ }
304
+ }
305
+ const legacy = await store.kv.get(CONFIG_TOKEN_KEY);
306
+ return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
307
+ }
308
+ async function writeStoredToken(store, token) {
309
+ const { settings } = store;
310
+ if (settings !== void 0) {
311
+ try {
312
+ await settings.set(SETTINGS_TOKEN_KEY, token);
313
+ await store.kv.delete(CONFIG_TOKEN_KEY);
314
+ return;
315
+ } catch (err) {
316
+ store.log?.warn?.(
317
+ `aexeo: could not store the EVAL_TOKEN encrypted in plugin settings; it will be written to plugin KV in plaintext instead. Set EMDASH_ENCRYPTION_KEY on the emdash host to fix this. Detail: ${describeError(err, token)}`
318
+ );
319
+ }
320
+ }
321
+ await store.kv.set(CONFIG_TOKEN_KEY, token);
86
322
  }
87
- async function handleAfterSave(_event, _ctx) {
323
+ function describeError(err, secret) {
324
+ const raw = err instanceof Error ? err.message : String(err);
325
+ return secret.length === 0 ? raw : raw.split(secret).join("[redacted]");
326
+ }
327
+ async function handleAfterSave(event, ctx) {
328
+ const runtime = await readSidecarConfig(ctx);
329
+ if (runtime === null) {
330
+ ctx.log?.warn?.(
331
+ "Aexeo afterSave skipped: configure the evaluator URL and token on the Setup page"
332
+ );
333
+ return;
334
+ }
335
+ await handleAfterSaveConfigured(
336
+ event,
337
+ ctx,
338
+ (documents) => evaluateViaSidecar(
339
+ ctx.http,
340
+ { url: runtime.url, authToken: runtime.token },
341
+ documents
342
+ )
343
+ );
344
+ }
345
+ async function handleAfterSaveConfigured(event, ctx, evaluator, suppressionFilter) {
346
+ const adapted = await adaptContentItemWithContext(
347
+ event.content,
348
+ ctx.content,
349
+ ctx.site?.locale
350
+ );
351
+ const document = adapted.document;
352
+ const { kv, log } = ctx;
353
+ const stored = { document, meta: adapted.meta };
354
+ await persistDocument(ctx, stored);
355
+ const result = await evaluator([document]);
356
+ if (!result.ok) {
357
+ log?.error?.(`afterSave evaluator failure (${result.reason})`, {
358
+ detail: result.detail
359
+ });
360
+ return;
361
+ }
362
+ const pageFindings = result.findings.filter(
363
+ (finding) => finding.scope === "page"
364
+ );
365
+ const filtered = suppressionFilter === void 0 ? pageFindings : suppressionFilter.apply(
366
+ {
367
+ route: document.route,
368
+ collection: adapted.meta.collection,
369
+ status: adapted.meta.status
370
+ },
371
+ pageFindings
372
+ );
373
+ await kv.set(findingsKey(document.route), {
374
+ route: document.route,
375
+ findings: filtered
376
+ });
88
377
  }
89
378
  var DEFAULT_COLLECTIONS = ["posts", "pages"];
90
379
  async function evaluateAndPersistAll(ctx, options) {
@@ -109,7 +398,11 @@ async function evaluateAndPersistAll(ctx, options) {
109
398
  ...cursor === void 0 ? {} : { cursor }
110
399
  });
111
400
  for (const item of page.items) {
112
- const adapted = adaptContentItem(item);
401
+ const adapted = await adaptContentItemWithContext(
402
+ item,
403
+ ctx.content,
404
+ ctx.site?.locale
405
+ );
113
406
  documents.push(adapted.document);
114
407
  documentRoutes.add(adapted.document.route);
115
408
  adaptedByRoute.set(adapted.document.route, {
@@ -137,18 +430,18 @@ async function evaluateAndPersistAll(ctx, options) {
137
430
  summary.errors.push(`${result.reason}: ${result.detail}`);
138
431
  return summary;
139
432
  }
140
- const findingsByRoute2 = /* @__PURE__ */ new Map();
433
+ const findingsByRoute = /* @__PURE__ */ new Map();
141
434
  for (const route of documentRoutes) {
142
- findingsByRoute2.set(route, []);
435
+ findingsByRoute.set(route, []);
143
436
  }
144
437
  for (const finding of result.findings) {
145
438
  const bucket = finding.scope === "page" ? finding.path : "*";
146
- const list = findingsByRoute2.get(bucket) ?? [];
439
+ const list = findingsByRoute.get(bucket) ?? [];
147
440
  list.push(finding);
148
- findingsByRoute2.set(bucket, list);
441
+ findingsByRoute.set(bucket, list);
149
442
  }
150
443
  let totalAfterSuppression = 0;
151
- for (const [route, findings] of findingsByRoute2) {
444
+ for (const [route, findings] of findingsByRoute) {
152
445
  const stored = adaptedByRoute.get(route);
153
446
  const context = {
154
447
  route,
@@ -161,8 +454,57 @@ async function evaluateAndPersistAll(ctx, options) {
161
454
  totalAfterSuppression += filtered.length;
162
455
  }
163
456
  summary.totalFindings = totalAfterSuppression;
457
+ if (summary.errors.length === 0) {
458
+ await removeStaleDocuments(ctx, collections, documentRoutes);
459
+ }
164
460
  return summary;
165
461
  }
462
+ async function persistDocument(ctx, stored) {
463
+ const route = stored.document.route;
464
+ const entries = await ctx.kv.list("document:");
465
+ for (const entry of entries) {
466
+ const previous = toStoredDocument(entry.value);
467
+ if (previous === null || previous.document.route === route || previous.meta.id !== stored.meta.id || previous.meta.collection !== stored.meta.collection) {
468
+ continue;
469
+ }
470
+ await ctx.kv.delete(entry.key);
471
+ await ctx.kv.delete(findingsKey(previous.document.route));
472
+ }
473
+ await ctx.kv.set(documentKey(route), stored);
474
+ }
475
+ async function removeStaleDocuments(ctx, collections, currentRoutes) {
476
+ const scannedCollections = new Set(collections);
477
+ const entries = await ctx.kv.list("document:");
478
+ for (const entry of entries) {
479
+ const stored = toStoredDocument(entry.value);
480
+ if (stored === null || !scannedCollections.has(stored.meta.collection) || currentRoutes.has(stored.document.route)) {
481
+ continue;
482
+ }
483
+ await ctx.kv.delete(entry.key);
484
+ await ctx.kv.delete(findingsKey(stored.document.route));
485
+ }
486
+ }
487
+ function toStoredDocument(value) {
488
+ if (value === null || typeof value !== "object") return null;
489
+ if ("document" in value && "meta" in value && value.document !== null && typeof value.document === "object" && "route" in value.document && typeof value.document.route === "string" && value.meta !== null && typeof value.meta === "object") {
490
+ return value;
491
+ }
492
+ if ("route" in value && typeof value.route === "string") {
493
+ const document = value;
494
+ return {
495
+ document,
496
+ meta: {
497
+ id: document.route,
498
+ collection: "",
499
+ slug: null,
500
+ status: "",
501
+ title: document.title ?? "",
502
+ publicUrl: null
503
+ }
504
+ };
505
+ }
506
+ return null;
507
+ }
166
508
  function findingsKey(route) {
167
509
  const normalized = route === "" || route === "/" ? "/" : route;
168
510
  return `findings:${normalized}`;
@@ -178,25 +520,8 @@ async function readAllStoredDocuments(kv) {
178
520
  const entries = await kv.list("document:");
179
521
  const out = [];
180
522
  for (const entry of entries) {
181
- const value = entry.value;
182
- if (value === null || typeof value !== "object") continue;
183
- if ("document" in value && "meta" in value) {
184
- out.push(value);
185
- continue;
186
- }
187
- if ("route" in value) {
188
- const document = value;
189
- out.push({
190
- document,
191
- meta: {
192
- id: document.route,
193
- collection: "",
194
- slug: null,
195
- status: "",
196
- title: document.title ?? ""
197
- }
198
- });
199
- }
523
+ const stored = toStoredDocument(entry.value);
524
+ if (stored !== null) out.push(stored);
200
525
  }
201
526
  return out;
202
527
  }
@@ -208,367 +533,167 @@ async function readFindings(kv, route) {
208
533
  return stored.findings;
209
534
  }
210
535
 
211
- // src/sidecar.ts
212
- async function evaluateViaSidecar(http, config, documents) {
213
- const body = {
214
- documents
215
- };
216
- if (config.configJson !== void 0) {
217
- body.configJson = config.configJson;
536
+ // src/sidecar-url.ts
537
+ function validateSidecarUrl(raw, allowedHosts = []) {
538
+ const url = raw.trim();
539
+ if (url.length === 0) {
540
+ return { ok: false, error: "Sidecar URL is required." };
218
541
  }
219
- let response;
542
+ let parsed;
220
543
  try {
221
- response = await http.fetch(`${trimTrailingSlash(config.url)}/evaluate`, {
222
- method: "POST",
223
- headers: {
224
- authorization: `Bearer ${config.authToken}`,
225
- "content-type": "application/json"
226
- },
227
- body: JSON.stringify(body)
228
- });
229
- } catch (err) {
230
- const detail = err instanceof Error ? err.message : String(err);
231
- return { ok: false, reason: "network_error", detail };
544
+ parsed = new URL(url);
545
+ } catch {
546
+ return { ok: false, error: `Sidecar URL is not a valid URL: ${url}` };
232
547
  }
233
- if (response.status === 401 || response.status === 403) {
548
+ if (parsed.protocol !== "https:") {
234
549
  return {
235
550
  ok: false,
236
- reason: "auth_error",
237
- detail: `sidecar rejected token (HTTP ${response.status})`
551
+ error: `Sidecar URL must be https: \u2014 the EVAL_TOKEN is sent as a bearer header and must never travel in cleartext. Got ${parsed.protocol}`
238
552
  };
239
553
  }
240
- if (!response.ok) {
241
- let detail = `sidecar HTTP ${response.status}`;
242
- try {
243
- detail = `${detail}: ${await response.text()}`;
244
- } catch {
245
- }
246
- return { ok: false, reason: "server_error", detail };
247
- }
248
- let raw;
249
- try {
250
- raw = await response.text();
251
- } catch (err) {
252
- const detail = err instanceof Error ? err.message : String(err);
253
- return { ok: false, reason: "invalid_response", detail };
254
- }
255
- try {
256
- const findings = JSON.parse(raw);
257
- if (!Array.isArray(findings)) {
258
- return {
259
- ok: false,
260
- reason: "invalid_response",
261
- detail: "sidecar returned non-array body"
262
- };
263
- }
264
- return { ok: true, findings };
265
- } catch (err) {
266
- const detail = err instanceof Error ? err.message : String(err);
267
- return { ok: false, reason: "invalid_response", detail };
554
+ const host = parsed.hostname.toLowerCase();
555
+ if (isBlockedHost(host)) {
556
+ return {
557
+ ok: false,
558
+ error: "Sidecar URL must not point at a loopback, private, or link-local address. Use the deployed *.workers.dev URL."
559
+ };
268
560
  }
269
- }
270
- function trimTrailingSlash(url) {
271
- return url.endsWith("/") ? url.slice(0, -1) : url;
272
- }
273
-
274
- // src/evaluator-sandbox.ts
275
- async function evaluate(_documents) {
276
- return [];
277
- }
278
- async function scoreSite(_documents) {
279
- return {
280
- overall_score: 0,
281
- citation_readiness_score: 0,
282
- truth_consistency_score: 0,
283
- answer_pack_score: 0,
284
- external_trust_alignment_score: null,
285
- route_scores: [],
286
- blockers: []
287
- };
288
- }
289
- function errorFindings(findings) {
290
- return findings.filter((finding) => finding.severity === "error");
291
- }
292
- function findingsByRoute(findings) {
293
- const grouped = /* @__PURE__ */ new Map();
294
- for (const finding of findings) {
295
- const bucket = grouped.get(finding.path);
296
- if (bucket) {
297
- bucket.push(finding);
298
- } else {
299
- grouped.set(finding.path, [finding]);
300
- }
561
+ if (allowedHosts.length > 0 && !hostMatchesAllowlist(host, allowedHosts)) {
562
+ return {
563
+ ok: false,
564
+ error: `Sidecar host "${host}" is not in this plugin's outbound allowlist (${allowedHosts.join(", ")}). The emdash sandbox would reject the fetch with "Host not allowed" \u2014 update evaluatorHost in astro.config and rebuild, or use that host.`
565
+ };
301
566
  }
302
- return grouped;
303
- }
304
-
305
- // src/indexnow.ts
306
- var INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
307
- async function submitIndexNow(config, urls) {
308
- const host = hostOf(config.siteUrl);
309
- if (host === null) {
310
- return failure("siteUrl is not a valid URL", []);
311
- }
312
- const keyLocation = config.keyLocation ?? defaultKeyLocation(config);
313
- const { submitted, rejected } = partitionByHost(urls, host);
314
- if (submitted.length === 0) {
315
- return failure("no submittable URLs after host check", rejected);
316
- }
317
- const payload = {
318
- host,
319
- key: config.key,
320
- keyLocation,
321
- urlList: submitted
322
- };
323
- const response = await fetch(INDEXNOW_ENDPOINT, {
324
- method: "POST",
325
- headers: { "content-type": "application/json" },
326
- body: JSON.stringify(payload)
327
- });
328
- return {
329
- ok: response.ok,
330
- status: response.status,
331
- endpoint: INDEXNOW_ENDPOINT,
332
- submitted,
333
- rejected
334
- };
567
+ return { ok: true, url };
335
568
  }
336
- function hostOf(rawUrl) {
337
- try {
338
- return new URL(rawUrl).host;
339
- } catch {
340
- return null;
569
+ function isBlockedHost(host) {
570
+ if (host === "localhost" || host.endsWith(".localhost")) {
571
+ return true;
341
572
  }
342
- }
343
- function defaultKeyLocation(config) {
344
- const siteUrl = config.siteUrl.endsWith("/") ? config.siteUrl.slice(0, -1) : config.siteUrl;
345
- return `${siteUrl}/${config.key}.txt`;
346
- }
347
- function partitionByHost(urls, expectedHost) {
348
- const submitted = [];
349
- const rejected = [];
350
- for (const candidate of urls) {
351
- const host = hostOf(candidate);
352
- if (host === expectedHost) {
353
- submitted.push(candidate);
354
- } else {
355
- rejected.push(candidate);
356
- }
573
+ if (host.startsWith("[")) {
574
+ return isBlockedIpv6(host);
357
575
  }
358
- return { submitted, rejected };
576
+ return isBlockedIpv4(host);
359
577
  }
360
- function failure(reason, rejected) {
361
- return {
362
- ok: false,
363
- status: 0,
364
- endpoint: INDEXNOW_ENDPOINT,
365
- submitted: [],
366
- rejected,
367
- reason
368
- };
369
- }
370
-
371
- // src/mcp.ts
372
- var checkTool = {
373
- name: "aexeo.check",
374
- description: "Evaluate a list of EmdashDocument values against the Aexeo rule engine and return the stable Finding contract. Mirrors the CLI's `aexeo-cli check` surface so agents, the admin UI, and the CI gate all see the same rule ids.",
375
- inputSchema: {
376
- type: "object",
377
- properties: {
378
- documents: {
379
- type: "array",
380
- description: "Array of EmdashDocument values to evaluate. Each entry carries route, title, optional description/canonical/lang, alternates, meta map, schema, and Portable Text body."
381
- },
382
- configJson: {
383
- type: "string",
384
- description: "Optional JSON-serialized Aexeo config. When absent the evaluator uses Config::default(), matching a cold CLI run."
385
- }
386
- },
387
- required: ["documents"]
388
- },
389
- handler: async ({ documents, configJson }) => {
390
- const options = configJson === void 0 ? {} : { configJson };
391
- const findings = await evaluate(documents, options);
392
- const errors = errorFindings(findings);
393
- const grouped = findingsByRoute(findings);
394
- const byRoute = {};
395
- for (const [route, items] of grouped.entries()) {
396
- byRoute[route] = items;
397
- }
398
- return {
399
- findings,
400
- errors,
401
- byRoute,
402
- totals: {
403
- findings: findings.length,
404
- errors: errors.length,
405
- warnings: findings.length - errors.length
406
- }
407
- };
578
+ function isBlockedIpv4(host) {
579
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
580
+ if (match === null) {
581
+ return false;
408
582
  }
409
- };
410
- var indexNowSubmitTool = {
411
- name: "aexeo.indexnow.submit",
412
- description: "Submit a list of URLs to the IndexNow freshness endpoint. Mirrors the aexeo-cli `indexnow submit` contract: the IndexNow protocol requires every submitted URL to belong to the configured site host. URLs that do not match the host are returned in `rejected` and never sent over the wire.",
413
- inputSchema: {
414
- type: "object",
415
- properties: {
416
- config: {
417
- type: "object",
418
- description: "IndexNow configuration: siteUrl, key, and optional keyLocation override.",
419
- properties: {
420
- siteUrl: { type: "string" },
421
- key: { type: "string" },
422
- keyLocation: { type: "string" }
423
- },
424
- required: ["siteUrl", "key"]
425
- },
426
- urls: {
427
- type: "array",
428
- items: { type: "string" },
429
- description: "Absolute URLs to submit. Must share siteUrl's host."
430
- }
431
- },
432
- required: ["config", "urls"]
433
- },
434
- handler: async ({ config, urls }) => submitIndexNow(config, urls)
435
- };
436
- var tools = [checkTool, indexNowSubmitTool];
437
-
438
- // src/sandbox-entry.ts
439
- function definePlugin(plugin) {
440
- return plugin;
583
+ const first = Number(match[1]);
584
+ const second = Number(match[2]);
585
+ if (first === 0) return true;
586
+ if (first === 127) return true;
587
+ if (first === 10) return true;
588
+ if (first === 172 && second >= 16 && second <= 31) return true;
589
+ if (first === 192 && second === 168) return true;
590
+ if (first === 169 && second === 254) return true;
591
+ if (first === 100 && second >= 64 && second <= 127) return true;
592
+ if (first >= 224) return true;
593
+ return false;
441
594
  }
442
- function normalizeInteraction(input) {
443
- if (!input || typeof input !== "object") {
444
- return { type: "page_load", page: "/findings" };
445
- }
446
- const raw = input;
447
- if (raw.type === "page_load") {
448
- return {
449
- type: "page_load",
450
- page: typeof raw.page === "string" && raw.page.length > 0 ? raw.page : "/findings"
451
- };
595
+ function isBlockedIpv6(host) {
596
+ const inner = host.slice(1, -1).toLowerCase();
597
+ if (inner === "::" || inner === "::1") {
598
+ return true;
452
599
  }
453
- if (raw.type === "block_action") {
454
- return {
455
- type: "block_action",
456
- action_id: typeof raw.action_id === "string" ? raw.action_id : "",
457
- ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
458
- value: raw.value
459
- };
600
+ if (inner.startsWith("::ffff:") || inner.startsWith("::7f")) {
601
+ return true;
460
602
  }
461
- if (raw.type === "form_submit") {
462
- return {
463
- type: "form_submit",
464
- action_id: typeof raw.action_id === "string" ? raw.action_id : "",
465
- ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
466
- values: raw.values && typeof raw.values === "object" ? raw.values : {}
467
- };
468
- }
469
- return { type: "page_load", page: "/findings" };
470
- }
471
- async function handleAdminRoute(input, ctx) {
472
- const body = normalizeInteraction(input?.input);
473
- ctx.log?.info?.(
474
- `aexeo route: type=${body.type} page=${body.type === "page_load" ? body.page : ""}`
475
- );
476
- const dispatch = { body, kv: ctx.kv, ctx };
477
- if (body.type === "page_load") {
478
- return handlePageLoad(dispatch, body.page);
603
+ const firstHextet = Number.parseInt(inner.split(":")[0] ?? "", 16);
604
+ if (Number.isNaN(firstHextet)) {
605
+ return false;
479
606
  }
480
- if (body.type === "block_action") {
481
- return handleBlockAction(
482
- dispatch,
483
- body.action_id,
484
- body.value
485
- );
607
+ if ((firstHextet & 65024) === 64512) {
608
+ return true;
486
609
  }
487
- if (body.type === "form_submit") {
488
- return handleFormSubmit(
489
- dispatch,
490
- body.action_id,
491
- body.values
492
- );
610
+ if ((firstHextet & 65472) === 65152) {
611
+ return true;
493
612
  }
494
- return handlePageLoad(dispatch, "findings");
613
+ return false;
495
614
  }
496
- async function handleFormSubmit(ctx, actionId, values) {
497
- if (actionId === "save_setup") {
498
- return handleSetupSubmit(ctx, values);
499
- }
500
- if (actionId === "view_document") {
501
- const picked = values["route_picker"];
502
- if (typeof picked === "string" && picked.length > 0) {
503
- return renderDocumentPanel(ctx, picked);
615
+ function hostMatchesAllowlist(host, allowedHosts) {
616
+ return allowedHosts.some((allowed) => {
617
+ const entry = allowed.trim().toLowerCase();
618
+ if (entry.length === 0) {
619
+ return false;
504
620
  }
505
- }
506
- return handlePageLoad(ctx, "findings");
621
+ if (entry.startsWith("*.")) {
622
+ const suffix = entry.slice(1);
623
+ return host.endsWith(suffix) && host.length > suffix.length;
624
+ }
625
+ return host === entry;
626
+ });
627
+ }
628
+
629
+ // src/evaluator-sandbox.ts
630
+ async function scoreSite(_documents) {
631
+ return {
632
+ overall_score: 0,
633
+ citation_readiness_score: 0,
634
+ truth_consistency_score: 0,
635
+ answer_pack_score: 0,
636
+ external_trust_alignment_score: null,
637
+ route_scores: [],
638
+ blockers: []
639
+ };
507
640
  }
508
- async function handlePageLoad(ctx, page) {
509
- const normalized = page.startsWith("/") ? page.slice(1) : page;
510
- if (normalized === "findings") {
511
- return renderFindingsPage(ctx);
512
- }
513
- if (normalized === "widget:aexeo-score") {
514
- return renderScoreWidget(ctx);
515
- }
516
- if (normalized === "document") {
517
- return renderDocumentPanel(ctx);
518
- }
519
- if (normalized === "setup") {
520
- return renderSetupPage(ctx);
641
+
642
+ // src/findings-view.ts
643
+ function normalizeInteraction(input) {
644
+ if (!input || typeof input !== "object") {
645
+ return { type: "page_load", page: "/findings" };
521
646
  }
522
- return notFound(page);
523
- }
524
- async function handleBlockAction(ctx, actionId, value) {
525
- if (actionId === "view_document" && typeof value === "string") {
526
- return renderDocumentPanel(ctx, value);
647
+ const raw = input;
648
+ if (raw.type === "page_load") {
649
+ return {
650
+ type: "page_load",
651
+ page: typeof raw.page === "string" && raw.page.length > 0 ? raw.page : "/findings"
652
+ };
527
653
  }
528
- if (actionId === "refresh_findings") {
529
- return handleRefresh(ctx);
654
+ if (raw.type === "block_action") {
655
+ return {
656
+ type: "block_action",
657
+ action_id: typeof raw.action_id === "string" ? raw.action_id : "",
658
+ ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
659
+ value: raw.value
660
+ };
530
661
  }
531
- if (actionId.startsWith("filter:")) {
532
- return renderFindingsPage(ctx);
662
+ if (raw.type === "form_submit") {
663
+ return {
664
+ type: "form_submit",
665
+ action_id: typeof raw.action_id === "string" ? raw.action_id : "",
666
+ ...typeof raw.block_id === "string" ? { block_id: raw.block_id } : {},
667
+ values: raw.values && typeof raw.values === "object" ? raw.values : {}
668
+ };
533
669
  }
534
- return notFound(actionId);
670
+ return { type: "page_load", page: "/findings" };
535
671
  }
536
- async function handleRefresh(ctx) {
537
- const sandboxEvaluator = async (documents) => {
538
- const runtime = await readSidecarConfig(ctx.kv);
539
- if (runtime === null) {
540
- return {
541
- ok: false,
542
- reason: "config_missing",
543
- detail: "sidecar not configured \u2014 open the Aexeo Setup page and enter your evaluator URL and token"
544
- };
545
- }
546
- return evaluateViaSidecar(
547
- ctx.ctx.http,
548
- { url: runtime.url, authToken: runtime.token },
549
- documents
550
- );
551
- };
672
+ async function handleRefresh(options) {
552
673
  let summary;
553
674
  try {
554
- summary = await evaluateAndPersistAll(ctx.ctx, {
555
- evaluator: sandboxEvaluator
675
+ summary = await evaluateAndPersistAll(options.ctx, {
676
+ collections: options.collections,
677
+ evaluator: options.evaluator,
678
+ // Always forwarded. See RefreshOptions.suppressionFilter.
679
+ suppressionFilter: options.suppressionFilter
556
680
  });
557
681
  } catch (err) {
558
682
  const detail = err instanceof Error ? err.message : String(err);
683
+ const message = `Refresh failed: ${detail}`;
559
684
  return {
560
685
  blocks: [
561
686
  { type: "header", text: "SEO findings" },
562
687
  {
563
688
  type: "banner",
564
- title: `Refresh failed: ${detail}`,
565
- variant: "alert"
689
+ title: message,
690
+ variant: options.tuning.refreshFailureVariant
566
691
  }
567
692
  ],
568
- toast: { message: `Refresh failed: ${detail}`, type: "error" }
693
+ toast: { message, type: "error" }
569
694
  };
570
695
  }
571
- const refreshed = await renderFindingsPage(ctx);
696
+ const refreshed = await renderFindingsPage(options.ctx.kv, options.tuning);
572
697
  const toastMessage = summary.errors.length === 0 ? `Refreshed ${summary.routesUpdated} routes (${summary.totalFindings} findings across ${summary.documentsScanned} documents)` : `Refresh completed with ${summary.errors.length} errors \u2014 see banner`;
573
698
  if (summary.errors.length > 0) {
574
699
  refreshed.blocks.unshift({
@@ -585,10 +710,43 @@ async function handleRefresh(ctx) {
585
710
  }
586
711
  };
587
712
  }
588
- async function renderFindingsPage(ctx) {
589
- const findings = await readAllFindings(ctx.kv);
713
+ async function readAllFindings(kv) {
714
+ const entries = await kv.list(
715
+ "findings:"
716
+ );
717
+ const out = [];
718
+ for (const entry of entries) {
719
+ if (entry.value === null) {
720
+ continue;
721
+ }
722
+ const route = entry.key.replace(/^findings:/, "");
723
+ for (const finding of entry.value.findings) {
724
+ out.push({ ...finding, document_route: route });
725
+ }
726
+ }
727
+ return out;
728
+ }
729
+ function severityFirst(a, b) {
730
+ const rank = (severity) => severity === "error" ? 0 : 1;
731
+ const diff = rank(a.severity) - rank(b.severity);
732
+ if (diff !== 0) {
733
+ return diff;
734
+ }
735
+ return a.rule_id.localeCompare(b.rule_id);
736
+ }
737
+ function uniqueRoutes(rows) {
738
+ const routes = /* @__PURE__ */ new Set();
739
+ for (const row of rows) {
740
+ routes.add(row.document_route);
741
+ }
742
+ return [...routes].sort();
743
+ }
744
+ async function renderFindingsPage(kv, tuning) {
745
+ const findings = await readAllFindings(kv);
590
746
  const errors = findings.filter((finding) => finding.severity === "error");
591
- const warnings = findings.filter((finding) => finding.severity === "warning");
747
+ const warnings = findings.filter(
748
+ (finding) => finding.severity === "warning"
749
+ );
592
750
  const sorted = [...findings].sort(severityFirst);
593
751
  const routes = uniqueRoutes(findings);
594
752
  const blocks = [
@@ -601,11 +759,9 @@ async function renderFindingsPage(ctx) {
601
759
  {
602
760
  type: "actions",
603
761
  elements: [
604
- // Refresh is the primary action: emdash 0.7.0's afterSave hook
605
- // can't reliably do I/O (post-response bridge invalidation), so
606
- // re-evaluation is admin-triggered rather than save-triggered.
607
- // Pressing this lists all content via the live in-request bridge,
608
- // calls the sidecar /evaluate, and writes findings back to KV.
762
+ // Refresh evaluates every configured entry. Pressing it lists
763
+ // content via the live in-request bridge, evaluates it, and
764
+ // writes findings back to KV (with suppressions applied).
609
765
  {
610
766
  type: "button",
611
767
  label: "Refresh",
@@ -623,7 +779,7 @@ async function renderFindingsPage(ctx) {
623
779
  },
624
780
  sorted.length === 0 ? {
625
781
  type: "context",
626
- text: "Once a document publishes, its rule findings list here."
782
+ text: tuning.emptyFindingsText
627
783
  } : findingsTable(sorted)
628
784
  ];
629
785
  if (routes.length > 0) {
@@ -642,37 +798,6 @@ async function renderFindingsPage(ctx) {
642
798
  }
643
799
  return { blocks };
644
800
  }
645
- async function readAllFindings(kv) {
646
- const entries = await kv.list(
647
- "findings:"
648
- );
649
- const out = [];
650
- for (const entry of entries) {
651
- if (entry.value === null) {
652
- continue;
653
- }
654
- const route = entry.key.replace(/^findings:/, "");
655
- for (const finding of entry.value.findings) {
656
- out.push({ ...finding, document_route: route });
657
- }
658
- }
659
- return out;
660
- }
661
- function uniqueRoutes(rows) {
662
- const routes = /* @__PURE__ */ new Set();
663
- for (const row of rows) {
664
- routes.add(row.document_route);
665
- }
666
- return [...routes].sort();
667
- }
668
- function severityFirst(a, b) {
669
- const rank = (severity) => severity === "error" ? 0 : 1;
670
- const diff = rank(a.severity) - rank(b.severity);
671
- if (diff !== 0) {
672
- return diff;
673
- }
674
- return a.rule_id.localeCompare(b.rule_id);
675
- }
676
801
  function findingsTable(rows) {
677
802
  return {
678
803
  type: "table",
@@ -690,40 +815,31 @@ function findingsTable(rows) {
690
815
  }))
691
816
  };
692
817
  }
693
- async function renderScoreWidget(ctx) {
694
- const documents = await readAllDocuments(ctx.kv);
818
+ async function renderScoreWidget(kv, tuning) {
819
+ const documents = await readAllDocuments(kv);
695
820
  if (documents.length === 0) {
696
821
  return {
697
822
  blocks: [
698
823
  { type: "header", text: "SEO score" },
699
824
  {
700
825
  type: "context",
701
- text: "No documents saved yet \u2014 score appears after the first emdash save."
826
+ text: tuning.emptyScoreText
702
827
  }
703
828
  ]
704
829
  };
705
830
  }
706
- const score = await scoreSite(documents);
831
+ const score = await tuning.score(documents, kv);
707
832
  const blocks = [
708
833
  {
709
834
  type: "stats",
710
835
  items: [
836
+ { label: "Overall", value: `${score.overall_score}` },
837
+ { label: "Citation", value: `${score.citation_readiness_score}` },
711
838
  {
712
- label: "Overall",
713
- value: `${score.overall_score}`
714
- },
715
- {
716
- label: "Citation",
717
- value: `${score.citation_readiness_score}`
718
- },
719
- {
720
- label: "Truth",
839
+ label: tuning.truthLabel(score),
721
840
  value: `${score.truth_consistency_score}`
722
841
  },
723
- {
724
- label: "Answers",
725
- value: `${score.answer_pack_score}`
726
- }
842
+ { label: "Answers", value: `${score.answer_pack_score}` }
727
843
  ]
728
844
  }
729
845
  ];
@@ -749,7 +865,7 @@ function topBlockersLine(score) {
749
865
  }
750
866
  return `Top blockers: ${top.join(" \u2022 ")}`;
751
867
  }
752
- async function renderDocumentPanel(ctx, route) {
868
+ async function renderDocumentPanel(kv, route) {
753
869
  if (route === void 0) {
754
870
  return {
755
871
  blocks: [
@@ -761,9 +877,11 @@ async function renderDocumentPanel(ctx, route) {
761
877
  ]
762
878
  };
763
879
  }
764
- const findings = await readFindings(ctx.kv, route);
880
+ const findings = await readFindings(kv, route);
765
881
  const errors = findings.filter((finding) => finding.severity === "error");
766
- const warnings = findings.filter((finding) => finding.severity === "warning");
882
+ const warnings = findings.filter(
883
+ (finding) => finding.severity === "warning"
884
+ );
767
885
  const sorted = [...findings].sort(severityFirst);
768
886
  return {
769
887
  blocks: [
@@ -796,13 +914,185 @@ function documentFindingsTable(findings) {
796
914
  }))
797
915
  };
798
916
  }
917
+ function notFound(page) {
918
+ return {
919
+ blocks: [
920
+ { type: "header", text: "Not found" },
921
+ { type: "context", text: `unknown page: ${page}` }
922
+ ]
923
+ };
924
+ }
925
+
926
+ // src/suppressions.ts
927
+ var NOOP_FILTER = {
928
+ apply: (_context, findings) => [...findings]
929
+ };
930
+ function compileSuppressions(rules) {
931
+ if (rules === void 0 || rules.length === 0) {
932
+ return NOOP_FILTER;
933
+ }
934
+ rules.forEach((rule, index) => {
935
+ const hasRoute = rule.routePattern !== void 0 && rule.routePattern.length > 0;
936
+ const hasRuleIds = rule.ruleIds !== void 0 && rule.ruleIds.length > 0;
937
+ const hasCollections = rule.collections !== void 0 && rule.collections.length > 0;
938
+ const hasStatuses = rule.statuses !== void 0 && rule.statuses.length > 0;
939
+ if (!hasRoute && !hasRuleIds && !hasCollections && !hasStatuses) {
940
+ throw new Error(
941
+ `aexeoPlugin: suppressions[${index}] is empty \u2014 set at least one of routePattern, ruleIds, collections, statuses`
942
+ );
943
+ }
944
+ });
945
+ const compiled = rules.map((rule) => ({
946
+ matchRoute: rule.routePattern === void 0 ? matchAlways : globToMatcher(rule.routePattern),
947
+ ruleIds: rule.ruleIds === void 0 || rule.ruleIds.length === 0 ? null : new Set(rule.ruleIds),
948
+ collections: rule.collections === void 0 || rule.collections.length === 0 ? null : new Set(rule.collections),
949
+ statuses: rule.statuses === void 0 || rule.statuses.length === 0 ? null : new Set(rule.statuses)
950
+ }));
951
+ return {
952
+ apply(context, findings) {
953
+ return findings.filter(
954
+ (finding) => !compiled.some((rule) => {
955
+ if (!rule.matchRoute(context.route)) return false;
956
+ if (rule.ruleIds !== null && !rule.ruleIds.has(finding.rule_id)) {
957
+ return false;
958
+ }
959
+ if (rule.collections !== null) {
960
+ if (context.collection === void 0 || !rule.collections.has(context.collection)) {
961
+ return false;
962
+ }
963
+ }
964
+ if (rule.statuses !== null) {
965
+ if (context.status === void 0 || !rule.statuses.has(context.status)) {
966
+ return false;
967
+ }
968
+ }
969
+ return true;
970
+ })
971
+ );
972
+ }
973
+ };
974
+ }
975
+ var matchAlways = () => true;
976
+ function globToMatcher(pattern) {
977
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
978
+ const DOUBLE_STAR_TOKEN = "";
979
+ const body = escaped.replace(/\*\*/g, DOUBLE_STAR_TOKEN).replace(/\*/g, "[^/]*").replace(new RegExp(DOUBLE_STAR_TOKEN, "g"), ".*").replace(/\?/g, "[^/]");
980
+ const re = new RegExp(`^${body}$`);
981
+ return (route) => re.test(route);
982
+ }
983
+
984
+ // src/sandbox-entry.ts
985
+ function definePlugin(plugin) {
986
+ return plugin;
987
+ }
988
+ var sandboxSuppressionFilter = compileSuppressions(
989
+ void 0
990
+ );
991
+ var adminViewTuning = {
992
+ emptyFindingsText: "Once a document publishes, its rule findings list here.",
993
+ emptyScoreText: "No documents saved yet \u2014 score appears after the first emdash save.",
994
+ refreshFailureVariant: "alert",
995
+ score: async (documents) => scoreSite(documents),
996
+ truthLabel: () => "Truth"
997
+ };
998
+ async function handleAdminRoute(input, ctx) {
999
+ const body = normalizeInteraction(input?.input);
1000
+ ctx.log?.info?.(
1001
+ `aexeo route: type=${body.type} page=${body.type === "page_load" ? body.page : ""}`
1002
+ );
1003
+ const dispatch = { body, kv: ctx.kv, ctx };
1004
+ if (body.type === "page_load") {
1005
+ return handlePageLoad(dispatch, body.page);
1006
+ }
1007
+ if (body.type === "block_action") {
1008
+ return handleBlockAction(
1009
+ dispatch,
1010
+ body.action_id,
1011
+ body.value
1012
+ );
1013
+ }
1014
+ if (body.type === "form_submit") {
1015
+ return handleFormSubmit(
1016
+ dispatch,
1017
+ body.action_id,
1018
+ body.values
1019
+ );
1020
+ }
1021
+ return handlePageLoad(dispatch, "findings");
1022
+ }
1023
+ async function handleFormSubmit(ctx, actionId, values) {
1024
+ if (actionId === "save_setup") {
1025
+ return handleSetupSubmit(ctx, values);
1026
+ }
1027
+ if (actionId === "view_document") {
1028
+ const picked = values["route_picker"];
1029
+ if (typeof picked === "string" && picked.length > 0) {
1030
+ return renderDocumentPanel(ctx.kv, picked);
1031
+ }
1032
+ }
1033
+ return handlePageLoad(ctx, "findings");
1034
+ }
1035
+ async function handlePageLoad(ctx, page) {
1036
+ const normalized = page.startsWith("/") ? page.slice(1) : page;
1037
+ if (normalized === "findings") {
1038
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1039
+ }
1040
+ if (normalized === "widget:aexeo-score") {
1041
+ return renderScoreWidget(ctx.kv, adminViewTuning);
1042
+ }
1043
+ if (normalized === "document") {
1044
+ return renderDocumentPanel(ctx.kv);
1045
+ }
1046
+ if (normalized === "setup") {
1047
+ return renderSetupPage(ctx);
1048
+ }
1049
+ return notFound(page);
1050
+ }
1051
+ async function handleBlockAction(ctx, actionId, value) {
1052
+ if (actionId === "view_document" && typeof value === "string") {
1053
+ return renderDocumentPanel(ctx.kv, value);
1054
+ }
1055
+ if (actionId === "refresh_findings") {
1056
+ return handleRefresh2(ctx);
1057
+ }
1058
+ if (actionId.startsWith("filter:")) {
1059
+ return renderFindingsPage(ctx.kv, adminViewTuning);
1060
+ }
1061
+ return notFound(actionId);
1062
+ }
1063
+ async function handleRefresh2(ctx) {
1064
+ const sandboxEvaluator = async (documents) => {
1065
+ const runtime = await readSidecarConfig(ctx.ctx);
1066
+ if (runtime === null) {
1067
+ return {
1068
+ ok: false,
1069
+ reason: "config_missing",
1070
+ detail: "sidecar not configured \u2014 open the Aexeo Setup page and enter your evaluator URL and token"
1071
+ };
1072
+ }
1073
+ return evaluateViaSidecar(
1074
+ ctx.ctx.http,
1075
+ { url: runtime.url, authToken: runtime.token },
1076
+ documents
1077
+ );
1078
+ };
1079
+ return handleRefresh({
1080
+ ctx: ctx.ctx,
1081
+ // The sandboxed descriptor takes no collection knobs, so the sweep
1082
+ // always covers the default set.
1083
+ collections: DEFAULT_COLLECTIONS,
1084
+ evaluator: sandboxEvaluator,
1085
+ suppressionFilter: sandboxSuppressionFilter,
1086
+ tuning: adminViewTuning
1087
+ });
1088
+ }
799
1089
  async function renderSetupPage(ctx, options = {}) {
800
- const existing = await readSidecarConfig(ctx.kv);
1090
+ const existing = await readSidecarConfig(ctx.ctx);
801
1091
  const blocks = [
802
1092
  { type: "header", text: "Aexeo setup" },
803
1093
  {
804
1094
  type: "context",
805
- text: "Paste the URL and auth token of your deployed aexeo-crawl-worker. These are stored in plugin KV and read at runtime \u2014 no rebuild required after a change. Rotate the token here whenever you redeploy the sidecar with a new secret."
1095
+ text: "Paste the URL and auth token of your deployed aexeo-crawl-worker. The token is stored encrypted by the emdash host; both values are read at runtime, so no rebuild is required after a change. Rotate the token here whenever you redeploy the sidecar with a new secret."
806
1096
  }
807
1097
  ];
808
1098
  if (options.bannerError !== void 0) {
@@ -850,39 +1140,23 @@ async function renderSetupPage(ctx, options = {}) {
850
1140
  );
851
1141
  return { blocks };
852
1142
  }
1143
+ function runtimeAllowedHosts() {
1144
+ const env = typeof process === "undefined" ? void 0 : process.env;
1145
+ return buildAllowedHosts(env?.["AEXEO_EVALUATOR_HOST"] ?? null);
1146
+ }
853
1147
  async function handleSetupSubmit(ctx, values) {
854
1148
  const rawUrl = values["evaluator_url"];
855
1149
  const rawToken = values["eval_token"];
856
1150
  const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
857
1151
  const token = typeof rawToken === "string" ? rawToken.trim() : "";
858
- if (url.length === 0) {
859
- return renderSetupPage(ctx, {
860
- bannerError: "Sidecar URL is required.",
861
- initialUrl: url
862
- });
863
- }
864
- let parsed;
865
- try {
866
- parsed = new URL(url);
867
- } catch {
868
- return renderSetupPage(ctx, {
869
- bannerError: `Sidecar URL is not a valid URL: ${url}`,
870
- initialUrl: url
871
- });
872
- }
873
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1152
+ const validated = validateSidecarUrl(url, runtimeAllowedHosts());
1153
+ if (!validated.ok) {
874
1154
  return renderSetupPage(ctx, {
875
- bannerError: `Sidecar URL must be http(s): got ${parsed.protocol}`,
1155
+ bannerError: validated.error,
876
1156
  initialUrl: url
877
1157
  });
878
1158
  }
879
- if (parsed.hostname === "localhost" || parsed.hostname.endsWith(".localhost") || /^127\./.test(parsed.hostname)) {
880
- return renderSetupPage(ctx, {
881
- bannerError: "emdash's sandbox blocks fetches to localhost (anti-SSRF). Use the deployed *.workers.dev URL.",
882
- initialUrl: url
883
- });
884
- }
885
- const existing = await readSidecarConfig(ctx.kv);
1159
+ const existing = await readSidecarConfig(ctx.ctx);
886
1160
  let resolvedToken;
887
1161
  if (token.length > 0) {
888
1162
  resolvedToken = token;
@@ -891,34 +1165,25 @@ async function handleSetupSubmit(ctx, values) {
891
1165
  } else {
892
1166
  return renderSetupPage(ctx, {
893
1167
  bannerError: "EVAL_TOKEN is required on first setup.",
894
- initialUrl: url
1168
+ initialUrl: validated.url
895
1169
  });
896
1170
  }
897
- const next = { url, token: resolvedToken };
898
- await writeSidecarConfig(ctx.kv, next);
1171
+ const next = {
1172
+ url: validated.url,
1173
+ token: resolvedToken
1174
+ };
1175
+ await writeSidecarConfig(ctx.ctx, next);
899
1176
  return renderSetupPage(ctx, {
900
1177
  bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
901
1178
  });
902
1179
  }
903
- function notFound(page) {
904
- return {
905
- blocks: [
906
- { type: "header", text: "Not found" },
907
- { type: "context", text: `unknown page: ${page}` }
908
- ]
909
- };
910
- }
911
1180
  var sandbox_entry_default = definePlugin({
912
1181
  hooks: {
913
1182
  "content:afterSave": handleAfterSave
914
1183
  },
915
1184
  routes: {
916
1185
  admin: { handler: handleAdminRoute }
917
- },
918
- // emdash's MCP server picks tools up from this field. The exact
919
- // host-side spec for plugin-contributed tools is still being mapped;
920
- // the field is harmless if unrecognized.
921
- mcpTools: tools
1186
+ }
922
1187
  });
923
1188
  export {
924
1189
  sandbox_entry_default as default