@rubytech/create-sitedesk-code 0.1.429 → 0.1.432

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 (144) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -3
  3. package/payload/platform/plugins/docs/PLUGIN.md +1 -0
  4. package/payload/platform/plugins/docs/references/internals.md +2 -2
  5. package/payload/platform/plugins/docs/references/workflows.md +61 -0
  6. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts +8 -1
  7. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.d.ts.map +1 -1
  8. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js +8 -1
  9. package/payload/platform/plugins/whatsapp/mcp/dist/resolve-read-account.js.map +1 -1
  10. package/payload/platform/plugins/workflows/PLUGIN.md +29 -9
  11. package/payload/platform/plugins/workflows/mcp/dist/index.js +34 -0
  12. package/payload/platform/plugins/workflows/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts +2 -2
  14. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts.map +1 -1
  15. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.js.map +1 -1
  16. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts +25 -0
  17. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts.map +1 -1
  18. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js +14 -1
  19. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js.map +1 -1
  20. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts +25 -1
  21. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts.map +1 -1
  22. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +79 -15
  23. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  24. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts +2 -0
  25. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts.map +1 -1
  26. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js +4 -0
  27. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js.map +1 -1
  28. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts +3 -1
  29. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts.map +1 -1
  30. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js +8 -1
  31. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js.map +1 -1
  32. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +24 -3
  33. package/payload/platform/services/telegram-channel/dist/instructions.js +1 -1
  34. package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
  35. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -5
  36. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  37. package/payload/platform/services/telegram-channel/dist/notification.js +9 -6
  38. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  39. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  40. package/payload/platform/services/telegram-channel/dist/server.js +15 -5
  41. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  42. package/payload/platform/services/webchat-channel/dist/instructions.js +1 -1
  43. package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
  44. package/payload/platform/services/webchat-channel/dist/notification.d.ts +12 -10
  45. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  46. package/payload/platform/services/webchat-channel/dist/notification.js +12 -11
  47. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  48. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  49. package/payload/platform/services/webchat-channel/dist/server.js +12 -1
  50. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  51. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +11 -6
  52. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/notification.js +11 -8
  54. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  55. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  56. package/payload/platform/services/whatsapp-channel/dist/server.js +57 -14
  57. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  58. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +26 -2
  59. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  60. package/payload/platform/services/whatsapp-channel/dist/targets.js +50 -2
  61. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  62. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.d.ts +8 -0
  63. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.d.ts.map +1 -0
  64. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.js +71 -0
  65. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/dist/index.js.map +1 -0
  66. package/payload/premium-plugins/sitedesk/lib/mcp-lifeline/package.json +7 -0
  67. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/PLUGIN.md +39 -3
  68. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.d.ts +2 -0
  69. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.d.ts.map +1 -0
  70. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.js +32 -0
  71. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/index.js.map +1 -0
  72. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.d.ts +10 -0
  73. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.d.ts.map +1 -0
  74. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.js +21 -0
  75. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/account.js.map +1 -0
  76. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.d.ts +24 -0
  77. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.d.ts.map +1 -0
  78. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.js +59 -0
  79. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/engine-run.js.map +1 -0
  80. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts +22 -0
  81. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.d.ts.map +1 -0
  82. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js +40 -0
  83. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/gates.js.map +1 -0
  84. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.d.ts +47 -0
  85. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.d.ts.map +1 -0
  86. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.js +60 -0
  87. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/geometry.js.map +1 -0
  88. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.d.ts +7 -0
  89. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.d.ts.map +1 -0
  90. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.js +9 -0
  91. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/log.js.map +1 -0
  92. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts +29 -0
  93. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.d.ts.map +1 -0
  94. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js +113 -0
  95. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/lib/render-run.js.map +1 -0
  96. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.d.ts +3 -0
  97. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.d.ts.map +1 -0
  98. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.js +48 -0
  99. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-engine-run.js.map +1 -0
  100. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.d.ts +3 -0
  101. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.d.ts.map +1 -0
  102. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.js +50 -0
  103. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/quote-render.js.map +1 -0
  104. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.d.ts +3 -0
  105. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.d.ts.map +1 -0
  106. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.js +42 -0
  107. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/dist/tools/takeoff-compute.js.map +1 -0
  108. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/package-lock.json +2684 -0
  109. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/package.json +21 -0
  110. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/index.ts +39 -0
  111. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/account.test.ts +49 -0
  112. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/engine-run.test.ts +105 -0
  113. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/gates.test.ts +51 -0
  114. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/geometry.test.ts +83 -0
  115. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/__tests__/render-run.test.ts +111 -0
  116. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/account.ts +20 -0
  117. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/engine-run.ts +80 -0
  118. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/gates.ts +39 -0
  119. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/geometry.ts +93 -0
  120. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/log.ts +8 -0
  121. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/lib/render-run.ts +125 -0
  122. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/quote-engine-run.ts +66 -0
  123. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/quote-render.ts +66 -0
  124. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/src/tools/takeoff-compute.ts +51 -0
  125. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/mcp/tsconfig.json +20 -0
  126. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-engine.md +24 -0
  127. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/quotation/references/quote-generation.md +67 -51
  128. package/payload/premium-plugins/sitedesk/plugins/sitedesk-job/skills/takeoff/SKILL.md +12 -0
  129. package/payload/server/public/assets/{AdminLoginScreens-C4AZT7cQ.js → AdminLoginScreens-BM45lL5G.js} +1 -1
  130. package/payload/server/public/assets/{admin-SaF1F4e-.js → admin-B8Eb9Wbj.js} +1 -1
  131. package/payload/server/public/assets/{audio-attachment-mime-BBW07eyG.js → audio-attachment-mime-Cx5d3Jls.js} +1 -1
  132. package/payload/server/public/assets/{chat-B9Qjt7C9.js → chat-LEEUAJOG.js} +1 -1
  133. package/payload/server/public/assets/data-CKojMQSz.js +1 -0
  134. package/payload/server/public/assets/{operator-CTJjI8te.js → operator-OwY_sWTH.js} +1 -1
  135. package/payload/server/public/assets/{page-CbI1wtef.js → page-B7nCws9K.js} +1 -1
  136. package/payload/server/public/assets/{page-JzxyFGle.js → page-C3L6-Ufk.js} +5 -5
  137. package/payload/server/public/assets/{public-DzeW3_eM.js → public-CSLXSSy5.js} +1 -1
  138. package/payload/server/public/chat.html +3 -3
  139. package/payload/server/public/data.html +3 -3
  140. package/payload/server/public/index.html +4 -4
  141. package/payload/server/public/operator.html +4 -4
  142. package/payload/server/public/public.html +3 -3
  143. package/payload/server/server.js +1647 -1470
  144. package/payload/server/public/assets/data-BKE6Sa-g.js +0 -1
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@maxy/sitedesk-job",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.12.1",
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.0",
18
+ "@types/node": "^22.0.0",
19
+ "vitest": "^4.1.2"
20
+ }
21
+ }
@@ -0,0 +1,39 @@
1
+ // sitedesk-job MCP server — apply-path toolification (Task 1540).
2
+ //
3
+ // Three pure-executor tools, all admin-side, over the operator's persisted
4
+ // per-account artifacts under $ACCOUNT_DIR/quoting/ plus provided inputs:
5
+ // quote-engine-run execute the already-persisted pricing engine → figures
6
+ // quote-render figures + persisted render script → three reconciling HTML docs
7
+ // takeoff-compute deterministic scale/area/count geometry → tagged quantity list
8
+ //
9
+ // The server embeds no item, rate, margin, roll-up, template, or fixed schema:
10
+ // it runs the operator's own scripts, it is not a pricing model. Module init
11
+ // never reads per-account env, so plugin enumeration with empty env boots the
12
+ // registry without faulting (CLAUDE.md spawn-enumeration invariant).
13
+
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+
17
+ import { logLine } from "./lib/log.js";
18
+ import { registerTakeoffCompute } from "./tools/takeoff-compute.js";
19
+ import { registerQuoteEngineRun } from "./tools/quote-engine-run.js";
20
+ import { registerQuoteRender } from "./tools/quote-render.js";
21
+
22
+ const accountId = process.env.ACCOUNT_ID ?? "local";
23
+
24
+ const server = new McpServer({ name: "maxy-sitedesk-job", version: "0.1.0" });
25
+
26
+ logLine(`[sitedesk-job] server started, account=${accountId}`);
27
+
28
+ registerTakeoffCompute(server);
29
+ registerQuoteEngineRun(server);
30
+ registerQuoteRender(server);
31
+
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+
35
+ function shutdown(): void {
36
+ process.exit(0);
37
+ }
38
+ process.on("SIGINT", shutdown);
39
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { resolveAccountDir } from "../account.js";
3
+
4
+ const SAVE = { ...process.env };
5
+
6
+ describe("resolveAccountDir", () => {
7
+ beforeEach(() => {
8
+ delete process.env.ACCOUNT_DIR;
9
+ delete process.env.PLATFORM_ROOT;
10
+ delete process.env.ACCOUNT_ID;
11
+ });
12
+ afterEach(() => {
13
+ process.env = { ...SAVE };
14
+ });
15
+
16
+ it("returns ACCOUNT_DIR verbatim when set", () => {
17
+ process.env.ACCOUNT_DIR = "/data/accounts/acme";
18
+ expect(resolveAccountDir()).toBe("/data/accounts/acme");
19
+ });
20
+
21
+ it("derives from PLATFORM_ROOT + ACCOUNT_ID when ACCOUNT_DIR absent", () => {
22
+ process.env.PLATFORM_ROOT = "/opt/maxy/platform";
23
+ process.env.ACCOUNT_ID = "acme";
24
+ expect(resolveAccountDir()).toBe("/opt/maxy/data/accounts/acme");
25
+ });
26
+
27
+ it("returns null when neither ACCOUNT_DIR nor PLATFORM_ROOT+ACCOUNT_ID is resolvable", () => {
28
+ expect(resolveAccountDir()).toBeNull();
29
+ });
30
+
31
+ it("rejects an ACCOUNT_ID containing a path separator", () => {
32
+ process.env.PLATFORM_ROOT = "/opt/maxy/platform";
33
+ process.env.ACCOUNT_ID = "acme/evil";
34
+ expect(resolveAccountDir()).toBeNull();
35
+ });
36
+
37
+ it("rejects an ACCOUNT_ID containing a parent traversal", () => {
38
+ process.env.PLATFORM_ROOT = "/opt/maxy/platform";
39
+ process.env.ACCOUNT_ID = "..";
40
+ expect(resolveAccountDir()).toBeNull();
41
+ });
42
+
43
+ it("ignores an empty/whitespace ACCOUNT_DIR and falls back", () => {
44
+ process.env.ACCOUNT_DIR = " ";
45
+ process.env.PLATFORM_ROOT = "/opt/maxy/platform";
46
+ process.env.ACCOUNT_ID = "acme";
47
+ expect(resolveAccountDir()).toBe("/opt/maxy/data/accounts/acme");
48
+ });
49
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { runEngine, writeFigures } from "../engine-run.js";
6
+
7
+ // A fixture engine simulating the operator's own persisted script: reads
8
+ // {methodPath, job, mode} on stdin, prices qty*rate, emits figures (quote) or a
9
+ // per-level delta (verify). Its rate lives in the fixture method, never here.
10
+ const GOOD_ENGINE = `
11
+ import { readFileSync } from "node:fs";
12
+ let input = "";
13
+ process.stdin.on("data", (d) => (input += d));
14
+ process.stdin.on("end", () => {
15
+ const { methodPath, job, mode } = JSON.parse(input);
16
+ const method = JSON.parse(readFileSync(methodPath, "utf8"));
17
+ const lines = job.lines.map((l, i) => ({ item: l.item ?? "line" + i, quantity: l.qty, quantitySource: "entered", sell: l.qty * method.rate, group: "all" }));
18
+ const total = lines.reduce((s, l) => s + l.sell, 0);
19
+ if (mode === "verify") {
20
+ const deltas = lines.map((l, i) => ({ level: "line", id: l.item, computed: l.sell, issued: job.lines[i].issued, delta: l.sell - job.lines[i].issued }));
21
+ deltas.push({ level: "total", id: "total", computed: total, issued: job.issuedTotal, delta: total - job.issuedTotal });
22
+ process.stdout.write(JSON.stringify({ deltas, worst: Math.max(...deltas.map((d) => Math.abs(d.delta))) }));
23
+ return;
24
+ }
25
+ process.stdout.write(JSON.stringify({ lines, groups: [{ name: "all", subtotal: total }], rollup: [], total, reconcile: { pass: true } }));
26
+ });
27
+ `;
28
+ const THROW_ENGINE = `process.stdin.resume(); process.stderr.write("boom"); process.exit(1);`;
29
+ const BADOUT_ENGINE = `process.stdin.resume(); process.stdout.write("not json at all");`;
30
+ const NOTOTAL_ENGINE = `process.stdin.resume(); process.stdout.write(JSON.stringify({ lines: [], groups: [] }));`;
31
+
32
+ let dir: string;
33
+ const job = { lines: [{ item: "a", qty: 3, issued: 30 }, { item: "b", qty: 2, issued: 20 }], issuedTotal: 50, header: { phone: "44700900000" } };
34
+
35
+ beforeAll(() => {
36
+ dir = mkdtempSync(join(tmpdir(), "sd-engine-"));
37
+ mkdirSync(join(dir, "quoting"), { recursive: true });
38
+ writeFileSync(join(dir, "quoting", "engine.mjs"), GOOD_ENGINE);
39
+ writeFileSync(join(dir, "quoting", "engine-throw.mjs"), THROW_ENGINE);
40
+ writeFileSync(join(dir, "quoting", "engine-badout.mjs"), BADOUT_ENGINE);
41
+ writeFileSync(join(dir, "quoting", "engine-nototal.mjs"), NOTOTAL_ENGINE);
42
+ writeFileSync(join(dir, "quoting", "method.json"), JSON.stringify({ rate: 10 }));
43
+ });
44
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
45
+
46
+ const eng = (name: string) => join(dir, "quoting", name);
47
+ const method = () => join(dir, "quoting", "method.json");
48
+
49
+ describe("runEngine — success", () => {
50
+ it("reproduces the job's total in quote mode", async () => {
51
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine.mjs"), methodPath: method(), job, mode: "quote" });
52
+ expect(r.ok).toBe(true);
53
+ if (r.ok) expect((r.figures as { total: number }).total).toBe(50);
54
+ });
55
+
56
+ it("returns an all-zero delta in verify mode when the engine matches history", async () => {
57
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine.mjs"), methodPath: method(), job, mode: "verify" });
58
+ expect(r.ok).toBe(true);
59
+ if (r.ok) expect((r.figures as { worst: number }).worst).toBe(0);
60
+ });
61
+ });
62
+
63
+ describe("runEngine — faults return zero figures and a distinct reason", () => {
64
+ it("engine-absent when the engine file is missing", async () => {
65
+ const r = await runEngine({ accountDir: dir, enginePath: eng("nope.mjs"), methodPath: method(), job, mode: "quote" });
66
+ expect(r).toMatchObject({ ok: false, reason: "engine-absent" });
67
+ });
68
+
69
+ it("method-absent when the method file is missing", async () => {
70
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine.mjs"), methodPath: eng("no-method.json"), job, mode: "quote" });
71
+ expect(r).toMatchObject({ ok: false, reason: "method-absent" });
72
+ });
73
+
74
+ it("script-error when the engine exits non-zero", async () => {
75
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine-throw.mjs"), methodPath: method(), job, mode: "quote" });
76
+ expect(r).toMatchObject({ ok: false, reason: "script-error" });
77
+ });
78
+
79
+ it("script-error when the engine emits non-JSON", async () => {
80
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine-badout.mjs"), methodPath: method(), job, mode: "quote" });
81
+ expect(r).toMatchObject({ ok: false, reason: "script-error" });
82
+ });
83
+
84
+ it("script-error when the quote output omits the total", async () => {
85
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine-nototal.mjs"), methodPath: method(), job, mode: "quote" });
86
+ expect(r).toMatchObject({ ok: false, reason: "script-error" });
87
+ });
88
+
89
+ it("returns script-error without crashing when the engine exits early under a large payload", async () => {
90
+ // 8000 lines serialise to ~180KB, well over the OS pipe buffer, so the stdin
91
+ // write races the early exit and raises EPIPE. Must surface as a clean fault.
92
+ const bigJob = { lines: Array.from({ length: 8000 }, (_, i) => ({ item: "item-" + i, qty: 1, issued: 1 })), issuedTotal: 8000, header: { phone: "44700900000" } };
93
+ const r = await runEngine({ accountDir: dir, enginePath: eng("engine-throw.mjs"), methodPath: method(), job: bigJob, mode: "quote" });
94
+ expect(r).toMatchObject({ ok: false, reason: "script-error" });
95
+ });
96
+ });
97
+
98
+ describe("writeFigures", () => {
99
+ it("persists figures to quoting/jobs/<jobId>/figures.json and returns the path", () => {
100
+ const p = writeFigures(dir, "job-xyz", { total: 50 });
101
+ expect(p).toBe(join(dir, "quoting", "jobs", "job-xyz", "figures.json"));
102
+ expect(existsSync(p)).toBe(true);
103
+ expect(JSON.parse(readFileSync(p, "utf8"))).toEqual({ total: 50 });
104
+ });
105
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { gateReconcile, gateMarginLeak, gateSignLink } from "../gates.js";
3
+
4
+ describe("gateReconcile — displayed total must equal the engine total", () => {
5
+ it("passes when data-quote-total matches", () => {
6
+ expect(gateReconcile('<p data-quote-total="1250.00">Total £1,250</p>', 1250)).toEqual({ pass: true, shown: 1250 });
7
+ });
8
+ it("fails when the displayed total differs", () => {
9
+ expect(gateReconcile('<p data-quote-total="1300">x</p>', 1250)).toEqual({ pass: false, shown: 1300 });
10
+ });
11
+ it("fails closed when no data-quote-total is present", () => {
12
+ expect(gateReconcile("<p>Total £1,250</p>", 1250)).toEqual({ pass: false });
13
+ });
14
+ it("fails closed on an empty data-quote-total marker, even when the engine total is zero", () => {
15
+ expect(gateReconcile('<p data-quote-total="">x</p>', 0)).toEqual({ pass: false });
16
+ });
17
+ });
18
+
19
+ describe("gateMarginLeak — client docs must not carry cost/margin markers", () => {
20
+ it("fails when data-internal-only is present", () => {
21
+ expect(gateMarginLeak('<td data-internal-only>cost £900</td>').pass).toBe(false);
22
+ });
23
+ it("passes when no data-internal-only marker is present", () => {
24
+ expect(gateMarginLeak("<td>£1,250</td>").pass).toBe(true);
25
+ });
26
+ });
27
+
28
+ describe("gateSignLink — client quote's acceptance anchor must be absolute https", () => {
29
+ it("passes an absolute https href", () => {
30
+ const r = gateSignLink('<a data-esign-accept href="https://q.pages.dev/sign">Sign online</a>');
31
+ expect(r).toEqual({ pass: true, href: "https://q.pages.dev/sign" });
32
+ });
33
+ it("fails no-anchor when the marked anchor is absent", () => {
34
+ expect(gateSignLink('<a href="https://q.pages.dev/sign">Sign</a>')).toEqual({ pass: false, reason: "no-anchor" });
35
+ });
36
+ it("fails empty on an empty href", () => {
37
+ expect(gateSignLink('<a data-esign-accept href="">Sign</a>')).toEqual({ pass: false, href: "", reason: "empty" });
38
+ });
39
+ it("fails fragment on a bare fragment", () => {
40
+ expect(gateSignLink('<a data-esign-accept href="#">Sign</a>')).toEqual({ pass: false, href: "#", reason: "fragment" });
41
+ });
42
+ it("fails non-https on a file:// href", () => {
43
+ expect(gateSignLink('<a data-esign-accept href="file:///tmp/x">Sign</a>')).toEqual({ pass: false, href: "file:///tmp/x", reason: "non-https" });
44
+ });
45
+ it("fails non-https on an http:// href", () => {
46
+ expect(gateSignLink('<a data-esign-accept href="http://q/sign">Sign</a>')).toEqual({ pass: false, href: "http://q/sign", reason: "non-https" });
47
+ });
48
+ it("fails relative on a rooted path", () => {
49
+ expect(gateSignLink('<a data-esign-accept href="/sign">Sign</a>')).toEqual({ pass: false, href: "/sign", reason: "relative" });
50
+ });
51
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeTakeoff } from "../geometry.js";
3
+
4
+ describe("computeTakeoff — bases", () => {
5
+ it("passes a printed dimension through exactly, keeping its unit", () => {
6
+ const r = computeTakeoff({ lines: [{ element: "external wall run", basis: "printed", value: 24.6, unit: "m" }] });
7
+ expect(r.lines[0]).toMatchObject({ element: "external wall run", quantity: 24.6, unit: "m", basis: "printed" });
8
+ });
9
+
10
+ it("passes an operator-supplied figure through exactly", () => {
11
+ const r = computeTakeoff({ lines: [{ element: "manholes", basis: "operator", value: 8, unit: "no." }] });
12
+ expect(r.lines[0].quantity).toBe(8);
13
+ });
14
+
15
+ it("derives a scaled length from a stated ratio", () => {
16
+ const r = computeTakeoff({ scale: { ratio: 50 }, lines: [{ element: "gable", basis: "scaled", measured: 0.5, kind: "length", unit: "m" }] });
17
+ expect(r.lines[0].quantity).toBe(25);
18
+ });
19
+
20
+ it("derives a scaled length from a known-dimension calibration", () => {
21
+ const r = computeTakeoff({ scale: { measuredRef: 100, actual: 5000 }, lines: [{ element: "gable", basis: "scaled", measured: 2, kind: "length" }] });
22
+ expect(r.lines[0].quantity).toBe(100); // factor 5000/100 = 50; 2*50
23
+ });
24
+
25
+ it("derives a scaled area as side x side x factor squared", () => {
26
+ const r = computeTakeoff({ scale: { ratio: 50 }, lines: [{ element: "kitchen floor", basis: "scaled", measured: 2, measuredB: 3, kind: "area", unit: "m2" }] });
27
+ expect(r.lines[0].quantity).toBe(15000); // 2*3*50^2
28
+ });
29
+
30
+ it("rolls up a list of counts by summation", () => {
31
+ const r = computeTakeoff({ lines: [{ element: "double sockets", basis: "count", count: [3, 4, 1] }] });
32
+ expect(r.lines[0].quantity).toBe(8);
33
+ });
34
+
35
+ it("passes a single count through", () => {
36
+ const r = computeTakeoff({ lines: [{ element: "double sockets", basis: "count", count: 8 }] });
37
+ expect(r.lines[0].quantity).toBe(8);
38
+ });
39
+ });
40
+
41
+ describe("computeTakeoff — cross-check", () => {
42
+ it("records agreement when a scaled quantity matches a printed figure within tolerance", () => {
43
+ const r = computeTakeoff({ scale: { ratio: 50 }, lines: [{ element: "gable", basis: "scaled", measured: 0.5, kind: "length", crossCheck: { value: 25, tolerance: 1 } }] });
44
+ expect(r.lines[0].crossCheck).toEqual({ printed: 25, delta: 0, disagree: false });
45
+ });
46
+
47
+ it("flags disagreement when a scaled quantity exceeds the printed figure by more than tolerance", () => {
48
+ const r = computeTakeoff({ scale: { ratio: 50 }, lines: [{ element: "gable", basis: "scaled", measured: 0.5, kind: "length", crossCheck: { value: 22, tolerance: 1 } }] });
49
+ expect(r.lines[0].crossCheck).toEqual({ printed: 22, delta: 3, disagree: true });
50
+ });
51
+ });
52
+
53
+ describe("computeTakeoff — counts split", () => {
54
+ it("reports how many lines are exact vs scale-approximated", () => {
55
+ const r = computeTakeoff({
56
+ scale: { ratio: 50 },
57
+ lines: [
58
+ { element: "wall", basis: "printed", value: 10 },
59
+ { element: "sockets", basis: "count", count: 4 },
60
+ { element: "floor", basis: "scaled", measured: 1, kind: "length" },
61
+ ],
62
+ });
63
+ expect(r.counts).toEqual({ exact: 2, scaled: 1 });
64
+ });
65
+ });
66
+
67
+ describe("computeTakeoff — faults (no partial list)", () => {
68
+ it("throws a named error when a scaled line has no scale", () => {
69
+ expect(() => computeTakeoff({ lines: [{ element: "gable", basis: "scaled", measured: 0.5, kind: "length" }] })).toThrow(/no scale/i);
70
+ });
71
+
72
+ it("throws a named error when a scaled area omits the second side", () => {
73
+ expect(() => computeTakeoff({ scale: { ratio: 50 }, lines: [{ element: "floor", basis: "scaled", measured: 2, kind: "area" }] })).toThrow(/second side/i);
74
+ });
75
+
76
+ it("throws a named error when a printed line omits its value", () => {
77
+ expect(() => computeTakeoff({ lines: [{ element: "wall", basis: "printed" }] })).toThrow(/value/i);
78
+ });
79
+
80
+ it("throws a named error when a calibration scale has a zero reference", () => {
81
+ expect(() => computeTakeoff({ scale: { measuredRef: 0, actual: 5000 }, lines: [{ element: "gable", basis: "scaled", measured: 2, kind: "length" }] })).toThrow(/scale/i);
82
+ });
83
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { runRender } from "../render-run.js";
6
+
7
+ // Fixture render scripts simulating the operator's own quoting/render.mjs: read
8
+ // {figures, signUrl}, emit the three HTML docs. Brand/layout is theirs; the tool
9
+ // only gates and files.
10
+ const GOOD = `
11
+ let input = "";
12
+ process.stdin.on("data", (d) => (input += d));
13
+ process.stdin.on("end", () => {
14
+ const { figures, signUrl } = JSON.parse(input);
15
+ const t = figures.total;
16
+ process.stdout.write(JSON.stringify({
17
+ internal: '<html><body><td data-internal-only>cost 900</td><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
18
+ breakdown: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p></body></html>',
19
+ quote: '<html><body><p data-quote-total="' + t + '">Total ' + t + '</p><a data-esign-accept href="' + signUrl + '">Sign online</a></body></html>',
20
+ }));
21
+ });
22
+ `;
23
+ const LEAK = GOOD.replace('breakdown: \'<html><body><p data-quote-total="\' + t + \'">Total \' + t + \'</p></body></html>\'',
24
+ 'breakdown: \'<html><body><td data-internal-only>cost 900</td><p data-quote-total="\' + t + \'">Total \' + t + \'</p></body></html>\'');
25
+ const DEADLINK = GOOD.replace('href="\' + signUrl + \'"', 'href=""');
26
+ // internal doc shows a total one higher than the engine total → reconcile-fail
27
+ const WRONGTOTAL = `
28
+ let input = "";
29
+ process.stdin.on("data", (d) => (input += d));
30
+ process.stdin.on("end", () => {
31
+ const { figures, signUrl } = JSON.parse(input);
32
+ const t = figures.total;
33
+ process.stdout.write(JSON.stringify({
34
+ internal: '<html><body><p data-quote-total="' + (t + 1) + '">Total</p></body></html>',
35
+ breakdown: '<html><body><p data-quote-total="' + t + '">Total</p></body></html>',
36
+ quote: '<html><body><p data-quote-total="' + t + '">Total</p><a data-esign-accept href="' + signUrl + '">Sign</a></body></html>',
37
+ }));
38
+ });
39
+ `;
40
+ const THROW = `process.stdin.resume(); process.exit(1);`;
41
+
42
+ let dir: string;
43
+ const figures = { total: 1250, header: { phone: "44700900000" }, lines: [] };
44
+ const signUrl = "https://q.pages.dev/sign";
45
+ const jobId = "job-1";
46
+
47
+ beforeAll(() => {
48
+ dir = mkdtempSync(join(tmpdir(), "sd-render-"));
49
+ mkdirSync(join(dir, "quoting"), { recursive: true });
50
+ writeFileSync(join(dir, "quoting", "render.mjs"), GOOD);
51
+ writeFileSync(join(dir, "quoting", "render-leak.mjs"), LEAK);
52
+ writeFileSync(join(dir, "quoting", "render-deadlink.mjs"), DEADLINK);
53
+ writeFileSync(join(dir, "quoting", "render-wrongtotal.mjs"), WRONGTOTAL);
54
+ writeFileSync(join(dir, "quoting", "render-throw.mjs"), THROW);
55
+ });
56
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
57
+ const rs = (n: string) => join(dir, "quoting", n);
58
+
59
+ describe("runRender — happy path files all three docs to their scopes", () => {
60
+ it("writes internal to admin scope, breakdown+quote to client scope, all gates green", async () => {
61
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render.mjs"), figures, signUrl, jobId });
62
+ expect(r.ok).toBe(true);
63
+ if (!r.ok) return;
64
+ expect(r.reconcile).toEqual({ internal: true, breakdown: true, quote: true });
65
+ expect(r.signLink.pass).toBe(true);
66
+ expect(r.docs.internal).toBe(join(dir, "memory/admin/customers/44700900000/quote-job-1-internal.html"));
67
+ expect(r.docs.breakdown).toBe(join(dir, "memory/users/44700900000/documents/quote-job-1-breakdown.html"));
68
+ expect(r.docs.quote).toBe(join(dir, "memory/users/44700900000/documents/quote-job-1.html"));
69
+ expect(existsSync(r.docs.internal) && existsSync(r.docs.breakdown) && existsSync(r.docs.quote)).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe("runRender — a failing gate is fail-closed and atomic (no doc filed)", () => {
74
+ it("margin-leak when a client doc carries data-internal-only", async () => {
75
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-leak.mjs"), figures, signUrl, jobId: "job-leak" });
76
+ expect(r).toMatchObject({ ok: false, reason: "margin-leak" });
77
+ expect(existsSync(join(dir, "memory/users/44700900000/documents/quote-job-leak.html"))).toBe(false);
78
+ });
79
+ it("sign-link when the client quote's acceptance href is dead", async () => {
80
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-deadlink.mjs"), figures, signUrl, jobId: "job-dead" });
81
+ expect(r).toMatchObject({ ok: false, reason: "sign-link" });
82
+ expect(existsSync(join(dir, "memory/admin/customers/44700900000/quote-job-dead-internal.html"))).toBe(false);
83
+ });
84
+ it("reconcile-fail when a doc's displayed total differs from the engine total", async () => {
85
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-wrongtotal.mjs"), figures, signUrl, jobId: "job-wt" });
86
+ expect(r).toMatchObject({ ok: false, reason: "reconcile-fail" });
87
+ });
88
+ it("render-error when the figures header phone contains a path traversal", async () => {
89
+ const evil = { total: 1250, header: { phone: "../../etc" }, lines: [] };
90
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render.mjs"), figures: evil, signUrl, jobId: "job-evil" });
91
+ expect(r).toMatchObject({ ok: false, reason: "render-error" });
92
+ expect(existsSync(join(dir, "memory/admin/customers/../../etc/quote-job-evil-internal.html"))).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe("runRender — script faults", () => {
97
+ it("render-absent when the render script is missing", async () => {
98
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("nope.mjs"), figures, signUrl, jobId });
99
+ expect(r).toMatchObject({ ok: false, reason: "render-absent" });
100
+ });
101
+ it("render-error when the render script exits non-zero", async () => {
102
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-throw.mjs"), figures, signUrl, jobId });
103
+ expect(r).toMatchObject({ ok: false, reason: "render-error" });
104
+ });
105
+
106
+ it("returns render-error without crashing when the render script exits early under a large payload", async () => {
107
+ const bigFigures = { total: 1250, header: { phone: "44700900000" }, lines: Array.from({ length: 8000 }, (_, i) => ({ item: "i" + i })) };
108
+ const r = await runRender({ accountDir: dir, renderScriptPath: rs("render-throw.mjs"), figures: bigFigures, signUrl, jobId: "job-big" });
109
+ expect(r).toMatchObject({ ok: false, reason: "render-error" });
110
+ });
111
+ });
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+
3
+ /**
4
+ * Resolve the account-scoped workspace root. `ACCOUNT_DIR` is the canonical
5
+ * spawn-set var; fall back to `PLATFORM_ROOT` + `ACCOUNT_ID` exactly as the
6
+ * memory plugin's kd-classify resolver does. Returns `null` when neither is
7
+ * resolvable — the caller raises a precise no-account-context fault rather than
8
+ * running an engine at an empty path. Reading env per call (never at module
9
+ * init) keeps plugin enumeration with empty env from faulting.
10
+ */
11
+ export function resolveAccountDir(): string | null {
12
+ const direct = process.env.ACCOUNT_DIR;
13
+ if (direct && direct.trim()) return direct;
14
+ const platformRoot = process.env.PLATFORM_ROOT;
15
+ const accountId = process.env.ACCOUNT_ID;
16
+ if (platformRoot && accountId && !accountId.includes("/") && !accountId.includes("..")) {
17
+ return join(platformRoot, "..", "data", "accounts", accountId);
18
+ }
19
+ return null;
20
+ }
@@ -0,0 +1,80 @@
1
+ // Execute the operator's already-persisted pricing engine as a child process.
2
+ // The engine is the operator's own bespoke script; this module never regenerates
3
+ // it, never parses the method, and embeds no rate/catalogue/roll-up. It fixes the
4
+ // I/O boundary a generated engine conforms to: {methodPath, job, mode} on stdin,
5
+ // figures JSON on stdout. A fault returns zero figures and a distinct cause.
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ export type EngineFault = "engine-absent" | "method-absent" | "script-error";
12
+ export type EngineMode = "quote" | "verify";
13
+
14
+ export type EngineResult =
15
+ | { ok: true; figures: Record<string, unknown> }
16
+ | { ok: false; reason: EngineFault; detail: string };
17
+
18
+ export interface RunEngineArgs {
19
+ accountDir: string;
20
+ enginePath: string; // absolute
21
+ methodPath: string; // absolute
22
+ job: unknown;
23
+ mode: EngineMode;
24
+ }
25
+
26
+ /** Spawn `node <enginePath>` in the account workspace, feed the job on stdin,
27
+ * and return the parsed figures. Existence of engine and method is gated first
28
+ * so a missing artifact is a named fault, never a script crash. */
29
+ export async function runEngine(a: RunEngineArgs): Promise<EngineResult> {
30
+ if (!existsSync(a.enginePath)) {
31
+ return { ok: false, reason: "engine-absent", detail: `no engine at ${a.enginePath}` };
32
+ }
33
+ if (!existsSync(a.methodPath)) {
34
+ return { ok: false, reason: "method-absent", detail: `no method at ${a.methodPath}` };
35
+ }
36
+
37
+ const payload = JSON.stringify({ methodPath: a.methodPath, job: a.job, mode: a.mode });
38
+ const { code, stdout, stderr } = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
39
+ const child = spawn("node", [a.enginePath], { cwd: a.accountDir });
40
+ let out = "";
41
+ let err = "";
42
+ child.stdout.on("data", (d) => (out += d));
43
+ child.stderr.on("data", (d) => (err += d));
44
+ child.on("error", (e) => resolve({ code: -1, stdout: out, stderr: String(e) }));
45
+ child.on("close", (c) => resolve({ code: c, stdout: out, stderr: err }));
46
+ // A script that exits before draining stdin closes the read end mid-write,
47
+ // raising EPIPE. Swallow it: the exit code from `close` carries the fault, so
48
+ // an early exit surfaces as script-error rather than crashing the server.
49
+ child.stdin.on("error", () => {});
50
+ child.stdin.write(payload);
51
+ child.stdin.end();
52
+ });
53
+
54
+ if (code !== 0) {
55
+ return { ok: false, reason: "script-error", detail: `engine exited ${code}: ${stderr.trim().slice(0, 400)}` };
56
+ }
57
+
58
+ let figures: Record<string, unknown>;
59
+ try {
60
+ figures = JSON.parse(stdout);
61
+ } catch {
62
+ return { ok: false, reason: "script-error", detail: `engine stdout was not JSON: ${stdout.trim().slice(0, 200)}` };
63
+ }
64
+
65
+ const required = a.mode === "quote" ? ["total", "reconcile"] : ["deltas"];
66
+ const missing = required.filter((k) => !(k in figures));
67
+ if (missing.length > 0) {
68
+ return { ok: false, reason: "script-error", detail: `engine output missing required field(s): ${missing.join(", ")}` };
69
+ }
70
+
71
+ return { ok: true, figures };
72
+ }
73
+
74
+ /** Persist figures to quoting/jobs/<jobId>/figures.json and return the path. */
75
+ export function writeFigures(accountDir: string, jobId: string, figures: unknown): string {
76
+ const path = join(accountDir, "quoting", "jobs", jobId, "figures.json");
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ writeFileSync(path, JSON.stringify(figures, null, 2));
79
+ return path;
80
+ }
@@ -0,0 +1,39 @@
1
+ // The three deterministic, fail-closed gates quote-render enforces on the HTML
2
+ // the operator's render script emits. They are structural checks over stable
3
+ // attributes the render script marks — not business content:
4
+ // data-quote-total the displayed grand total (reconcile against the engine)
5
+ // data-internal-only cost/margin-bearing figures (must be absent from client docs)
6
+ // data-esign-accept the client quote's Sign-Online anchor (absolute https only)
7
+
8
+ export interface ReconcileVerdict { pass: boolean; shown?: number }
9
+ export interface LeakVerdict { pass: boolean }
10
+ export interface SignLinkVerdict { pass: boolean; href?: string; reason?: "no-anchor" | "empty" | "fragment" | "non-https" | "relative" }
11
+
12
+ /** The document's displayed grand total (data-quote-total) must equal the
13
+ * engine total. No marker → fail closed (an unverifiable total is not shipped). */
14
+ export function gateReconcile(html: string, engineTotal: number): ReconcileVerdict {
15
+ const m = html.match(/data-quote-total="([^"]*)"/i);
16
+ if (!m || m[1].trim() === "") return { pass: false };
17
+ const shown = Number(m[1]);
18
+ if (!Number.isFinite(shown)) return { pass: false };
19
+ return { pass: shown === engineTotal, shown };
20
+ }
21
+
22
+ /** A client document carrying a cost/margin marker (data-internal-only) is a
23
+ * leak — the boundary is structural, not a matter of remembering to omit. */
24
+ export function gateMarginLeak(html: string): LeakVerdict {
25
+ return { pass: !/\bdata-internal-only\b/i.test(html) };
26
+ }
27
+
28
+ /** The client quote's Sign-Online anchor href must be an absolute https URL —
29
+ * the same verdict ladder the quote-generation link-liveness gate applies. */
30
+ export function gateSignLink(html: string): SignLinkVerdict {
31
+ const tag = html.match(/<a[^>]*\bdata-esign-accept\b[^>]*>/i)?.[0];
32
+ if (!tag) return { pass: false, reason: "no-anchor" };
33
+ const href = tag.match(/href="([^"]*)"/i)?.[1] ?? "";
34
+ if (/^https:\/\//i.test(href)) return { pass: true, href };
35
+ if (href === "") return { pass: false, href, reason: "empty" };
36
+ if (href.startsWith("#")) return { pass: false, href, reason: "fragment" };
37
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return { pass: false, href, reason: "non-https" };
38
+ return { pass: false, href, reason: "relative" };
39
+ }