@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.
- package/README.md +571 -0
- package/dist/cases.d.ts +66 -0
- package/dist/cases.d.ts.map +1 -0
- package/dist/cases.js +79 -0
- package/dist/cases.js.map +1 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +125 -0
- package/dist/client.js.map +1 -0
- package/dist/communityNotes.d.ts +49 -0
- package/dist/communityNotes.d.ts.map +1 -0
- package/dist/communityNotes.js +104 -0
- package/dist/communityNotes.js.map +1 -0
- package/dist/credential.d.ts +58 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +71 -0
- package/dist/credential.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +82 -0
- package/dist/defaults.js.map +1 -0
- package/dist/digest.d.ts +44 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +79 -0
- package/dist/digest.js.map +1 -0
- package/dist/envelope.d.ts +143 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +258 -0
- package/dist/envelope.js.map +1 -0
- package/dist/errors.d.ts +90 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +151 -0
- package/dist/errors.js.map +1 -0
- package/dist/esm/cases.js +74 -0
- package/dist/esm/cases.js.map +1 -0
- package/dist/esm/client.js +121 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/communityNotes.js +100 -0
- package/dist/esm/communityNotes.js.map +1 -0
- package/dist/esm/credential.js +67 -0
- package/dist/esm/credential.js.map +1 -0
- package/dist/esm/defaults.js +77 -0
- package/dist/esm/defaults.js.map +1 -0
- package/dist/esm/digest.js +74 -0
- package/dist/esm/digest.js.map +1 -0
- package/dist/esm/envelope.js +252 -0
- package/dist/esm/envelope.js.map +1 -0
- package/dist/esm/errors.js +140 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/express/index.js +29 -0
- package/dist/esm/express/index.js.map +1 -0
- package/dist/esm/express/middleware.js +293 -0
- package/dist/esm/express/middleware.js.map +1 -0
- package/dist/esm/express/store.js +69 -0
- package/dist/esm/express/store.js.map +1 -0
- package/dist/esm/express/verify.js +133 -0
- package/dist/esm/express/verify.js.map +1 -0
- package/dist/esm/index.js +57 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/outbox/client.js +43 -0
- package/dist/esm/outbox/client.js.map +1 -0
- package/dist/esm/outbox/decision.js +153 -0
- package/dist/esm/outbox/decision.js.map +1 -0
- package/dist/esm/outbox/delivery.js +129 -0
- package/dist/esm/outbox/delivery.js.map +1 -0
- package/dist/esm/outbox/enforcement/executor.js +219 -0
- package/dist/esm/outbox/enforcement/executor.js.map +1 -0
- package/dist/esm/outbox/enforcement/planner.js +326 -0
- package/dist/esm/outbox/enforcement/planner.js.map +1 -0
- package/dist/esm/outbox/evidence.js +173 -0
- package/dist/esm/outbox/evidence.js.map +1 -0
- package/dist/esm/outbox/inbound.js +87 -0
- package/dist/esm/outbox/inbound.js.map +1 -0
- package/dist/esm/outbox/index.js +71 -0
- package/dist/esm/outbox/index.js.map +1 -0
- package/dist/esm/outbox/intake.js +140 -0
- package/dist/esm/outbox/intake.js.map +1 -0
- package/dist/esm/outbox/integration.js +116 -0
- package/dist/esm/outbox/integration.js.map +1 -0
- package/dist/esm/outbox/outbox/dispatcher.js +114 -0
- package/dist/esm/outbox/outbox/dispatcher.js.map +1 -0
- package/dist/esm/outbox/outbox/service.js +271 -0
- package/dist/esm/outbox/outbox/service.js.map +1 -0
- package/dist/esm/outbox/postgres/index.js +41 -0
- package/dist/esm/outbox/postgres/index.js.map +1 -0
- package/dist/esm/outbox/postgres/registries.js +155 -0
- package/dist/esm/outbox/postgres/registries.js.map +1 -0
- package/dist/esm/outbox/postgres/reportColumns.js +191 -0
- package/dist/esm/outbox/postgres/reportColumns.js.map +1 -0
- package/dist/esm/outbox/postgres/store/enforcement.js +158 -0
- package/dist/esm/outbox/postgres/store/enforcement.js.map +1 -0
- package/dist/esm/outbox/postgres/store/events.js +101 -0
- package/dist/esm/outbox/postgres/store/events.js.map +1 -0
- package/dist/esm/outbox/postgres/store/index.js +59 -0
- package/dist/esm/outbox/postgres/store/index.js.map +1 -0
- package/dist/esm/outbox/postgres/store/outbox.js +207 -0
- package/dist/esm/outbox/postgres/store/outbox.js.map +1 -0
- package/dist/esm/outbox/postgres/store/reports.js +231 -0
- package/dist/esm/outbox/postgres/store/reports.js.map +1 -0
- package/dist/esm/outbox/postgres/store/transaction.js +29 -0
- package/dist/esm/outbox/postgres/store/transaction.js.map +1 -0
- package/dist/esm/outbox/postgres/tables.js +270 -0
- package/dist/esm/outbox/postgres/tables.js.map +1 -0
- package/dist/esm/outbox/reconciliation.js +157 -0
- package/dist/esm/outbox/reconciliation.js.map +1 -0
- package/dist/esm/outbox/reportStatus.js +22 -0
- package/dist/esm/outbox/reportStatus.js.map +1 -0
- package/dist/esm/outbox/retention.js +33 -0
- package/dist/esm/outbox/retention.js.map +1 -0
- package/dist/esm/outbox/store/types.js +30 -0
- package/dist/esm/outbox/store/types.js.map +1 -0
- package/dist/esm/outbox/types.js +23 -0
- package/dist/esm/outbox/types.js.map +1 -0
- package/dist/esm/outbox/webhook.js +145 -0
- package/dist/esm/outbox/webhook.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/reports.js +74 -0
- package/dist/esm/reports.js.map +1 -0
- package/dist/esm/testing/fixtures.js +132 -0
- package/dist/esm/testing/fixtures.js.map +1 -0
- package/dist/esm/testing/index.js +33 -0
- package/dist/esm/testing/index.js.map +1 -0
- package/dist/esm/testing/sandbox.js +337 -0
- package/dist/esm/testing/sandbox.js.map +1 -0
- package/dist/esm/testing/webhook-simulator.js +93 -0
- package/dist/esm/testing/webhook-simulator.js.map +1 -0
- package/dist/esm/transport.js +177 -0
- package/dist/esm/transport.js.map +1 -0
- package/dist/esm/webhookEndpoints.js +139 -0
- package/dist/esm/webhookEndpoints.js.map +1 -0
- package/dist/express/index.d.ts +32 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +40 -0
- package/dist/express/index.js.map +1 -0
- package/dist/express/middleware.d.ts +97 -0
- package/dist/express/middleware.d.ts.map +1 -0
- package/dist/express/middleware.js +298 -0
- package/dist/express/middleware.js.map +1 -0
- package/dist/express/store.d.ts +45 -0
- package/dist/express/store.d.ts.map +1 -0
- package/dist/express/store.js +72 -0
- package/dist/express/store.js.map +1 -0
- package/dist/express/verify.d.ts +86 -0
- package/dist/express/verify.d.ts.map +1 -0
- package/dist/express/verify.js +138 -0
- package/dist/express/verify.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox/client.d.ts +35 -0
- package/dist/outbox/client.d.ts.map +1 -0
- package/dist/outbox/client.js +46 -0
- package/dist/outbox/client.js.map +1 -0
- package/dist/outbox/decision.d.ts +36 -0
- package/dist/outbox/decision.d.ts.map +1 -0
- package/dist/outbox/decision.js +159 -0
- package/dist/outbox/decision.js.map +1 -0
- package/dist/outbox/delivery.d.ts +50 -0
- package/dist/outbox/delivery.d.ts.map +1 -0
- package/dist/outbox/delivery.js +135 -0
- package/dist/outbox/delivery.js.map +1 -0
- package/dist/outbox/enforcement/executor.d.ts +26 -0
- package/dist/outbox/enforcement/executor.d.ts.map +1 -0
- package/dist/outbox/enforcement/executor.js +222 -0
- package/dist/outbox/enforcement/executor.js.map +1 -0
- package/dist/outbox/enforcement/planner.d.ts +50 -0
- package/dist/outbox/enforcement/planner.d.ts.map +1 -0
- package/dist/outbox/enforcement/planner.js +333 -0
- package/dist/outbox/enforcement/planner.js.map +1 -0
- package/dist/outbox/evidence.d.ts +114 -0
- package/dist/outbox/evidence.d.ts.map +1 -0
- package/dist/outbox/evidence.js +181 -0
- package/dist/outbox/evidence.js.map +1 -0
- package/dist/outbox/inbound.d.ts +78 -0
- package/dist/outbox/inbound.d.ts.map +1 -0
- package/dist/outbox/inbound.js +91 -0
- package/dist/outbox/inbound.js.map +1 -0
- package/dist/outbox/index.d.ts +79 -0
- package/dist/outbox/index.d.ts.map +1 -0
- package/dist/outbox/index.js +98 -0
- package/dist/outbox/index.js.map +1 -0
- package/dist/outbox/intake.d.ts +62 -0
- package/dist/outbox/intake.d.ts.map +1 -0
- package/dist/outbox/intake.js +145 -0
- package/dist/outbox/intake.js.map +1 -0
- package/dist/outbox/integration.d.ts +62 -0
- package/dist/outbox/integration.d.ts.map +1 -0
- package/dist/outbox/integration.js +119 -0
- package/dist/outbox/integration.js.map +1 -0
- package/dist/outbox/outbox/dispatcher.d.ts +26 -0
- package/dist/outbox/outbox/dispatcher.d.ts.map +1 -0
- package/dist/outbox/outbox/dispatcher.js +119 -0
- package/dist/outbox/outbox/dispatcher.js.map +1 -0
- package/dist/outbox/outbox/service.d.ts +124 -0
- package/dist/outbox/outbox/service.d.ts.map +1 -0
- package/dist/outbox/outbox/service.js +279 -0
- package/dist/outbox/outbox/service.js.map +1 -0
- package/dist/outbox/postgres/index.d.ts +44 -0
- package/dist/outbox/postgres/index.d.ts.map +1 -0
- package/dist/outbox/postgres/index.js +51 -0
- package/dist/outbox/postgres/index.js.map +1 -0
- package/dist/outbox/postgres/registries.d.ts +69 -0
- package/dist/outbox/postgres/registries.d.ts.map +1 -0
- package/dist/outbox/postgres/registries.js +159 -0
- package/dist/outbox/postgres/registries.js.map +1 -0
- package/dist/outbox/postgres/reportColumns.d.ts +154 -0
- package/dist/outbox/postgres/reportColumns.d.ts.map +1 -0
- package/dist/outbox/postgres/reportColumns.js +195 -0
- package/dist/outbox/postgres/reportColumns.js.map +1 -0
- package/dist/outbox/postgres/store/enforcement.d.ts +33 -0
- package/dist/outbox/postgres/store/enforcement.d.ts.map +1 -0
- package/dist/outbox/postgres/store/enforcement.js +161 -0
- package/dist/outbox/postgres/store/enforcement.js.map +1 -0
- package/dist/outbox/postgres/store/events.d.ts +46 -0
- package/dist/outbox/postgres/store/events.d.ts.map +1 -0
- package/dist/outbox/postgres/store/events.js +104 -0
- package/dist/outbox/postgres/store/events.js.map +1 -0
- package/dist/outbox/postgres/store/index.d.ts +27 -0
- package/dist/outbox/postgres/store/index.d.ts.map +1 -0
- package/dist/outbox/postgres/store/index.js +62 -0
- package/dist/outbox/postgres/store/index.js.map +1 -0
- package/dist/outbox/postgres/store/outbox.d.ts +8 -0
- package/dist/outbox/postgres/store/outbox.d.ts.map +1 -0
- package/dist/outbox/postgres/store/outbox.js +210 -0
- package/dist/outbox/postgres/store/outbox.js.map +1 -0
- package/dist/outbox/postgres/store/reports.d.ts +69 -0
- package/dist/outbox/postgres/store/reports.d.ts.map +1 -0
- package/dist/outbox/postgres/store/reports.js +234 -0
- package/dist/outbox/postgres/store/reports.js.map +1 -0
- package/dist/outbox/postgres/store/transaction.d.ts +49 -0
- package/dist/outbox/postgres/store/transaction.d.ts.map +1 -0
- package/dist/outbox/postgres/store/transaction.js +32 -0
- package/dist/outbox/postgres/store/transaction.js.map +1 -0
- package/dist/outbox/postgres/tables.d.ts +737 -0
- package/dist/outbox/postgres/tables.d.ts.map +1 -0
- package/dist/outbox/postgres/tables.js +273 -0
- package/dist/outbox/postgres/tables.js.map +1 -0
- package/dist/outbox/reconciliation.d.ts +49 -0
- package/dist/outbox/reconciliation.d.ts.map +1 -0
- package/dist/outbox/reconciliation.js +162 -0
- package/dist/outbox/reconciliation.js.map +1 -0
- package/dist/outbox/reportStatus.d.ts +12 -0
- package/dist/outbox/reportStatus.d.ts.map +1 -0
- package/dist/outbox/reportStatus.js +25 -0
- package/dist/outbox/reportStatus.js.map +1 -0
- package/dist/outbox/retention.d.ts +33 -0
- package/dist/outbox/retention.d.ts.map +1 -0
- package/dist/outbox/retention.js +36 -0
- package/dist/outbox/retention.js.map +1 -0
- package/dist/outbox/store/types.d.ts +466 -0
- package/dist/outbox/store/types.d.ts.map +1 -0
- package/dist/outbox/store/types.js +31 -0
- package/dist/outbox/store/types.js.map +1 -0
- package/dist/outbox/types.d.ts +660 -0
- package/dist/outbox/types.d.ts.map +1 -0
- package/dist/outbox/types.js +24 -0
- package/dist/outbox/types.js.map +1 -0
- package/dist/outbox/webhook.d.ts +15 -0
- package/dist/outbox/webhook.d.ts.map +1 -0
- package/dist/outbox/webhook.js +148 -0
- package/dist/outbox/webhook.js.map +1 -0
- package/dist/reports.d.ts +73 -0
- package/dist/reports.d.ts.map +1 -0
- package/dist/reports.js +78 -0
- package/dist/reports.js.map +1 -0
- package/dist/testing/fixtures.d.ts +62 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +137 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +43 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/sandbox.d.ts +109 -0
- package/dist/testing/sandbox.d.ts.map +1 -0
- package/dist/testing/sandbox.js +342 -0
- package/dist/testing/sandbox.js.map +1 -0
- package/dist/testing/webhook-simulator.d.ts +78 -0
- package/dist/testing/webhook-simulator.d.ts.map +1 -0
- package/dist/testing/webhook-simulator.js +98 -0
- package/dist/testing/webhook-simulator.js.map +1 -0
- package/dist/transport.d.ts +59 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +181 -0
- package/dist/transport.js.map +1 -0
- package/dist/webhookEndpoints.d.ts +147 -0
- package/dist/webhookEndpoints.d.ts.map +1 -0
- package/dist/webhookEndpoints.js +143 -0
- package/dist/webhookEndpoints.js.map +1 -0
- package/package.json +127 -0
- package/src/cases.ts +119 -0
- package/src/client.ts +202 -0
- package/src/communityNotes.ts +194 -0
- package/src/credential.ts +100 -0
- package/src/defaults.ts +87 -0
- package/src/digest.ts +91 -0
- package/src/envelope.ts +432 -0
- package/src/errors.ts +165 -0
- package/src/express/index.ts +43 -0
- package/src/express/middleware.ts +387 -0
- package/src/express/store.ts +85 -0
- package/src/express/verify.ts +166 -0
- package/src/index.ts +107 -0
- package/src/outbox/client.ts +75 -0
- package/src/outbox/decision.ts +209 -0
- package/src/outbox/delivery.ts +163 -0
- package/src/outbox/enforcement/executor.ts +288 -0
- package/src/outbox/enforcement/planner.ts +377 -0
- package/src/outbox/evidence.ts +231 -0
- package/src/outbox/inbound.ts +140 -0
- package/src/outbox/index.ts +160 -0
- package/src/outbox/intake.ts +175 -0
- package/src/outbox/integration.ts +205 -0
- package/src/outbox/outbox/dispatcher.ts +131 -0
- package/src/outbox/outbox/service.ts +444 -0
- package/src/outbox/postgres/index.ts +59 -0
- package/src/outbox/postgres/registries.ts +177 -0
- package/src/outbox/postgres/reportColumns.ts +281 -0
- package/src/outbox/postgres/store/enforcement.ts +186 -0
- package/src/outbox/postgres/store/events.ts +112 -0
- package/src/outbox/postgres/store/index.ts +72 -0
- package/src/outbox/postgres/store/outbox.ts +273 -0
- package/src/outbox/postgres/store/reports.ts +349 -0
- package/src/outbox/postgres/store/transaction.ts +64 -0
- package/src/outbox/postgres/tables.ts +339 -0
- package/src/outbox/reconciliation.ts +199 -0
- package/src/outbox/reportStatus.ts +24 -0
- package/src/outbox/retention.ts +34 -0
- package/src/outbox/store/types.ts +528 -0
- package/src/outbox/types.ts +753 -0
- package/src/outbox/webhook.ts +165 -0
- package/src/reports.ts +138 -0
- package/src/testing/fixtures.ts +183 -0
- package/src/testing/index.ts +57 -0
- package/src/testing/sandbox.ts +469 -0
- package/src/testing/webhook-simulator.ts +166 -0
- package/src/transport.ts +250 -0
- package/src/webhookEndpoints.ts +256 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import type { ReportInput } from '../index.js';
|
|
3
|
+
import type {
|
|
4
|
+
ModerationReportFields,
|
|
5
|
+
ModerationSubjectProvider,
|
|
6
|
+
ModerationSubjectSnapshot,
|
|
7
|
+
ModerationTaxonomy,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turning a stored report into the thing the SDK delivers.
|
|
12
|
+
*
|
|
13
|
+
* This is NOT a case-envelope builder, and the difference matters enough to
|
|
14
|
+
* name: `@crowdsource.you/core` builds the Case Envelope, and it deliberately does
|
|
15
|
+
* not export the function that does it. What this module produces is the SDK's
|
|
16
|
+
* `ReportInput` — a description of the material — and the SDK derives the
|
|
17
|
+
* envelope from it: resource ids, relations, digests, pseudonymous principal
|
|
18
|
+
* refs, the identity binding proof, the pinned policy version, the privacy terms
|
|
19
|
+
* and the idempotency key.
|
|
20
|
+
*
|
|
21
|
+
* That is not a technicality. Those derived values are exactly what the case
|
|
22
|
+
* dedup key is computed over, so an application that composed its own envelope
|
|
23
|
+
* would be the reason two reporters about one object opened two cases — and "one
|
|
24
|
+
* penalty per incident" would fail in production with nothing failing in a test.
|
|
25
|
+
* Building the description and letting the SDK build the document is what keeps
|
|
26
|
+
* that property true for every application at once.
|
|
27
|
+
*
|
|
28
|
+
* So this module does three things and no more: ask the registry for a snapshot
|
|
29
|
+
* of the reported object, translate the reporter's categories into allegations,
|
|
30
|
+
* and assemble both with the report's own identity.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The material could not be described, because nothing can describe it.
|
|
35
|
+
*
|
|
36
|
+
* This is a DEFECT, not a state, and it should be unreachable. A report whose
|
|
37
|
+
* type has no subject provider never gets a delivery event in the first place —
|
|
38
|
+
* intake decides that from the same registry this module reads — so an event
|
|
39
|
+
* that arrives here has been created by something that bypassed intake, or by a
|
|
40
|
+
* deployment where a provider was removed while its reports were still in
|
|
41
|
+
* flight.
|
|
42
|
+
*
|
|
43
|
+
* `retryable: false` therefore dead-letters the outbox event, so the
|
|
44
|
+
* reconciliation sweep counts it and a human looks. The alternative — writing a
|
|
45
|
+
* local state — would file a genuine defect in the one place that looks
|
|
46
|
+
* identical to the deliberate local-only reports, and nothing would ever alert
|
|
47
|
+
* on it.
|
|
48
|
+
*
|
|
49
|
+
* Separate from "the object is gone": a deleted object is a fact about the world
|
|
50
|
+
* and closes the report normally.
|
|
51
|
+
*/
|
|
52
|
+
export class ModerationSubjectUnsupportedError extends Error {
|
|
53
|
+
readonly retryable = false;
|
|
54
|
+
|
|
55
|
+
constructor(reportedType: string) {
|
|
56
|
+
super(`No moderation subject provider is registered for '${reportedType}'.`);
|
|
57
|
+
this.name = 'ModerationSubjectUnsupportedError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The application's taxonomy produced no allegation for a report.
|
|
63
|
+
*
|
|
64
|
+
* Not retryable and never silently defaulted. A report with no allegation is not
|
|
65
|
+
* a report, and substituting `other.unclassifiable` here would put a claim in
|
|
66
|
+
* front of a jury that no reporter made and that no version of the mapping
|
|
67
|
+
* records — the taxonomy is the application's to fix, and dead-lettering is what
|
|
68
|
+
* makes somebody fix it.
|
|
69
|
+
*/
|
|
70
|
+
export class ModerationTaxonomyError extends Error {
|
|
71
|
+
readonly retryable = false;
|
|
72
|
+
|
|
73
|
+
constructor(categories: readonly string[]) {
|
|
74
|
+
super(
|
|
75
|
+
`The report taxonomy produced no allegation for categories [${categories.join(', ')}]. ` +
|
|
76
|
+
'Map every category to a code; use `other.unclassifiable` for one the universal taxonomy cannot name.',
|
|
77
|
+
);
|
|
78
|
+
this.name = 'ModerationTaxonomyError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* SHA-256 of the snapshot, stored on the report.
|
|
84
|
+
*
|
|
85
|
+
* Taken over the described MATERIAL, not over the whole `ReportInput`: the
|
|
86
|
+
* report id, the reporter and the allegations are properties of the report, and
|
|
87
|
+
* including them would mean two people reporting identical content produced
|
|
88
|
+
* different hashes — which is the opposite of what this hash is for. Key order
|
|
89
|
+
* is fixed by the literal below rather than by `Object.keys`, so the digest is
|
|
90
|
+
* stable across Node versions and across a refactor that reorders a field.
|
|
91
|
+
*/
|
|
92
|
+
export function snapshotHash(snapshot: ModerationSubjectSnapshot): string {
|
|
93
|
+
const canonical = JSON.stringify({
|
|
94
|
+
subject: {
|
|
95
|
+
externalId: snapshot.subject.externalId,
|
|
96
|
+
type: snapshot.subject.type,
|
|
97
|
+
author: snapshot.subject.author?.oxyUserId ?? null,
|
|
98
|
+
},
|
|
99
|
+
content: snapshot.content,
|
|
100
|
+
attachments: snapshot.attachments ?? [],
|
|
101
|
+
context: snapshot.context ?? [],
|
|
102
|
+
});
|
|
103
|
+
return `sha256:${createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The subject providers, indexed by the application's own reported type. */
|
|
107
|
+
export interface SubjectRegistry {
|
|
108
|
+
/**
|
|
109
|
+
* The provider for a reported type, or `undefined` when it is not deliverable.
|
|
110
|
+
*
|
|
111
|
+
* The single authority on whether a report leaves this deployment. Intake asks
|
|
112
|
+
* before queueing a delivery, and this module asks again when it builds one; a
|
|
113
|
+
* type this returns `undefined` for is stored and never enqueued.
|
|
114
|
+
*/
|
|
115
|
+
providerFor(reportedType: string): ModerationSubjectProvider | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* The reported types wired to CrowdSource, as the registry itself sees them.
|
|
118
|
+
*
|
|
119
|
+
* Exists so an application can pin the set in a test. That is not ceremony:
|
|
120
|
+
* the difference between a delivered type and a local-only one is invisible in
|
|
121
|
+
* a 201, so registering a provider — or forgetting to — is a change no
|
|
122
|
+
* response body would reveal.
|
|
123
|
+
*/
|
|
124
|
+
deliverableTypes(): string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createSubjectRegistry(
|
|
128
|
+
providers: readonly ModerationSubjectProvider[],
|
|
129
|
+
): SubjectRegistry {
|
|
130
|
+
const byType = new Map<string, ModerationSubjectProvider>();
|
|
131
|
+
for (const provider of providers) {
|
|
132
|
+
const existing = byType.get(provider.reportedType);
|
|
133
|
+
if (existing) {
|
|
134
|
+
/**
|
|
135
|
+
* Two providers for one noun is not a merge to resolve quietly: whichever
|
|
136
|
+
* one lost would describe nothing, and the subject type a jury reasons
|
|
137
|
+
* about would depend on array order.
|
|
138
|
+
*/
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Two moderation subject providers are registered for '${provider.reportedType}' ` +
|
|
141
|
+
`('${existing.subjectType}' and '${provider.subjectType}'). A reported type has one provider.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
byType.set(provider.reportedType, provider);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
providerFor: (reportedType) => byType.get(reportedType),
|
|
149
|
+
deliverableTypes: () => Array.from(byType.keys()),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ModerationReportInput {
|
|
154
|
+
/** What the SDK delivers. */
|
|
155
|
+
readonly reportInput: ReportInput;
|
|
156
|
+
/** The digest to store on the local report. */
|
|
157
|
+
readonly snapshotHash: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Describe a stored report for delivery, or report why it cannot be described.
|
|
162
|
+
*
|
|
163
|
+
* Returns `null` when the reported object no longer exists. That is not an error
|
|
164
|
+
* either: content deleted between the report and its delivery is ordinary, and
|
|
165
|
+
* evidence stays available through retention on CrowdSource's side — but an
|
|
166
|
+
* object the application never got to snapshot has no evidence to keep.
|
|
167
|
+
*/
|
|
168
|
+
export async function buildModerationReportInput(input: {
|
|
169
|
+
report: Pick<
|
|
170
|
+
ModerationReportFields,
|
|
171
|
+
'reportedType' | 'reportedId' | 'reporter' | 'categories' | 'details' | 'createdAt'
|
|
172
|
+
> & { id: string };
|
|
173
|
+
registry: SubjectRegistry;
|
|
174
|
+
taxonomy: ModerationTaxonomy;
|
|
175
|
+
}): Promise<ModerationReportInput | null> {
|
|
176
|
+
const { report, registry, taxonomy } = input;
|
|
177
|
+
const provider = registry.providerFor(report.reportedType);
|
|
178
|
+
if (!provider) throw new ModerationSubjectUnsupportedError(report.reportedType);
|
|
179
|
+
|
|
180
|
+
const snapshot = await provider.snapshot(report.reportedId);
|
|
181
|
+
if (!snapshot) return null;
|
|
182
|
+
|
|
183
|
+
const allegationCodes = taxonomy.allegationsFor(report.categories);
|
|
184
|
+
if (allegationCodes.length === 0) throw new ModerationTaxonomyError(report.categories);
|
|
185
|
+
const details = report.details?.trim();
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
reportInput: {
|
|
189
|
+
externalReportId: report.id,
|
|
190
|
+
subject: snapshot.subject,
|
|
191
|
+
content: snapshot.content,
|
|
192
|
+
...(snapshot.attachments === undefined ? {} : { attachments: snapshot.attachments }),
|
|
193
|
+
...(snapshot.context === undefined ? {} : { context: snapshot.context }),
|
|
194
|
+
/**
|
|
195
|
+
* The reporter's own words ride on the FIRST allegation only.
|
|
196
|
+
*
|
|
197
|
+
* Repeating one free-text field across every code would say the reporter
|
|
198
|
+
* wrote it about each of them separately, and details are the reporter's
|
|
199
|
+
* claim and never evidence for it.
|
|
200
|
+
*/
|
|
201
|
+
allegations: allegationCodes.map((code, index) =>
|
|
202
|
+
index === 0 && details ? { code, details } : { code },
|
|
203
|
+
),
|
|
204
|
+
/**
|
|
205
|
+
* The Oxy subject IS the binding proof. Reporters are stored as Oxy user
|
|
206
|
+
* ids, so there is no separate binding step to implement here.
|
|
207
|
+
*/
|
|
208
|
+
reportedBy: { oxyUserId: report.reporter },
|
|
209
|
+
/**
|
|
210
|
+
* The moment the USER reported it — the local report's own timestamp, not
|
|
211
|
+
* the moment of delivery. Any value invented per attempt would make every
|
|
212
|
+
* retry from the outbox a permanent 409.
|
|
213
|
+
*/
|
|
214
|
+
submittedAt: report.createdAt,
|
|
215
|
+
metadata: {
|
|
216
|
+
/**
|
|
217
|
+
* The application's own entries FIRST, so the two below cannot be
|
|
218
|
+
* shadowed. An adopter that names `taxonomyVersion` or `categories` gets
|
|
219
|
+
* them overwritten rather than winning — a case has to be readable back
|
|
220
|
+
* against the mapping that produced it, and that is not negotiable per
|
|
221
|
+
* application.
|
|
222
|
+
*/
|
|
223
|
+
...taxonomy.metadata,
|
|
224
|
+
/** So a case can be read back against the mapping that produced it. */
|
|
225
|
+
taxonomyVersion: taxonomy.version,
|
|
226
|
+
categories: [...report.categories].sort().join(','),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
snapshotHash: snapshotHash(snapshot),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ProcessedEventStore } from '../express/index.js';
|
|
2
|
+
import { decisionApplyEventId, type OutboxService } from './outbox/service.js';
|
|
3
|
+
import { MODERATION_EVENT_RETENTION_SECONDS } from './retention.js';
|
|
4
|
+
import type { ModerationEventStore, ModerationTransactionRunner } from './store/types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What happens between "a signed decision arrived" and "2xx".
|
|
8
|
+
*
|
|
9
|
+
* The receiver's contract is to answer quickly and queue the processing. So
|
|
10
|
+
* exactly two writes happen here, in ONE transaction — the event's audit row is
|
|
11
|
+
* completed and a durable `decision.apply` event is created — and the dispatcher
|
|
12
|
+
* does the rest.
|
|
13
|
+
*
|
|
14
|
+
* The transaction is what makes the dedupe safe. The middleware has already
|
|
15
|
+
* claimed the event id by inserting the row (see {@link createProcessedEventStore});
|
|
16
|
+
* if completing that row and queueing the work were two operations, a crash
|
|
17
|
+
* between them would leave an event that is permanently deduplicated with no
|
|
18
|
+
* work queued — a decision silently lost, with a row that says it arrived.
|
|
19
|
+
* Committing both together means the only two possible outcomes are "recorded
|
|
20
|
+
* and queued" or "neither", and "neither" releases the claim and gets
|
|
21
|
+
* redelivered.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The webhook dedupe store, backed by the moderation event log.
|
|
26
|
+
*
|
|
27
|
+
* `@crowdsource.you/core/express` defaults to an in-process store and says exactly
|
|
28
|
+
* when that is not enough: two instances behind a load balancer each keep their
|
|
29
|
+
* own, so a redelivery landing on the other instance is not deduplicated. Every
|
|
30
|
+
* Oxy backend runs several tasks behind one load balancer, so this is that case.
|
|
31
|
+
*
|
|
32
|
+
* The claim/release contract is the SDK's, and it is the right one. A row
|
|
33
|
+
* inserted BEFORE the handler runs means a concurrent redelivery cannot also run
|
|
34
|
+
* it; deleting that row when the handler THROWS means the sender's retry
|
|
35
|
+
* schedule can still deliver the event later. Recording the id only after
|
|
36
|
+
* success would let two copies run at once; recording it before and never
|
|
37
|
+
* releasing would make a transient failure permanent and lose a decision
|
|
38
|
+
* silently.
|
|
39
|
+
*
|
|
40
|
+
* The retention window is computed HERE rather than in a store, so both backends
|
|
41
|
+
* keep an audit row for the same length of time.
|
|
42
|
+
*/
|
|
43
|
+
export function createProcessedEventStore<TTx>(
|
|
44
|
+
events: ModerationEventStore<TTx>,
|
|
45
|
+
): ProcessedEventStore {
|
|
46
|
+
return {
|
|
47
|
+
async claim(eventId: string): Promise<boolean> {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
return await events.claim({
|
|
50
|
+
eventId,
|
|
51
|
+
receivedAt: now,
|
|
52
|
+
expiresAt: new Date(now.getTime() + MODERATION_EVENT_RETENTION_SECONDS * 1_000),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async release(eventId: string): Promise<void> {
|
|
57
|
+
await events.release(eventId);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RecordDecisionEventInput {
|
|
63
|
+
eventId: string;
|
|
64
|
+
type: string;
|
|
65
|
+
caseId: string;
|
|
66
|
+
/**
|
|
67
|
+
* The decision as delivered.
|
|
68
|
+
*
|
|
69
|
+
* `unknown`, deliberately. It is stored whole and parsed against the published
|
|
70
|
+
* contract by the worker that acts on it — these payloads are deliberately
|
|
71
|
+
* loose, and validating here would mean an event whose shape this deployment
|
|
72
|
+
* does not recognise yet is refused at the door and retried until it
|
|
73
|
+
* dead-letters, instead of being kept until the code catches up.
|
|
74
|
+
*/
|
|
75
|
+
decision: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface InboundService {
|
|
79
|
+
/** Record a decision-bearing event and queue its application. */
|
|
80
|
+
recordDecisionEvent(input: RecordDecisionEventInput): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Record an event there is nothing to do about.
|
|
83
|
+
*
|
|
84
|
+
* `case.created`, `case.escalated`, `case.closed` and any type a newer
|
|
85
|
+
* CrowdSource introduces. No outbox row, because no work — but the row is
|
|
86
|
+
* kept, because "did CrowdSource tell us about this case, and when" is the
|
|
87
|
+
* first question asked when a report looks stuck, and it has to be answerable.
|
|
88
|
+
*/
|
|
89
|
+
recordIgnoredEvent(input: {
|
|
90
|
+
eventId: string;
|
|
91
|
+
type: string;
|
|
92
|
+
caseId?: string;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createInboundService<TTx>(input: {
|
|
97
|
+
transaction: ModerationTransactionRunner<TTx>;
|
|
98
|
+
events: ModerationEventStore<TTx>;
|
|
99
|
+
outbox: OutboxService<TTx>;
|
|
100
|
+
}): InboundService {
|
|
101
|
+
return {
|
|
102
|
+
async recordDecisionEvent(event) {
|
|
103
|
+
await input.transaction.run(async (tx) => {
|
|
104
|
+
const now = new Date();
|
|
105
|
+
await input.events.markQueued(
|
|
106
|
+
{
|
|
107
|
+
eventId: event.eventId,
|
|
108
|
+
type: event.type,
|
|
109
|
+
caseId: event.caseId,
|
|
110
|
+
payload: { caseId: event.caseId, decision: event.decision },
|
|
111
|
+
now,
|
|
112
|
+
},
|
|
113
|
+
tx,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await input.outbox.enqueue(
|
|
117
|
+
{
|
|
118
|
+
eventId: decisionApplyEventId(event.eventId),
|
|
119
|
+
kind: 'decision.apply',
|
|
120
|
+
payload: {
|
|
121
|
+
eventId: event.eventId,
|
|
122
|
+
caseId: event.caseId,
|
|
123
|
+
decision: event.decision,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
tx,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async recordIgnoredEvent(event) {
|
|
132
|
+
await input.events.markIgnored({
|
|
133
|
+
eventId: event.eventId,
|
|
134
|
+
type: event.type,
|
|
135
|
+
...(event.caseId === undefined ? {} : { caseId: event.caseId }),
|
|
136
|
+
now: new Date(),
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@crowdsource.you/core/outbox` — the application half of a CrowdSource integration.
|
|
3
|
+
*
|
|
4
|
+
* An application that adopts CrowdSource has to solve the same six problems
|
|
5
|
+
* every other application does: store a report and its promise of delivery
|
|
6
|
+
* atomically, deliver it with retries and a dead-letter path, receive signed
|
|
7
|
+
* decisions without a body parser destroying the signature, deduplicate
|
|
8
|
+
* redeliveries across several tasks, apply a decision without a stale revision
|
|
9
|
+
* overwriting a fresh one, and carry out consequences exactly once and
|
|
10
|
+
* reversibly. None of that has anything to do with what the application's
|
|
11
|
+
* objects are.
|
|
12
|
+
*
|
|
13
|
+
* So all of it is here, and an application supplies four things: its subjects,
|
|
14
|
+
* its category mapping, its enforcement tables, and a STORE built by the
|
|
15
|
+
* PostgreSQL store factory.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { createModerationIntegration } from '@crowdsource.you/core/outbox';
|
|
19
|
+
* import { postgresModerationStore } from '@crowdsource.you/core/outbox/postgres';
|
|
20
|
+
*
|
|
21
|
+
* const moderation = createModerationIntegration({
|
|
22
|
+
* store,
|
|
23
|
+
* crowdSource: { enabled: true, serviceKey, webhookSecret, enforcementMode: 'observe' },
|
|
24
|
+
* subjects: [listingSubjectProvider(), reviewSubjectProvider()],
|
|
25
|
+
* taxonomy: { version: '2026.07', allegationsFor },
|
|
26
|
+
* enforcement: commerceEnforcement,
|
|
27
|
+
* logger,
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Indexes before the first write: the unique ones ARE the exactly-once
|
|
31
|
+
* // mechanism, and an index that does not exist yet refuses nothing.
|
|
32
|
+
* await store.ensureSchema();
|
|
33
|
+
*
|
|
34
|
+
* // BEFORE express.json() — the signature covers the bytes that arrived.
|
|
35
|
+
* app.use('/webhooks', moderation.webhookRouter());
|
|
36
|
+
* app.use(express.json());
|
|
37
|
+
*
|
|
38
|
+
* moderation.dispatcher.start();
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* Two invariants are ENFORCED here rather than documented, because both fail
|
|
42
|
+
* silently and neither shows up in a test that only asserts the happy path:
|
|
43
|
+
*
|
|
44
|
+
* 1. Nothing can be enqueued that is not already recorded in the outbox, in the
|
|
45
|
+
* same transaction. {@link ModerationOutboxTransactionError} is thrown by the
|
|
46
|
+
* only writer of that collection when the transaction it was handed is not
|
|
47
|
+
* open.
|
|
48
|
+
* 2. The webhook receiver reads raw bytes. Mounted after a JSON parser it
|
|
49
|
+
* refuses rather than verifying a signature over a re-serialisation.
|
|
50
|
+
*
|
|
51
|
+
* Types come from `@crowdsource.you/contracts` and `@crowdsource.you/core`. This
|
|
52
|
+
* package re-exports none of them: a `Decision` or a `TaxonomyCode` has exactly
|
|
53
|
+
* one definition.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export { createModerationIntegration } from './integration.js';
|
|
57
|
+
export type { ModerationIntegration } from './integration.js';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The retention windows are storage-independent policy, so they stay here while
|
|
61
|
+
* PostgreSQL-specific tables and stores live behind the `/postgres` subpath.
|
|
62
|
+
*/
|
|
63
|
+
export {
|
|
64
|
+
MODERATION_EVENT_RETENTION_SECONDS,
|
|
65
|
+
MODERATION_OUTBOX_RETENTION_SECONDS,
|
|
66
|
+
} from './retention.js';
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
ModerationOutboxTransactionError,
|
|
70
|
+
decisionApplyEventId,
|
|
71
|
+
isRetryableDeliveryError,
|
|
72
|
+
reportSubmitEventId,
|
|
73
|
+
} from './outbox/service.js';
|
|
74
|
+
export type {
|
|
75
|
+
ModerationOutboxFailure,
|
|
76
|
+
ModerationOutboxHandler,
|
|
77
|
+
OutboxDrain,
|
|
78
|
+
OutboxService,
|
|
79
|
+
} from './outbox/service.js';
|
|
80
|
+
|
|
81
|
+
export type {
|
|
82
|
+
ModerationEnforcementInsert,
|
|
83
|
+
ModerationEnforcementKey,
|
|
84
|
+
ModerationEnforcementStore,
|
|
85
|
+
ModerationEventStore,
|
|
86
|
+
ModerationOutboxStore,
|
|
87
|
+
ModerationReportDecisionUpdate,
|
|
88
|
+
ModerationReportInsert,
|
|
89
|
+
ModerationReportRef,
|
|
90
|
+
ModerationReportStore,
|
|
91
|
+
ModerationStore,
|
|
92
|
+
ModerationTransactionRunner,
|
|
93
|
+
} from './store/types.js';
|
|
94
|
+
|
|
95
|
+
export { ModerationOutboxDispatcher } from './outbox/dispatcher.js';
|
|
96
|
+
export { ModerationReconciliationJob } from './reconciliation.js';
|
|
97
|
+
export type { ReconcileModerationReports } from './reconciliation.js';
|
|
98
|
+
|
|
99
|
+
export { DuplicateReportError } from './intake.js';
|
|
100
|
+
|
|
101
|
+
export {
|
|
102
|
+
CrowdSourceUnavailableError,
|
|
103
|
+
ModerationDeliveryRejectedError,
|
|
104
|
+
} from './delivery.js';
|
|
105
|
+
|
|
106
|
+
export {
|
|
107
|
+
ModerationDecisionDeferredError,
|
|
108
|
+
ModerationDecisionRejectedError,
|
|
109
|
+
} from './decision.js';
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
ModerationSubjectUnsupportedError,
|
|
113
|
+
ModerationTaxonomyError,
|
|
114
|
+
createSubjectRegistry,
|
|
115
|
+
snapshotHash,
|
|
116
|
+
} from './evidence.js';
|
|
117
|
+
export type { ModerationReportInput, SubjectRegistry } from './evidence.js';
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
ModerationRestoreDirectionError,
|
|
121
|
+
assertRestoreDirection,
|
|
122
|
+
planEnforcement,
|
|
123
|
+
primaryAction,
|
|
124
|
+
} from './enforcement/planner.js';
|
|
125
|
+
export type { EnforcementExecutor } from './enforcement/executor.js';
|
|
126
|
+
|
|
127
|
+
export { localStatusForDecision } from './reportStatus.js';
|
|
128
|
+
|
|
129
|
+
export { createProcessedEventStore } from './inbound.js';
|
|
130
|
+
export type { InboundService, RecordDecisionEventInput } from './inbound.js';
|
|
131
|
+
|
|
132
|
+
export type {
|
|
133
|
+
CreateReportInput,
|
|
134
|
+
CreateReportResult,
|
|
135
|
+
CrowdSourceConnectionConfig,
|
|
136
|
+
EnforcementEffect,
|
|
137
|
+
EnforcementOutcome,
|
|
138
|
+
EnforcementPreviousState,
|
|
139
|
+
EnforcementSubject,
|
|
140
|
+
ModerationContextResource,
|
|
141
|
+
ModerationDispatchResult,
|
|
142
|
+
ModerationEnforcementConfig,
|
|
143
|
+
ModerationEnforcementMode,
|
|
144
|
+
ModerationIntegrationConfig,
|
|
145
|
+
ModerationLocalStatus,
|
|
146
|
+
ModerationLogger,
|
|
147
|
+
ModerationMetrics,
|
|
148
|
+
ModerationOutboxEvent,
|
|
149
|
+
ModerationOutboxKind,
|
|
150
|
+
ModerationOutboxPayload,
|
|
151
|
+
ModerationOutboxStatus,
|
|
152
|
+
ModerationReconciliationResult,
|
|
153
|
+
ModerationReportFields,
|
|
154
|
+
ModerationResource,
|
|
155
|
+
ModerationSubjectProvider,
|
|
156
|
+
ModerationSubjectSnapshot,
|
|
157
|
+
ModerationTaxonomy,
|
|
158
|
+
PlannedEnforcementAction,
|
|
159
|
+
ReportDecisionExtraFields,
|
|
160
|
+
} from './types.js';
|