@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,165 @@
1
+ import { Router } from 'express';
2
+ import { crowdsourceWebhooks, type ProcessedEventStore } from '../express/index.js';
3
+ import type { InboundService } from './inbound.js';
4
+ import type { ModerationLogger, ModerationMetrics } from './types.js';
5
+
6
+ /**
7
+ * `POST /crowdsource` — where decisions come back.
8
+ *
9
+ * ## The mount is part of the correctness
10
+ *
11
+ * This router MUST be mounted BEFORE `express.json()`. The signature covers
12
+ * `timestamp + "." + rawBody` — the bytes that arrived — and once a JSON parser
13
+ * has run, those bytes are gone. `@crowdsource.you/core/express` looks for a raw
14
+ * Buffer; handed a parsed `req.body` it refuses and raises a configuration error
15
+ * rather than verifying a signature over a re-serialisation. That refusal is the
16
+ * correct behaviour and it is also why the mount order cannot be got wrong
17
+ * silently.
18
+ *
19
+ * The property to TEST is not the mount order — that only proves the order. It
20
+ * is that no parser ran: assert `typeof req.body === 'undefined'` from inside
21
+ * the route.
22
+ *
23
+ * ## What this handler does and does not do
24
+ *
25
+ * It records and returns. A receiver should answer 2xx quickly and queue the
26
+ * processing, and the reason is not latency: applying a decision means reading
27
+ * objects, planning enforcement and writing several collections, and a receiver
28
+ * that did all that inline would time out under a burst and be retried while the
29
+ * first attempt was still running. So the event and a durable `decision.apply`
30
+ * outbox row commit in ONE transaction, and the dispatcher does the work.
31
+ *
32
+ * Nothing here is authenticated by Oxy. The HMAC IS the authentication, and an
33
+ * Oxy session must never satisfy this route — it is not a user endpoint.
34
+ */
35
+
36
+ /**
37
+ * One string field out of an event payload this version does not know.
38
+ *
39
+ * A webhook envelope's `data` is deliberately OPAQUE in the contract: an
40
+ * unrecognised event's payload is whatever a newer CrowdSource decided to send,
41
+ * and the exported type says so — property access on it does not compile. That
42
+ * is the contract being honest rather than an obstacle, so this reads the key
43
+ * defensively instead of asserting a shape nobody has verified. Anything that is
44
+ * not a string is treated as absent, which is the only safe reading of a field
45
+ * this deployment has never seen.
46
+ */
47
+ function stringField(source: unknown, key: string): string | undefined {
48
+ if (typeof source !== 'object' || source === null) return undefined;
49
+ const value: unknown = Reflect.get(source, key);
50
+ return typeof value === 'string' ? value : undefined;
51
+ }
52
+
53
+ export function createWebhookRouter(input: {
54
+ inbound: InboundService;
55
+ store: ProcessedEventStore;
56
+ secret?: string;
57
+ previousSecret?: string;
58
+ logger: ModerationLogger;
59
+ metrics?: ModerationMetrics;
60
+ /** Defaults to `/crowdsource`. */
61
+ path?: string;
62
+ }): Router {
63
+ const router = Router();
64
+
65
+ if (!input.secret) {
66
+ /**
67
+ * Not mounted, rather than mounted and permissive.
68
+ *
69
+ * A route that answers anything at all without a secret is a route that will
70
+ * one day be reasoned about as if it verified something. An unconfigured
71
+ * deployment 404s here, which is indistinguishable from not having the
72
+ * feature — which is exactly what it is.
73
+ */
74
+ input.logger.info(
75
+ '[CrowdSource] webhook route not mounted: no webhook secret is configured',
76
+ );
77
+ return router;
78
+ }
79
+
80
+ router.post(
81
+ input.path ?? '/crowdsource',
82
+ crowdsourceWebhooks({
83
+ secret: input.secret,
84
+ ...(input.previousSecret === undefined
85
+ ? {}
86
+ : { previousSecret: input.previousSecret }),
87
+ // Shared across tasks: the in-process default would dedupe only the
88
+ // instance that happened to receive both copies of a redelivery.
89
+ store: input.store,
90
+ on: {
91
+ /**
92
+ * A decision, provisional or final. Both are queued: a provisional
93
+ * decision is real and is recorded; what may be ACTED on is decided by
94
+ * the enforcement mode, not by discarding the event here.
95
+ */
96
+ 'case.decided': async (event) => {
97
+ await input.inbound.recordDecisionEvent({
98
+ eventId: event.id,
99
+ type: event.type,
100
+ caseId: event.data.caseId,
101
+ decision: event.data.decision,
102
+ });
103
+ },
104
+ /**
105
+ * A later revision replacing an earlier one. The SAME path: the decision
106
+ * worker compares revisions and the enforcement executor reverses what
107
+ * the superseded revision did. A correction is not a special case with
108
+ * its own code — it is an ordinary decision that supersedes another, and
109
+ * giving it a separate path is how a restore ends up not being
110
+ * idempotent.
111
+ */
112
+ 'decision.corrected': async (event) => {
113
+ await input.inbound.recordDecisionEvent({
114
+ eventId: event.id,
115
+ type: event.type,
116
+ caseId: event.data.caseId,
117
+ decision: event.data.decision,
118
+ });
119
+ },
120
+ /**
121
+ * An appeal's outcome carries a decision too, and it is the current
122
+ * answer for the case, so it takes the same path.
123
+ */
124
+ 'appeal.decided': async (event) => {
125
+ await input.inbound.recordDecisionEvent({
126
+ eventId: event.id,
127
+ type: event.type,
128
+ caseId: event.data.caseId,
129
+ decision: event.data.decision,
130
+ });
131
+ },
132
+ },
133
+ /**
134
+ * Every other event type — including one this version of the contracts
135
+ * package has never heard of.
136
+ *
137
+ * Recorded rather than dropped. `case.created`, `case.escalated` and
138
+ * `case.closed` carry no decision and nothing to enforce, but "did
139
+ * CrowdSource tell us about this case, and when" is the first question
140
+ * asked when a report appears stuck, and the answer has to exist
141
+ * somewhere.
142
+ */
143
+ onUnhandled: async (event) => {
144
+ const caseId = stringField(event.data, 'caseId');
145
+ await input.inbound.recordIgnoredEvent({
146
+ eventId: event.id,
147
+ type: event.type,
148
+ ...(caseId === undefined ? {} : { caseId }),
149
+ });
150
+ },
151
+ /**
152
+ * A refusal reason and nothing else — never a body, a header or a
153
+ * signature. It is a bounded label, so it can be a metric.
154
+ */
155
+ onRejected: (rejection) => {
156
+ input.metrics?.incrementCounter('crowdsource_webhook_rejected_total', 1, {
157
+ rejection,
158
+ });
159
+ input.logger.warn('[CrowdSource] webhook delivery refused', { rejection });
160
+ },
161
+ }),
162
+ );
163
+
164
+ return router;
165
+ }
package/src/reports.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `POST /v1/reports` and `GET /v1/reports/{id}` (§10.2, §10.4).
3
+ *
4
+ * The 202 this returns means what §7.1 says it means: the report is stored and
5
+ * durable rows exist for everything that happens next. It does not mean a jury
6
+ * exists, a decision exists, or that any downstream call succeeded — and an
7
+ * application that treats it as "moderation is done" has misread the contract.
8
+ * The decision comes back later, over a webhook.
9
+ */
10
+
11
+ import {
12
+ CreateReportResponseSchema,
13
+ type CreateReportResponse,
14
+ type ReportStatus,
15
+ } from '@crowdsource.you/contracts';
16
+ import { z } from 'zod';
17
+
18
+ import { composeCaseEnvelope, defaultIdempotencyKey, type ReportInput } from './envelope.js';
19
+ import { CrowdSourceTransportError } from './errors.js';
20
+ import type { Transport } from './transport.js';
21
+
22
+ /**
23
+ * `GET /v1/reports/{id}` (§10.2 "receipt, caseId and limited status").
24
+ *
25
+ * Declared here because `@crowdsource.you/contracts` publishes the DOCUMENTS
26
+ * that travel over the API and not every HTTP response shape. The receipt and
27
+ * the case view below both belong in contracts; until they move, these are the
28
+ * types integrators compile against and the loose parse is what keeps a newer
29
+ * server from breaking an older client (§10.11).
30
+ */
31
+ export interface ReportReceipt {
32
+ readonly reportId: string;
33
+ readonly externalReportId: string;
34
+ readonly caseId: string;
35
+ /**
36
+ * §3.2's report states, kept open. §10.11 requires a newer server not to break
37
+ * an older client, and a state added to §3.2 is exactly that case: the union
38
+ * still autocompletes the four this version knows and still type-checks the
39
+ * fifth when it arrives.
40
+ */
41
+ readonly status: ReportStatus | (string & {});
42
+ /** ISO-8601 UTC, as the API sends it. */
43
+ readonly receivedAt: string;
44
+ }
45
+
46
+ const ReportReceiptSchema = z.looseObject({
47
+ reportId: z.string(),
48
+ externalReportId: z.string(),
49
+ caseId: z.string(),
50
+ status: z.string(),
51
+ receivedAt: z.string(),
52
+ });
53
+
54
+ export interface ReportRequestOptions {
55
+ readonly signal?: AbortSignal;
56
+ }
57
+
58
+ export class Reports {
59
+ private readonly transport: Transport;
60
+ /**
61
+ * Awaited rather than read: with an Oxy service token the client has no
62
+ * credential to read an application id off, so the id is resolved from
63
+ * CrowdSource once and this holds that answer (settled or still in flight).
64
+ * A service key resolves to a plain string, and awaiting one costs nothing.
65
+ */
66
+ private readonly applicationId: string | Promise<string>;
67
+ private readonly environment: 'production' | 'sandbox';
68
+
69
+ constructor(input: {
70
+ transport: Transport;
71
+ applicationId: string | Promise<string>;
72
+ environment: 'production' | 'sandbox';
73
+ }) {
74
+ this.transport = input.transport;
75
+ this.applicationId = input.applicationId;
76
+ this.environment = input.environment;
77
+ }
78
+
79
+ /**
80
+ * Delivers a report.
81
+ *
82
+ * Call this from a delivery worker draining the application's own outbox, not
83
+ * from the request handler that answered the user (§7.1). The user is told
84
+ * their report was received the moment it is stored locally; this call is what
85
+ * eventually makes it CrowdSource's problem, and it is allowed to fail and be
86
+ * retried without the user ever knowing.
87
+ *
88
+ * Re-delivering the same report is safe and returns the same `reportId`. What
89
+ * is NOT safe is re-delivering the same `externalReportId` with a CHANGED
90
+ * body: §10.5 answers 409 and this client surfaces it as
91
+ * `CrowdSourceApiError` with `isPayloadConflict` and `retryable: false`,
92
+ * because no number of retries makes two different payloads one report.
93
+ */
94
+ async create(
95
+ input: ReportInput,
96
+ options: ReportRequestOptions = {},
97
+ ): Promise<CreateReportResponse> {
98
+ const envelope = composeCaseEnvelope(input, {
99
+ applicationId: await this.applicationId,
100
+ environment: this.environment,
101
+ });
102
+
103
+ const response = await this.transport.request<unknown>({
104
+ method: 'POST',
105
+ path: '/v1/reports',
106
+ body: { externalReportId: input.externalReportId, envelope },
107
+ idempotencyKey: input.idempotencyKey ?? defaultIdempotencyKey(input.externalReportId),
108
+ signal: options.signal,
109
+ });
110
+
111
+ const parsed = CreateReportResponseSchema.safeParse(response);
112
+ if (!parsed.success) {
113
+ throw new CrowdSourceTransportError(
114
+ 'CrowdSource accepted the report but answered with a body this client does not recognise.',
115
+ { retryable: false, cause: parsed.error },
116
+ );
117
+ }
118
+ return parsed.data;
119
+ }
120
+
121
+ /** Reads back the receipt for a report this application delivered. */
122
+ async get(reportId: string, options: ReportRequestOptions = {}): Promise<ReportReceipt> {
123
+ const response = await this.transport.request<unknown>({
124
+ method: 'GET',
125
+ path: `/v1/reports/${encodeURIComponent(reportId)}`,
126
+ signal: options.signal,
127
+ });
128
+
129
+ const parsed = ReportReceiptSchema.safeParse(response);
130
+ if (!parsed.success) {
131
+ throw new CrowdSourceTransportError(
132
+ 'CrowdSource answered with a report receipt this client does not recognise.',
133
+ { retryable: false, cause: parsed.error },
134
+ );
135
+ }
136
+ return parsed.data;
137
+ }
138
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Fixtures.
3
+ *
4
+ * Every builder below runs its result through the published contract before
5
+ * returning it. A fixture that does not validate is a failing test, not
6
+ * something to loosen — it is how an integrator finds out a contract moved, and
7
+ * it is the only mechanism that stops this package from teaching a shape
8
+ * CrowdSource will refuse.
9
+ *
10
+ * Everything here is synthetic. No real reported material, no real evidence, no
11
+ * real reviewer or reporter identity ever ships in a test package: §13.5
12
+ * minimises what exists at all, and a fixture file is forever.
13
+ */
14
+
15
+ import {
16
+ CASE_ENVELOPE_SCHEMA_VERSION,
17
+ CaseEnvelopeSchema,
18
+ DecisionSchema,
19
+ KnownWebhookEventSchema,
20
+ UNIVERSAL_TAXONOMY_VERSION,
21
+ type CaseEnvelope,
22
+ type Decision,
23
+ type KnownWebhookEvent,
24
+ type TaxonomyCode,
25
+ } from '@crowdsource.you/contracts';
26
+ import { createHash } from 'node:crypto';
27
+
28
+ /** Deterministic, so a fixture used twice produces the same bytes. */
29
+ function digestOf(value: unknown): string {
30
+ return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
31
+ }
32
+
33
+ export interface CaseEnvelopeFixtureOptions {
34
+ readonly applicationId?: string;
35
+ readonly externalReportId?: string;
36
+ readonly externalSubjectId?: string;
37
+ readonly text?: string;
38
+ readonly allegations?: readonly TaxonomyCode[];
39
+ /** An `oxy_user` binding carries a proof; §11.14 admits no effect without one. */
40
+ readonly authorOxyUserId?: string;
41
+ readonly allowCommunityReview?: boolean;
42
+ }
43
+
44
+ /**
45
+ * A minimal valid Case Envelope: one text resource, one allegation, one author.
46
+ *
47
+ * Deliberately the SMALLEST thing the contract accepts rather than a showcase of
48
+ * every field. A fixture that exercises everything hides which field a test
49
+ * actually depends on, and every optional field it carries is one more thing a
50
+ * contract change can break for a reason unrelated to the test.
51
+ */
52
+ export function caseEnvelopeFixture(options: CaseEnvelopeFixtureOptions = {}): CaseEnvelope {
53
+ const text = options.text ?? 'Synthetic reported text for an integration test.';
54
+ const authorOxyUserId = options.authorOxyUserId ?? 'oxy_test_author';
55
+
56
+ return CaseEnvelopeSchema.parse({
57
+ schemaVersion: CASE_ENVELOPE_SCHEMA_VERSION,
58
+ applicationId: options.applicationId ?? 'app_test',
59
+ externalReportId: options.externalReportId ?? 'report_test_1',
60
+ source: { environment: 'sandbox', submittedAt: '2026-07-29T00:00:00.000Z' },
61
+ subject: {
62
+ externalId: options.externalSubjectId ?? 'post_test_1',
63
+ type: 'social.post',
64
+ primaryResourceId: 'res_subject',
65
+ },
66
+ principalBindings: [
67
+ {
68
+ principalRef: 'p_author',
69
+ type: 'oxy_user',
70
+ externalPrincipalId: authorOxyUserId,
71
+ bindingProofId: authorOxyUserId,
72
+ },
73
+ ],
74
+ resources: [
75
+ {
76
+ id: 'res_subject',
77
+ type: 'text',
78
+ role: 'subject',
79
+ language: 'en',
80
+ data: { text },
81
+ sha256: digestOf({ type: 'text', data: { text } }),
82
+ authorPrincipalRef: 'p_author',
83
+ },
84
+ ],
85
+ relations: [],
86
+ allegations: (options.allegations ?? ['harassment.targeted_abuse']).map((code) => ({ code })),
87
+ policy: { policySetId: 'crowdsource.baseline', version: '2026.07' },
88
+ privacy: {
89
+ retentionDays: 30,
90
+ allowCommunityReview: options.allowCommunityReview ?? true,
91
+ },
92
+ });
93
+ }
94
+
95
+ export interface DecisionFixtureOptions {
96
+ readonly id?: string;
97
+ readonly caseId?: string;
98
+ readonly revision?: number;
99
+ readonly outcome?: Decision['outcome'];
100
+ readonly status?: Decision['status'];
101
+ readonly findingCode?: TaxonomyCode;
102
+ readonly supersedesDecisionId?: string;
103
+ }
104
+
105
+ /**
106
+ * A published decision (Appendix B).
107
+ *
108
+ * The jury arithmetic is real, not decorative: `agreement` must equal
109
+ * `winningVotes / decisiveVotes` or the contract refuses the document. That
110
+ * check is the auditable trace of "one qualified person, one vote", so a fixture
111
+ * that faked it would teach a shape the service will never emit.
112
+ */
113
+ export function decisionFixture(options: DecisionFixtureOptions = {}): Decision {
114
+ const outcome = options.outcome ?? 'violation';
115
+ const revision = options.revision ?? 1;
116
+
117
+ return DecisionSchema.parse({
118
+ id: options.id ?? 'dec_test_1',
119
+ caseId: options.caseId ?? 'case_test_1',
120
+ revision,
121
+ status: options.status ?? 'final',
122
+ outcome,
123
+ contextSufficiency: 'sufficient',
124
+ confidence: 1,
125
+ findings:
126
+ outcome === 'violation'
127
+ ? [
128
+ {
129
+ code: options.findingCode ?? 'harassment.targeted_abuse',
130
+ resourceIds: ['res_subject'],
131
+ severity: 'medium',
132
+ scope: 'application_local',
133
+ attribution: 'author',
134
+ },
135
+ ]
136
+ : [],
137
+ recommendedActions:
138
+ outcome === 'violation'
139
+ ? [{ action: 'remove_or_restrict', targetResourceIds: ['res_subject'] }]
140
+ : [{ action: 'no_action' }],
141
+ jury: {
142
+ size: 3,
143
+ decisiveVotes: 3,
144
+ winningVotes: 3,
145
+ agreement: 1,
146
+ specialistPresent: false,
147
+ },
148
+ policyVersions: {
149
+ taxonomy: UNIVERSAL_TAXONOMY_VERSION,
150
+ application: '2026.07',
151
+ oxyConduct: '2026.07',
152
+ },
153
+ ...(options.supersedesDecisionId === undefined
154
+ ? {}
155
+ : { supersedesDecisionId: options.supersedesDecisionId }),
156
+ publishedAt: '2026-07-29T12:00:00.000Z',
157
+ });
158
+ }
159
+
160
+ export interface WebhookEventFixtureOptions {
161
+ readonly id?: string;
162
+ readonly organizationId?: string;
163
+ readonly applicationId?: string;
164
+ readonly createdAt?: string;
165
+ readonly caseId?: string;
166
+ readonly decision?: Decision;
167
+ }
168
+
169
+ /** A `case.decided` delivery (§10.7) — the one event §10.7 specifies in full. */
170
+ export function caseDecidedEventFixture(
171
+ options: WebhookEventFixtureOptions = {},
172
+ ): KnownWebhookEvent {
173
+ const decision = options.decision ?? decisionFixture({ caseId: options.caseId });
174
+
175
+ return KnownWebhookEventSchema.parse({
176
+ id: options.id ?? 'evt_test_1',
177
+ type: 'case.decided',
178
+ createdAt: options.createdAt ?? '2026-07-29T12:00:00.000Z',
179
+ organizationId: options.organizationId ?? 'org_test',
180
+ applicationId: options.applicationId ?? 'app_test',
181
+ data: { caseId: options.caseId ?? decision.caseId, decision },
182
+ });
183
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `@crowdsource.you/core/testing` — integrate against CrowdSource before a jury
3
+ * exists.
4
+ *
5
+ * ```ts
6
+ * import { createCrowdSourceSandbox } from '@crowdsource.you/core/testing';
7
+ * import { CrowdSource } from '@crowdsource.you/core';
8
+ *
9
+ * const sandbox = createCrowdSourceSandbox();
10
+ * const crowdsource = new CrowdSource({
11
+ * serviceKey: sandbox.serviceKey,
12
+ * baseUrl: sandbox.baseUrl,
13
+ * fetch: sandbox.fetch,
14
+ * });
15
+ *
16
+ * const { caseId } = await crowdsource.reports.create({ ... });
17
+ * const decision = sandbox.decide(caseId, { outcome: 'violation' });
18
+ * await sandbox.deliver('http://localhost:3000/webhooks/crowdsource', sandbox.eventFor(decision));
19
+ * ```
20
+ *
21
+ * The report goes through the real client, the sandbox applies the real rules
22
+ * (tenant from the credential, idempotency, 409 on a changed body, one case per
23
+ * reported version), and the webhook that comes back is genuinely signed — so
24
+ * the receiver being tested is the receiver that will run in production.
25
+ *
26
+ * The simulator can also deliver a stale, forged or tampered event on purpose.
27
+ * Asserting that a receiver REFUSES those is the half of a webhook test that
28
+ * actually proves something.
29
+ */
30
+
31
+ export {
32
+ caseDecidedEventFixture,
33
+ caseEnvelopeFixture,
34
+ decisionFixture,
35
+ } from './fixtures.js';
36
+ export type {
37
+ CaseEnvelopeFixtureOptions,
38
+ DecisionFixtureOptions,
39
+ WebhookEventFixtureOptions,
40
+ } from './fixtures.js';
41
+
42
+ export { CrowdSourceSandbox, createCrowdSourceSandbox } from './sandbox.js';
43
+ export type {
44
+ CrowdSourceSandboxOptions,
45
+ SandboxCase,
46
+ SandboxDecisionInput,
47
+ SandboxReport,
48
+ } from './sandbox.js';
49
+
50
+ export { WebhookSimulator, signWebhookDelivery } from './webhook-simulator.js';
51
+ export type {
52
+ SignWebhookInput,
53
+ SignedWebhookDelivery,
54
+ WebhookDeliveryOverrides,
55
+ WebhookDeliveryResult,
56
+ WebhookSimulatorOptions,
57
+ } from './webhook-simulator.js';