@aeptus/aexeo-emdash 0.8.18 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +104 -6
  2. package/INSTALL.md +13 -4
  3. package/LICENSE +0 -0
  4. package/README.md +0 -0
  5. package/dist/adapter.d.ts +0 -0
  6. package/dist/adapter.d.ts.map +0 -0
  7. package/dist/admin/Absorbability.d.ts +0 -0
  8. package/dist/admin/Absorbability.d.ts.map +0 -0
  9. package/dist/admin/Accessibility.d.ts +0 -0
  10. package/dist/admin/Accessibility.d.ts.map +0 -0
  11. package/dist/admin/Citability.d.ts +0 -0
  12. package/dist/admin/Citability.d.ts.map +0 -0
  13. package/dist/admin/EntityLegitimacy.d.ts +0 -0
  14. package/dist/admin/EntityLegitimacy.d.ts.map +0 -0
  15. package/dist/admin/EntityPresence.d.ts +0 -0
  16. package/dist/admin/EntityPresence.d.ts.map +0 -0
  17. package/dist/admin/Facts.d.ts +0 -0
  18. package/dist/admin/Facts.d.ts.map +0 -0
  19. package/dist/admin/Findings.d.ts +0 -0
  20. package/dist/admin/Findings.d.ts.map +0 -0
  21. package/dist/admin/PillarView.d.ts +0 -0
  22. package/dist/admin/PillarView.d.ts.map +0 -0
  23. package/dist/admin/Retrievability.d.ts +0 -0
  24. package/dist/admin/Retrievability.d.ts.map +0 -0
  25. package/dist/admin.d.ts +0 -0
  26. package/dist/admin.d.ts.map +0 -0
  27. package/dist/admin.js +0 -0
  28. package/dist/configured.d.ts +0 -0
  29. package/dist/configured.d.ts.map +1 -1
  30. package/dist/configured.js +331 -283
  31. package/dist/data-route.d.ts +0 -0
  32. package/dist/data-route.d.ts.map +0 -0
  33. package/dist/evaluator-sandbox.d.ts +0 -0
  34. package/dist/evaluator-sandbox.d.ts.map +0 -0
  35. package/dist/evaluator.d.ts +0 -0
  36. package/dist/evaluator.d.ts.map +0 -0
  37. package/dist/facts-route.d.ts +0 -0
  38. package/dist/facts-route.d.ts.map +0 -0
  39. package/dist/findings-view.d.ts +136 -0
  40. package/dist/findings-view.d.ts.map +1 -0
  41. package/dist/index.d.ts +0 -0
  42. package/dist/index.d.ts.map +0 -0
  43. package/dist/index.js +13 -2
  44. package/dist/plugin.d.ts +22 -2
  45. package/dist/plugin.d.ts.map +1 -1
  46. package/dist/presence-check.d.ts +0 -0
  47. package/dist/presence-check.d.ts.map +0 -0
  48. package/dist/presence-route.d.ts +0 -0
  49. package/dist/presence-route.d.ts.map +0 -0
  50. package/dist/sandbox-entry.d.ts +2 -22
  51. package/dist/sandbox-entry.d.ts.map +1 -1
  52. package/dist/sandbox-entry.js +410 -353
  53. package/dist/sandbox.d.ts +2 -0
  54. package/dist/sandbox.d.ts.map +1 -1
  55. package/dist/sidecar-url.d.ts +18 -0
  56. package/dist/sidecar-url.d.ts.map +1 -0
  57. package/dist/sidecar.d.ts +0 -0
  58. package/dist/sidecar.d.ts.map +0 -0
  59. package/dist/suppressions.d.ts +0 -0
  60. package/dist/suppressions.d.ts.map +0 -0
  61. package/dist/types.d.ts +0 -0
  62. package/dist/types.d.ts.map +0 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.d.ts.map +0 -0
  65. package/dist/wasm-init.d.ts +0 -0
  66. package/dist/wasm-init.d.ts.map +0 -0
  67. package/package.json +4 -2
  68. package/wasm/aexeo_emdash_bridge.d.ts +0 -0
  69. package/wasm/aexeo_emdash_bridge.js +0 -0
  70. package/wasm/aexeo_emdash_bridge_bg.d.ts +0 -0
  71. package/wasm/aexeo_emdash_bridge_bg.js +0 -0
  72. package/wasm/aexeo_emdash_bridge_bg.wasm +0 -0
  73. package/wasm/aexeo_emdash_bridge_bg.wasm.d.ts +0 -0
  74. package/dist/indexnow.d.ts +0 -15
  75. package/dist/indexnow.d.ts.map +0 -1
  76. package/dist/mcp.d.ts +0 -28
  77. package/dist/mcp.d.ts.map +0 -1
