@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,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-process CrowdSource, so an integration can be exercised end to end
|
|
3
|
+
* before a jury exists.
|
|
4
|
+
*
|
|
5
|
+
* The path an application actually cares about is: deliver a report → get a
|
|
6
|
+
* case → a decision is published → a signed webhook arrives → enforcement runs.
|
|
7
|
+
* Today the middle of that does not exist anywhere — sortition, review and
|
|
8
|
+
* consensus are not built, so nothing publishes a decision. Waiting for them
|
|
9
|
+
* means an integrator cannot write, or test, the half of their code that
|
|
10
|
+
* matters most: what they DO when a decision says `violation`.
|
|
11
|
+
*
|
|
12
|
+
* So this sandbox implements the two ends and lets the test drive the middle.
|
|
13
|
+
* It accepts reports over a `fetch` the real `@crowdsource.you/core` client can be
|
|
14
|
+
* pointed at — same transport, same idempotency, same 409, same tenant check —
|
|
15
|
+
* and `decide()` publishes a decision the way consensus eventually will, which
|
|
16
|
+
* `deliver()` then sends as a genuinely signed `case.decided`.
|
|
17
|
+
*
|
|
18
|
+
* **What it is and is not.** It is a faithful implementation of the RULES an
|
|
19
|
+
* integrator's code depends on: `applicationId` from the credential, an
|
|
20
|
+
* idempotency key that returns the same `reportId`, a 409 for a reused
|
|
21
|
+
* `externalReportId` with a changed body, and §7.3's "two reports about the same
|
|
22
|
+
* version of the same content are one case". It is NOT the service, it holds no
|
|
23
|
+
* state between processes, and where it and the backend ever disagree the
|
|
24
|
+
* backend is right. The deduplication projection below mirrors the backend's
|
|
25
|
+
* `modules/evidence/contentSnapshot.ts`; that logic belongs in the contracts
|
|
26
|
+
* package so both sides share one implementation, and until it moves this file
|
|
27
|
+
* is the second copy — which is a thing to know rather than a thing to rely on.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
CreateReportRequestSchema,
|
|
34
|
+
DecisionSchema,
|
|
35
|
+
KnownWebhookEventSchema,
|
|
36
|
+
PRINCIPAL_TARGETED_RELATION_TYPES,
|
|
37
|
+
UNIVERSAL_TAXONOMY_VERSION,
|
|
38
|
+
type CaseEnvelope,
|
|
39
|
+
type Decision,
|
|
40
|
+
type DecisionFinding,
|
|
41
|
+
type DecisionOutcome,
|
|
42
|
+
type DecisionRecommendedAction,
|
|
43
|
+
type KnownWebhookEvent,
|
|
44
|
+
type TaxonomyCode,
|
|
45
|
+
} from '@crowdsource.you/contracts';
|
|
46
|
+
|
|
47
|
+
import { WebhookSimulator, type WebhookDeliveryResult } from './webhook-simulator.js';
|
|
48
|
+
|
|
49
|
+
export interface SandboxReport {
|
|
50
|
+
readonly reportId: string;
|
|
51
|
+
readonly externalReportId: string;
|
|
52
|
+
readonly caseId: string;
|
|
53
|
+
readonly idempotencyKey: string;
|
|
54
|
+
readonly envelope: CaseEnvelope;
|
|
55
|
+
/** `merged` when this report joined a case another report had already opened. */
|
|
56
|
+
readonly status: 'received' | 'merged';
|
|
57
|
+
readonly receivedAt: string;
|
|
58
|
+
/**
|
|
59
|
+
* The fingerprint of what was delivered — the backend calls it `payloadHash`
|
|
60
|
+
* and computes it over `{ externalReportId, envelope }`, deliberately without
|
|
61
|
+
* the idempotency key, so the same content re-sent under a fresh key is
|
|
62
|
+
* recognised as the same report rather than conflicting with it.
|
|
63
|
+
*/
|
|
64
|
+
readonly payloadHash: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SandboxCase {
|
|
68
|
+
readonly caseId: string;
|
|
69
|
+
readonly externalSubjectId: string;
|
|
70
|
+
readonly dedupKey: string;
|
|
71
|
+
readonly policy: CaseEnvelope['policy'];
|
|
72
|
+
/** The union of what every merged report alleged (§6.2). */
|
|
73
|
+
readonly allegationCodes: readonly TaxonomyCode[];
|
|
74
|
+
readonly reportIds: readonly string[];
|
|
75
|
+
readonly decisions: readonly Decision[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CrowdSourceSandboxOptions {
|
|
79
|
+
readonly applicationId?: string;
|
|
80
|
+
readonly organizationId?: string;
|
|
81
|
+
readonly webhookSecret?: string;
|
|
82
|
+
/** The origin the client believes it is calling. Never reached over a socket. */
|
|
83
|
+
readonly baseUrl?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SandboxDecisionInput {
|
|
87
|
+
readonly outcome?: DecisionOutcome;
|
|
88
|
+
readonly status?: Decision['status'];
|
|
89
|
+
readonly findings?: readonly DecisionFinding[];
|
|
90
|
+
readonly recommendedActions?: readonly DecisionRecommendedAction[];
|
|
91
|
+
readonly confidence?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_BASE_URL = 'https://sandbox.crowdsource.test';
|
|
95
|
+
|
|
96
|
+
function publicId(prefix: string): string {
|
|
97
|
+
return `${prefix}_${randomUUID().replace(/-/g, '')}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sha256Hex(value: string): string {
|
|
101
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* §7.3's `contentEnvelopeHash`, over the same projection the backend hashes.
|
|
106
|
+
*
|
|
107
|
+
* The three normalisations that make two reporters agree, restated so a reader
|
|
108
|
+
* does not have to open the backend to know what is excluded: order is removed
|
|
109
|
+
* where it is not meaning, proof-of-delivery fields are dropped from principal
|
|
110
|
+
* bindings, and nothing per-reporter is included at all — no
|
|
111
|
+
* `externalReportId`, no `source`, no `allegations`, no reporter binding, no
|
|
112
|
+
* `urgency`, no `metadata`.
|
|
113
|
+
*/
|
|
114
|
+
function contentHashOf(envelope: CaseEnvelope): string {
|
|
115
|
+
const referencedPrincipals = new Set<string>();
|
|
116
|
+
for (const resource of envelope.resources) {
|
|
117
|
+
if (resource.authorPrincipalRef !== undefined) {
|
|
118
|
+
referencedPrincipals.add(resource.authorPrincipalRef);
|
|
119
|
+
}
|
|
120
|
+
if (resource.type === 'listing' && resource.data.sellerRef !== undefined) {
|
|
121
|
+
referencedPrincipals.add(resource.data.sellerRef);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const relation of envelope.relations) {
|
|
125
|
+
if (PRINCIPAL_TARGETED_RELATION_TYPES.some((type) => type === relation.type)) {
|
|
126
|
+
referencedPrincipals.add(relation.to);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const snapshot = {
|
|
131
|
+
schemaVersion: envelope.schemaVersion,
|
|
132
|
+
subject: envelope.subject,
|
|
133
|
+
resources: [...envelope.resources].sort((left, right) => (left.id < right.id ? -1 : 1)),
|
|
134
|
+
relations: [...envelope.relations].sort((left, right) =>
|
|
135
|
+
`${left.from} ${left.type} ${left.to}` < `${right.from} ${right.type} ${right.to}` ? -1 : 1,
|
|
136
|
+
),
|
|
137
|
+
principals: envelope.principalBindings
|
|
138
|
+
.filter((binding) => referencedPrincipals.has(binding.principalRef))
|
|
139
|
+
.sort((left, right) => (left.principalRef < right.principalRef ? -1 : 1))
|
|
140
|
+
.map((binding) => ({
|
|
141
|
+
principalRef: binding.principalRef,
|
|
142
|
+
type: binding.type,
|
|
143
|
+
externalPrincipalId: binding.externalPrincipalId,
|
|
144
|
+
})),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return sha256Hex(JSON.stringify(snapshot));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
151
|
+
return new Response(JSON.stringify(body), {
|
|
152
|
+
status,
|
|
153
|
+
headers: { 'content-type': 'application/json' },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function apiError(status: number, code: string, message: string): Response {
|
|
158
|
+
return jsonResponse(status, { error: { code, message } });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class CrowdSourceSandbox {
|
|
162
|
+
readonly applicationId: string;
|
|
163
|
+
readonly organizationId: string;
|
|
164
|
+
readonly baseUrl: string;
|
|
165
|
+
readonly webhookSecret: string;
|
|
166
|
+
/** The one opaque string an integration configures. */
|
|
167
|
+
readonly serviceKey: string;
|
|
168
|
+
|
|
169
|
+
private readonly bearerToken: string;
|
|
170
|
+
private readonly reportsById = new Map<string, SandboxReport>();
|
|
171
|
+
private readonly reportsByIdempotencyKey = new Map<string, SandboxReport>();
|
|
172
|
+
private readonly reportsByExternalId = new Map<string, SandboxReport>();
|
|
173
|
+
private readonly casesById = new Map<string, SandboxCase>();
|
|
174
|
+
private readonly caseIdByDedupKey = new Map<string, string>();
|
|
175
|
+
private readonly decisionsById = new Map<string, Decision>();
|
|
176
|
+
|
|
177
|
+
constructor(options: CrowdSourceSandboxOptions = {}) {
|
|
178
|
+
this.applicationId = options.applicationId ?? publicId('app');
|
|
179
|
+
this.organizationId = options.organizationId ?? publicId('org');
|
|
180
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
181
|
+
this.webhookSecret = options.webhookSecret ?? randomUUID();
|
|
182
|
+
|
|
183
|
+
const credentialId = publicId('csk');
|
|
184
|
+
const secret = randomUUID().replace(/-/g, '');
|
|
185
|
+
this.bearerToken = `${credentialId}.${secret}`;
|
|
186
|
+
this.serviceKey = [this.applicationId, credentialId, secret].join(':');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get reports(): readonly SandboxReport[] {
|
|
190
|
+
return [...this.reportsById.values()];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get cases(): readonly SandboxCase[] {
|
|
194
|
+
return [...this.casesById.values()];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The `fetch` to hand `new CrowdSource({ fetch, serviceKey, baseUrl })`. */
|
|
198
|
+
readonly fetch: typeof globalThis.fetch = async (input, init) => {
|
|
199
|
+
const url = new URL(
|
|
200
|
+
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
|
|
201
|
+
);
|
|
202
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
203
|
+
const headers = new Headers(init?.headers);
|
|
204
|
+
|
|
205
|
+
if (headers.get('authorization') !== `Bearer ${this.bearerToken}`) {
|
|
206
|
+
return apiError(401, 'unauthorized', 'The service credential is missing or invalid.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (method === 'POST' && url.pathname === '/v1/reports') {
|
|
210
|
+
return this.createReport(headers, init?.body);
|
|
211
|
+
}
|
|
212
|
+
if (method === 'GET' && url.pathname.startsWith('/v1/reports/')) {
|
|
213
|
+
return this.readReport(decodeURIComponent(url.pathname.slice('/v1/reports/'.length)));
|
|
214
|
+
}
|
|
215
|
+
if (method === 'GET' && url.pathname.startsWith('/v1/cases/')) {
|
|
216
|
+
return this.readCase(decodeURIComponent(url.pathname.slice('/v1/cases/'.length)));
|
|
217
|
+
}
|
|
218
|
+
if (method === 'GET' && url.pathname.startsWith('/v1/decisions/')) {
|
|
219
|
+
return this.readDecision(decodeURIComponent(url.pathname.slice('/v1/decisions/'.length)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Everything else, including the upload routes, answers 404 — the same thing
|
|
224
|
+
* the deployed backend does today. A sandbox that faked an endpoint the
|
|
225
|
+
* service does not serve would let an integration pass its tests and fail on
|
|
226
|
+
* its first real call.
|
|
227
|
+
*/
|
|
228
|
+
return apiError(404, 'not_found', `The sandbox does not serve ${method} ${url.pathname}.`);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Publishes a decision for a case, the way consensus eventually will.
|
|
233
|
+
*
|
|
234
|
+
* A published revision is immutable (Appendix F), so calling this twice
|
|
235
|
+
* produces revision 2 superseding revision 1 rather than editing anything.
|
|
236
|
+
*/
|
|
237
|
+
decide(caseId: string, input: SandboxDecisionInput = {}): Decision {
|
|
238
|
+
const stored = this.casesById.get(caseId);
|
|
239
|
+
if (!stored) throw new Error(`The sandbox holds no case '${caseId}'.`);
|
|
240
|
+
|
|
241
|
+
const previous = stored.decisions.at(-1);
|
|
242
|
+
const revision = (previous?.revision ?? 0) + 1;
|
|
243
|
+
const outcome = input.outcome ?? 'violation';
|
|
244
|
+
|
|
245
|
+
const decision = DecisionSchema.parse({
|
|
246
|
+
id: publicId('dec'),
|
|
247
|
+
caseId,
|
|
248
|
+
revision,
|
|
249
|
+
status: input.status ?? 'final',
|
|
250
|
+
outcome,
|
|
251
|
+
contextSufficiency: outcome === 'insufficient_context' ? 'insufficient' : 'sufficient',
|
|
252
|
+
confidence: input.confidence ?? 1,
|
|
253
|
+
findings:
|
|
254
|
+
input.findings ??
|
|
255
|
+
(outcome === 'violation'
|
|
256
|
+
? [
|
|
257
|
+
{
|
|
258
|
+
code: stored.allegationCodes[0] ?? 'other.unclassifiable',
|
|
259
|
+
resourceIds: ['res_subject'],
|
|
260
|
+
severity: 'medium',
|
|
261
|
+
scope: 'application_local',
|
|
262
|
+
attribution: 'author',
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
: []),
|
|
266
|
+
recommendedActions:
|
|
267
|
+
input.recommendedActions ??
|
|
268
|
+
(outcome === 'violation' ? [{ action: 'remove_or_restrict' }] : [{ action: 'no_action' }]),
|
|
269
|
+
jury: { size: 3, decisiveVotes: 3, winningVotes: 3, agreement: 1, specialistPresent: false },
|
|
270
|
+
policyVersions: {
|
|
271
|
+
taxonomy: UNIVERSAL_TAXONOMY_VERSION,
|
|
272
|
+
application: stored.policy.version,
|
|
273
|
+
oxyConduct: stored.policy.version,
|
|
274
|
+
},
|
|
275
|
+
...(previous === undefined ? {} : { supersedesDecisionId: previous.id }),
|
|
276
|
+
publishedAt: new Date().toISOString(),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
this.decisionsById.set(decision.id, decision);
|
|
280
|
+
this.casesById.set(caseId, { ...stored, decisions: [...stored.decisions, decision] });
|
|
281
|
+
return decision;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The `case.decided` (or `decision.corrected`) event for a decision. */
|
|
285
|
+
eventFor(decision: Decision): KnownWebhookEvent {
|
|
286
|
+
return KnownWebhookEventSchema.parse({
|
|
287
|
+
id: publicId('evt'),
|
|
288
|
+
type: decision.revision === 1 ? 'case.decided' : 'decision.corrected',
|
|
289
|
+
createdAt: new Date().toISOString(),
|
|
290
|
+
organizationId: this.organizationId,
|
|
291
|
+
applicationId: this.applicationId,
|
|
292
|
+
data: { caseId: decision.caseId, decision },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Signs and POSTs an event to the integration's webhook endpoint. */
|
|
297
|
+
async deliver(url: string, event: unknown): Promise<WebhookDeliveryResult> {
|
|
298
|
+
return await new WebhookSimulator({ secret: this.webhookSecret, url }).deliver(event);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private createReport(headers: Headers, body: BodyInit | null | undefined): Response {
|
|
302
|
+
const idempotencyKey = headers.get('idempotency-key');
|
|
303
|
+
if (idempotencyKey === null || idempotencyKey.length === 0) {
|
|
304
|
+
return apiError(400, 'invalid_request', 'The Idempotency-Key header is required.');
|
|
305
|
+
}
|
|
306
|
+
if (typeof body !== 'string') {
|
|
307
|
+
return apiError(400, 'invalid_request', 'The request body must be JSON.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let payload: unknown;
|
|
311
|
+
try {
|
|
312
|
+
payload = JSON.parse(body) as unknown;
|
|
313
|
+
} catch {
|
|
314
|
+
return apiError(400, 'invalid_request', 'The request body must be JSON.');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const parsed = CreateReportRequestSchema.safeParse(payload);
|
|
318
|
+
if (!parsed.success) {
|
|
319
|
+
return apiError(
|
|
320
|
+
422,
|
|
321
|
+
'unprocessable_envelope',
|
|
322
|
+
`The envelope cannot be processed — ${parsed.error.issues
|
|
323
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
324
|
+
.join('; ')}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const { externalReportId, envelope } = parsed.data;
|
|
328
|
+
|
|
329
|
+
if (envelope.applicationId !== this.applicationId) {
|
|
330
|
+
return apiError(
|
|
331
|
+
403,
|
|
332
|
+
'forbidden',
|
|
333
|
+
'The envelope names a different applicationId than the credential it was delivered with.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const payloadHash = sha256Hex(JSON.stringify(parsed.data));
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Appendix D, both halves. The same key with the same payload is the SAME
|
|
341
|
+
* report and returns the same `reportId`; the same `externalReportId` with a
|
|
342
|
+
* DIFFERENT payload is §10.5's 409 — the two cannot both be that report, and
|
|
343
|
+
* no retry resolves it.
|
|
344
|
+
*/
|
|
345
|
+
const byKey = this.reportsByIdempotencyKey.get(idempotencyKey);
|
|
346
|
+
if (byKey !== undefined) {
|
|
347
|
+
if (byKey.payloadHash !== payloadHash) {
|
|
348
|
+
return apiError(
|
|
349
|
+
409,
|
|
350
|
+
'conflict',
|
|
351
|
+
'This Idempotency-Key was already used with a different payload.',
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return this.receiptResponse(byKey);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const byExternalId = this.reportsByExternalId.get(externalReportId);
|
|
358
|
+
if (byExternalId !== undefined) {
|
|
359
|
+
if (byExternalId.payloadHash !== payloadHash) {
|
|
360
|
+
return apiError(
|
|
361
|
+
409,
|
|
362
|
+
'conflict',
|
|
363
|
+
`externalReportId '${externalReportId}' was already delivered with different content.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return this.receiptResponse(byExternalId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const dedupKey = sha256Hex(
|
|
370
|
+
[
|
|
371
|
+
this.applicationId,
|
|
372
|
+
envelope.subject.externalId,
|
|
373
|
+
contentHashOf(envelope),
|
|
374
|
+
`${envelope.policy.policySetId}@${envelope.policy.version}`,
|
|
375
|
+
].join(':'),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const existingCaseId = this.caseIdByDedupKey.get(dedupKey);
|
|
379
|
+
const merged = existingCaseId !== undefined;
|
|
380
|
+
const caseId = existingCaseId ?? publicId('case');
|
|
381
|
+
|
|
382
|
+
const report: SandboxReport = {
|
|
383
|
+
reportId: publicId('rpt'),
|
|
384
|
+
externalReportId,
|
|
385
|
+
caseId,
|
|
386
|
+
idempotencyKey,
|
|
387
|
+
envelope,
|
|
388
|
+
status: merged ? 'merged' : 'received',
|
|
389
|
+
receivedAt: new Date().toISOString(),
|
|
390
|
+
payloadHash,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
this.reportsById.set(report.reportId, report);
|
|
394
|
+
this.reportsByIdempotencyKey.set(idempotencyKey, report);
|
|
395
|
+
this.reportsByExternalId.set(externalReportId, report);
|
|
396
|
+
|
|
397
|
+
const existingCase = this.casesById.get(caseId);
|
|
398
|
+
this.casesById.set(caseId, {
|
|
399
|
+
caseId,
|
|
400
|
+
externalSubjectId: envelope.subject.externalId,
|
|
401
|
+
dedupKey,
|
|
402
|
+
policy: envelope.policy,
|
|
403
|
+
allegationCodes: [
|
|
404
|
+
...new Set([
|
|
405
|
+
...(existingCase?.allegationCodes ?? []),
|
|
406
|
+
...envelope.allegations.map((allegation) => allegation.code),
|
|
407
|
+
]),
|
|
408
|
+
],
|
|
409
|
+
reportIds: [...(existingCase?.reportIds ?? []), report.reportId],
|
|
410
|
+
decisions: existingCase?.decisions ?? [],
|
|
411
|
+
});
|
|
412
|
+
this.caseIdByDedupKey.set(dedupKey, caseId);
|
|
413
|
+
|
|
414
|
+
return this.receiptResponse(report);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private receiptResponse(report: SandboxReport): Response {
|
|
418
|
+
return jsonResponse(202, {
|
|
419
|
+
reportId: report.reportId,
|
|
420
|
+
caseId: report.caseId,
|
|
421
|
+
status: report.status,
|
|
422
|
+
merged: report.status === 'merged',
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private readReport(reportId: string): Response {
|
|
427
|
+
const report = this.reportsById.get(reportId);
|
|
428
|
+
if (!report) return apiError(404, 'not_found', 'No such report.');
|
|
429
|
+
|
|
430
|
+
return jsonResponse(200, {
|
|
431
|
+
reportId: report.reportId,
|
|
432
|
+
externalReportId: report.externalReportId,
|
|
433
|
+
caseId: report.caseId,
|
|
434
|
+
status: report.status,
|
|
435
|
+
receivedAt: report.receivedAt,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private readCase(caseId: string): Response {
|
|
440
|
+
const stored = this.casesById.get(caseId);
|
|
441
|
+
if (!stored) return apiError(404, 'not_found', 'No such case.');
|
|
442
|
+
|
|
443
|
+
return jsonResponse(200, {
|
|
444
|
+
caseId: stored.caseId,
|
|
445
|
+
status: stored.decisions.length > 0 ? 'decided' : 'awaiting_review',
|
|
446
|
+
subject: { externalId: stored.externalSubjectId, type: 'social.post' },
|
|
447
|
+
policy: stored.policy,
|
|
448
|
+
taxonomyVersion: UNIVERSAL_TAXONOMY_VERSION,
|
|
449
|
+
allegationCodes: stored.allegationCodes,
|
|
450
|
+
reportCount: stored.reportIds.length,
|
|
451
|
+
sensitivityClass: 'standard',
|
|
452
|
+
currentRevision: stored.decisions.length,
|
|
453
|
+
createdAt: new Date(0).toISOString(),
|
|
454
|
+
updatedAt: new Date().toISOString(),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private readDecision(decisionId: string): Response {
|
|
459
|
+
const decision = this.decisionsById.get(decisionId);
|
|
460
|
+
if (!decision) return apiError(404, 'not_found', 'No such decision.');
|
|
461
|
+
return jsonResponse(200, decision);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function createCrowdSourceSandbox(
|
|
466
|
+
options: CrowdSourceSandboxOptions = {},
|
|
467
|
+
): CrowdSourceSandbox {
|
|
468
|
+
return new CrowdSourceSandbox(options);
|
|
469
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook simulator — the producer side of §10.8.
|
|
3
|
+
*
|
|
4
|
+
* An integrator can point this at their own receiver and watch a real delivery
|
|
5
|
+
* arrive, signed exactly as CrowdSource signs one, without a jury having sat and
|
|
6
|
+
* without anything being enforced against a real user.
|
|
7
|
+
*
|
|
8
|
+
* It deliberately also makes it easy to send a delivery that is WRONG:
|
|
9
|
+
* `expired`, `wrongSecret`, `tampered` and a raw `signature` override are here
|
|
10
|
+
* so an integration test can assert that the receiver REFUSES those. A test
|
|
11
|
+
* suite that only ever sends valid deliveries proves the receiver can say yes.
|
|
12
|
+
* Proving it can say no is the part that matters, and it is the part nobody
|
|
13
|
+
* writes unless the tooling makes it a one-liner.
|
|
14
|
+
*
|
|
15
|
+
* Signing lives here rather than in `@crowdsource.you/core/express` because the two
|
|
16
|
+
* are opposite sides of the contract: the verifier must never import a signer
|
|
17
|
+
* that could be "corrected" until they agree with each other and both disagree
|
|
18
|
+
* with the service. What they share instead is
|
|
19
|
+
* `buildWebhookSignedPayload` from the contracts package, which is the one
|
|
20
|
+
* definition of what gets signed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHmac } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
WEBHOOK_EVENT_ID_HEADER,
|
|
27
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
28
|
+
WEBHOOK_SIGNATURE_VERSION,
|
|
29
|
+
WEBHOOK_TIMESTAMP_HEADER,
|
|
30
|
+
buildWebhookSignedPayload,
|
|
31
|
+
} from '@crowdsource.you/contracts';
|
|
32
|
+
|
|
33
|
+
export interface SignedWebhookDelivery {
|
|
34
|
+
/** Headers as CrowdSource sends them, plus `content-type`. */
|
|
35
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
36
|
+
/** The exact bytes that were signed. Send these, not a re-serialisation. */
|
|
37
|
+
readonly body: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SignWebhookInput {
|
|
41
|
+
readonly secret: string;
|
|
42
|
+
/**
|
|
43
|
+
* The event to deliver. Typed as `unknown` on purpose: a simulator that only
|
|
44
|
+
* accepted `KnownWebhookEvent` could not send an event type from a future
|
|
45
|
+
* version of the service, which is precisely the case §10.11 asks every
|
|
46
|
+
* receiver to survive.
|
|
47
|
+
*/
|
|
48
|
+
readonly event: unknown;
|
|
49
|
+
/** Unix seconds. Defaults to now. */
|
|
50
|
+
readonly timestampSeconds?: number;
|
|
51
|
+
/** Overrides the body sent, WITHOUT re-signing it. For tamper tests. */
|
|
52
|
+
readonly tamperedBody?: string;
|
|
53
|
+
/** Overrides the signature header verbatim. For forgery tests. */
|
|
54
|
+
readonly signature?: string;
|
|
55
|
+
/** Overrides the event id header, to test header/body disagreement. */
|
|
56
|
+
readonly eventId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function eventIdOf(event: unknown): string {
|
|
60
|
+
if (typeof event === 'object' && event !== null && 'id' in event) {
|
|
61
|
+
const id: unknown = (event as { id: unknown }).id;
|
|
62
|
+
if (typeof id === 'string') return id;
|
|
63
|
+
}
|
|
64
|
+
throw new TypeError('A webhook event must carry a string `id` (§10.7).');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Signs one delivery. */
|
|
68
|
+
export function signWebhookDelivery(input: SignWebhookInput): SignedWebhookDelivery {
|
|
69
|
+
const signedBody = JSON.stringify(input.event);
|
|
70
|
+
const timestamp = String(input.timestampSeconds ?? Math.floor(Date.now() / 1_000));
|
|
71
|
+
|
|
72
|
+
const signature =
|
|
73
|
+
input.signature ??
|
|
74
|
+
`${WEBHOOK_SIGNATURE_VERSION}=${createHmac('sha256', input.secret)
|
|
75
|
+
.update(buildWebhookSignedPayload(timestamp, signedBody), 'utf8')
|
|
76
|
+
.digest('hex')}`;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
headers: {
|
|
80
|
+
'content-type': 'application/json',
|
|
81
|
+
[WEBHOOK_EVENT_ID_HEADER]: input.eventId ?? eventIdOf(input.event),
|
|
82
|
+
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
|
|
83
|
+
[WEBHOOK_SIGNATURE_HEADER]: signature,
|
|
84
|
+
},
|
|
85
|
+
// The tampered body is sent WITHOUT re-signing, which is the whole point:
|
|
86
|
+
// the signature above covers `signedBody` and the receiver is handed
|
|
87
|
+
// something else.
|
|
88
|
+
body: input.tamperedBody ?? signedBody,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface WebhookDeliveryResult {
|
|
93
|
+
readonly status: number;
|
|
94
|
+
readonly body: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface WebhookSimulatorOptions {
|
|
98
|
+
readonly secret: string;
|
|
99
|
+
/** The receiver's webhook URL. */
|
|
100
|
+
readonly url: string;
|
|
101
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** How a delivery should be wrong, for a test that asserts a refusal. */
|
|
105
|
+
export interface WebhookDeliveryOverrides {
|
|
106
|
+
/** Signs with a timestamp far outside §10.8's five-minute window. */
|
|
107
|
+
readonly expired?: boolean;
|
|
108
|
+
/** Signs with a secret the receiver does not hold. */
|
|
109
|
+
readonly wrongSecret?: string;
|
|
110
|
+
/** Sends a body different from the one that was signed. */
|
|
111
|
+
readonly tamperedBody?: string;
|
|
112
|
+
readonly signature?: string;
|
|
113
|
+
readonly eventId?: string;
|
|
114
|
+
readonly timestampSeconds?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Comfortably outside the five-minute window, in either direction. */
|
|
118
|
+
const EXPIRED_SKEW_SECONDS = 3_600;
|
|
119
|
+
|
|
120
|
+
export class WebhookSimulator {
|
|
121
|
+
private readonly options: WebhookSimulatorOptions;
|
|
122
|
+
|
|
123
|
+
constructor(options: WebhookSimulatorOptions) {
|
|
124
|
+
this.options = options;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Signs a delivery without sending it. */
|
|
128
|
+
sign(event: unknown, overrides: WebhookDeliveryOverrides = {}): SignedWebhookDelivery {
|
|
129
|
+
return signWebhookDelivery({
|
|
130
|
+
secret: overrides.wrongSecret ?? this.options.secret,
|
|
131
|
+
event,
|
|
132
|
+
timestampSeconds:
|
|
133
|
+
overrides.timestampSeconds ??
|
|
134
|
+
Math.floor(Date.now() / 1_000) - (overrides.expired === true ? EXPIRED_SKEW_SECONDS : 0),
|
|
135
|
+
...(overrides.tamperedBody === undefined ? {} : { tamperedBody: overrides.tamperedBody }),
|
|
136
|
+
...(overrides.signature === undefined ? {} : { signature: overrides.signature }),
|
|
137
|
+
...(overrides.eventId === undefined ? {} : { eventId: overrides.eventId }),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Signs and POSTs a delivery to the receiver. */
|
|
142
|
+
async deliver(
|
|
143
|
+
event: unknown,
|
|
144
|
+
overrides: WebhookDeliveryOverrides = {},
|
|
145
|
+
): Promise<WebhookDeliveryResult> {
|
|
146
|
+
const delivery = this.sign(event, overrides);
|
|
147
|
+
const fetchImpl = this.options.fetch ?? globalThis.fetch;
|
|
148
|
+
|
|
149
|
+
const response = await fetchImpl(this.options.url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: delivery.headers,
|
|
152
|
+
body: delivery.body,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const text = await response.text();
|
|
156
|
+
let body: unknown = null;
|
|
157
|
+
if (text.length > 0) {
|
|
158
|
+
try {
|
|
159
|
+
body = JSON.parse(text) as unknown;
|
|
160
|
+
} catch {
|
|
161
|
+
body = text;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { status: response.status, body };
|
|
165
|
+
}
|
|
166
|
+
}
|