@crowdsource.you/core 1.2.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 (339) hide show
  1. package/README.md +571 -0
  2. package/dist/cases.d.ts +66 -0
  3. package/dist/cases.d.ts.map +1 -0
  4. package/dist/cases.js +79 -0
  5. package/dist/cases.js.map +1 -0
  6. package/dist/client.d.ts +90 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +125 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/communityNotes.d.ts +49 -0
  11. package/dist/communityNotes.d.ts.map +1 -0
  12. package/dist/communityNotes.js +104 -0
  13. package/dist/communityNotes.js.map +1 -0
  14. package/dist/credential.d.ts +58 -0
  15. package/dist/credential.d.ts.map +1 -0
  16. package/dist/credential.js +71 -0
  17. package/dist/credential.js.map +1 -0
  18. package/dist/defaults.d.ts +61 -0
  19. package/dist/defaults.d.ts.map +1 -0
  20. package/dist/defaults.js +82 -0
  21. package/dist/defaults.js.map +1 -0
  22. package/dist/digest.d.ts +44 -0
  23. package/dist/digest.d.ts.map +1 -0
  24. package/dist/digest.js +79 -0
  25. package/dist/digest.js.map +1 -0
  26. package/dist/envelope.d.ts +143 -0
  27. package/dist/envelope.d.ts.map +1 -0
  28. package/dist/envelope.js +258 -0
  29. package/dist/envelope.js.map +1 -0
  30. package/dist/errors.d.ts +90 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +151 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/esm/cases.js +74 -0
  35. package/dist/esm/cases.js.map +1 -0
  36. package/dist/esm/client.js +121 -0
  37. package/dist/esm/client.js.map +1 -0
  38. package/dist/esm/communityNotes.js +100 -0
  39. package/dist/esm/communityNotes.js.map +1 -0
  40. package/dist/esm/credential.js +67 -0
  41. package/dist/esm/credential.js.map +1 -0
  42. package/dist/esm/defaults.js +77 -0
  43. package/dist/esm/defaults.js.map +1 -0
  44. package/dist/esm/digest.js +74 -0
  45. package/dist/esm/digest.js.map +1 -0
  46. package/dist/esm/envelope.js +252 -0
  47. package/dist/esm/envelope.js.map +1 -0
  48. package/dist/esm/errors.js +140 -0
  49. package/dist/esm/errors.js.map +1 -0
  50. package/dist/esm/express/index.js +29 -0
  51. package/dist/esm/express/index.js.map +1 -0
  52. package/dist/esm/express/middleware.js +293 -0
  53. package/dist/esm/express/middleware.js.map +1 -0
  54. package/dist/esm/express/store.js +69 -0
  55. package/dist/esm/express/store.js.map +1 -0
  56. package/dist/esm/express/verify.js +133 -0
  57. package/dist/esm/express/verify.js.map +1 -0
  58. package/dist/esm/index.js +57 -0
  59. package/dist/esm/index.js.map +1 -0
  60. package/dist/esm/outbox/client.js +43 -0
  61. package/dist/esm/outbox/client.js.map +1 -0
  62. package/dist/esm/outbox/decision.js +153 -0
  63. package/dist/esm/outbox/decision.js.map +1 -0
  64. package/dist/esm/outbox/delivery.js +129 -0
  65. package/dist/esm/outbox/delivery.js.map +1 -0
  66. package/dist/esm/outbox/enforcement/executor.js +219 -0
  67. package/dist/esm/outbox/enforcement/executor.js.map +1 -0
  68. package/dist/esm/outbox/enforcement/planner.js +326 -0
  69. package/dist/esm/outbox/enforcement/planner.js.map +1 -0
  70. package/dist/esm/outbox/evidence.js +173 -0
  71. package/dist/esm/outbox/evidence.js.map +1 -0
  72. package/dist/esm/outbox/inbound.js +87 -0
  73. package/dist/esm/outbox/inbound.js.map +1 -0
  74. package/dist/esm/outbox/index.js +71 -0
  75. package/dist/esm/outbox/index.js.map +1 -0
  76. package/dist/esm/outbox/intake.js +140 -0
  77. package/dist/esm/outbox/intake.js.map +1 -0
  78. package/dist/esm/outbox/integration.js +116 -0
  79. package/dist/esm/outbox/integration.js.map +1 -0
  80. package/dist/esm/outbox/outbox/dispatcher.js +114 -0
  81. package/dist/esm/outbox/outbox/dispatcher.js.map +1 -0
  82. package/dist/esm/outbox/outbox/service.js +271 -0
  83. package/dist/esm/outbox/outbox/service.js.map +1 -0
  84. package/dist/esm/outbox/postgres/index.js +41 -0
  85. package/dist/esm/outbox/postgres/index.js.map +1 -0
  86. package/dist/esm/outbox/postgres/registries.js +155 -0
  87. package/dist/esm/outbox/postgres/registries.js.map +1 -0
  88. package/dist/esm/outbox/postgres/reportColumns.js +191 -0
  89. package/dist/esm/outbox/postgres/reportColumns.js.map +1 -0
  90. package/dist/esm/outbox/postgres/store/enforcement.js +158 -0
  91. package/dist/esm/outbox/postgres/store/enforcement.js.map +1 -0
  92. package/dist/esm/outbox/postgres/store/events.js +101 -0
  93. package/dist/esm/outbox/postgres/store/events.js.map +1 -0
  94. package/dist/esm/outbox/postgres/store/index.js +59 -0
  95. package/dist/esm/outbox/postgres/store/index.js.map +1 -0
  96. package/dist/esm/outbox/postgres/store/outbox.js +207 -0
  97. package/dist/esm/outbox/postgres/store/outbox.js.map +1 -0
  98. package/dist/esm/outbox/postgres/store/reports.js +231 -0
  99. package/dist/esm/outbox/postgres/store/reports.js.map +1 -0
  100. package/dist/esm/outbox/postgres/store/transaction.js +29 -0
  101. package/dist/esm/outbox/postgres/store/transaction.js.map +1 -0
  102. package/dist/esm/outbox/postgres/tables.js +270 -0
  103. package/dist/esm/outbox/postgres/tables.js.map +1 -0
  104. package/dist/esm/outbox/reconciliation.js +157 -0
  105. package/dist/esm/outbox/reconciliation.js.map +1 -0
  106. package/dist/esm/outbox/reportStatus.js +22 -0
  107. package/dist/esm/outbox/reportStatus.js.map +1 -0
  108. package/dist/esm/outbox/retention.js +33 -0
  109. package/dist/esm/outbox/retention.js.map +1 -0
  110. package/dist/esm/outbox/store/types.js +30 -0
  111. package/dist/esm/outbox/store/types.js.map +1 -0
  112. package/dist/esm/outbox/types.js +23 -0
  113. package/dist/esm/outbox/types.js.map +1 -0
  114. package/dist/esm/outbox/webhook.js +145 -0
  115. package/dist/esm/outbox/webhook.js.map +1 -0
  116. package/dist/esm/package.json +1 -0
  117. package/dist/esm/reports.js +74 -0
  118. package/dist/esm/reports.js.map +1 -0
  119. package/dist/esm/testing/fixtures.js +132 -0
  120. package/dist/esm/testing/fixtures.js.map +1 -0
  121. package/dist/esm/testing/index.js +33 -0
  122. package/dist/esm/testing/index.js.map +1 -0
  123. package/dist/esm/testing/sandbox.js +337 -0
  124. package/dist/esm/testing/sandbox.js.map +1 -0
  125. package/dist/esm/testing/webhook-simulator.js +93 -0
  126. package/dist/esm/testing/webhook-simulator.js.map +1 -0
  127. package/dist/esm/transport.js +177 -0
  128. package/dist/esm/transport.js.map +1 -0
  129. package/dist/esm/webhookEndpoints.js +139 -0
  130. package/dist/esm/webhookEndpoints.js.map +1 -0
  131. package/dist/express/index.d.ts +32 -0
  132. package/dist/express/index.d.ts.map +1 -0
  133. package/dist/express/index.js +40 -0
  134. package/dist/express/index.js.map +1 -0
  135. package/dist/express/middleware.d.ts +97 -0
  136. package/dist/express/middleware.d.ts.map +1 -0
  137. package/dist/express/middleware.js +298 -0
  138. package/dist/express/middleware.js.map +1 -0
  139. package/dist/express/store.d.ts +45 -0
  140. package/dist/express/store.d.ts.map +1 -0
  141. package/dist/express/store.js +72 -0
  142. package/dist/express/store.js.map +1 -0
  143. package/dist/express/verify.d.ts +86 -0
  144. package/dist/express/verify.d.ts.map +1 -0
  145. package/dist/express/verify.js +138 -0
  146. package/dist/express/verify.js.map +1 -0
  147. package/dist/index.d.ts +67 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +89 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/outbox/client.d.ts +35 -0
  152. package/dist/outbox/client.d.ts.map +1 -0
  153. package/dist/outbox/client.js +46 -0
  154. package/dist/outbox/client.js.map +1 -0
  155. package/dist/outbox/decision.d.ts +36 -0
  156. package/dist/outbox/decision.d.ts.map +1 -0
  157. package/dist/outbox/decision.js +159 -0
  158. package/dist/outbox/decision.js.map +1 -0
  159. package/dist/outbox/delivery.d.ts +50 -0
  160. package/dist/outbox/delivery.d.ts.map +1 -0
  161. package/dist/outbox/delivery.js +135 -0
  162. package/dist/outbox/delivery.js.map +1 -0
  163. package/dist/outbox/enforcement/executor.d.ts +26 -0
  164. package/dist/outbox/enforcement/executor.d.ts.map +1 -0
  165. package/dist/outbox/enforcement/executor.js +222 -0
  166. package/dist/outbox/enforcement/executor.js.map +1 -0
  167. package/dist/outbox/enforcement/planner.d.ts +50 -0
  168. package/dist/outbox/enforcement/planner.d.ts.map +1 -0
  169. package/dist/outbox/enforcement/planner.js +333 -0
  170. package/dist/outbox/enforcement/planner.js.map +1 -0
  171. package/dist/outbox/evidence.d.ts +114 -0
  172. package/dist/outbox/evidence.d.ts.map +1 -0
  173. package/dist/outbox/evidence.js +181 -0
  174. package/dist/outbox/evidence.js.map +1 -0
  175. package/dist/outbox/inbound.d.ts +78 -0
  176. package/dist/outbox/inbound.d.ts.map +1 -0
  177. package/dist/outbox/inbound.js +91 -0
  178. package/dist/outbox/inbound.js.map +1 -0
  179. package/dist/outbox/index.d.ts +79 -0
  180. package/dist/outbox/index.d.ts.map +1 -0
  181. package/dist/outbox/index.js +98 -0
  182. package/dist/outbox/index.js.map +1 -0
  183. package/dist/outbox/intake.d.ts +62 -0
  184. package/dist/outbox/intake.d.ts.map +1 -0
  185. package/dist/outbox/intake.js +145 -0
  186. package/dist/outbox/intake.js.map +1 -0
  187. package/dist/outbox/integration.d.ts +62 -0
  188. package/dist/outbox/integration.d.ts.map +1 -0
  189. package/dist/outbox/integration.js +119 -0
  190. package/dist/outbox/integration.js.map +1 -0
  191. package/dist/outbox/outbox/dispatcher.d.ts +26 -0
  192. package/dist/outbox/outbox/dispatcher.d.ts.map +1 -0
  193. package/dist/outbox/outbox/dispatcher.js +119 -0
  194. package/dist/outbox/outbox/dispatcher.js.map +1 -0
  195. package/dist/outbox/outbox/service.d.ts +124 -0
  196. package/dist/outbox/outbox/service.d.ts.map +1 -0
  197. package/dist/outbox/outbox/service.js +279 -0
  198. package/dist/outbox/outbox/service.js.map +1 -0
  199. package/dist/outbox/postgres/index.d.ts +44 -0
  200. package/dist/outbox/postgres/index.d.ts.map +1 -0
  201. package/dist/outbox/postgres/index.js +51 -0
  202. package/dist/outbox/postgres/index.js.map +1 -0
  203. package/dist/outbox/postgres/registries.d.ts +69 -0
  204. package/dist/outbox/postgres/registries.d.ts.map +1 -0
  205. package/dist/outbox/postgres/registries.js +159 -0
  206. package/dist/outbox/postgres/registries.js.map +1 -0
  207. package/dist/outbox/postgres/reportColumns.d.ts +154 -0
  208. package/dist/outbox/postgres/reportColumns.d.ts.map +1 -0
  209. package/dist/outbox/postgres/reportColumns.js +195 -0
  210. package/dist/outbox/postgres/reportColumns.js.map +1 -0
  211. package/dist/outbox/postgres/store/enforcement.d.ts +33 -0
  212. package/dist/outbox/postgres/store/enforcement.d.ts.map +1 -0
  213. package/dist/outbox/postgres/store/enforcement.js +161 -0
  214. package/dist/outbox/postgres/store/enforcement.js.map +1 -0
  215. package/dist/outbox/postgres/store/events.d.ts +46 -0
  216. package/dist/outbox/postgres/store/events.d.ts.map +1 -0
  217. package/dist/outbox/postgres/store/events.js +104 -0
  218. package/dist/outbox/postgres/store/events.js.map +1 -0
  219. package/dist/outbox/postgres/store/index.d.ts +27 -0
  220. package/dist/outbox/postgres/store/index.d.ts.map +1 -0
  221. package/dist/outbox/postgres/store/index.js +62 -0
  222. package/dist/outbox/postgres/store/index.js.map +1 -0
  223. package/dist/outbox/postgres/store/outbox.d.ts +8 -0
  224. package/dist/outbox/postgres/store/outbox.d.ts.map +1 -0
  225. package/dist/outbox/postgres/store/outbox.js +210 -0
  226. package/dist/outbox/postgres/store/outbox.js.map +1 -0
  227. package/dist/outbox/postgres/store/reports.d.ts +69 -0
  228. package/dist/outbox/postgres/store/reports.d.ts.map +1 -0
  229. package/dist/outbox/postgres/store/reports.js +234 -0
  230. package/dist/outbox/postgres/store/reports.js.map +1 -0
  231. package/dist/outbox/postgres/store/transaction.d.ts +49 -0
  232. package/dist/outbox/postgres/store/transaction.d.ts.map +1 -0
  233. package/dist/outbox/postgres/store/transaction.js +32 -0
  234. package/dist/outbox/postgres/store/transaction.js.map +1 -0
  235. package/dist/outbox/postgres/tables.d.ts +737 -0
  236. package/dist/outbox/postgres/tables.d.ts.map +1 -0
  237. package/dist/outbox/postgres/tables.js +273 -0
  238. package/dist/outbox/postgres/tables.js.map +1 -0
  239. package/dist/outbox/reconciliation.d.ts +49 -0
  240. package/dist/outbox/reconciliation.d.ts.map +1 -0
  241. package/dist/outbox/reconciliation.js +162 -0
  242. package/dist/outbox/reconciliation.js.map +1 -0
  243. package/dist/outbox/reportStatus.d.ts +12 -0
  244. package/dist/outbox/reportStatus.d.ts.map +1 -0
  245. package/dist/outbox/reportStatus.js +25 -0
  246. package/dist/outbox/reportStatus.js.map +1 -0
  247. package/dist/outbox/retention.d.ts +33 -0
  248. package/dist/outbox/retention.d.ts.map +1 -0
  249. package/dist/outbox/retention.js +36 -0
  250. package/dist/outbox/retention.js.map +1 -0
  251. package/dist/outbox/store/types.d.ts +466 -0
  252. package/dist/outbox/store/types.d.ts.map +1 -0
  253. package/dist/outbox/store/types.js +31 -0
  254. package/dist/outbox/store/types.js.map +1 -0
  255. package/dist/outbox/types.d.ts +660 -0
  256. package/dist/outbox/types.d.ts.map +1 -0
  257. package/dist/outbox/types.js +24 -0
  258. package/dist/outbox/types.js.map +1 -0
  259. package/dist/outbox/webhook.d.ts +15 -0
  260. package/dist/outbox/webhook.d.ts.map +1 -0
  261. package/dist/outbox/webhook.js +148 -0
  262. package/dist/outbox/webhook.js.map +1 -0
  263. package/dist/reports.d.ts +73 -0
  264. package/dist/reports.d.ts.map +1 -0
  265. package/dist/reports.js +78 -0
  266. package/dist/reports.js.map +1 -0
  267. package/dist/testing/fixtures.d.ts +62 -0
  268. package/dist/testing/fixtures.d.ts.map +1 -0
  269. package/dist/testing/fixtures.js +137 -0
  270. package/dist/testing/fixtures.js.map +1 -0
  271. package/dist/testing/index.d.ts +36 -0
  272. package/dist/testing/index.d.ts.map +1 -0
  273. package/dist/testing/index.js +43 -0
  274. package/dist/testing/index.js.map +1 -0
  275. package/dist/testing/sandbox.d.ts +109 -0
  276. package/dist/testing/sandbox.d.ts.map +1 -0
  277. package/dist/testing/sandbox.js +342 -0
  278. package/dist/testing/sandbox.js.map +1 -0
  279. package/dist/testing/webhook-simulator.d.ts +78 -0
  280. package/dist/testing/webhook-simulator.d.ts.map +1 -0
  281. package/dist/testing/webhook-simulator.js +98 -0
  282. package/dist/testing/webhook-simulator.js.map +1 -0
  283. package/dist/transport.d.ts +59 -0
  284. package/dist/transport.d.ts.map +1 -0
  285. package/dist/transport.js +181 -0
  286. package/dist/transport.js.map +1 -0
  287. package/dist/webhookEndpoints.d.ts +147 -0
  288. package/dist/webhookEndpoints.d.ts.map +1 -0
  289. package/dist/webhookEndpoints.js +143 -0
  290. package/dist/webhookEndpoints.js.map +1 -0
  291. package/package.json +127 -0
  292. package/src/cases.ts +119 -0
  293. package/src/client.ts +202 -0
  294. package/src/communityNotes.ts +194 -0
  295. package/src/credential.ts +100 -0
  296. package/src/defaults.ts +87 -0
  297. package/src/digest.ts +91 -0
  298. package/src/envelope.ts +432 -0
  299. package/src/errors.ts +165 -0
  300. package/src/express/index.ts +43 -0
  301. package/src/express/middleware.ts +387 -0
  302. package/src/express/store.ts +85 -0
  303. package/src/express/verify.ts +166 -0
  304. package/src/index.ts +107 -0
  305. package/src/outbox/client.ts +75 -0
  306. package/src/outbox/decision.ts +209 -0
  307. package/src/outbox/delivery.ts +163 -0
  308. package/src/outbox/enforcement/executor.ts +288 -0
  309. package/src/outbox/enforcement/planner.ts +377 -0
  310. package/src/outbox/evidence.ts +231 -0
  311. package/src/outbox/inbound.ts +140 -0
  312. package/src/outbox/index.ts +160 -0
  313. package/src/outbox/intake.ts +175 -0
  314. package/src/outbox/integration.ts +205 -0
  315. package/src/outbox/outbox/dispatcher.ts +131 -0
  316. package/src/outbox/outbox/service.ts +444 -0
  317. package/src/outbox/postgres/index.ts +59 -0
  318. package/src/outbox/postgres/registries.ts +177 -0
  319. package/src/outbox/postgres/reportColumns.ts +281 -0
  320. package/src/outbox/postgres/store/enforcement.ts +186 -0
  321. package/src/outbox/postgres/store/events.ts +112 -0
  322. package/src/outbox/postgres/store/index.ts +72 -0
  323. package/src/outbox/postgres/store/outbox.ts +273 -0
  324. package/src/outbox/postgres/store/reports.ts +349 -0
  325. package/src/outbox/postgres/store/transaction.ts +64 -0
  326. package/src/outbox/postgres/tables.ts +339 -0
  327. package/src/outbox/reconciliation.ts +199 -0
  328. package/src/outbox/reportStatus.ts +24 -0
  329. package/src/outbox/retention.ts +34 -0
  330. package/src/outbox/store/types.ts +528 -0
  331. package/src/outbox/types.ts +753 -0
  332. package/src/outbox/webhook.ts +165 -0
  333. package/src/reports.ts +138 -0
  334. package/src/testing/fixtures.ts +183 -0
  335. package/src/testing/index.ts +57 -0
  336. package/src/testing/sandbox.ts +469 -0
  337. package/src/testing/webhook-simulator.ts +166 -0
  338. package/src/transport.ts +250 -0
  339. package/src/webhookEndpoints.ts +256 -0
