@runfusion/fusion 0.26.0 → 0.27.0

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 (171) hide show
  1. package/dist/bin.js +11036 -1992
  2. package/dist/client/assets/AgentDetailView-B7QRcHJH.css +1 -0
  3. package/dist/client/assets/AgentDetailView-DwLmRXTY.js +18 -0
  4. package/dist/client/assets/{AgentsView-D6Zi5zfP.js → AgentsView-D-N6aA0P.js} +12 -7
  5. package/dist/client/assets/ChatView-DnCdKu8Z.js +1 -0
  6. package/dist/client/assets/{DevServerView--_WBvIDQ.js → DevServerView-BiA1nYtt.js} +1 -1
  7. package/dist/client/assets/{DirectoryPicker-xedtR-Rd.js → DirectoryPicker-DvBviDG6.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-Bg2oaZks.js → DocumentsView-BWXOxpuq.js} +1 -1
  9. package/dist/client/assets/{EvalsView-B3uOCXfr.js → EvalsView-CJFbtL7i.js} +1 -1
  10. package/dist/client/assets/{ExperimentalAgentOnboardingModal-Bx6yXVS5.js → ExperimentalAgentOnboardingModal-DuGIPd0B.js} +1 -1
  11. package/dist/client/assets/InsightsView-BBpRiolN.js +11 -0
  12. package/dist/client/assets/{MemoryView-xcN_eouf.js → MemoryView-48LuNkKk.js} +2 -2
  13. package/dist/client/assets/NodesView-CGQWSNZM.js +14 -0
  14. package/dist/client/assets/{PiExtensionsManager-Cc8aAZXg.js → PiExtensionsManager-i-7UL2oh.js} +2 -2
  15. package/dist/client/assets/PluginManager-DoSAykD6.js +1 -0
  16. package/dist/client/assets/{ResearchView-CERNf7sJ.js → ResearchView-XZuRtOxE.js} +1 -1
  17. package/dist/client/assets/{SettingsModal-Cis-4Lot.css → SettingsModal-Ci0_sqbU.css} +1 -1
  18. package/dist/client/assets/{SettingsModal-B1r0yASu.js → SettingsModal-CmeF8CN4.js} +1 -1
  19. package/dist/client/assets/SettingsModal-DBcjf9Bu.js +31 -0
  20. package/dist/client/assets/SettingsModal-DWKgRxBA.css +1 -0
  21. package/dist/client/assets/{SetupWizardModal-D1q548_L.js → SetupWizardModal-CgtvpMX9.js} +1 -1
  22. package/dist/client/assets/{SkillsView-ClLM6u6p.js → SkillsView-DErYRumF.js} +1 -1
  23. package/dist/client/assets/{StashRecoveryView-ze0pEZ5U.js → StashRecoveryView-QJrNS4Vg.js} +1 -1
  24. package/dist/client/assets/{TodoView-CTmIfy2M.js → TodoView-BD9NRwq0.js} +2 -2
  25. package/dist/client/assets/{dashboard-view-CyWN-d02.js → dashboard-view-BWGH_fAq.js} +1 -1
  26. package/dist/client/assets/dashboard-view-BoTzlP8b.css +1 -0
  27. package/dist/client/assets/dashboard-view-Ws9_ZnKu.js +21 -0
  28. package/dist/client/assets/{folder-open-BZuKESeq.js → folder-open-CHSlllzf.js} +1 -1
  29. package/dist/client/assets/index-DCovGm5b.css +1 -0
  30. package/dist/client/assets/index-bEwSVl7B.js +692 -0
  31. package/dist/client/assets/{star-D75YKEq-.js → star-BgVwWAPz.js} +1 -1
  32. package/dist/client/assets/{upload-BYYTgWFj.js → upload-CAzycxr9.js} +1 -1
  33. package/dist/client/assets/{users-RS90Aii3.js → users-CZnxCCCJ.js} +1 -1
  34. package/dist/client/index.html +2 -2
  35. package/dist/client/version.json +1 -1
  36. package/dist/droid-cli/package.json +1 -1
  37. package/dist/droid-cli/src/__tests__/index.test.ts +228 -0
  38. package/dist/extension.js +5517 -1193
  39. package/dist/pi-claude-cli/package.json +1 -1
  40. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +36 -22
  41. package/dist/pi-claude-cli/src/provider.ts +7 -1
  42. package/dist/plugins/fusion-plugin-cli-printing-press/manifest.json +19 -1
  43. package/dist/plugins/fusion-plugin-cli-printing-press/package.json +20 -2
  44. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/TestRunnerPanel.test.tsx +99 -0
  45. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/config-flow.test.ts +91 -0
  46. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/dashboard-view.test.tsx +40 -0
  47. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/dashboard-views.test.ts +46 -0
  48. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/draft-store.test.ts +50 -0
  49. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/fixtures/exec-mock.ts +80 -0
  50. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/fixtures/fixtures.test.ts +40 -0
  51. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/fixtures/registry.ts +82 -0
  52. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/generator.test.ts +54 -0
  53. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/manage-view.test.tsx +98 -0
  54. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts +21 -5
  55. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/registration.test.ts +29 -0
  56. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/run-routes.test.ts +98 -0
  57. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/runner.test.ts +55 -0
  58. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/runtime-availability.test.ts +61 -0
  59. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/validation.test.ts +30 -0
  60. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/wizard-routes.test.ts +61 -0
  61. package/dist/plugins/fusion-plugin-cli-printing-press/src/__tests__/workflow-integration.test.ts +19 -0
  62. package/dist/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.css +43 -0
  63. package/dist/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.tsx +49 -0
  64. package/dist/plugins/fusion-plugin-cli-printing-press/src/generation/generator.ts +95 -0
  65. package/dist/plugins/fusion-plugin-cli-printing-press/src/generation/redact.ts +9 -0
  66. package/dist/plugins/fusion-plugin-cli-printing-press/src/generation/runner.ts +79 -0
  67. package/dist/plugins/fusion-plugin-cli-printing-press/src/generation/types.ts +31 -0
  68. package/dist/plugins/fusion-plugin-cli-printing-press/src/index.ts +46 -2
  69. package/dist/plugins/fusion-plugin-cli-printing-press/src/manage/EditDraftModal.tsx +75 -0
  70. package/dist/plugins/fusion-plugin-cli-printing-press/src/manage/useDrafts.ts +73 -0
  71. package/dist/plugins/fusion-plugin-cli-printing-press/src/manage-view.css +79 -0
  72. package/dist/plugins/fusion-plugin-cli-printing-press/src/manage-view.tsx +122 -0
  73. package/dist/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts +272 -0
  74. package/dist/plugins/fusion-plugin-cli-printing-press/src/run/TestRunnerPanel.css +70 -0
  75. package/dist/plugins/fusion-plugin-cli-printing-press/src/run/TestRunnerPanel.tsx +98 -0
  76. package/dist/plugins/fusion-plugin-cli-printing-press/src/run/useRunGeneratedCli.ts +37 -0
  77. package/dist/plugins/fusion-plugin-cli-printing-press/src/runtime/__tests__/executor-runtime-env.test.ts +191 -0
  78. package/dist/plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts +75 -0
  79. package/dist/plugins/fusion-plugin-cli-printing-press/src/storage/draft-store.ts +85 -0
  80. package/dist/plugins/fusion-plugin-cli-printing-press/src/store/__tests__/cli-press-store.test.ts +128 -0
  81. package/dist/plugins/fusion-plugin-cli-printing-press/src/store/__tests__/credentials.test.ts +62 -0
  82. package/dist/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-store.ts +427 -0
  83. package/dist/plugins/fusion-plugin-cli-printing-press/src/store/cli-press-types.ts +110 -0
  84. package/dist/plugins/fusion-plugin-cli-printing-press/src/store/credentials.ts +95 -0
  85. package/dist/plugins/fusion-plugin-cli-printing-press/src/wizard/steps.tsx +55 -0
  86. package/dist/plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts +33 -0
  87. package/dist/plugins/fusion-plugin-cli-printing-press/src/wizard/validation.ts +63 -0
  88. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
  89. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  90. package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
  91. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  92. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  93. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  94. package/dist/plugins/fusion-plugin-reports/manifest.json +10 -0
  95. package/dist/plugins/fusion-plugin-reports/package.json +18 -2
  96. package/dist/plugins/fusion-plugin-reports/src/__tests__/approval.test.ts +164 -0
  97. package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +14 -0
  98. package/dist/plugins/fusion-plugin-reports/src/__tests__/routes-approval.test.ts +109 -0
  99. package/dist/plugins/fusion-plugin-reports/src/__tests__/scaffold.test.ts +60 -0
  100. package/dist/plugins/fusion-plugin-reports/src/__tests__/share-blocks.test.ts +83 -0
  101. package/dist/plugins/fusion-plugin-reports/src/aggregation.ts +23 -0
  102. package/dist/plugins/fusion-plugin-reports/src/approval.ts +97 -0
  103. package/dist/plugins/fusion-plugin-reports/src/cadence.ts +23 -0
  104. package/dist/plugins/fusion-plugin-reports/src/dashboard/ReportsView.css +82 -0
  105. package/dist/plugins/fusion-plugin-reports/src/dashboard/ReportsView.tsx +24 -0
  106. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/ReportComparisonDrawer.test.tsx +12 -0
  107. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/ReportDetailPanel.test.tsx +12 -0
  108. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/ReportFiltersBar.test.tsx +14 -0
  109. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/ReportsView.test.tsx +27 -0
  110. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/api.test.ts +19 -0
  111. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/useReportSectionDiff.test.ts +11 -0
  112. package/dist/plugins/fusion-plugin-reports/src/dashboard/__tests__/useReports.test.ts +13 -0
  113. package/dist/plugins/fusion-plugin-reports/src/dashboard/api.ts +85 -0
  114. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportApprovalPanel.css +59 -0
  115. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportApprovalPanel.tsx +58 -0
  116. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportComparisonDrawer.tsx +21 -0
  117. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportDetailPanel.tsx +29 -0
  118. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportEmptyState.tsx +3 -0
  119. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportFiltersBar.tsx +19 -0
  120. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ReportListItem.tsx +8 -0
  121. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.css +29 -0
  122. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx +43 -0
  123. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ReportApprovalPanel.test.tsx +38 -0
  124. package/dist/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx +24 -0
  125. package/dist/plugins/fusion-plugin-reports/src/dashboard/test-setup.ts +18 -0
  126. package/dist/plugins/fusion-plugin-reports/src/dashboard/types.ts +22 -0
  127. package/dist/plugins/fusion-plugin-reports/src/dashboard/useReportPreview.ts +44 -0
  128. package/dist/plugins/fusion-plugin-reports/src/dashboard/useReportSectionDiff.ts +59 -0
  129. package/dist/plugins/fusion-plugin-reports/src/dashboard/useReports.ts +71 -0
  130. package/dist/plugins/fusion-plugin-reports/src/dashboard/useViewportMode.ts +13 -0
  131. package/dist/plugins/fusion-plugin-reports/src/dashboard-view.tsx +6 -0
  132. package/dist/plugins/fusion-plugin-reports/src/index.ts +48 -2
  133. package/dist/plugins/fusion-plugin-reports/src/pipeline.ts +58 -0
  134. package/dist/plugins/fusion-plugin-reports/src/render/__tests__/escape.test.ts +20 -0
  135. package/dist/plugins/fusion-plugin-reports/src/render/__tests__/html-template.test.ts +110 -0
  136. package/dist/plugins/fusion-plugin-reports/src/render/__tests__/standalone-html.test.ts +66 -0
  137. package/dist/plugins/fusion-plugin-reports/src/render/escape.ts +12 -0
  138. package/dist/plugins/fusion-plugin-reports/src/render/html-styles.ts +40 -0
  139. package/dist/plugins/fusion-plugin-reports/src/render/html-template.ts +137 -0
  140. package/dist/plugins/fusion-plugin-reports/src/render/index.ts +4 -0
  141. package/dist/plugins/fusion-plugin-reports/src/render/standalone-html.ts +75 -0
  142. package/dist/plugins/fusion-plugin-reports/src/report-schema.ts +31 -0
  143. package/dist/plugins/fusion-plugin-reports/src/routes/__tests__/report-export-routes.test.ts +104 -0
  144. package/dist/plugins/fusion-plugin-reports/src/routes/report-approval-routes.ts +98 -0
  145. package/dist/plugins/fusion-plugin-reports/src/routes/report-export-routes.ts +77 -0
  146. package/dist/plugins/fusion-plugin-reports/src/routes/report-list-routes.ts +72 -0
  147. package/dist/plugins/fusion-plugin-reports/src/runs-store.ts +69 -0
  148. package/dist/plugins/fusion-plugin-reports/src/share-blocks.ts +82 -0
  149. package/dist/plugins/fusion-plugin-reports/src/store/report-store.ts +51 -2
  150. package/dist/plugins/fusion-plugin-reports/src/store/report-types.ts +6 -1
  151. package/dist/plugins/fusion-plugin-roadmap/bundled.js +1528 -29391
  152. package/dist/plugins/fusion-plugin-roadmap/manifest.json +1 -1
  153. package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
  154. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
  155. package/package.json +1 -1
  156. package/skill/fusion/references/engine-tools.md +1 -1
  157. package/skill/fusion/references/extension-tools.md +3 -3
  158. package/skill/fusion/references/fusion-capabilities.md +1 -1
  159. package/dist/client/assets/AgentDetailView-BwJaLqZh.css +0 -1
  160. package/dist/client/assets/AgentDetailView-Cv-vgOj3.js +0 -18
  161. package/dist/client/assets/ChatView-CAHjY9uO.js +0 -1
  162. package/dist/client/assets/InsightsView-Q1zvtF4F.js +0 -11
  163. package/dist/client/assets/NodesView-RxXg58_Q.js +0 -14
  164. package/dist/client/assets/PluginManager-BEkyBajl.js +0 -1
  165. package/dist/client/assets/SettingsModal-BLsac7CJ.js +0 -31
  166. package/dist/client/assets/SettingsModal-BNSrO1M9.css +0 -1
  167. package/dist/client/assets/dashboard-view-4xAN3yO5.js +0 -21
  168. package/dist/client/assets/dashboard-view-BkTMSZYn.css +0 -1
  169. package/dist/client/assets/index-Bdw6llW6.js +0 -692
  170. package/dist/client/assets/index-CZGlyJuS.css +0 -1
  171. package/dist/plugins/fusion-plugin-roadmap/bundled.css +0 -1093
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Report } from "../../store/report-types.js";
3
+ import { renderStandaloneReportHtml, slugifyReportFilename } from "../standalone-html.js";
4
+
5
+ function createRecord(metadata: Record<string, unknown> = {}): Report {
6
+ return {
7
+ id: "rep_1",
8
+ cadence: "daily",
9
+ periodStart: "2026-05-01",
10
+ periodEnd: "2026-05-02",
11
+ title: "Demo",
12
+ status: "review_complete",
13
+ generationStartedAt: "2026-05-02T00:00:00.000Z",
14
+ generationCompletedAt: "2026-05-02T00:01:00.000Z",
15
+ reviewStartedAt: null,
16
+ reviewCompletedAt: null,
17
+ approvedAt: null,
18
+ approvedBy: null,
19
+ publishedAt: null,
20
+ archivedAt: null,
21
+ failureReason: null,
22
+ approvalState: "not_required",
23
+ approvalHistory: [],
24
+ draftMarkdown: null,
25
+ renderedHtmlPath: null,
26
+ renderedHtml: null,
27
+ renderedHtmlGeneratedAt: null,
28
+ metadata,
29
+ combinedReview: null,
30
+ createdAt: "2026-05-02T00:00:00.000Z",
31
+ updatedAt: "2026-05-02T00:01:00.000Z",
32
+ };
33
+ }
34
+
35
+ describe("renderStandaloneReportHtml", () => {
36
+ it("renders one full html document with one style block", () => {
37
+ const html = renderStandaloneReportHtml(createRecord());
38
+ expect(html.startsWith("<!doctype html>")).toBe(true);
39
+ expect((html.match(/<style>/g) ?? []).length).toBe(1);
40
+ expect(html).toContain("--space-xs");
41
+ });
42
+
43
+ it("contains no external links except allowlisted ones", () => {
44
+ const html = renderStandaloneReportHtml(createRecord());
45
+ const matches = [...html.matchAll(/(href|src)=\"https?:[^\"]+/gi)];
46
+ expect(matches.length).toBe(0);
47
+ });
48
+
49
+ it("strips external image sources", () => {
50
+ const html = renderStandaloneReportHtml(createRecord({
51
+ settings: { branding: { logoDataUri: "http://example.com/logo.png" } },
52
+ }));
53
+ expect(html).not.toContain("http://example.com/logo.png");
54
+ });
55
+
56
+ it("is deterministic for same input", () => {
57
+ const a = renderStandaloneReportHtml(createRecord());
58
+ const b = renderStandaloneReportHtml(createRecord());
59
+ expect(a).toBe(b);
60
+ });
61
+
62
+ it("slugifies report filenames", () => {
63
+ const slug = slugifyReportFilename({ title: "My Weekly Report", periodStart: "2026-05-01", periodEnd: "2026-05-02" });
64
+ expect(slug).toBe("my-weekly-report-2026-05-01-2026-05-02.html");
65
+ });
66
+ });
@@ -0,0 +1,12 @@
1
+ export function escapeHtml(input: string): string {
2
+ return input
3
+ .replaceAll("&", "&amp;")
4
+ .replaceAll("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("'", "&#39;");
8
+ }
9
+
10
+ export function escapeAttr(input: string): string {
11
+ return escapeHtml(input).replaceAll("`", "&#96;");
12
+ }
@@ -0,0 +1,40 @@
1
+ import { escapeAttr } from "./escape.js";
2
+
3
+ export interface ReportBranding {
4
+ accentColor?: string;
5
+ logoDataUri?: string;
6
+ logoTextColor?: string;
7
+ }
8
+
9
+ export const REPORT_STYLESHEET = `
10
+ :root {
11
+ --space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px;
12
+ --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px;
13
+ --bg: #0d1117; --surface: #161b22; --card: #1f2733; --text: #e6edf3; --text-muted: #8b949e; --border: #30363d;
14
+ --triage: #8b949e; --todo: #58a6ff; --in-progress: #d29922; --in-review: #a371f7; --done: #3fb950;
15
+ --color-success: #3fb950; --color-error: #f85149; --color-warning: #d29922; --color-info: #58a6ff;
16
+ --report-accent: #5b8def;
17
+ }
18
+ [data-theme="light"] {
19
+ --bg: #ffffff; --surface: #f6f8fa; --card: #ffffff; --text: #1f2328; --text-muted: #59636e; --border: #d0d7de;
20
+ }
21
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
22
+ .report { max-width: 980px; margin: 0 auto; padding: var(--space-xl); }
23
+ .report-header, .report-section, .agent-card, .report-footer { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); }
24
+ .report-title { margin: 0 0 var(--space-sm); font-size: 28px; }
25
+ .report-meta { display: flex; flex-wrap: wrap; gap: var(--space-sm); color: var(--text-muted); }
26
+ .pill { border-radius: 999px; padding: 2px 10px; border: 1px solid var(--border); }
27
+ .status { background: color-mix(in srgb, var(--report-accent) 22%, transparent); color: var(--report-accent); }
28
+ .section-title { margin: 0 0 var(--space-sm); font-size: 18px; }
29
+ .section-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-md); }
30
+ .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-md); }
31
+ ul { margin: var(--space-sm) 0 0; padding-left: 18px; }
32
+ `;
33
+
34
+ export function buildBrandingCss(branding: ReportBranding | undefined): string {
35
+ if (!branding) return "";
36
+ const accent = branding.accentColor ? `--report-accent: ${escapeAttr(branding.accentColor)};` : "";
37
+ const logo = branding.logoTextColor ? `--report-logo-text: ${escapeAttr(branding.logoTextColor)};` : "";
38
+ if (!accent && !logo) return "";
39
+ return `:root { ${accent} ${logo} }`;
40
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Rendering approach: all agent/user text is escaped as plain text.
3
+ * No markdown/HTML pass-through is allowed in this renderer.
4
+ */
5
+ import type { CombinedReview } from "../review-types.js";
6
+ import type { Report } from "../store/report-types.js";
7
+ import { escapeAttr, escapeHtml } from "./escape.js";
8
+ import { buildBrandingCss, REPORT_STYLESHEET, type ReportBranding } from "./html-styles.js";
9
+
10
+ export interface ReportRecord extends Report {
11
+ metadata: Record<string, unknown>;
12
+ }
13
+
14
+ export interface ReportRenderOptions {
15
+ theme?: "dark" | "light" | "auto";
16
+ includeChrome?: boolean;
17
+ }
18
+
19
+ interface ReportSectionsPayload {
20
+ summary?: string;
21
+ system?: SectionBuckets;
22
+ perAgent?: Array<AgentSection>;
23
+ dataCoverage?: string[];
24
+ }
25
+
26
+ interface SectionBuckets {
27
+ wins?: string[];
28
+ highlights?: string[];
29
+ lowlights?: string[];
30
+ proposals?: string[];
31
+ deepDives?: string[];
32
+ }
33
+
34
+ interface AgentSection extends SectionBuckets {
35
+ agentId: string;
36
+ agentName?: string;
37
+ }
38
+
39
+ function asObj(v: unknown): Record<string, unknown> {
40
+ return v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
41
+ }
42
+
43
+ function asString(v: unknown): string | undefined {
44
+ return typeof v === "string" && v.trim() ? v : undefined;
45
+ }
46
+
47
+ function asStringArray(v: unknown): string[] {
48
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map((x) => x.trim()).filter(Boolean) : [];
49
+ }
50
+
51
+ function extract(record: ReportRecord): { sections: ReportSectionsPayload; order: string[]; enabled: Set<string>; branding: ReportBranding } {
52
+ const metadata = asObj(record.metadata);
53
+ const sections = asObj(metadata.sections);
54
+ const settings = asObj(metadata.settings);
55
+ const branding = asObj(settings.branding);
56
+ const order = asStringArray(settings.sectionOrder);
57
+ const enabled = new Set(asStringArray(settings.enabledSections));
58
+ return {
59
+ sections: {
60
+ summary: asString(sections.summary),
61
+ system: asObj(sections.system) as SectionBuckets,
62
+ perAgent: Array.isArray(sections.perAgent)
63
+ ? sections.perAgent.map((item) => {
64
+ const agent = asObj(item);
65
+ return {
66
+ agentId: asString(agent.agentId) ?? "unknown",
67
+ agentName: asString(agent.agentName),
68
+ wins: asStringArray(agent.wins),
69
+ highlights: asStringArray(agent.highlights),
70
+ lowlights: asStringArray(agent.lowlights),
71
+ proposals: asStringArray(agent.proposals),
72
+ deepDives: asStringArray(agent.deepDives),
73
+ };
74
+ })
75
+ : [],
76
+ dataCoverage: asStringArray(sections.dataCoverage),
77
+ },
78
+ order,
79
+ enabled,
80
+ branding: {
81
+ accentColor: asString(branding.accentColor),
82
+ logoDataUri: asString(branding.logoDataUri),
83
+ logoTextColor: asString(branding.logoTextColor),
84
+ },
85
+ };
86
+ }
87
+
88
+ function listSection(title: string, items: string[] | undefined, marker: string): string {
89
+ if (!items || items.length === 0) return "";
90
+ return `<section class="panel" data-section="${marker}"><h3>${escapeHtml(title)}</h3><ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
91
+ }
92
+
93
+ function cadenceLabel(cadence: string): string {
94
+ return cadence.charAt(0).toUpperCase() + cadence.slice(1);
95
+ }
96
+
97
+ export function renderReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
98
+ const { sections, order, enabled, branding } = extract(record);
99
+ const dataTheme = options.theme && options.theme !== "auto" ? options.theme : "dark";
100
+ const includeChrome = options.includeChrome !== false;
101
+ const title = asString(record.title) ?? "Fusion Activity Report";
102
+ const summary = sections.summary ? `<section class="report-section" data-section="summary"><h2 class="section-title">Executive Summary</h2><p>${escapeHtml(sections.summary)}</p></section>` : "";
103
+ const system = sections.system ?? {};
104
+ const systemMap: Record<string, string> = {
105
+ wins: listSection("Wins", system.wins, "system-wins"),
106
+ highlights: listSection("Highlights", system.highlights, "system-highlights"),
107
+ lowlights: listSection("Lowlights", system.lowlights, "system-lowlights"),
108
+ proposals: listSection("Proposals", system.proposals, "system-proposals"),
109
+ "deep-dives": listSection("Deep dives", system.deepDives, "system-deep-dives"),
110
+ };
111
+ const orderedKeys = order.length > 0 ? [...order, ...Object.keys(systemMap).filter((k) => !order.includes(k))] : Object.keys(systemMap);
112
+ const systemSections = orderedKeys
113
+ .filter((key) => key in systemMap)
114
+ .filter((key) => enabled.size === 0 || enabled.has(key))
115
+ .map((key) => systemMap[key])
116
+ .join("");
117
+
118
+ const perAgent = sections.perAgent ?? [];
119
+ const perAgentHtml = (enabled.size === 0 || enabled.has("per-agent"))
120
+ ? `<section class="report-section" data-section="agent-card"><h2 class="section-title">Per-agent sections</h2>${perAgent.map((agent) => `<article class="agent-card" data-agent-id="${escapeAttr(agent.agentId)}"><h3>${escapeHtml(agent.agentName ?? agent.agentId)}</h3><div class="section-grid">${listSection("Wins", agent.wins, "agent-wins")}${listSection("Highlights", agent.highlights, "agent-highlights")}${listSection("Lowlights", agent.lowlights, "agent-lowlights")}${listSection("Proposals", agent.proposals, "agent-proposals")}${listSection("Deep dives", agent.deepDives, "agent-deep-dives")}</div></article>`).join("")}</section>`
121
+ : "";
122
+
123
+ const coverage = sections.dataCoverage ?? ["Task board", "Agent activity", "Missions", "Run audit", "Workflow/test/build", "Manual notes"];
124
+ const coverageSection = `<section class="report-footer" data-section="data-coverage"><h2 class="section-title">Data sources & coverage</h2><ul>${coverage.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
125
+
126
+ const review = (record.combinedReview as CombinedReview | null) ?? null;
127
+ const reviewSection = review
128
+ ? `<section class="report-footer" data-section="review-panel"><h2 class="section-title">Review panel summary</h2><p>${escapeHtml(review.overallVerdict)} — ${escapeHtml(review.consensusSummary)}</p><ul>${review.individual.map((member) => `<li>${escapeHtml(member.memberName)}: ${escapeHtml(member.verdict)}</li>`).join("")}</ul></section>`
129
+ : "";
130
+
131
+ const header = `<header class="report-header"><h1 class="report-title">${escapeHtml(title)}</h1><div class="report-meta"><span class="pill">${escapeHtml(cadenceLabel(record.cadence))}</span><span class="pill">${escapeHtml(record.periodStart)} → ${escapeHtml(record.periodEnd)}</span><span class="pill">Generated ${escapeHtml(record.generationCompletedAt ?? record.updatedAt)}</span><span class="pill status">${escapeHtml(record.status)}</span></div>${branding.logoDataUri ? `<p><img src="${escapeAttr(branding.logoDataUri)}" alt="Logo" style="max-height:36px"/></p>` : ""}</header>`;
132
+
133
+ const article = `<article class="report">${header}${summary}${systemSections ? `<section class="report-section"><h2 class="section-title">System-wide rollup</h2><div class="section-grid">${systemSections}</div></section>` : ""}${perAgentHtml}${coverageSection}${reviewSection}</article>`;
134
+
135
+ if (!includeChrome) return article;
136
+ return `<!doctype html><html lang="en" data-theme="${escapeAttr(dataTheme)}"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(title)}</title><style>${REPORT_STYLESHEET}\n${buildBrandingCss(branding)}</style></head><body>${article}</body></html>`;
137
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./escape.js";
2
+ export * from "./html-styles.js";
3
+ export * from "./html-template.js";
4
+ export * from "./standalone-html.js";
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import type { Report } from "../store/report-types.js";
4
+ import { escapeAttr } from "./escape.js";
5
+ import { buildBrandingCss, REPORT_STYLESHEET } from "./html-styles.js";
6
+ import { renderReportHtml, type ReportRecord, type ReportRenderOptions } from "./html-template.js";
7
+
8
+ const ALLOWLISTED_LINK_PREFIXES = ["https://runfusion.ai"];
9
+
10
+ function inferMime(path: string): string {
11
+ const ext = extname(path).toLowerCase();
12
+ if (ext === ".png") return "image/png";
13
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
14
+ if (ext === ".gif") return "image/gif";
15
+ if (ext === ".webp") return "image/webp";
16
+ if (ext === ".svg") return "image/svg+xml";
17
+ return "application/octet-stream";
18
+ }
19
+
20
+ function toDataUri(path: string): string {
21
+ const mime = inferMime(path);
22
+ const bytes = readFileSync(path);
23
+ return `data:${mime};base64,${bytes.toString("base64")}`;
24
+ }
25
+
26
+ function removeScripts(html: string): string {
27
+ return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
28
+ }
29
+
30
+ function sanitizeExternalImages(html: string): string {
31
+ return html.replace(/<img\b([^>]*?)\ssrc=["'](https?:[^"']+)["']([^>]*)>/gi, "<!-- stripped external image -->");
32
+ }
33
+
34
+ function assertNoExternalRefs(html: string): string {
35
+ const matches = [...html.matchAll(/(href|src)\s*=\s*["'](https?:[^"']+)["']/gi)];
36
+ const disallowed = matches.filter((m) => !ALLOWLISTED_LINK_PREFIXES.some((prefix) => m[2]?.startsWith(prefix)));
37
+ if (disallowed.length === 0) return html;
38
+ if (process.env.NODE_ENV !== "production") {
39
+ throw new Error(`Standalone HTML contains external refs: ${disallowed.map((m) => m[2]).join(", ")}`);
40
+ }
41
+ return `${html}\n<!-- WARNING: stripped/retained external refs detected -->`;
42
+ }
43
+
44
+ function resolveBrandLogo(record: ReportRecord): string | undefined {
45
+ const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata as Record<string, unknown> : {};
46
+ const settings = metadata.settings && typeof metadata.settings === "object" ? metadata.settings as Record<string, unknown> : {};
47
+ const branding = settings.branding && typeof settings.branding === "object" ? settings.branding as Record<string, unknown> : {};
48
+ const logoDataUri = typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined;
49
+ const logoPath = typeof branding.logoPath === "string" ? branding.logoPath : undefined;
50
+ if (logoDataUri?.startsWith("data:")) return logoDataUri;
51
+ if (logoPath && !/^https?:/i.test(logoPath)) return toDataUri(logoPath);
52
+ return undefined;
53
+ }
54
+
55
+ export function slugifyReportFilename(record: Pick<Report, "title" | "periodStart" | "periodEnd">): string {
56
+ const base = `${record.title}-${record.periodStart}-${record.periodEnd}`.toLowerCase();
57
+ const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
58
+ return `${slug || "fusion-report"}.html`;
59
+ }
60
+
61
+ export function renderStandaloneReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
62
+ const logoDataUri = resolveBrandLogo(record);
63
+ const metadata = record.metadata && typeof record.metadata === "object" ? { ...(record.metadata as Record<string, unknown>) } : {};
64
+ const settings = metadata.settings && typeof metadata.settings === "object" ? { ...(metadata.settings as Record<string, unknown>) } : {};
65
+ const branding = settings.branding && typeof settings.branding === "object" ? { ...(settings.branding as Record<string, unknown>) } : {};
66
+ if (logoDataUri) branding.logoDataUri = logoDataUri;
67
+ settings.branding = branding;
68
+ metadata.settings = settings;
69
+
70
+ const html = renderReportHtml({ ...record, metadata }, { ...options, includeChrome: true });
71
+ const styleBlock = `<style>${REPORT_STYLESHEET}\n${buildBrandingCss({ accentColor: typeof branding.accentColor === "string" ? branding.accentColor : undefined, logoDataUri: typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined, logoTextColor: typeof branding.logoTextColor === "string" ? branding.logoTextColor : undefined })}</style>`;
72
+ const withSingleStyle = html.replace(/<style>[\s\S]*?<\/style>/i, styleBlock);
73
+ const sanitized = sanitizeExternalImages(removeScripts(withSingleStyle));
74
+ return assertNoExternalRefs(sanitized);
75
+ }
@@ -1,5 +1,12 @@
1
1
  import type { Database } from "@fusion/core";
2
2
 
3
+ function addColumnIfMissing(db: Database, table: string, column: string, ddl: string): boolean {
4
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
5
+ if (columns.some((entry) => entry.name === column)) return false;
6
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
7
+ return true;
8
+ }
9
+
3
10
  export function ensureReportSchema(db: Database): void {
4
11
  db.exec(`
5
12
  CREATE TABLE IF NOT EXISTS reports (
@@ -18,8 +25,12 @@ export function ensureReportSchema(db: Database): void {
18
25
  publishedAt TEXT,
19
26
  archivedAt TEXT,
20
27
  failureReason TEXT,
28
+ approval_state TEXT NOT NULL DEFAULT 'not_required',
29
+ approval_history TEXT NOT NULL DEFAULT '[]',
21
30
  draftMarkdown TEXT,
22
31
  renderedHtmlPath TEXT,
32
+ rendered_html TEXT,
33
+ rendered_html_generated_at TEXT,
23
34
  metadataJson TEXT NOT NULL DEFAULT '{}',
24
35
  combinedReviewJson TEXT,
25
36
  createdAt TEXT NOT NULL,
@@ -35,4 +46,24 @@ export function ensureReportSchema(db: Database): void {
35
46
  CREATE INDEX IF NOT EXISTS idxReportsPeriod
36
47
  ON reports(periodStart, periodEnd, id);
37
48
  `);
49
+
50
+ addColumnIfMissing(db, "reports", "rendered_html", "TEXT");
51
+ addColumnIfMissing(db, "reports", "rendered_html_generated_at", "TEXT");
52
+ addColumnIfMissing(db, "reports", "approval_state", "TEXT NOT NULL DEFAULT 'not_required'");
53
+ addColumnIfMissing(db, "reports", "approval_history", "TEXT NOT NULL DEFAULT '[]'");
54
+
55
+ db.exec(`
56
+ UPDATE reports
57
+ SET approval_state = 'published',
58
+ publishedAt = COALESCE(publishedAt, generationCompletedAt)
59
+ WHERE status = 'published';
60
+
61
+ UPDATE reports
62
+ SET approval_state = 'published'
63
+ WHERE status = 'approved';
64
+
65
+ UPDATE reports
66
+ SET approval_state = 'not_required'
67
+ WHERE status = 'review_complete';
68
+ `);
38
69
  }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { PluginContext } from "@fusion/core";
3
+ import type { Report } from "../../store/report-types.js";
4
+ import { createReportExportRoutes } from "../report-export-routes.js";
5
+
6
+ function report(overrides: Partial<Report> = {}): Report {
7
+ return {
8
+ id: "rep_1",
9
+ cadence: "daily",
10
+ periodStart: "2026-05-01",
11
+ periodEnd: "2026-05-02",
12
+ title: "Demo",
13
+ status: "review_complete",
14
+ generationStartedAt: "2026-05-02T00:00:00.000Z",
15
+ generationCompletedAt: "2026-05-02T00:01:00.000Z",
16
+ reviewStartedAt: null,
17
+ reviewCompletedAt: null,
18
+ approvedAt: null,
19
+ approvedBy: null,
20
+ publishedAt: null,
21
+ archivedAt: null,
22
+ failureReason: null,
23
+ approvalState: "not_required",
24
+ approvalHistory: [],
25
+ draftMarkdown: null,
26
+ renderedHtmlPath: null,
27
+ renderedHtml: null,
28
+ renderedHtmlGeneratedAt: null,
29
+ metadata: {},
30
+ combinedReview: null,
31
+ createdAt: "2026-05-02T00:00:00.000Z",
32
+ updatedAt: "2026-05-02T00:01:00.000Z",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function ctxWithStore(store: { getReport: (id: string) => Report | null; setRenderedHtml: (id: string, html: string) => void }): PluginContext {
38
+ return {
39
+ pluginId: "fusion-plugin-reports",
40
+ taskStore: { getDatabase: () => ({}), getReportStore: () => store } as any,
41
+ settings: {},
42
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
43
+ emitEvent: vi.fn(),
44
+ createAiSession: undefined,
45
+ resolveProjectTaskStore: undefined,
46
+ ...({} as any),
47
+ } as PluginContext;
48
+ }
49
+
50
+ describe("report export routes", () => {
51
+ it("returns export html with attachment header", async () => {
52
+ const routes = createReportExportRoutes();
53
+ const route = routes.find((r) => r.path.endsWith("export.html"))!;
54
+ const record = report();
55
+ const getReport = vi.fn().mockReturnValue(record);
56
+ const setRenderedHtml = vi.fn();
57
+ const ctx = ctxWithStore({ getReport, setRenderedHtml });
58
+ const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
59
+ expect(res.status).toBe(200);
60
+ expect(res.contentType).toContain("text/html");
61
+ expect(res.headers["Content-Disposition"]).toContain("attachment;");
62
+ });
63
+
64
+ it("returns 404 for missing id", async () => {
65
+ const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
66
+ const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(null), setRenderedHtml: vi.fn() });
67
+ const res = await route.handler({ params: { id: "missing" } }, ctx as any) as any;
68
+ expect(res.status).toBe(404);
69
+ });
70
+
71
+ it("returns 409 for generating report", async () => {
72
+ const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
73
+ const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report({ status: "generating" })), setRenderedHtml: vi.fn() });
74
+ const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
75
+ expect(res.status).toBe(409);
76
+ });
77
+
78
+ it("returns body-only preview html", async () => {
79
+ const route = createReportExportRoutes().find((r) => r.path.endsWith("preview.html"))!;
80
+ const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report()), setRenderedHtml: vi.fn() });
81
+ const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
82
+ expect(res.status).toBe(200);
83
+ expect(res.contentType).toContain("text/html");
84
+ expect(res.body).toContain("<article");
85
+ expect(res.body).not.toContain("<!doctype html>");
86
+ });
87
+
88
+ it("caches rendered html after first export", async () => {
89
+ const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
90
+ const mutable = report();
91
+ const getReport = vi.fn().mockImplementation(() => mutable);
92
+ const setRenderedHtml = vi.fn().mockImplementation((_id: string, html: string) => {
93
+ mutable.renderedHtml = html;
94
+ });
95
+ const ctx = ctxWithStore({ getReport, setRenderedHtml });
96
+
97
+ const first = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
98
+ const second = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
99
+
100
+ expect(first.status).toBe(200);
101
+ expect(second.status).toBe(200);
102
+ expect(setRenderedHtml).toHaveBeenCalledTimes(1);
103
+ });
104
+ });
@@ -0,0 +1,98 @@
1
+ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
2
+ import { applyDecision, type ApprovalActor, type ApprovalDecision, type ApprovalSettings } from "../approval.js";
3
+ import { getApprovalRequired, getApproverAgentIds, getAutoPublishOnApproval, getPublishTargets } from "../settings.js";
4
+ import { buildShareBlocks } from "../share-blocks.js";
5
+ import { ReportStore } from "../store/report-store.js";
6
+
7
+ interface RouteRequest {
8
+ params: Record<string, string>;
9
+ body?: Record<string, unknown>;
10
+ headers?: Record<string, string | undefined>;
11
+ }
12
+
13
+ const reportStoreCache = new WeakMap<object, ReportStore>();
14
+
15
+ function getStore(ctx: PluginContext): ReportStore {
16
+ const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore };
17
+ if (typeof taskStoreWithReports.getReportStore === "function") return taskStoreWithReports.getReportStore();
18
+ const key = ctx.taskStore as object;
19
+ const cached = reportStoreCache.get(key);
20
+ if (cached) return cached;
21
+ const store = new ReportStore(ctx.taskStore.getDatabase());
22
+ reportStoreCache.set(key, store);
23
+ return store;
24
+ }
25
+
26
+ function settingsFromContext(ctx: PluginContext): ApprovalSettings {
27
+ return {
28
+ approvalRequired: getApprovalRequired(ctx.settings),
29
+ autoPublishOnApproval: getAutoPublishOnApproval(ctx.settings),
30
+ approverAgentIds: getApproverAgentIds(ctx.settings),
31
+ publishTargets: getPublishTargets(ctx.settings),
32
+ };
33
+ }
34
+
35
+ function actorFromRequest(request: RouteRequest, ctx: PluginContext): ApprovalActor {
36
+ const actorType = request.headers?.["x-fusion-actor-type"];
37
+ const actorId = request.headers?.["x-fusion-user"]
38
+ ?? (typeof request.body?.decidedBy === "string" ? request.body.decidedBy : undefined)
39
+ ?? "unknown";
40
+ if (!request.headers?.["x-fusion-user"] && !request.body?.decidedBy) {
41
+ ctx.logger.warn("reports approval route missing actor identity; using unknown");
42
+ }
43
+ return { id: actorId, type: actorType === "agent" ? "agent" : "human" };
44
+ }
45
+
46
+ function decisionFromRequest(request: RouteRequest, action: ApprovalDecision["action"], actor: ApprovalActor): ApprovalDecision {
47
+ return {
48
+ action,
49
+ decidedBy: actor.id,
50
+ decidedAt: new Date().toISOString(),
51
+ note: typeof request.body?.note === "string" && request.body.note.trim().length > 0 ? request.body.note.trim() : undefined,
52
+ };
53
+ }
54
+
55
+ function notFound(id: string): PluginRouteResponse {
56
+ return { status: 404, body: { error: `Report ${id} not found` } };
57
+ }
58
+
59
+ export function createReportApprovalRoutes(): PluginRouteDefinition[] {
60
+ const mutate = (action: ApprovalDecision["action"]) => async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
61
+ const request = req as RouteRequest;
62
+ const reportId = request.params.id;
63
+ const store = getStore(ctx);
64
+ const report = store.getReport(reportId);
65
+ if (!report) return notFound(reportId);
66
+
67
+ const actor = actorFromRequest(request, ctx);
68
+ const settings = settingsFromContext(ctx);
69
+ const decision = decisionFromRequest(request, action, actor);
70
+ const result = applyDecision(report, decision, settings, actor);
71
+ if ("error" in result) {
72
+ return { status: result.error === "unauthorized" ? 403 : 409, body: { error: result.error } };
73
+ }
74
+
75
+ const updated = store.updateReport(reportId, result.updatedReport);
76
+ return { status: 200, body: { report: updated, sideEffects: result.sideEffects } };
77
+ };
78
+
79
+ return [
80
+ { method: "POST", path: "/reports/:id/approve", handler: mutate("approve") },
81
+ { method: "POST", path: "/reports/:id/reject", handler: mutate("reject") },
82
+ { method: "POST", path: "/reports/:id/publish", handler: mutate("publish") },
83
+ {
84
+ method: "GET",
85
+ path: "/reports/:id/share-blocks",
86
+ handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
87
+ const request = req as RouteRequest;
88
+ const reportId = request.params.id;
89
+ const report = getStore(ctx).getReport(reportId);
90
+ if (!report) return notFound(reportId);
91
+ if (!(report.approvalState === "approved" || report.approvalState === "published")) {
92
+ return { status: 409, body: { error: "Share blocks unlock after approval" } };
93
+ }
94
+ return { status: 200, body: buildShareBlocks(report) };
95
+ },
96
+ },
97
+ ];
98
+ }