openpitstop 1.0.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/demo-repo/README.md +24 -0
  4. package/demo-repo/package-lock.json +4483 -0
  5. package/demo-repo/package.json +17 -0
  6. package/demo-repo/src/config.js +5 -0
  7. package/demo-repo/src/db.js +31 -0
  8. package/demo-repo/src/server.js +13 -0
  9. package/demo-repo/src/userController.js +22 -0
  10. package/demo-repo/src/userRepo.js +18 -0
  11. package/demo-repo/src/userService.js +59 -0
  12. package/demo-repo/test/user.test.js +12 -0
  13. package/demo-repo-fintech/README.md +27 -0
  14. package/demo-repo-fintech/package-lock.json +1023 -0
  15. package/demo-repo-fintech/package.json +14 -0
  16. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-conc-1-charged-2.test.mjs +175 -0
  17. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-retry-2-charged-2.test.mjs +175 -0
  18. package/demo-repo-fintech/pitstop-repro-ledger-order-ord-webhook-3-charged-2.test.mjs +175 -0
  19. package/demo-repo-fintech/src/gateway.js +26 -0
  20. package/demo-repo-fintech/src/server.js +61 -0
  21. package/demo-repo-generators/package-lock.json +1508 -0
  22. package/demo-repo-generators/package.json +17 -0
  23. package/demo-repo-generators/public/index.html +10 -0
  24. package/demo-repo-generators/scripts/build.js +17 -0
  25. package/demo-repo-generators/src/counter.js +16 -0
  26. package/demo-repo-integrity/README.md +49 -0
  27. package/demo-repo-integrity/package.json +12 -0
  28. package/demo-repo-integrity/src/money.js +19 -0
  29. package/demo-repo-integrity/test/money.test.js +17 -0
  30. package/dist/analyzers/accessibility.d.ts +18 -0
  31. package/dist/analyzers/accessibility.js +248 -0
  32. package/dist/analyzers/accessibility.js.map +1 -0
  33. package/dist/analyzers/dependencyGraph.d.ts +14 -0
  34. package/dist/analyzers/dependencyGraph.js +206 -0
  35. package/dist/analyzers/dependencyGraph.js.map +1 -0
  36. package/dist/analyzers/devex.d.ts +19 -0
  37. package/dist/analyzers/devex.js +301 -0
  38. package/dist/analyzers/devex.js.map +1 -0
  39. package/dist/analyzers/duplication.d.ts +2 -0
  40. package/dist/analyzers/duplication.js +47 -0
  41. package/dist/analyzers/duplication.js.map +1 -0
  42. package/dist/analyzers/index.d.ts +12 -0
  43. package/dist/analyzers/index.js +38 -0
  44. package/dist/analyzers/index.js.map +1 -0
  45. package/dist/analyzers/integrity/assertionLiteralTamper.d.ts +2 -0
  46. package/dist/analyzers/integrity/assertionLiteralTamper.js +164 -0
  47. package/dist/analyzers/integrity/assertionLiteralTamper.js.map +1 -0
  48. package/dist/analyzers/integrity/exceptionSwallow.d.ts +2 -0
  49. package/dist/analyzers/integrity/exceptionSwallow.js +198 -0
  50. package/dist/analyzers/integrity/exceptionSwallow.js.map +1 -0
  51. package/dist/analyzers/integrity/exitCheat.d.ts +2 -0
  52. package/dist/analyzers/integrity/exitCheat.js +31 -0
  53. package/dist/analyzers/integrity/exitCheat.js.map +1 -0
  54. package/dist/analyzers/integrity/git.d.ts +10 -0
  55. package/dist/analyzers/integrity/git.js +181 -0
  56. package/dist/analyzers/integrity/git.js.map +1 -0
  57. package/dist/analyzers/integrity/hardcodedMatch.d.ts +2 -0
  58. package/dist/analyzers/integrity/hardcodedMatch.js +93 -0
  59. package/dist/analyzers/integrity/hardcodedMatch.js.map +1 -0
  60. package/dist/analyzers/integrity/helpers.d.ts +21 -0
  61. package/dist/analyzers/integrity/helpers.js +66 -0
  62. package/dist/analyzers/integrity/helpers.js.map +1 -0
  63. package/dist/analyzers/integrity/index.d.ts +10 -0
  64. package/dist/analyzers/integrity/index.js +27 -0
  65. package/dist/analyzers/integrity/index.js.map +1 -0
  66. package/dist/analyzers/integrity/mockOverreach.d.ts +2 -0
  67. package/dist/analyzers/integrity/mockOverreach.js +72 -0
  68. package/dist/analyzers/integrity/mockOverreach.js.map +1 -0
  69. package/dist/analyzers/integrity/suppressionCreep.d.ts +2 -0
  70. package/dist/analyzers/integrity/suppressionCreep.js +27 -0
  71. package/dist/analyzers/integrity/suppressionCreep.js.map +1 -0
  72. package/dist/analyzers/integrity/testTamper.d.ts +2 -0
  73. package/dist/analyzers/integrity/testTamper.js +153 -0
  74. package/dist/analyzers/integrity/testTamper.js.map +1 -0
  75. package/dist/analyzers/integrity/types.d.ts +63 -0
  76. package/dist/analyzers/integrity/types.js +13 -0
  77. package/dist/analyzers/integrity/types.js.map +1 -0
  78. package/dist/analyzers/ledger/attacks.d.ts +17 -0
  79. package/dist/analyzers/ledger/attacks.js +135 -0
  80. package/dist/analyzers/ledger/attacks.js.map +1 -0
  81. package/dist/analyzers/ledger/discover.d.ts +15 -0
  82. package/dist/analyzers/ledger/discover.js +122 -0
  83. package/dist/analyzers/ledger/discover.js.map +1 -0
  84. package/dist/analyzers/ledger/evidence.d.ts +22 -0
  85. package/dist/analyzers/ledger/evidence.js +127 -0
  86. package/dist/analyzers/ledger/evidence.js.map +1 -0
  87. package/dist/analyzers/ledger/harness.d.ts +4 -0
  88. package/dist/analyzers/ledger/harness.js +270 -0
  89. package/dist/analyzers/ledger/harness.js.map +1 -0
  90. package/dist/analyzers/ledger/index.d.ts +14 -0
  91. package/dist/analyzers/ledger/index.js +116 -0
  92. package/dist/analyzers/ledger/index.js.map +1 -0
  93. package/dist/analyzers/ledger/types.d.ts +31 -0
  94. package/dist/analyzers/ledger/types.js +2 -0
  95. package/dist/analyzers/ledger/types.js.map +1 -0
  96. package/dist/analyzers/perf.d.ts +2 -0
  97. package/dist/analyzers/perf.js +106 -0
  98. package/dist/analyzers/perf.js.map +1 -0
  99. package/dist/analyzers/reliability.d.ts +22 -0
  100. package/dist/analyzers/reliability.js +339 -0
  101. package/dist/analyzers/reliability.js.map +1 -0
  102. package/dist/analyzers/routes.d.ts +17 -0
  103. package/dist/analyzers/routes.js +115 -0
  104. package/dist/analyzers/routes.js.map +1 -0
  105. package/dist/analyzers/security.d.ts +2 -0
  106. package/dist/analyzers/security.js +302 -0
  107. package/dist/analyzers/security.js.map +1 -0
  108. package/dist/analyzers/suiteRunner.d.ts +64 -0
  109. package/dist/analyzers/suiteRunner.js +381 -0
  110. package/dist/analyzers/suiteRunner.js.map +1 -0
  111. package/dist/analyzers/tests.d.ts +2 -0
  112. package/dist/analyzers/tests.js +215 -0
  113. package/dist/analyzers/tests.js.map +1 -0
  114. package/dist/analyzers/types.d.ts +203 -0
  115. package/dist/analyzers/types.js +2 -0
  116. package/dist/analyzers/types.js.map +1 -0
  117. package/dist/analyzers/util.d.ts +35 -0
  118. package/dist/analyzers/util.js +209 -0
  119. package/dist/analyzers/util.js.map +1 -0
  120. package/dist/ciCheckA.d.ts +2 -0
  121. package/dist/ciCheckA.js +5 -0
  122. package/dist/ciCheckA.js.map +1 -0
  123. package/dist/ciCheckB.d.ts +2 -0
  124. package/dist/ciCheckB.js +6 -0
  125. package/dist/ciCheckB.js.map +1 -0
  126. package/dist/cli.d.ts +2 -0
  127. package/dist/cli.js +65 -0
  128. package/dist/cli.js.map +1 -0
  129. package/dist/commands/budget.d.ts +33 -0
  130. package/dist/commands/budget.js +108 -0
  131. package/dist/commands/budget.js.map +1 -0
  132. package/dist/commands/ci.d.ts +14 -0
  133. package/dist/commands/ci.js +157 -0
  134. package/dist/commands/ci.js.map +1 -0
  135. package/dist/commands/demo.d.ts +2 -0
  136. package/dist/commands/demo.js +163 -0
  137. package/dist/commands/demo.js.map +1 -0
  138. package/dist/commands/digest.d.ts +8 -0
  139. package/dist/commands/digest.js +189 -0
  140. package/dist/commands/digest.js.map +1 -0
  141. package/dist/commands/doctor.d.ts +2 -0
  142. package/dist/commands/doctor.js +98 -0
  143. package/dist/commands/doctor.js.map +1 -0
  144. package/dist/commands/drive.d.ts +3 -0
  145. package/dist/commands/drive.js +170 -0
  146. package/dist/commands/drive.js.map +1 -0
  147. package/dist/commands/gate.d.ts +2 -0
  148. package/dist/commands/gate.js +136 -0
  149. package/dist/commands/gate.js.map +1 -0
  150. package/dist/commands/honesty.d.ts +2 -0
  151. package/dist/commands/honesty.js +182 -0
  152. package/dist/commands/honesty.js.map +1 -0
  153. package/dist/commands/inspect.d.ts +2 -0
  154. package/dist/commands/inspect.js +244 -0
  155. package/dist/commands/inspect.js.map +1 -0
  156. package/dist/commands/install.d.ts +2 -0
  157. package/dist/commands/install.js +81 -0
  158. package/dist/commands/install.js.map +1 -0
  159. package/dist/commands/integrity.d.ts +2 -0
  160. package/dist/commands/integrity.js +74 -0
  161. package/dist/commands/integrity.js.map +1 -0
  162. package/dist/commands/memory.d.ts +6 -0
  163. package/dist/commands/memory.js +90 -0
  164. package/dist/commands/memory.js.map +1 -0
  165. package/dist/commands/pen.d.ts +17 -0
  166. package/dist/commands/pen.js +158 -0
  167. package/dist/commands/pen.js.map +1 -0
  168. package/dist/commands/prompt.d.ts +2 -0
  169. package/dist/commands/prompt.js +37 -0
  170. package/dist/commands/prompt.js.map +1 -0
  171. package/dist/commands/readyCheck.d.ts +21 -0
  172. package/dist/commands/readyCheck.js +68 -0
  173. package/dist/commands/readyCheck.js.map +1 -0
  174. package/dist/commands/report.d.ts +2 -0
  175. package/dist/commands/report.js +48 -0
  176. package/dist/commands/report.js.map +1 -0
  177. package/dist/commands/repro.d.ts +3 -0
  178. package/dist/commands/repro.js +61 -0
  179. package/dist/commands/repro.js.map +1 -0
  180. package/dist/commands/scan.d.ts +27 -0
  181. package/dist/commands/scan.js +348 -0
  182. package/dist/commands/scan.js.map +1 -0
  183. package/dist/commands/share.d.ts +2 -0
  184. package/dist/commands/share.js +163 -0
  185. package/dist/commands/share.js.map +1 -0
  186. package/dist/commands/trends.d.ts +2 -0
  187. package/dist/commands/trends.js +103 -0
  188. package/dist/commands/trends.js.map +1 -0
  189. package/dist/commands/try.d.ts +12 -0
  190. package/dist/commands/try.js +208 -0
  191. package/dist/commands/try.js.map +1 -0
  192. package/dist/commands/verify.d.ts +44 -0
  193. package/dist/commands/verify.js +381 -0
  194. package/dist/commands/verify.js.map +1 -0
  195. package/dist/commands/watch.d.ts +10 -0
  196. package/dist/commands/watch.js +70 -0
  197. package/dist/commands/watch.js.map +1 -0
  198. package/dist/evidence.d.ts +37 -0
  199. package/dist/evidence.js +61 -0
  200. package/dist/evidence.js.map +1 -0
  201. package/dist/graph/correlate.d.ts +7 -0
  202. package/dist/graph/correlate.js +246 -0
  203. package/dist/graph/correlate.js.map +1 -0
  204. package/dist/graph/integrity.d.ts +22 -0
  205. package/dist/graph/integrity.js +39 -0
  206. package/dist/graph/integrity.js.map +1 -0
  207. package/dist/installer/targets.d.ts +23 -0
  208. package/dist/installer/targets.js +162 -0
  209. package/dist/installer/targets.js.map +1 -0
  210. package/dist/memory/store.d.ts +33 -0
  211. package/dist/memory/store.js +131 -0
  212. package/dist/memory/store.js.map +1 -0
  213. package/dist/pen/dynamic.d.ts +36 -0
  214. package/dist/pen/dynamic.js +622 -0
  215. package/dist/pen/dynamic.js.map +1 -0
  216. package/dist/pen/fix.d.ts +35 -0
  217. package/dist/pen/fix.js +239 -0
  218. package/dist/pen/fix.js.map +1 -0
  219. package/dist/pen/proof.d.ts +46 -0
  220. package/dist/pen/proof.js +112 -0
  221. package/dist/pen/proof.js.map +1 -0
  222. package/dist/pen/report.d.ts +11 -0
  223. package/dist/pen/report.js +258 -0
  224. package/dist/pen/report.js.map +1 -0
  225. package/dist/pen/static.d.ts +17 -0
  226. package/dist/pen/static.js +565 -0
  227. package/dist/pen/static.js.map +1 -0
  228. package/dist/pen/store.d.ts +11 -0
  229. package/dist/pen/store.js +36 -0
  230. package/dist/pen/store.js.map +1 -0
  231. package/dist/pen/types.d.ts +112 -0
  232. package/dist/pen/types.js +53 -0
  233. package/dist/pen/types.js.map +1 -0
  234. package/dist/report/format.d.ts +126 -0
  235. package/dist/report/format.js +706 -0
  236. package/dist/report/format.js.map +1 -0
  237. package/dist/report/score.d.ts +47 -0
  238. package/dist/report/score.js +164 -0
  239. package/dist/report/score.js.map +1 -0
  240. package/dist/repro/framework.d.ts +26 -0
  241. package/dist/repro/framework.js +224 -0
  242. package/dist/repro/framework.js.map +1 -0
  243. package/dist/repro/generate.d.ts +14 -0
  244. package/dist/repro/generate.js +669 -0
  245. package/dist/repro/generate.js.map +1 -0
  246. package/dist/repro/ids.d.ts +32 -0
  247. package/dist/repro/ids.js +175 -0
  248. package/dist/repro/ids.js.map +1 -0
  249. package/dist/repro/index.d.ts +24 -0
  250. package/dist/repro/index.js +69 -0
  251. package/dist/repro/index.js.map +1 -0
  252. package/dist/repro/pen.d.ts +26 -0
  253. package/dist/repro/pen.js +321 -0
  254. package/dist/repro/pen.js.map +1 -0
  255. package/dist/repro/run.d.ts +18 -0
  256. package/dist/repro/run.js +36 -0
  257. package/dist/repro/run.js.map +1 -0
  258. package/dist/sandbox/proxy.d.ts +41 -0
  259. package/dist/sandbox/proxy.js +231 -0
  260. package/dist/sandbox/proxy.js.map +1 -0
  261. package/dist/sandbox/startCmd.d.ts +24 -0
  262. package/dist/sandbox/startCmd.js +71 -0
  263. package/dist/sandbox/startCmd.js.map +1 -0
  264. package/dist/ui/spinner.d.ts +18 -0
  265. package/dist/ui/spinner.js +26 -0
  266. package/dist/ui/spinner.js.map +1 -0
  267. package/dist/verify/metrics.d.ts +13 -0
  268. package/dist/verify/metrics.js +72 -0
  269. package/dist/verify/metrics.js.map +1 -0
  270. package/package.json +68 -0
  271. package/scripts/cheat-demo.cjs +102 -0
  272. package/templates/ledger/preload.cjs +331 -0
  273. package/templates/pen/preload.cjs +196 -0
  274. package/templates/pitstop.prompt.md +371 -0
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "pitstop-demo-fintech",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "OpenPitStop's intentionally-broken fintech demo API — a payment endpoint with NO idempotency guard",
6
+ "scripts": {
7
+ "start": "node src/server.js",
8
+ "test": "node --test"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.2",
12
+ "razorpay": "^2.9.4"
13
+ }
14
+ }
@@ -0,0 +1,175 @@
1
+ // ==============================================================================
2
+ // OpenPitStop permanent repro test — finding ledger-18f7bcc7
3
+ // Linked from .pitstop/scan-latest.json (issue / cluster finding id ledger-18f7bcc7).
4
+ //
5
+ // Scenario: concurrent-double-submit on /api/orders/:id/charge
6
+ // order ord_conc_1 · idempotency key idem_conc_1
7
+ // Gateway contract: exactly ONE charge for key "pay_idem_conc_1"
8
+ // (observed now: 2 charges — the bug this test exists to guard).
9
+ //
10
+ // Run via: npx openpitstop repro ledger-18f7bcc7
11
+ // The app is booted under OpenPitStop's nock sandbox, so no request can ever reach
12
+ // a real payment gateway; the "gateway" is a local mock that records every call.
13
+ // This test MUST FAIL on the buggy code and MUST PASS only after the fix.
14
+ // ==============================================================================
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ const REPO = process.cwd();
20
+ const PRELOAD = process.env.PITSTOP_LEDGER_PRELOAD;
21
+ const GATEWAY_HOST = "https://api.razorpay.com";
22
+ const ENDPOINT = "/api/orders/:id/charge";
23
+ const SCENARIO = "concurrent-double-submit";
24
+ const ORDER_ID = "ord_conc_1";
25
+ const IDEM_KEY = "idem_conc_1";
26
+ const EXPECTED_KEY = "pay_idem_conc_1";
27
+ const AMOUNT = 1000;
28
+ const CURRENCY = "INR";
29
+ const NODE_BASED = new Set(["node","node.exe","nodejs","npm","npm.cmd","npx","npx.cmd","yarn","yarn.cmd","pnpm","pnpm.cmd","tsx","ts-node","babel-node","ojs","vitest","jest"]);
30
+
31
+ import { spawn } from "node:child_process";
32
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
33
+ import net from "node:net";
34
+ import path from "node:path";
35
+
36
+ function freePort() {
37
+ return new Promise((resolve, reject) => {
38
+ const s = net.createServer();
39
+ s.unref();
40
+ s.on("error", reject);
41
+ s.listen(0, () => {
42
+ const p = s.address().port;
43
+ s.close(() => resolve(p));
44
+ });
45
+ });
46
+ }
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ function resolveStart() {
50
+ const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
51
+ const start = (pkg.scripts && pkg.scripts.start) || "";
52
+ if (!start) throw new Error("no start script in package.json");
53
+ const tokens = start.trim().split(/\s+/);
54
+ if (!NODE_BASED.has(tokens[0])) {
55
+ throw new Error("start script runs " + tokens[0] + ", which is not Node-based — refusing");
56
+ }
57
+ return { cmd: tokens[0], args: tokens.slice(1) };
58
+ }
59
+
60
+ function resolvePath(route, orderId) {
61
+ return route
62
+ .replace(/:([A-Za-z_][\w-]*)/g, (_, name) => (/id|order/i.test(name) ? orderId : name))
63
+ .replace(/\{([A-Za-z_][\w-]*)\}/g, (_, name) => (/id|order/i.test(name) ? orderId : name));
64
+ }
65
+
66
+ async function post(baseUrl, p, body, headers) {
67
+ try {
68
+ const res = await fetch(baseUrl + p, { method: "POST", headers, body: JSON.stringify(body) });
69
+ await res.text().catch(() => "");
70
+ return res.status;
71
+ } catch {
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ function controlHas(controlPath, needle) {
77
+ if (!existsSync(controlPath)) return false;
78
+ try {
79
+ return readFileSync(controlPath, "utf8").split(/\r?\n/).some((l) => l.includes(needle));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function countCharges(gatewayLogPath) {
86
+ if (!existsSync(gatewayLogPath)) return [];
87
+ const out = [];
88
+ for (const line of readFileSync(gatewayLogPath, "utf8").split(/\r?\n/)) {
89
+ const t = line.trim();
90
+ if (!t) continue;
91
+ try {
92
+ const e = JSON.parse(t);
93
+ if (e.charge && e.key === EXPECTED_KEY) out.push(e);
94
+ } catch {}
95
+ }
96
+ return out;
97
+ }
98
+
99
+ test("pitstop repro ledger-18f7bcc7: exactly one gateway charge for key '" + EXPECTED_KEY + "'", async () => {
100
+ assert.ok(PRELOAD, "PITSTOP_LEDGER_PRELOAD not set — run this via `npx openpitstop repro ledger-18f7bcc7`");
101
+
102
+ const start = resolveStart();
103
+ const runDir = path.join(REPO, ".pitstop", "repro", new Date().toISOString().replace(/[:.]/g, "-"));
104
+ mkdirSync(runDir, { recursive: true });
105
+ const controlPath = path.join(runDir, "control.jsonl");
106
+ const gatewayLogPath = path.join(runDir, "gateway.log.jsonl");
107
+ const port = await freePort();
108
+ const baseUrl = "http://127.0.0.1:" + port;
109
+
110
+ const env = {
111
+ ...process.env,
112
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, "--require=" + PRELOAD].filter(Boolean).join(" "),
113
+ PORT: String(port),
114
+ PITSTOP_LEDGER_CONTROL: controlPath,
115
+ PITSTOP_LEDGER_GATEWAY_LOG: gatewayLogPath,
116
+ PITSTOP_LEDGER_GATEWAY_HOSTS: GATEWAY_HOST,
117
+ RAZORPAY_KEY_ID: process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000",
118
+ RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret",
119
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || "sk_test_pitstop_fake",
120
+ NODE_ENV: process.env.NODE_ENV || "test",
121
+ };
122
+ const child = spawn(start.cmd, start.args, { cwd: REPO, env, stdio: ["ignore", "ignore", "inherit"] });
123
+ try {
124
+ const deadline = Date.now() + 25000;
125
+ let up = false;
126
+ while (Date.now() < deadline) {
127
+ if (child.exitCode !== null) break;
128
+ if (controlHas(controlPath, '"event":"armed"')) {
129
+ try {
130
+ await fetch(baseUrl + "/__pitstop_ledger_probe__");
131
+ up = true;
132
+ break;
133
+ } catch {}
134
+ }
135
+ await sleep(150);
136
+ }
137
+ assert.ok(up, "app did not come up under the sandbox within 25s (" + start.cmd + " " + start.args.join(" ") + ")");
138
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during startup");
139
+
140
+ if (SCENARIO === "concurrent-double-submit" || SCENARIO === "delayed-retry") {
141
+ const reqBody = { amount: AMOUNT, currency: CURRENCY };
142
+ const headers = { "Content-Type": "application/json", ...(IDEM_KEY ? { "Idempotency-Key": IDEM_KEY } : {}) };
143
+ const p = resolvePath(ENDPOINT, ORDER_ID);
144
+ if (SCENARIO === "concurrent-double-submit") {
145
+ await Promise.all([post(baseUrl, p, reqBody, headers), post(baseUrl, p, reqBody, headers)]);
146
+ } else {
147
+ await post(baseUrl, p, reqBody, headers);
148
+ await sleep(2000);
149
+ await post(baseUrl, p, reqBody, headers);
150
+ }
151
+ } else {
152
+ const payload = {
153
+ event: "payment.captured",
154
+ id: ORDER_ID + "_evt",
155
+ payload: { payment: { entity: { id: EXPECTED_KEY, amount: AMOUNT, currency: CURRENCY, order_id: ORDER_ID } } },
156
+ };
157
+ const headers = { "Content-Type": "application/json" };
158
+ await post(baseUrl, ENDPOINT, payload, headers);
159
+ await sleep(300);
160
+ await post(baseUrl, ENDPOINT, payload, headers);
161
+ }
162
+
163
+ await sleep(400);
164
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during the attack");
165
+ const charges = countCharges(gatewayLogPath);
166
+ assert.equal(
167
+ charges.length,
168
+ 1,
169
+ "expected exactly 1 gateway charge for key '" + EXPECTED_KEY + "', saw " + charges.length +
170
+ ". This repro proves the idempotency bug: it FAILS on the buggy code and must PASS only after the fix.",
171
+ );
172
+ } finally {
173
+ try { child.kill(); } catch {}
174
+ }
175
+ });
@@ -0,0 +1,175 @@
1
+ // ==============================================================================
2
+ // OpenPitStop permanent repro test — finding ledger-46f2bb8e
3
+ // Linked from .pitstop/scan-latest.json (issue / cluster finding id ledger-46f2bb8e).
4
+ //
5
+ // Scenario: delayed-retry on /api/orders/:id/charge
6
+ // order ord_retry_2 · idempotency key idem_retry_2
7
+ // Gateway contract: exactly ONE charge for key "pay_idem_retry_2"
8
+ // (observed now: 2 charges — the bug this test exists to guard).
9
+ //
10
+ // Run via: npx openpitstop repro ledger-46f2bb8e
11
+ // The app is booted under OpenPitStop's nock sandbox, so no request can ever reach
12
+ // a real payment gateway; the "gateway" is a local mock that records every call.
13
+ // This test MUST FAIL on the buggy code and MUST PASS only after the fix.
14
+ // ==============================================================================
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ const REPO = process.cwd();
20
+ const PRELOAD = process.env.PITSTOP_LEDGER_PRELOAD;
21
+ const GATEWAY_HOST = "https://api.razorpay.com";
22
+ const ENDPOINT = "/api/orders/:id/charge";
23
+ const SCENARIO = "delayed-retry";
24
+ const ORDER_ID = "ord_retry_2";
25
+ const IDEM_KEY = "idem_retry_2";
26
+ const EXPECTED_KEY = "pay_idem_retry_2";
27
+ const AMOUNT = 1000;
28
+ const CURRENCY = "INR";
29
+ const NODE_BASED = new Set(["node","node.exe","nodejs","npm","npm.cmd","npx","npx.cmd","yarn","yarn.cmd","pnpm","pnpm.cmd","tsx","ts-node","babel-node","ojs","vitest","jest"]);
30
+
31
+ import { spawn } from "node:child_process";
32
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
33
+ import net from "node:net";
34
+ import path from "node:path";
35
+
36
+ function freePort() {
37
+ return new Promise((resolve, reject) => {
38
+ const s = net.createServer();
39
+ s.unref();
40
+ s.on("error", reject);
41
+ s.listen(0, () => {
42
+ const p = s.address().port;
43
+ s.close(() => resolve(p));
44
+ });
45
+ });
46
+ }
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ function resolveStart() {
50
+ const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
51
+ const start = (pkg.scripts && pkg.scripts.start) || "";
52
+ if (!start) throw new Error("no start script in package.json");
53
+ const tokens = start.trim().split(/\s+/);
54
+ if (!NODE_BASED.has(tokens[0])) {
55
+ throw new Error("start script runs " + tokens[0] + ", which is not Node-based — refusing");
56
+ }
57
+ return { cmd: tokens[0], args: tokens.slice(1) };
58
+ }
59
+
60
+ function resolvePath(route, orderId) {
61
+ return route
62
+ .replace(/:([A-Za-z_][\w-]*)/g, (_, name) => (/id|order/i.test(name) ? orderId : name))
63
+ .replace(/\{([A-Za-z_][\w-]*)\}/g, (_, name) => (/id|order/i.test(name) ? orderId : name));
64
+ }
65
+
66
+ async function post(baseUrl, p, body, headers) {
67
+ try {
68
+ const res = await fetch(baseUrl + p, { method: "POST", headers, body: JSON.stringify(body) });
69
+ await res.text().catch(() => "");
70
+ return res.status;
71
+ } catch {
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ function controlHas(controlPath, needle) {
77
+ if (!existsSync(controlPath)) return false;
78
+ try {
79
+ return readFileSync(controlPath, "utf8").split(/\r?\n/).some((l) => l.includes(needle));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function countCharges(gatewayLogPath) {
86
+ if (!existsSync(gatewayLogPath)) return [];
87
+ const out = [];
88
+ for (const line of readFileSync(gatewayLogPath, "utf8").split(/\r?\n/)) {
89
+ const t = line.trim();
90
+ if (!t) continue;
91
+ try {
92
+ const e = JSON.parse(t);
93
+ if (e.charge && e.key === EXPECTED_KEY) out.push(e);
94
+ } catch {}
95
+ }
96
+ return out;
97
+ }
98
+
99
+ test("pitstop repro ledger-46f2bb8e: exactly one gateway charge for key '" + EXPECTED_KEY + "'", async () => {
100
+ assert.ok(PRELOAD, "PITSTOP_LEDGER_PRELOAD not set — run this via `npx openpitstop repro ledger-46f2bb8e`");
101
+
102
+ const start = resolveStart();
103
+ const runDir = path.join(REPO, ".pitstop", "repro", new Date().toISOString().replace(/[:.]/g, "-"));
104
+ mkdirSync(runDir, { recursive: true });
105
+ const controlPath = path.join(runDir, "control.jsonl");
106
+ const gatewayLogPath = path.join(runDir, "gateway.log.jsonl");
107
+ const port = await freePort();
108
+ const baseUrl = "http://127.0.0.1:" + port;
109
+
110
+ const env = {
111
+ ...process.env,
112
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, "--require=" + PRELOAD].filter(Boolean).join(" "),
113
+ PORT: String(port),
114
+ PITSTOP_LEDGER_CONTROL: controlPath,
115
+ PITSTOP_LEDGER_GATEWAY_LOG: gatewayLogPath,
116
+ PITSTOP_LEDGER_GATEWAY_HOSTS: GATEWAY_HOST,
117
+ RAZORPAY_KEY_ID: process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000",
118
+ RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret",
119
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || "sk_test_pitstop_fake",
120
+ NODE_ENV: process.env.NODE_ENV || "test",
121
+ };
122
+ const child = spawn(start.cmd, start.args, { cwd: REPO, env, stdio: ["ignore", "ignore", "inherit"] });
123
+ try {
124
+ const deadline = Date.now() + 25000;
125
+ let up = false;
126
+ while (Date.now() < deadline) {
127
+ if (child.exitCode !== null) break;
128
+ if (controlHas(controlPath, '"event":"armed"')) {
129
+ try {
130
+ await fetch(baseUrl + "/__pitstop_ledger_probe__");
131
+ up = true;
132
+ break;
133
+ } catch {}
134
+ }
135
+ await sleep(150);
136
+ }
137
+ assert.ok(up, "app did not come up under the sandbox within 25s (" + start.cmd + " " + start.args.join(" ") + ")");
138
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during startup");
139
+
140
+ if (SCENARIO === "concurrent-double-submit" || SCENARIO === "delayed-retry") {
141
+ const reqBody = { amount: AMOUNT, currency: CURRENCY };
142
+ const headers = { "Content-Type": "application/json", ...(IDEM_KEY ? { "Idempotency-Key": IDEM_KEY } : {}) };
143
+ const p = resolvePath(ENDPOINT, ORDER_ID);
144
+ if (SCENARIO === "concurrent-double-submit") {
145
+ await Promise.all([post(baseUrl, p, reqBody, headers), post(baseUrl, p, reqBody, headers)]);
146
+ } else {
147
+ await post(baseUrl, p, reqBody, headers);
148
+ await sleep(2000);
149
+ await post(baseUrl, p, reqBody, headers);
150
+ }
151
+ } else {
152
+ const payload = {
153
+ event: "payment.captured",
154
+ id: ORDER_ID + "_evt",
155
+ payload: { payment: { entity: { id: EXPECTED_KEY, amount: AMOUNT, currency: CURRENCY, order_id: ORDER_ID } } },
156
+ };
157
+ const headers = { "Content-Type": "application/json" };
158
+ await post(baseUrl, ENDPOINT, payload, headers);
159
+ await sleep(300);
160
+ await post(baseUrl, ENDPOINT, payload, headers);
161
+ }
162
+
163
+ await sleep(400);
164
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during the attack");
165
+ const charges = countCharges(gatewayLogPath);
166
+ assert.equal(
167
+ charges.length,
168
+ 1,
169
+ "expected exactly 1 gateway charge for key '" + EXPECTED_KEY + "', saw " + charges.length +
170
+ ". This repro proves the idempotency bug: it FAILS on the buggy code and must PASS only after the fix.",
171
+ );
172
+ } finally {
173
+ try { child.kill(); } catch {}
174
+ }
175
+ });
@@ -0,0 +1,175 @@
1
+ // ==============================================================================
2
+ // OpenPitStop permanent repro test — finding ledger-a97b7d00
3
+ // Linked from .pitstop/scan-latest.json (issue / cluster finding id ledger-a97b7d00).
4
+ //
5
+ // Scenario: duplicate-webhook on /api/webhooks/razorpay
6
+ // order ord_webhook_3 · idempotency key idem_webhook_3
7
+ // Gateway contract: exactly ONE charge for key "pay_webhook_3"
8
+ // (observed now: 2 charges — the bug this test exists to guard).
9
+ //
10
+ // Run via: npx openpitstop repro ledger-a97b7d00
11
+ // The app is booted under OpenPitStop's nock sandbox, so no request can ever reach
12
+ // a real payment gateway; the "gateway" is a local mock that records every call.
13
+ // This test MUST FAIL on the buggy code and MUST PASS only after the fix.
14
+ // ==============================================================================
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ const REPO = process.cwd();
20
+ const PRELOAD = process.env.PITSTOP_LEDGER_PRELOAD;
21
+ const GATEWAY_HOST = "https://api.razorpay.com";
22
+ const ENDPOINT = "/api/webhooks/razorpay";
23
+ const SCENARIO = "duplicate-webhook";
24
+ const ORDER_ID = "ord_webhook_3";
25
+ const IDEM_KEY = "idem_webhook_3";
26
+ const EXPECTED_KEY = "pay_webhook_3";
27
+ const AMOUNT = 1000;
28
+ const CURRENCY = "INR";
29
+ const NODE_BASED = new Set(["node","node.exe","nodejs","npm","npm.cmd","npx","npx.cmd","yarn","yarn.cmd","pnpm","pnpm.cmd","tsx","ts-node","babel-node","ojs","vitest","jest"]);
30
+
31
+ import { spawn } from "node:child_process";
32
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
33
+ import net from "node:net";
34
+ import path from "node:path";
35
+
36
+ function freePort() {
37
+ return new Promise((resolve, reject) => {
38
+ const s = net.createServer();
39
+ s.unref();
40
+ s.on("error", reject);
41
+ s.listen(0, () => {
42
+ const p = s.address().port;
43
+ s.close(() => resolve(p));
44
+ });
45
+ });
46
+ }
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ function resolveStart() {
50
+ const pkg = JSON.parse(readFileSync(path.join(REPO, "package.json"), "utf8"));
51
+ const start = (pkg.scripts && pkg.scripts.start) || "";
52
+ if (!start) throw new Error("no start script in package.json");
53
+ const tokens = start.trim().split(/\s+/);
54
+ if (!NODE_BASED.has(tokens[0])) {
55
+ throw new Error("start script runs " + tokens[0] + ", which is not Node-based — refusing");
56
+ }
57
+ return { cmd: tokens[0], args: tokens.slice(1) };
58
+ }
59
+
60
+ function resolvePath(route, orderId) {
61
+ return route
62
+ .replace(/:([A-Za-z_][\w-]*)/g, (_, name) => (/id|order/i.test(name) ? orderId : name))
63
+ .replace(/\{([A-Za-z_][\w-]*)\}/g, (_, name) => (/id|order/i.test(name) ? orderId : name));
64
+ }
65
+
66
+ async function post(baseUrl, p, body, headers) {
67
+ try {
68
+ const res = await fetch(baseUrl + p, { method: "POST", headers, body: JSON.stringify(body) });
69
+ await res.text().catch(() => "");
70
+ return res.status;
71
+ } catch {
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ function controlHas(controlPath, needle) {
77
+ if (!existsSync(controlPath)) return false;
78
+ try {
79
+ return readFileSync(controlPath, "utf8").split(/\r?\n/).some((l) => l.includes(needle));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function countCharges(gatewayLogPath) {
86
+ if (!existsSync(gatewayLogPath)) return [];
87
+ const out = [];
88
+ for (const line of readFileSync(gatewayLogPath, "utf8").split(/\r?\n/)) {
89
+ const t = line.trim();
90
+ if (!t) continue;
91
+ try {
92
+ const e = JSON.parse(t);
93
+ if (e.charge && e.key === EXPECTED_KEY) out.push(e);
94
+ } catch {}
95
+ }
96
+ return out;
97
+ }
98
+
99
+ test("pitstop repro ledger-a97b7d00: exactly one gateway charge for key '" + EXPECTED_KEY + "'", async () => {
100
+ assert.ok(PRELOAD, "PITSTOP_LEDGER_PRELOAD not set — run this via `npx openpitstop repro ledger-a97b7d00`");
101
+
102
+ const start = resolveStart();
103
+ const runDir = path.join(REPO, ".pitstop", "repro", new Date().toISOString().replace(/[:.]/g, "-"));
104
+ mkdirSync(runDir, { recursive: true });
105
+ const controlPath = path.join(runDir, "control.jsonl");
106
+ const gatewayLogPath = path.join(runDir, "gateway.log.jsonl");
107
+ const port = await freePort();
108
+ const baseUrl = "http://127.0.0.1:" + port;
109
+
110
+ const env = {
111
+ ...process.env,
112
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, "--require=" + PRELOAD].filter(Boolean).join(" "),
113
+ PORT: String(port),
114
+ PITSTOP_LEDGER_CONTROL: controlPath,
115
+ PITSTOP_LEDGER_GATEWAY_LOG: gatewayLogPath,
116
+ PITSTOP_LEDGER_GATEWAY_HOSTS: GATEWAY_HOST,
117
+ RAZORPAY_KEY_ID: process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000",
118
+ RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret",
119
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || "sk_test_pitstop_fake",
120
+ NODE_ENV: process.env.NODE_ENV || "test",
121
+ };
122
+ const child = spawn(start.cmd, start.args, { cwd: REPO, env, stdio: ["ignore", "ignore", "inherit"] });
123
+ try {
124
+ const deadline = Date.now() + 25000;
125
+ let up = false;
126
+ while (Date.now() < deadline) {
127
+ if (child.exitCode !== null) break;
128
+ if (controlHas(controlPath, '"event":"armed"')) {
129
+ try {
130
+ await fetch(baseUrl + "/__pitstop_ledger_probe__");
131
+ up = true;
132
+ break;
133
+ } catch {}
134
+ }
135
+ await sleep(150);
136
+ }
137
+ assert.ok(up, "app did not come up under the sandbox within 25s (" + start.cmd + " " + start.args.join(" ") + ")");
138
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during startup");
139
+
140
+ if (SCENARIO === "concurrent-double-submit" || SCENARIO === "delayed-retry") {
141
+ const reqBody = { amount: AMOUNT, currency: CURRENCY };
142
+ const headers = { "Content-Type": "application/json", ...(IDEM_KEY ? { "Idempotency-Key": IDEM_KEY } : {}) };
143
+ const p = resolvePath(ENDPOINT, ORDER_ID);
144
+ if (SCENARIO === "concurrent-double-submit") {
145
+ await Promise.all([post(baseUrl, p, reqBody, headers), post(baseUrl, p, reqBody, headers)]);
146
+ } else {
147
+ await post(baseUrl, p, reqBody, headers);
148
+ await sleep(2000);
149
+ await post(baseUrl, p, reqBody, headers);
150
+ }
151
+ } else {
152
+ const payload = {
153
+ event: "payment.captured",
154
+ id: ORDER_ID + "_evt",
155
+ payload: { payment: { entity: { id: EXPECTED_KEY, amount: AMOUNT, currency: CURRENCY, order_id: ORDER_ID } } },
156
+ };
157
+ const headers = { "Content-Type": "application/json" };
158
+ await post(baseUrl, ENDPOINT, payload, headers);
159
+ await sleep(300);
160
+ await post(baseUrl, ENDPOINT, payload, headers);
161
+ }
162
+
163
+ await sleep(400);
164
+ assert.ok(!controlHas(controlPath, '"event":"abort"'), "sandbox aborted during the attack");
165
+ const charges = countCharges(gatewayLogPath);
166
+ assert.equal(
167
+ charges.length,
168
+ 1,
169
+ "expected exactly 1 gateway charge for key '" + EXPECTED_KEY + "', saw " + charges.length +
170
+ ". This repro proves the idempotency bug: it FAILS on the buggy code and must PASS only after the fix.",
171
+ );
172
+ } finally {
173
+ try { child.kill(); } catch {}
174
+ }
175
+ });
@@ -0,0 +1,26 @@
1
+ // Payment-gateway wrapper around the official razorpay Node SDK.
2
+ // All outbound calls go through the SDK's HTTP client (axios -> https), so
3
+ // OpenPitStop's ledger sandbox (nock) can intercept them before they leave the
4
+ // process. In ledger mode the gateway is always a mock — never the real one.
5
+ const Razorpay = require("razorpay");
6
+
7
+ // Fake credentials are fine: requests are intercepted by nock in ledger mode
8
+ // and never actually reach razorpay.
9
+ const KEY_ID = process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000";
10
+ const KEY_SECRET = process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret";
11
+
12
+ const instance = new Razorpay({
13
+ key_id: KEY_ID,
14
+ key_secret: KEY_SECRET,
15
+ });
16
+
17
+ /**
18
+ * Charge a payment. This is THE money-moving call: it captures an authorized
19
+ * payment at the gateway. Every invocation produces one gateway charge.
20
+ */
21
+ async function charge(paymentId, amount, currency) {
22
+ const res = await instance.payments.capture(paymentId, amount, currency);
23
+ return res;
24
+ }
25
+
26
+ module.exports = { charge, KEY_ID };
@@ -0,0 +1,61 @@
1
+ const express = require("express");
2
+ const gateway = require("./gateway");
3
+
4
+ const app = express();
5
+ app.use(express.json());
6
+
7
+ // In-memory "ledger". Deliberately has NO idempotency keying: a retried or
8
+ // replayed request results in another gateway charge. This is the bug.
9
+ const charges = new Map(); // key -> charge log
10
+ const chargeLog = (key) => {
11
+ if (!charges.has(key)) charges.set(key, []);
12
+ return charges.get(key);
13
+ };
14
+
15
+ /**
16
+ * Charge an order. NO idempotency check: send the same payload + the same
17
+ * Idempotency-Key twice and the gateway is charged twice.
18
+ */
19
+ app.post("/api/orders/:id/charge", async (req, res) => {
20
+ const { id } = req.params;
21
+ const { amount, currency } = req.body || {};
22
+ const idemKey = req.get("Idempotency-Key") || req.get("X-Idempotency-Key") || null;
23
+
24
+ // The payment being charged is derived from the idempotency key (or order id).
25
+ // The key is received but NEVER deduped — that is the bug under test.
26
+ const paymentId = idemKey ? `pay_${idemKey}` : `pay_${id}`;
27
+ const payment = await gateway.charge(paymentId, amount, currency);
28
+
29
+ chargeLog(idemKey || id).push({
30
+ action: "charge",
31
+ paymentId,
32
+ idemKey,
33
+ at: new Date().toISOString(),
34
+ });
35
+ res.json({ ok: true, orderId: id, paymentId, charges: chargeLog(idemKey || id).length });
36
+ });
37
+
38
+ /**
39
+ * Razorpay webhook. Bug: every delivered event triggers a gateway capture with
40
+ * no dedup, so replaying the same webhook double-charges.
41
+ */
42
+ app.post("/api/webhooks/razorpay", async (req, res) => {
43
+ const event = req.body || {};
44
+ const entity = event?.payload?.payment?.entity;
45
+ if (event.event === "payment.captured" && entity) {
46
+ await gateway.charge(entity.id, entity.amount, entity.currency);
47
+ chargeLog(entity.id).push({
48
+ action: "capture",
49
+ paymentId: entity.id,
50
+ at: new Date().toISOString(),
51
+ });
52
+ }
53
+ res.json({ received: true });
54
+ });
55
+
56
+ if (require.main === module) {
57
+ const port = Number(process.env.PORT) || 4000;
58
+ app.listen(port, () => console.log(`pitstop-demo-fintech listening on ${port}`));
59
+ }
60
+
61
+ module.exports = app;