@@ -0,0 +1,175 @@
1
+ import type { SubjectRegistry } from './evidence.js';
2
+ import { reportSubmitEventId, type OutboxService } from './outbox/service.js';
3
+ import type {
4
+ ModerationReportStore,
5
+ ModerationTransactionRunner,
6
+ } from './store/types.js';
7
+ import type {
8
+ CreateReportInput,
9
+ CreateReportResult,
10
+ ModerationReportFields,
11
+ } from './types.js';
12
+
13
+ /**
14
+ * Storing a report and, when there is somewhere to send it, the promise to
15
+ * deliver it — in one operation.
16
+ *
17
+ * This is the only thing in the integration that a user waits for. A 201 from an
18
+ * application's report route means the report row and its outbox event committed
19
+ * together. It does NOT mean CrowdSource accepted anything — CrowdSource may be
20
+ * unreachable, mid-deploy or not yet configured, and the reporter is told their
21
+ * report was received either way, because it was.
22
+ *
23
+ * The transaction is the whole mechanism. Two writes outside one would give two
24
+ * failure modes that are both silent: a report with no delivery event (the
25
+ * report exists, nothing will ever send it, and nobody finds out until somebody
26
+ * asks why a case never opened) or a delivery event with no report (a delivery
27
+ * worker looking up an id that was rolled back). Neither surfaces as an error at
28
+ * the moment it happens, which is exactly why this has to be atomic rather than
29
+ * carefully ordered.
30
+ *
31
+ * The one report with NO delivery event is the one whose type has no subject
32
+ * provider, and that is a different claim entirely: not "delivery failed" but
33
+ * "there was never a route out of this application for this kind of object".
34
+ * Those two must not be conflated, which is why they are different `localStatus`
35
+ * values and why the absent route is written down as a reason rather than
36
+ * inferred from a missing row.
37
+ */
38
+
39
+ export class DuplicateReportError<TReport> extends Error {
40
+ readonly existing: TReport;
41
+
42
+ constructor(existing: TReport) {
43
+ super('This item has already been reported by this reporter.');
44
+ this.name = 'DuplicateReportError';
45
+ this.existing = existing;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Refuses an identifier that is not a string, before it reaches the store.
51
+ *
52
+ * The input type says these are strings, but a type is erased at runtime and a
53
+ * truthiness check passes anything non-empty — including an object. What that
54
+ * costs depends on the backend, and the difference is worth stating rather than
55
+ * flattening: on Mongo a `{ $ne: null }` becomes a query OPERATOR, so the
56
+ * duplicate lookup matches an UNRELATED report and answers "you already reported
57
+ * this" about somebody else's row; on Postgres a bound parameter cannot become
58
+ * an operator, so that particular failure class does not exist there.
59
+ *
60
+ * The guard is not Mongo's, though. A non-string still reaches the insert and
61
+ * stores something that is not an id where an id belongs, on any backend — and
62
+ * the check lives here rather than at an application's route because this
63
+ * function is exported: a queue worker, a reconciliation script or a future
64
+ * admin path is under no obligation to have passed a route's validation, and a
65
+ * guard that only exists at one caller is a guard that holds until the second
66
+ * one arrives.
67
+ */
68
+ function requireIdentifier(value: unknown, field: string): string {
69
+ /**
70
+ * `trim()` and not `length === 0`, because `' '` is not an identifier.
71
+ *
72
+ * A whitespace-only reporter or reported id is not a string somebody meant:
73
+ * it comes from a form field, a trimmed-elsewhere value, or a client that
74
+ * sends `' '` for absent. Stored, it is an id that matches nothing and reads
75
+ * as present — the duplicate check finds no earlier report, the delivery
76
+ * carries it as `externalReportId`, and nothing fails until a human asks why
77
+ * a case names nobody.
78
+ */
79
+ if (typeof value !== 'string' || value.trim() === '') {
80
+ throw new TypeError(`createReport: ${field} must be a non-empty string.`);
81
+ }
82
+ return value;
83
+ }
84
+
85
+ /**
86
+ * Why a report is not going anywhere, in words an operator can read.
87
+ *
88
+ * Stored on the row rather than left to be inferred from a missing outbox event.
89
+ * A missing row is also what a lost write looks like, and the two need to be
90
+ * distinguishable months later without re-deriving which types had providers at
91
+ * the time. Bounded by the schema's 300-character limit.
92
+ */
93
+ function localOnlyReason(reportedType: string): string {
94
+ return (
95
+ `There is no moderation subject provider for '${reportedType}', so this report is ` +
96
+ 'recorded locally and is not sent for community review.'
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Store the report, and queue its delivery in the same transaction.
102
+ *
103
+ * Delivery is queued when — and only when — the reported type has a subject
104
+ * provider. A type without one is stored at `received` with the reason recorded,
105
+ * which is the behaviour the application had before CrowdSource existed: the
106
+ * report is a receipt and a local record, and nothing else ever happens to it.
107
+ *
108
+ * That branch is the reason the two writes stay in one transaction rather than
109
+ * being ordered carefully. The condition is read BEFORE the transaction body
110
+ * decides anything, so `localStatus` and the presence of an outbox row are
111
+ * decided together from one fact — a report can never commit as `queued` with
112
+ * nothing to deliver it, nor as `received` with a delivery event that will try
113
+ * anyway.
114
+ *
115
+ * Intake deliberately does not read whether the integration is enabled. A report
116
+ * taken while it is off still gets its delivery event, so turning the flag on
117
+ * delivers the backlog instead of stranding it — the dispatcher is what is
118
+ * gated, not the durable record. Nothing here is conditional on a third party's
119
+ * state; only on whether this application knows how to describe the object at
120
+ * all.
121
+ */
122
+ export function createIntake<TReport extends ModerationReportFields, TTx>(input: {
123
+ transaction: ModerationTransactionRunner<TTx>;
124
+ reports: ModerationReportStore<TReport, TTx>;
125
+ registry: SubjectRegistry;
126
+ outbox: OutboxService<TTx>;
127
+ }): (report: CreateReportInput) => Promise<CreateReportResult<TReport>> {
128
+ return async (report) => {
129
+ const reporter = requireIdentifier(report.reporter, 'reporter');
130
+ const reportedId = requireIdentifier(report.reportedId, 'reportedId');
131
+ const reportedType = requireIdentifier(report.reportedType, 'reportedType');
132
+ if (!Array.isArray(report.categories) || report.categories.length === 0) {
133
+ throw new TypeError('createReport: categories must be a non-empty array.');
134
+ }
135
+ for (const category of report.categories) {
136
+ requireIdentifier(category, 'categories[]');
137
+ }
138
+ const deliverable = input.registry.providerFor(reportedType) !== undefined;
139
+
140
+ return await input.transaction.run(async (tx) => {
141
+ const existing = await input.reports.findDuplicate(
142
+ { reporter, reportedId, reportedType },
143
+ tx,
144
+ );
145
+ if (existing) throw new DuplicateReportError(existing);
146
+
147
+ const created = await input.reports.insert(
148
+ {
149
+ reportedType,
150
+ reportedId,
151
+ reporter,
152
+ categories: [...report.categories],
153
+ ...(report.details === undefined ? {} : { details: report.details }),
154
+ localStatus: deliverable ? 'queued' : 'received',
155
+ ...(deliverable ? {} : { localStatusReason: localOnlyReason(reportedType) }),
156
+ ...(report.extra === undefined ? {} : { extra: report.extra }),
157
+ },
158
+ tx,
159
+ );
160
+
161
+ if (!deliverable) return { report: created };
162
+
163
+ const outboxEventId = await input.outbox.enqueue(
164
+ {
165
+ eventId: reportSubmitEventId(created.id),
166
+ kind: 'report.submit',
167
+ payload: { reportId: created.id },
168
+ },
169
+ tx,
170
+ );
171
+
172
+ return { report: created, outboxEventId };
173
+ });
174
+ };
175
+ }
@@ -0,0 +1,205 @@
1
+ import type { Router } from 'express';
2
+ import { createClientProvider, type CrowdSourceClientProvider } from './client.js';
3
+ import { createDecisionWorker } from './decision.js';
4
+ import { createDeliveryWorker } from './delivery.js';
5
+ import { createEnforcementExecutor, type EnforcementExecutor } from './enforcement/executor.js';
6
+ import { assertRestoreDirection } from './enforcement/planner.js';
7
+ import { createSubjectRegistry, type SubjectRegistry } from './evidence.js';
8
+ import { createInboundService, createProcessedEventStore } from './inbound.js';
9
+ import { ModerationOutboxDispatcher, createOutboxRouter } from './outbox/dispatcher.js';
10
+ import { createOutboxService } from './outbox/service.js';
11
+ import { createIntake } from './intake.js';
12
+ import {
13
+ ModerationReconciliationJob,
14
+ createReconciliation,
15
+ type ReconcileModerationReports,
16
+ } from './reconciliation.js';
17
+ import { createWebhookRouter } from './webhook.js';
18
+ import type {
19
+ CreateReportInput,
20
+ CreateReportResult,
21
+ ModerationIntegrationConfig,
22
+ ModerationReportFields,
23
+ } from './types.js';
24
+
25
+ /**
26
+ * Everything wired together, from one object.
27
+ *
28
+ * A factory rather than a set of module-level singletons, and that is not
29
+ * stylistic: a module-level client, dispatcher or registry cannot be built
30
+ * twice, which makes two integrations in one test process impossible and makes
31
+ * test isolation depend on module-registry surgery. The store is built OUTSIDE
32
+ * and passed in, so the same wiring serves either backend and neither is
33
+ * reachable from here.
34
+ *
35
+ * `TTx` is deliberately absent from the returned interface. It is inferred from
36
+ * the config's store, used only inside this factory, and never surfaces — so a
37
+ * caller holding a `ModerationIntegration` cannot tell which backend built it,
38
+ * which is what lets one test suite run against both.
39
+ */
40
+ export interface ModerationIntegration<
41
+ TReport extends ModerationReportFields,
42
+ TAction extends string,
43
+ > {
44
+ /**
45
+ * Store a report and, when there is somewhere to send it, the promise to
46
+ * deliver it — in ONE transaction.
47
+ *
48
+ * Throws `DuplicateReportError` when this reporter already reported this
49
+ * object, and `TypeError` for an identifier that is not a non-empty string.
50
+ */
51
+ createReport(input: CreateReportInput): Promise<CreateReportResult<TReport>>;
52
+
53
+ /**
54
+ * The webhook receiver. **Mount this BEFORE `express.json()`** — the signature
55
+ * covers the bytes that arrived, and a parser destroys them.
56
+ *
57
+ * Returns an empty router when no webhook secret is configured: an
58
+ * unconfigured deployment 404s, which is indistinguishable from not having the
59
+ * feature.
60
+ */
61
+ webhookRouter(options?: { path?: string }): Router;
62
+
63
+ /**
64
+ * The outbox loop. Safe on every task — every event is claimed under a lease
65
+ * with an owner check, so N tasks share the work.
66
+ */
67
+ readonly dispatcher: ModerationOutboxDispatcher;
68
+
69
+ /**
70
+ * The reconciliation sweep on a timer. Start it from a LEADER-elected
71
+ * scheduler only.
72
+ */
73
+ readonly reconciliationJob: ModerationReconciliationJob;
74
+
75
+ /** One reconciliation sweep, on demand. */
76
+ readonly reconcile: ReconcileModerationReports;
77
+
78
+ /** The reported types that have a subject provider, so a test can pin the set. */
79
+ deliverableTypes(): string[];
80
+
81
+ readonly registry: SubjectRegistry;
82
+ readonly enforcement: EnforcementExecutor<TAction>;
83
+ readonly client: CrowdSourceClientProvider;
84
+ }
85
+
86
+ export function createModerationIntegration<
87
+ TReport extends ModerationReportFields,
88
+ TAction extends string,
89
+ TTx,
90
+ >(
91
+ config: ModerationIntegrationConfig<TReport, TAction, TTx>,
92
+ ): ModerationIntegration<TReport, TAction> {
93
+ /**
94
+ * Refuse an inverted `restoreAction` before anything is wired. It cannot be
95
+ * caught by the type — both directions are `TAction[]` — and it does not fail
96
+ * at runtime; it applies a punishment on an accepted appeal.
97
+ */
98
+ assertRestoreDirection(config.enforcement);
99
+
100
+ const store = config.store;
101
+ const outbox = createOutboxService({ store: store.outbox, logger: config.logger });
102
+ const registry = createSubjectRegistry(config.subjects);
103
+ const client = createClientProvider({
104
+ config: config.crowdSource,
105
+ logger: config.logger,
106
+ });
107
+
108
+ const enforcement = createEnforcementExecutor<TAction>({
109
+ enforcement: store.enforcement,
110
+ config: config.enforcement,
111
+ defaultMode: config.crowdSource.enforcementMode,
112
+ logger: config.logger,
113
+ ...(config.metrics === undefined ? {} : { metrics: config.metrics }),
114
+ });
115
+
116
+ const deliverReport = createDeliveryWorker({
117
+ reports: store.reports,
118
+ registry,
119
+ taxonomy: config.taxonomy,
120
+ client,
121
+ logger: config.logger,
122
+ ...(config.metrics === undefined ? {} : { metrics: config.metrics }),
123
+ });
124
+
125
+ const applyDecision = createDecisionWorker({
126
+ reports: store.reports,
127
+ executor: enforcement,
128
+ enforcement: config.enforcement,
129
+ logger: config.logger,
130
+ ...(config.reportDecisionExtraFields === undefined
131
+ ? {}
132
+ : { reportDecisionExtraFields: config.reportDecisionExtraFields }),
133
+ });
134
+
135
+ const inbound = createInboundService({
136
+ transaction: store.transaction,
137
+ events: store.events,
138
+ outbox,
139
+ });
140
+
141
+ const reconcile = createReconciliation({
142
+ transaction: store.transaction,
143
+ reports: store.reports,
144
+ outbox,
145
+ logger: config.logger,
146
+ ...(config.crowdSource.staleSubmittedHours === undefined
147
+ ? {}
148
+ : { staleSubmittedHours: config.crowdSource.staleSubmittedHours }),
149
+ });
150
+
151
+ const dispatcher = new ModerationOutboxDispatcher({
152
+ outbox,
153
+ handler: createOutboxRouter({ deliverReport, applyDecision }),
154
+ logger: config.logger,
155
+ enabled: config.crowdSource.enabled,
156
+ ...(config.crowdSource.outboxPollIntervalMs === undefined
157
+ ? {}
158
+ : { pollIntervalMs: config.crowdSource.outboxPollIntervalMs }),
159
+ ...(config.crowdSource.outboxBatchSize === undefined
160
+ ? {}
161
+ : { batchSize: config.crowdSource.outboxBatchSize }),
162
+ });
163
+
164
+ const reconciliationJob = new ModerationReconciliationJob({
165
+ reconcile,
166
+ logger: config.logger,
167
+ enabled: config.crowdSource.enabled,
168
+ ...(config.crowdSource.reconciliationIntervalMs === undefined
169
+ ? {}
170
+ : { intervalMs: config.crowdSource.reconciliationIntervalMs }),
171
+ });
172
+
173
+ return {
174
+ createReport: createIntake({
175
+ transaction: store.transaction,
176
+ reports: store.reports,
177
+ registry,
178
+ outbox,
179
+ }),
180
+
181
+ webhookRouter(options = {}) {
182
+ return createWebhookRouter({
183
+ inbound,
184
+ store: createProcessedEventStore(store.events),
185
+ ...(config.crowdSource.webhookSecret === undefined
186
+ ? {}
187
+ : { secret: config.crowdSource.webhookSecret }),
188
+ ...(config.crowdSource.webhookPreviousSecret === undefined
189
+ ? {}
190
+ : { previousSecret: config.crowdSource.webhookPreviousSecret }),
191
+ logger: config.logger,
192
+ ...(config.metrics === undefined ? {} : { metrics: config.metrics }),
193
+ ...(options.path === undefined ? {} : { path: options.path }),
194
+ });
195
+ },
196
+
197
+ dispatcher,
198
+ reconciliationJob,
199
+ reconcile,
200
+ deliverableTypes: () => registry.deliverableTypes(),
201
+ registry,
202
+ enforcement,
203
+ client,
204
+ };
205
+ }
@@ -0,0 +1,131 @@
1
+ import type { ModerationLogger, ModerationOutboxEvent } from '../types.js';
2
+ import type { ModerationOutboxHandler, OutboxDrain } from './service.js';
3
+
4
+ /**
5
+ * The loop that drains the moderation outbox.
6
+ *
7
+ * A bounded interval, one batch in flight at a time, an abort signal that stops
8
+ * claiming new work but lets the event already being handled reach a durable
9
+ * state.
10
+ *
11
+ * It is NOT leader-gated, and that is a property of the claim rather than an
12
+ * oversight. Every event is taken under a lease with an owner check, so N tasks
13
+ * draining the same collection simply share the work — and a task dying
14
+ * mid-delivery has its lease expire and its event reclaimed, which a single
15
+ * leader would not give us.
16
+ *
17
+ * `enabled` gates the LOOP, never the durable record. Reports taken while the
18
+ * integration is off keep their outbox rows and deliver when it is switched on;
19
+ * running the loop instead would count attempts against a deployment that has
20
+ * nowhere to send anything and dead-letter the backlog it was supposed to
21
+ * preserve.
22
+ */
23
+
24
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
25
+
26
+ export class ModerationOutboxDispatcher {
27
+ private timer: ReturnType<typeof setInterval> | null = null;
28
+ private inFlight: Promise<void> | null = null;
29
+ private abortController: AbortController | null = null;
30
+ private running = false;
31
+
32
+ constructor(
33
+ private readonly options: {
34
+ outbox: OutboxDrain;
35
+ handler: ModerationOutboxHandler;
36
+ logger: ModerationLogger;
37
+ enabled: boolean;
38
+ pollIntervalMs?: number;
39
+ batchSize?: number;
40
+ },
41
+ ) {}
42
+
43
+ start(): void {
44
+ if (this.running) return;
45
+ if (!this.options.enabled) {
46
+ this.options.logger.info(
47
+ '[CrowdSource] outbox dispatcher not started: the integration is disabled',
48
+ );
49
+ return;
50
+ }
51
+ const intervalMs = this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
52
+ this.running = true;
53
+ this.abortController = new AbortController();
54
+ void this.tick();
55
+ this.timer = setInterval(() => {
56
+ void this.tick();
57
+ }, intervalMs);
58
+ this.timer.unref?.();
59
+ this.options.logger.info('[CrowdSource] outbox dispatcher started', {
60
+ intervalMs,
61
+ batchSize: this.options.batchSize,
62
+ });
63
+ }
64
+
65
+ async stop(): Promise<void> {
66
+ this.running = false;
67
+ const controller = this.abortController;
68
+ controller?.abort();
69
+ if (this.timer) {
70
+ clearInterval(this.timer);
71
+ this.timer = null;
72
+ }
73
+ await this.inFlight;
74
+ if (this.abortController === controller) {
75
+ this.abortController = null;
76
+ }
77
+ }
78
+
79
+ private async tick(): Promise<void> {
80
+ if (!this.running) return;
81
+ if (this.inFlight) return this.inFlight;
82
+ const work = this.options.outbox
83
+ .dispatch({
84
+ handler: this.options.handler,
85
+ ...(this.options.batchSize === undefined
86
+ ? {}
87
+ : { batchSize: this.options.batchSize }),
88
+ ...(this.abortController === null
89
+ ? {}
90
+ : { signal: this.abortController.signal }),
91
+ })
92
+ .then(({ processed, failed, deadLettered }) => {
93
+ if (processed > 0 || failed > 0) {
94
+ this.options.logger.info('[CrowdSource] outbox batch complete', {
95
+ processed,
96
+ failed,
97
+ deadLettered,
98
+ });
99
+ }
100
+ })
101
+ .catch((error: unknown) => {
102
+ // Claim/database failures happen outside the per-event retry block. Keep
103
+ // the interval alive and avoid an unhandled rejection.
104
+ this.options.logger.error('[CrowdSource] outbox tick failed', {
105
+ error: error instanceof Error ? error.message : String(error),
106
+ });
107
+ })
108
+ .finally(() => {
109
+ if (this.inFlight === work) this.inFlight = null;
110
+ });
111
+ this.inFlight = work;
112
+ return work;
113
+ }
114
+ }
115
+
116
+ /** Route an event to the worker that owns its kind. */
117
+ export function createOutboxRouter(handlers: {
118
+ deliverReport: (event: ModerationOutboxEvent) => Promise<void>;
119
+ applyDecision: (event: ModerationOutboxEvent) => Promise<void>;
120
+ }): ModerationOutboxHandler {
121
+ return async (event: ModerationOutboxEvent): Promise<void> => {
122
+ switch (event.kind) {
123
+ case 'report.submit':
124
+ await handlers.deliverReport(event);
125
+ return;
126
+ case 'decision.apply':
127
+ await handlers.applyDecision(event);
128
+ return;
129
+ }
130
+ };
131
+ }