@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,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
|
+
}
|