@@ -255,11 +255,27 @@ function trimTrailingSlash(url) {
255
255
  }
256
256
 
257
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
+ }
258
266
  var CONFIG_URL_KEY = "config:evaluator_url";
259
267
  var CONFIG_TOKEN_KEY = "config:eval_token";
260
- async function readSidecarConfig(kv) {
261
- const url = await kv.get(CONFIG_URL_KEY);
262
- 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);
263
279
  if (typeof url !== "string" || typeof token !== "string") {
264
280
  return null;
265
281
  }
@@ -268,12 +284,48 @@ async function readSidecarConfig(kv) {
268
284
  }
269
285
  return { url, token };
270
286
  }
271
- async function writeSidecarConfig(kv, config) {
272
- await kv.set(CONFIG_URL_KEY, config.url);
273
- 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);
322
+ }
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]");
274
326
  }
275
327
  async function handleAfterSave(event, ctx) {
276
- const runtime = await readSidecarConfig(ctx.kv);
328
+ const runtime = await readSidecarConfig(ctx);
277
329
  if (runtime === null) {
278
330
  ctx.log?.warn?.(
279
331
  "Aexeo afterSave skipped: configure the evaluator URL and token on the Setup page"
@@ -378,18 +430,18 @@ async function evaluateAndPersistAll(ctx, options) {
378
430
  summary.errors.push(`${result.reason}: ${result.detail}`);
379
431
  return summary;
380
432
  }
381
- const findingsByRoute2 = /* @__PURE__ */ new Map();
433
+ const findingsByRoute = /* @__PURE__ */ new Map();
382
434
  for (const route of documentRoutes) {
383
- findingsByRoute2.set(route, []);
435
+ findingsByRoute.set(route, []);
384
436
  }
385
437
  for (const finding of result.findings) {
386
438
  const bucket = finding.scope === "page" ? finding.path : "*";
387
- const list = findingsByRoute2.get(bucket) ?? [];
439
+ const list = findingsByRoute.get(bucket) ?? [];
388
440
  list.push(finding);
389
- findingsByRoute2.set(bucket, list);
441
+ findingsByRoute.set(bucket, list);
390
442
  }
391
443
  let totalAfterSuppression = 0;
392
- for (const [route, findings] of findingsByRoute2) {
444
+ for (const [route, findings] of findingsByRoute) {
393
445
  const stored = adaptedByRoute.get(route);
394
446
  const context = {
395
447
  route,
@@ -481,10 +533,100 @@ async function readFindings(kv, route) {
481
533
  return stored.findings;
482
534
  }
483
535
 
484
- // src/evaluator-sandbox.ts
485
- async function evaluate(_documents) {
486
- return [];
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." };
541
+ }
542
+ let parsed;
543
+ try {
544
+ parsed = new URL(url);
545
+ } catch {
546
+ return { ok: false, error: `Sidecar URL is not a valid URL: ${url}` };
547
+ }
548
+ if (parsed.protocol !== "https:") {
549
+ return {
550
+ ok: false,
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}`
552
+ };
553
+ }
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
+ };
560
+ }
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
+ };
566
+ }
567
+ return { ok: true, url };
487
568
  }
569
+ function isBlockedHost(host) {
570
+ if (host === "localhost" || host.endsWith(".localhost")) {
571
+ return true;
572
+ }
573
+ if (host.startsWith("[")) {
574
+ return isBlockedIpv6(host);
575
+ }
576
+ return isBlockedIpv4(host);
577
+ }
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;
582
+ }
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;
594
+ }
595
+ function isBlockedIpv6(host) {
596
+ const inner = host.slice(1, -1).toLowerCase();
597
+ if (inner === "::" || inner === "::1") {
598
+ return true;
599
+ }
600
+ if (inner.startsWith("::ffff:") || inner.startsWith("::7f")) {
601
+ return true;
602
+ }
603
+ const firstHextet = Number.parseInt(inner.split(":")[0] ?? "", 16);
604
+ if (Number.isNaN(firstHextet)) {
605
+ return false;
606
+ }
607
+ if ((firstHextet & 65024) === 64512) {
608
+ return true;
609
+ }
610
+ if ((firstHextet & 65472) === 65152) {
611
+ return true;
612
+ }
613
+ return false;
614
+ }
615
+ function hostMatchesAllowlist(host, allowedHosts) {
616
+ return allowedHosts.some((allowed) => {
617
+ const entry = allowed.trim().toLowerCase();
618
+ if (entry.length === 0) {
619
+ return false;
620
+ }
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
488
630
  async function scoreSite(_documents) {
489
631
  return {
490
632
  overall_score: 0,
@@ -496,159 +638,8 @@ async function scoreSite(_documents) {
496
638
  blockers: []
497
639
  };
498
640
  }
499
- function errorFindings(findings) {
500
- return findings.filter((finding) => finding.severity === "error");
501
- }
502
- function findingsByRoute(findings) {
503
- const grouped = /* @__PURE__ */ new Map();
504
- for (const finding of findings) {
505
- const bucket = grouped.get(finding.path);
506
- if (bucket) {
507
- bucket.push(finding);
508
- } else {
509
- grouped.set(finding.path, [finding]);
510
- }
511
- }
512
- return grouped;
513
- }
514
641
 
515
- // src/indexnow.ts
516
- var INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
517
- async function submitIndexNow(config, urls) {
518
- const host = hostOf(config.siteUrl);
519
- if (host === null) {
520
- return failure("siteUrl is not a valid URL", []);
521
- }
522
- const keyLocation = config.keyLocation ?? defaultKeyLocation(config);
523
- const { submitted, rejected } = partitionByHost(urls, host);
524
- if (submitted.length === 0) {
525
- return failure("no submittable URLs after host check", rejected);
526
- }
527
- const payload = {
528
- host,
529
- key: config.key,
530
- keyLocation,
531
- urlList: submitted
532
- };
533
- const response = await fetch(INDEXNOW_ENDPOINT, {
534
- method: "POST",
535
- headers: { "content-type": "application/json" },
536
- body: JSON.stringify(payload)
537
- });
538
- return {
539
- ok: response.ok,
540
- status: response.status,
541
- endpoint: INDEXNOW_ENDPOINT,
542
- submitted,
543
- rejected
544
- };
545
- }
546
- function hostOf(rawUrl) {
547
- try {
548
- return new URL(rawUrl).host;
549
- } catch {
550
- return null;
551
- }
552
- }
553
- function defaultKeyLocation(config) {
554
- const siteUrl = config.siteUrl.endsWith("/") ? config.siteUrl.slice(0, -1) : config.siteUrl;
555
- return `${siteUrl}/${config.key}.txt`;
556
- }
557
- function partitionByHost(urls, expectedHost) {
558
- const submitted = [];
559
- const rejected = [];
560
- for (const candidate of urls) {
561
- const host = hostOf(candidate);
562
- if (host === expectedHost) {
563
- submitted.push(candidate);
564
- } else {
565
- rejected.push(candidate);
566
- }
567
- }
568
- return { submitted, rejected };
569
- }
570
- function failure(reason, rejected) {
571
- return {
572
- ok: false,
573
- status: 0,
574
- endpoint: INDEXNOW_ENDPOINT,
575
- submitted: [],
576
- rejected,
577
- reason
578
- };
579
- }
580
-
581
- // src/mcp.ts
582
- var checkTool = {
583
- name: "aexeo.check",
584
- 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.",
585
- inputSchema: {
586
- type: "object",
587
- properties: {
588
- documents: {
589
- type: "array",
590
- description: "Array of EmdashDocument values to evaluate. Each entry carries route, title, optional description/canonical/lang, alternates, meta map, schema, and Portable Text body."
591
- },
592
- configJson: {
593
- type: "string",
594
- description: "Optional JSON-serialized Aexeo config. When absent the evaluator uses Config::default(), matching a cold CLI run."
595
- }
596
- },
597
- required: ["documents"]
598
- },
599
- handler: async ({ documents, configJson }) => {
600
- const options = configJson === void 0 ? {} : { configJson };
601
- const findings = await evaluate(documents, options);
602
- const errors = errorFindings(findings);
603
- const grouped = findingsByRoute(findings);
604
- const byRoute = {};
605
- for (const [route, items] of grouped.entries()) {
606
- byRoute[route] = items;
607
- }
608
- return {
609
- findings,
610
- errors,
611
- byRoute,
612
- totals: {
613
- findings: findings.length,
614
- errors: errors.length,
615
- warnings: findings.length - errors.length
616
- }
617
- };
618
- }
619
- };
620
- var indexNowSubmitTool = {
621
- name: "aexeo.indexnow.submit",
622
- 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.",
623
- inputSchema: {
624
- type: "object",
625
- properties: {
626
- config: {
627
- type: "object",
628
- description: "IndexNow configuration: siteUrl, key, and optional keyLocation override.",
629
- properties: {
630
- siteUrl: { type: "string" },
631
- key: { type: "string" },
632
- keyLocation: { type: "string" }
633
- },
634
- required: ["siteUrl", "key"]
635
- },
636
- urls: {
637
- type: "array",
638
- items: { type: "string" },
639
- description: "Absolute URLs to submit. Must share siteUrl's host."
640
- }
641
- },
642
- required: ["config", "urls"]
643
- },
644
- handler: async ({ config, urls }) => submitIndexNow(config, urls)
645
- };
646
- var tools = [checkTool, indexNowSubmitTool];
647
-
648
- // src/sandbox-entry.ts
649
- function definePlugin(plugin) {
650
- return plugin;
651
- }
642
+ // src/findings-view.ts
652
643
  function normalizeInteraction(input) {
653
644
  if (!input || typeof input !== "object") {
654
645
  return { type: "page_load", page: "/findings" };
@@ -678,107 +669,31 @@ function normalizeInteraction(input) {
678
669
  }
679
670
  return { type: "page_load", page: "/findings" };
680
671
  }
681
- async function handleAdminRoute(input, ctx) {
682
- const body = normalizeInteraction(input?.input);
683
- ctx.log?.info?.(
684
- `aexeo route: type=${body.type} page=${body.type === "page_load" ? body.page : ""}`
685
- );
686
- const dispatch = { body, kv: ctx.kv, ctx };
687
- if (body.type === "page_load") {
688
- return handlePageLoad(dispatch, body.page);
689
- }
690
- if (body.type === "block_action") {
691
- return handleBlockAction(
692
- dispatch,
693
- body.action_id,
694
- body.value
695
- );
696
- }
697
- if (body.type === "form_submit") {
698
- return handleFormSubmit(
699
- dispatch,
700
- body.action_id,
701
- body.values
702
- );
703
- }
704
- return handlePageLoad(dispatch, "findings");
705
- }
706
- async function handleFormSubmit(ctx, actionId, values) {
707
- if (actionId === "save_setup") {
708
- return handleSetupSubmit(ctx, values);
709
- }
710
- if (actionId === "view_document") {
711
- const picked = values["route_picker"];
712
- if (typeof picked === "string" && picked.length > 0) {
713
- return renderDocumentPanel(ctx, picked);
714
- }
715
- }
716
- return handlePageLoad(ctx, "findings");
717
- }
718
- async function handlePageLoad(ctx, page) {
719
- const normalized = page.startsWith("/") ? page.slice(1) : page;
720
- if (normalized === "findings") {
721
- return renderFindingsPage(ctx);
722
- }
723
- if (normalized === "widget:aexeo-score") {
724
- return renderScoreWidget(ctx);
725
- }
726
- if (normalized === "document") {
727
- return renderDocumentPanel(ctx);
728
- }
729
- if (normalized === "setup") {
730
- return renderSetupPage(ctx);
731
- }
732
- return notFound(page);
733
- }
734
- async function handleBlockAction(ctx, actionId, value) {
735
- if (actionId === "view_document" && typeof value === "string") {
736
- return renderDocumentPanel(ctx, value);
737
- }
738
- if (actionId === "refresh_findings") {
739
- return handleRefresh(ctx);
740
- }
741
- if (actionId.startsWith("filter:")) {
742
- return renderFindingsPage(ctx);
743
- }
744
- return notFound(actionId);
745
- }
746
- async function handleRefresh(ctx) {
747
- const sandboxEvaluator = async (documents) => {
748
- const runtime = await readSidecarConfig(ctx.kv);
749
- if (runtime === null) {
750
- return {
751
- ok: false,
752
- reason: "config_missing",
753
- detail: "sidecar not configured \u2014 open the Aexeo Setup page and enter your evaluator URL and token"
754
- };
755
- }
756
- return evaluateViaSidecar(
757
- ctx.ctx.http,
758
- { url: runtime.url, authToken: runtime.token },
759
- documents
760
- );
761
- };
672
+ async function handleRefresh(options) {
762
673
  let summary;
763
674
  try {
764
- summary = await evaluateAndPersistAll(ctx.ctx, {
765
- 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
766
680
  });
767
681
  } catch (err) {
768
682
  const detail = err instanceof Error ? err.message : String(err);
683
+ const message = `Refresh failed: ${detail}`;
769
684
  return {
770
685
  blocks: [
771
686
  { type: "header", text: "SEO findings" },
772
687
  {
773
688
  type: "banner",
774
- title: `Refresh failed: ${detail}`,
775
- variant: "alert"
689
+ title: message,
690
+ variant: options.tuning.refreshFailureVariant
776
691
  }
777
692
  ],
778
- toast: { message: `Refresh failed: ${detail}`, type: "error" }
693
+ toast: { message, type: "error" }
779
694
  };
780
695
  }
781
- const refreshed = await renderFindingsPage(ctx);
696
+ const refreshed = await renderFindingsPage(options.ctx.kv, options.tuning);
782
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`;
783
698
  if (summary.errors.length > 0) {
784
699
  refreshed.blocks.unshift({
@@ -795,10 +710,43 @@ async function handleRefresh(ctx) {
795
710
  }
796
711
  };
797
712
  }
798
- async function renderFindingsPage(ctx) {
799
- 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);
800
746
  const errors = findings.filter((finding) => finding.severity === "error");
801
- const warnings = findings.filter((finding) => finding.severity === "warning");
747
+ const warnings = findings.filter(
748
+ (finding) => finding.severity === "warning"
749
+ );
802
750
  const sorted = [...findings].sort(severityFirst);
803
751
  const routes = uniqueRoutes(findings);
804
752
  const blocks = [
@@ -812,8 +760,8 @@ async function renderFindingsPage(ctx) {
812
760
  type: "actions",
813
761
  elements: [
814
762
  // Refresh evaluates every configured entry. Pressing it lists
815
- // content via the live in-request bridge,
816
- // calls the sidecar /evaluate, and writes findings back to KV.
763
+ // content via the live in-request bridge, evaluates it, and
764
+ // writes findings back to KV (with suppressions applied).
817
765
  {
818
766
  type: "button",
819
767
  label: "Refresh",
@@ -831,7 +779,7 @@ async function renderFindingsPage(ctx) {
831
779
  },
832
780
  sorted.length === 0 ? {
833
781
  type: "context",
834
- text: "Once a document publishes, its rule findings list here."
782
+ text: tuning.emptyFindingsText
835
783
  } : findingsTable(sorted)
836
784
  ];
837
785
  if (routes.length > 0) {
@@ -850,37 +798,6 @@ async function renderFindingsPage(ctx) {
850
798
  }
851
799
  return { blocks };
852
800
  }
853
- async function readAllFindings(kv) {
854
- const entries = await kv.list(
855
- "findings:"
856
- );
857
- const out = [];
858
- for (const entry of entries) {
859
- if (entry.value === null) {
860
- continue;
861
- }
862
- const route = entry.key.replace(/^findings:/, "");
863
- for (const finding of entry.value.findings) {
864
- out.push({ ...finding, document_route: route });
865
- }
866
- }
867
- return out;
868
- }
869
- function uniqueRoutes(rows) {
870
- const routes = /* @__PURE__ */ new Set();
871
- for (const row of rows) {
872
- routes.add(row.document_route);
873
- }
874
- return [...routes].sort();
875
- }
876
- function severityFirst(a, b) {
877
- const rank = (severity) => severity === "error" ? 0 : 1;
878
- const diff = rank(a.severity) - rank(b.severity);
879
- if (diff !== 0) {
880
- return diff;
881
- }
882
- return a.rule_id.localeCompare(b.rule_id);
883
- }
884
801
  function findingsTable(rows) {
885
802
  return {
886
803
  type: "table",
@@ -898,40 +815,31 @@ function findingsTable(rows) {
898
815
  }))
899
816
  };
900
817
  }
901
- async function renderScoreWidget(ctx) {
902
- const documents = await readAllDocuments(ctx.kv);
818
+ async function renderScoreWidget(kv, tuning) {
819
+ const documents = await readAllDocuments(kv);
903
820
  if (documents.length === 0) {
904
821
  return {
905
822
  blocks: [
906
823
  { type: "header", text: "SEO score" },
907
824
  {
908
825
  type: "context",
909
- text: "No documents saved yet \u2014 score appears after the first emdash save."
826
+ text: tuning.emptyScoreText
910
827
  }
911
828
  ]
912
829
  };
913
830
  }
914
- const score = await scoreSite(documents);
831
+ const score = await tuning.score(documents, kv);
915
832
  const blocks = [
916
833
  {
917
834
  type: "stats",
918
835
  items: [
836
+ { label: "Overall", value: `${score.overall_score}` },
837
+ { label: "Citation", value: `${score.citation_readiness_score}` },
919
838
  {
920
- label: "Overall",
921
- value: `${score.overall_score}`
922
- },
923
- {
924
- label: "Citation",
925
- value: `${score.citation_readiness_score}`
926
- },
927
- {
928
- label: "Truth",
839
+ label: tuning.truthLabel(score),
929
840
  value: `${score.truth_consistency_score}`
930
841
  },
931
- {
932
- label: "Answers",
933
- value: `${score.answer_pack_score}`
934
- }
842
+ { label: "Answers", value: `${score.answer_pack_score}` }
935
843
  ]
936
844
  }
937
845
  ];
@@ -957,7 +865,7 @@ function topBlockersLine(score) {
957
865
  }
958
866
  return `Top blockers: ${top.join(" \u2022 ")}`;
959
867
  }
960
- async function renderDocumentPanel(ctx, route) {
868
+ async function renderDocumentPanel(kv, route) {
961
869
  if (route === void 0) {
962
870
  return {
963
871
  blocks: [
@@ -969,9 +877,11 @@ async function renderDocumentPanel(ctx, route) {
969
877
  ]
970
878
  };
971
879
  }
972
- const findings = await readFindings(ctx.kv, route);
880
+ const findings = await readFindings(kv, route);
973
881
  const errors = findings.filter((finding) => finding.severity === "error");
974
- const warnings = findings.filter((finding) => finding.severity === "warning");
882
+ const warnings = findings.filter(
883
+ (finding) => finding.severity === "warning"
884
+ );
975
885
  const sorted = [...findings].sort(severityFirst);
976
886
  return {
977
887
  blocks: [
@@ -1004,13 +914,185 @@ function documentFindingsTable(findings) {
1004
914
  }))
1005
915
  };
1006
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
+ }
1007
1089
  async function renderSetupPage(ctx, options = {}) {
1008
- const existing = await readSidecarConfig(ctx.kv);
1090
+ const existing = await readSidecarConfig(ctx.ctx);
1009
1091
  const blocks = [
1010
1092
  { type: "header", text: "Aexeo setup" },
1011
1093
  {
1012
1094
  type: "context",
1013
- 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."
1014
1096
  }
1015
1097
  ];
1016
1098
  if (options.bannerError !== void 0) {
@@ -1058,39 +1140,23 @@ async function renderSetupPage(ctx, options = {}) {
1058
1140
  );
1059
1141
  return { blocks };
1060
1142
  }
1143
+ function runtimeAllowedHosts() {
1144
+ const env = typeof process === "undefined" ? void 0 : process.env;
1145
+ return buildAllowedHosts(env?.["AEXEO_EVALUATOR_HOST"] ?? null);
1146
+ }
1061
1147
  async function handleSetupSubmit(ctx, values) {
1062
1148
  const rawUrl = values["evaluator_url"];
1063
1149
  const rawToken = values["eval_token"];
1064
1150
  const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
1065
1151
  const token = typeof rawToken === "string" ? rawToken.trim() : "";
1066
- if (url.length === 0) {
1152
+ const validated = validateSidecarUrl(url, runtimeAllowedHosts());
1153
+ if (!validated.ok) {
1067
1154
  return renderSetupPage(ctx, {
1068
- bannerError: "Sidecar URL is required.",
1155
+ bannerError: validated.error,
1069
1156
  initialUrl: url
1070
1157
  });
1071
1158
  }
1072
- let parsed;
1073
- try {
1074
- parsed = new URL(url);
1075
- } catch {
1076
- return renderSetupPage(ctx, {
1077
- bannerError: `Sidecar URL is not a valid URL: ${url}`,
1078
- initialUrl: url
1079
- });
1080
- }
1081
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1082
- return renderSetupPage(ctx, {
1083
- bannerError: `Sidecar URL must be http(s): got ${parsed.protocol}`,
1084
- initialUrl: url
1085
- });
1086
- }
1087
- if (parsed.hostname === "localhost" || parsed.hostname.endsWith(".localhost") || /^127\./.test(parsed.hostname)) {
1088
- return renderSetupPage(ctx, {
1089
- bannerError: "emdash's sandbox blocks fetches to localhost (anti-SSRF). Use the deployed *.workers.dev URL.",
1090
- initialUrl: url
1091
- });
1092
- }
1093
- const existing = await readSidecarConfig(ctx.kv);
1159
+ const existing = await readSidecarConfig(ctx.ctx);
1094
1160
  let resolvedToken;
1095
1161
  if (token.length > 0) {
1096
1162
  resolvedToken = token;
@@ -1099,34 +1165,25 @@ async function handleSetupSubmit(ctx, values) {
1099
1165
  } else {
1100
1166
  return renderSetupPage(ctx, {
1101
1167
  bannerError: "EVAL_TOKEN is required on first setup.",
1102
- initialUrl: url
1168
+ initialUrl: validated.url
1103
1169
  });
1104
1170
  }
1105
- const next = { url, token: resolvedToken };
1106
- await writeSidecarConfig(ctx.kv, next);
1171
+ const next = {
1172
+ url: validated.url,
1173
+ token: resolvedToken
1174
+ };
1175
+ await writeSidecarConfig(ctx.ctx, next);
1107
1176
  return renderSetupPage(ctx, {
1108
1177
  bannerSuccess: "Configuration saved. Click Refresh on the findings page to evaluate."
1109
1178
  });
1110
1179
  }
1111
- function notFound(page) {
1112
- return {
1113
- blocks: [
1114
- { type: "header", text: "Not found" },
1115
- { type: "context", text: `unknown page: ${page}` }
1116
- ]
1117
- };
1118
- }
1119
1180
  var sandbox_entry_default = definePlugin({
1120
1181
  hooks: {
1121
1182
  "content:afterSave": handleAfterSave
1122
1183
  },
1123
1184
  routes: {
1124
1185
  admin: { handler: handleAdminRoute }
1125
- },
1126
- // emdash's MCP server picks tools up from this field. The exact
1127
- // host-side spec for plugin-contributed tools is still being mapped;
1128
- // the field is harmless if unrecognized.
1129
- mcpTools: tools
1186
+ }
1130
1187
  });
1131
1188
  export {
1132
1189
  sandbox_entry_default as default