@oxyhq/crowdsource-contracts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +129 -0
  2. package/dist/case-envelope.d.ts +1130 -0
  3. package/dist/case-envelope.d.ts.map +1 -0
  4. package/dist/case-envelope.js +383 -0
  5. package/dist/case-envelope.js.map +1 -0
  6. package/dist/decisions.d.ts +353 -0
  7. package/dist/decisions.d.ts.map +1 -0
  8. package/dist/decisions.js +198 -0
  9. package/dist/decisions.js.map +1 -0
  10. package/dist/index.d.ts +45 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +61 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/json-schema.d.ts +43 -0
  15. package/dist/json-schema.d.ts.map +1 -0
  16. package/dist/json-schema.js +83 -0
  17. package/dist/json-schema.js.map +1 -0
  18. package/dist/policies.d.ts +286 -0
  19. package/dist/policies.d.ts.map +1 -0
  20. package/dist/policies.js +178 -0
  21. package/dist/policies.js.map +1 -0
  22. package/dist/primitives.d.ts +185 -0
  23. package/dist/primitives.d.ts.map +1 -0
  24. package/dist/primitives.js +231 -0
  25. package/dist/primitives.js.map +1 -0
  26. package/dist/reputation-events.d.ts +349 -0
  27. package/dist/reputation-events.d.ts.map +1 -0
  28. package/dist/reputation-events.js +128 -0
  29. package/dist/reputation-events.js.map +1 -0
  30. package/dist/resources.d.ts +484 -0
  31. package/dist/resources.d.ts.map +1 -0
  32. package/dist/resources.js +436 -0
  33. package/dist/resources.js.map +1 -0
  34. package/dist/reviews.d.ts +276 -0
  35. package/dist/reviews.d.ts.map +1 -0
  36. package/dist/reviews.js +144 -0
  37. package/dist/reviews.js.map +1 -0
  38. package/dist/taxonomy.d.ts +266 -0
  39. package/dist/taxonomy.d.ts.map +1 -0
  40. package/dist/taxonomy.js +282 -0
  41. package/dist/taxonomy.js.map +1 -0
  42. package/dist/webhooks.d.ts +604 -0
  43. package/dist/webhooks.d.ts.map +1 -0
  44. package/dist/webhooks.js +192 -0
  45. package/dist/webhooks.js.map +1 -0
  46. package/package.json +56 -0
  47. package/src/case-envelope.ts +433 -0
  48. package/src/decisions.ts +216 -0
  49. package/src/index.ts +45 -0
  50. package/src/json-schema.ts +89 -0
  51. package/src/policies.ts +203 -0
  52. package/src/primitives.ts +283 -0
  53. package/src/reputation-events.ts +144 -0
  54. package/src/resources.ts +489 -0
  55. package/src/reviews.ts +159 -0
  56. package/src/taxonomy.ts +313 -0
  57. package/src/webhooks.ts +215 -0
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The universal classification layer (§6.1, §6.3).
3
+ *
4
+ * §6.1 separates three layers and warns that mixing them makes the service
5
+ * unusable across applications with different rules. This module is the FIRST
6
+ * layer only — "what does the material contain or represent?" — and it belongs
7
+ * to CrowdSource. Whether that content violates anything is the second layer
8
+ * (`policies.ts`, per tenant) and whether it should move global trust is the
9
+ * third (`reputation-events.ts`). Nothing here may encode a single tenant's
10
+ * vocabulary.
11
+ *
12
+ * The code list is CLOSED. `other.policy_specific` and `other.unclassifiable`
13
+ * are the escape hatches the plan provides for material that does not fit; an
14
+ * open string would let a tenant mint private codes, which is precisely the
15
+ * cross-application comparability §6.1 exists to protect. Adding a code is an
16
+ * additive change that bumps `UNIVERSAL_TAXONOMY_VERSION`, and §6.4 requires
17
+ * every decision to record the version it was classified under, so historical
18
+ * decisions keep meaning what they meant.
19
+ */
20
+
21
+ import { z } from 'zod';
22
+
23
+ /** §6.3 families. */
24
+ export const TAXONOMY_FAMILIES = [
25
+ 'integrity',
26
+ 'harassment',
27
+ 'hate',
28
+ 'violence',
29
+ 'sexual_content',
30
+ 'child_safety',
31
+ 'self_harm',
32
+ 'privacy',
33
+ 'commerce',
34
+ 'platform_abuse',
35
+ 'other',
36
+ ] as const;
37
+
38
+ export const TaxonomyFamilySchema = z.enum(TAXONOMY_FAMILIES);
39
+ export type TaxonomyFamily = z.infer<typeof TaxonomyFamilySchema>;
40
+
41
+ const INTEGRITY_CODES = [
42
+ 'integrity.spam',
43
+ 'integrity.scam',
44
+ 'integrity.fraud',
45
+ 'integrity.impersonation',
46
+ 'integrity.coordinated_manipulation',
47
+ ] as const;
48
+
49
+ const HARASSMENT_CODES = [
50
+ 'harassment.insult',
51
+ 'harassment.targeted_abuse',
52
+ 'harassment.sexual_harassment',
53
+ 'harassment.doxxing',
54
+ 'harassment.credible_threat',
55
+ ] as const;
56
+
57
+ const HATE_CODES = [
58
+ 'hate.dehumanization',
59
+ 'hate.slur',
60
+ 'hate.incitement',
61
+ 'hate.protected_targeting',
62
+ ] as const;
63
+
64
+ const VIOLENCE_CODES = [
65
+ 'violence.graphic',
66
+ 'violence.threat',
67
+ 'violence.instruction',
68
+ 'violence.celebration',
69
+ ] as const;
70
+
71
+ const SEXUAL_CONTENT_CODES = [
72
+ 'sexual_content.nudity',
73
+ 'sexual_content.explicit_activity',
74
+ 'sexual_content.non_consensual',
75
+ 'sexual_content.exploitation',
76
+ ] as const;
77
+
78
+ const CHILD_SAFETY_CODES = [
79
+ 'child_safety.sexualization',
80
+ 'child_safety.grooming',
81
+ 'child_safety.exploitation',
82
+ ] as const;
83
+
84
+ const SELF_HARM_CODES = [
85
+ 'self_harm.promotion',
86
+ 'self_harm.instruction',
87
+ 'self_harm.imminent_risk',
88
+ ] as const;
89
+
90
+ const PRIVACY_CODES = [
91
+ 'privacy.personal_information',
92
+ 'privacy.intimate_media',
93
+ 'privacy.location_exposure',
94
+ ] as const;
95
+
96
+ const COMMERCE_CODES = [
97
+ 'commerce.prohibited_item',
98
+ 'commerce.counterfeit',
99
+ 'commerce.misleading_listing',
100
+ 'commerce.unsafe_product',
101
+ ] as const;
102
+
103
+ const PLATFORM_ABUSE_CODES = [
104
+ 'platform_abuse.ban_evasion',
105
+ 'platform_abuse.report_abuse',
106
+ 'platform_abuse.automation_abuse',
107
+ ] as const;
108
+
109
+ const OTHER_CODES = ['other.policy_specific', 'other.unclassifiable'] as const;
110
+
111
+ /**
112
+ * §6.3, grouped.
113
+ *
114
+ * §9.4 requires consensus on the "main taxonomic family", not only on the exact
115
+ * code, so the grouping is part of the contract rather than something the
116
+ * consensus engine re-derives from string prefixes.
117
+ */
118
+ export const TAXONOMY_CODES_BY_FAMILY = Object.freeze({
119
+ integrity: INTEGRITY_CODES,
120
+ harassment: HARASSMENT_CODES,
121
+ hate: HATE_CODES,
122
+ violence: VIOLENCE_CODES,
123
+ sexual_content: SEXUAL_CONTENT_CODES,
124
+ child_safety: CHILD_SAFETY_CODES,
125
+ self_harm: SELF_HARM_CODES,
126
+ privacy: PRIVACY_CODES,
127
+ commerce: COMMERCE_CODES,
128
+ platform_abuse: PLATFORM_ABUSE_CODES,
129
+ other: OTHER_CODES,
130
+ } as const);
131
+
132
+ export const UNIVERSAL_TAXONOMY_CODES = [
133
+ ...INTEGRITY_CODES,
134
+ ...HARASSMENT_CODES,
135
+ ...HATE_CODES,
136
+ ...VIOLENCE_CODES,
137
+ ...SEXUAL_CONTENT_CODES,
138
+ ...CHILD_SAFETY_CODES,
139
+ ...SELF_HARM_CODES,
140
+ ...PRIVACY_CODES,
141
+ ...COMMERCE_CODES,
142
+ ...PLATFORM_ABUSE_CODES,
143
+ ...OTHER_CODES,
144
+ ] as const;
145
+
146
+ export const TaxonomyCodeSchema = z.enum(UNIVERSAL_TAXONOMY_CODES);
147
+ export type TaxonomyCode = z.infer<typeof TaxonomyCodeSchema>;
148
+
149
+ /**
150
+ * The version of the code list above.
151
+ *
152
+ * §6.4: every decision records the taxonomy version it was decided under, and a
153
+ * policy update never silently rewrites history. Keeping the constant in the
154
+ * same module as the codes is what makes that possible — the two cannot drift.
155
+ * The value is the one the plan uses in Appendix B and §11.6.
156
+ */
157
+ export const UNIVERSAL_TAXONOMY_VERSION = '2026.1';
158
+
159
+ /** The family a code belongs to. */
160
+ export function taxonomyFamilyOf(code: TaxonomyCode): TaxonomyFamily {
161
+ const [family] = code.split('.');
162
+ return TaxonomyFamilySchema.parse(family);
163
+ }
164
+
165
+ /** §9.4 / §11.8 severity scale. */
166
+ export const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const;
167
+ export const SeveritySchema = z.enum(SEVERITIES);
168
+ export type Severity = z.infer<typeof SeveritySchema>;
169
+
170
+ /**
171
+ * How far a finding reaches.
172
+ *
173
+ * §11.6 and Appendix B use `oxy_network`; §11.7.5 additionally names
174
+ * `identity_integrity` as a scope that may produce an Oxy Trust effect. The
175
+ * plan never names the third value — the one that means "this matters to the
176
+ * application and stops there" — even though §6.5's entire argument is that
177
+ * most local restrictions must NOT become global sanctions. `application_local`
178
+ * is the contract's name for it and is the ONE token in this package invented
179
+ * rather than quoted. Modelling it as an absent field instead would make
180
+ * §11.7.5 a presence check, which fails open.
181
+ */
182
+ export const FINDING_SCOPES = ['application_local', 'oxy_network', 'identity_integrity'] as const;
183
+ export const FindingScopeSchema = z.enum(FINDING_SCOPES);
184
+ export type FindingScope = z.infer<typeof FindingScopeSchema>;
185
+
186
+ /**
187
+ * Scopes that §11.7.5 allows to reach Oxy Trust.
188
+ *
189
+ * Exported as its own schema so `reputation-events.ts` states the rule in the
190
+ * type rather than re-checking it at runtime.
191
+ */
192
+ export const REPUTATION_ELIGIBLE_FINDING_SCOPES = ['oxy_network', 'identity_integrity'] as const;
193
+ export const ReputationEligibleFindingScopeSchema = z.enum(REPUTATION_ELIGIBLE_FINDING_SCOPES);
194
+ export type ReputationEligibleFindingScope = z.infer<typeof ReputationEligibleFindingScopeSchema>;
195
+
196
+ /**
197
+ * Who a finding attributes conduct to.
198
+ *
199
+ * Appendix B and §11.6 use `author`. §11.11 and §11.12 describe confirmed
200
+ * report abuse and confirmed review abuse producing conduct effects of their
201
+ * own, which is where the other two values come from. There is deliberately no
202
+ * `unknown`: attribution exists to name a principal, and §11.7.4 will not let
203
+ * an effect land without a binding proof for that principal anyway.
204
+ */
205
+ export const FINDING_ATTRIBUTIONS = ['author', 'reporter', 'reviewer'] as const;
206
+ export const FindingAttributionSchema = z.enum(FINDING_ATTRIBUTIONS);
207
+ export type FindingAttribution = z.infer<typeof FindingAttributionSchema>;
208
+
209
+ /**
210
+ * What CrowdSource may recommend an application do.
211
+ *
212
+ * The plan writes action tokens in two places. §6.2, §9.3, §10.7 and Appendix B
213
+ * recommend `remove_or_restrict` and `allow_with_label`; §7.6 tabulates, per
214
+ * decision outcome, the actions an application may take in response. They are
215
+ * the same vocabulary seen from the two ends of one exchange, so the contract
216
+ * carries the union as one closed list — otherwise a recommendation and the
217
+ * enforcement that answers it would not be comparable, and §7.6's requirement
218
+ * that an application "record what it did and why" would compare apples to
219
+ * pears.
220
+ *
221
+ * §7.6's outcome→action table is NOT reproduced as a constraint. It bounds what
222
+ * an application may do in response to an outcome, not what a jury may
223
+ * recommend — Appendix B recommends `remove_or_restrict`, which does not appear
224
+ * in §7.6's `violation` row at all. Binding the two would reject the plan's own
225
+ * reference decision.
226
+ */
227
+ export const RECOMMENDED_ACTIONS = [
228
+ 'remove_or_restrict',
229
+ 'allow_with_label',
230
+ 'remove',
231
+ 'hide',
232
+ 'label',
233
+ 'age_gate',
234
+ 'reduce_distribution',
235
+ 'freeze_transaction',
236
+ 'suspend_user',
237
+ 'request_changes',
238
+ 'allow',
239
+ 'restore',
240
+ 'no_action',
241
+ 'request_more_context',
242
+ 'hold',
243
+ 'local_manual_review',
244
+ 'keep_restricted_temporarily',
245
+ 'escalate',
246
+ 'no_global_effect',
247
+ 'specialist_queue',
248
+ 'legal_queue',
249
+ 'safety_queue',
250
+ ] as const;
251
+
252
+ export const RecommendedActionSchema = z.enum(RECOMMENDED_ACTIONS);
253
+ export type RecommendedAction = z.infer<typeof RecommendedActionSchema>;
254
+
255
+ /**
256
+ * The context that makes a classification not mean what it usually means —
257
+ * §9.2's and §9.4's "excepción", §6.2's `context`.
258
+ *
259
+ * §6.2's worked example is where this field comes from and what fixes its
260
+ * shape: the jury's finding is `sexual_content.nudity, severity = medium,
261
+ * context = artistic`, and it is that qualifier — not the code and not the
262
+ * severity — that turns the same classification into a violation under one
263
+ * application's policy and not under another's. It therefore belongs to layer
264
+ * one, beside the code: a reviewer describes what the material IS, and
265
+ * "artistic nudity" is a different description from "nudity".
266
+ *
267
+ * §9.4 makes it one of the six dimensions consensus is measured on, which is
268
+ * the reason it must be a CLOSED list. Two reviewers who both answer
269
+ * `no_violation` but for incompatible reasons — one because the material is
270
+ * documentary, one because they think the rule does not cover it at all — have
271
+ * not agreed about the material, and a free-text field could not tell the two
272
+ * apart. An open token would also be a channel for case content to reach a
273
+ * decision record, which §13.5 forbids.
274
+ *
275
+ * Only `artistic` is the plan's own word. The rest are the exception vocabulary
276
+ * every published moderation policy shares, and they are named here rather than
277
+ * left to a tenant because layer one is CrowdSource's (§6.1) — a tenant that
278
+ * could mint its own exception tokens would make findings incomparable across
279
+ * applications, which is exactly what §6.1 exists to prevent. Absence means no
280
+ * exception applies, which is the safe direction: a finding with no exception
281
+ * stands as classified.
282
+ */
283
+ export const FINDING_CONTEXTS = [
284
+ 'artistic',
285
+ 'educational',
286
+ 'documentary',
287
+ 'newsworthy',
288
+ 'satire',
289
+ 'counter_speech',
290
+ 'medical',
291
+ 'consensual',
292
+ 'fictional',
293
+ ] as const;
294
+ export const FindingContextSchema = z.enum(FINDING_CONTEXTS);
295
+ export type FindingContext = z.infer<typeof FindingContextSchema>;
296
+
297
+ /**
298
+ * A tenant's advance classification of how exposing the material is.
299
+ *
300
+ * The plan names exactly one value — `standard`, in Appendix A — and §7.5
301
+ * clearly implies at least one more (categories that never reach a community
302
+ * jury). Rather than invent the rest, this stays an open lowercase token: it is
303
+ * a HINT (§5.2: "never shown as a verdict"), the authoritative
304
+ * `sensitivity_class` is computed by triage server-side (§12.8), and access to
305
+ * sensitive material is gated on that computed class, never on what the tenant
306
+ * asserted. Closing this list is a product decision that has not been made.
307
+ */
308
+ export const SensitivityHintSchema = z
309
+ .string()
310
+ .min(1)
311
+ .max(40)
312
+ .regex(/^[a-z][a-z0-9_]*$/, 'must be a lowercase token');
313
+ export type SensitivityHint = z.infer<typeof SensitivityHintSchema>;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Outbound webhooks (§10.6–§10.9) and the signature contract (§10.8).
3
+ *
4
+ * §10.11 asks for two behaviours that pull in opposite directions: unknown
5
+ * EVENTS must be ignored safely, and unknown FIELDS must not break clients.
6
+ * This module gives each its own schema rather than compromising on one.
7
+ *
8
+ * * `WebhookEventEnvelopeSchema` validates only what every event has —
9
+ * identity, type, timing, tenant — and leaves `data` opaque. A receiver
10
+ * verifies the signature, records the event id for idempotency, and ignores
11
+ * what it does not recognise, without a schema update ever being the reason
12
+ * a delivery fails.
13
+ * * `KnownWebhookEventSchema` is the discriminated union of the eight events
14
+ * §10.6 defines, for the branch that actually handles one.
15
+ *
16
+ * Everything here is `.loose()`. These payloads travel from CrowdSource to a
17
+ * tenant, so an unknown field is a newer server, not an attack; stripping it
18
+ * would silently discard data from a receiver that persists `event.data` for
19
+ * later processing — which is precisely what §10.8's "respond 2xx quickly and
20
+ * queue the processing" tells receivers to do.
21
+ */
22
+
23
+ import { z } from 'zod';
24
+
25
+ import { CreateReportResponseSchema } from './case-envelope';
26
+ import { DecisionSchema } from './decisions';
27
+ import { IdentifierSchema, TimestampSchema } from './primitives';
28
+
29
+ /** §10.6. */
30
+ export const WEBHOOK_EVENT_TYPES = [
31
+ 'report.received',
32
+ 'case.created',
33
+ 'case.escalated',
34
+ 'case.decided',
35
+ 'decision.corrected',
36
+ 'appeal.created',
37
+ 'appeal.decided',
38
+ 'case.closed',
39
+ ] as const;
40
+ export const WebhookEventTypeSchema = z.enum(WEBHOOK_EVENT_TYPES);
41
+ export type WebhookEventType = z.infer<typeof WebhookEventTypeSchema>;
42
+
43
+ /**
44
+ * Any event type, including ones this version of the contract does not know.
45
+ *
46
+ * Shape-checked but not enumerated, so that "unknown events must be ignored
47
+ * safely" is something a receiver can DO rather than something it is told about
48
+ * after its parse has already thrown.
49
+ */
50
+ export const AnyWebhookEventTypeSchema = z
51
+ .string()
52
+ .min(3)
53
+ .max(64)
54
+ .regex(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/, 'must be a dotted lowercase event type');
55
+
56
+ const webhookEnvelopeShape = {
57
+ id: IdentifierSchema,
58
+ createdAt: TimestampSchema,
59
+ organizationId: IdentifierSchema,
60
+ applicationId: IdentifierSchema,
61
+ };
62
+
63
+ /**
64
+ * The envelope every delivery shares (§10.7), with `data` left opaque.
65
+ *
66
+ * Parse with this first. `id` is the idempotency key §10.8 requires receivers
67
+ * to store; `type` decides whether there is anything to do.
68
+ */
69
+ export const WebhookEventEnvelopeSchema = z.looseObject({
70
+ ...webhookEnvelopeShape,
71
+ type: AnyWebhookEventTypeSchema,
72
+ data: z.record(z.string(), z.unknown()),
73
+ });
74
+ export type WebhookEventEnvelope = z.infer<typeof WebhookEventEnvelopeSchema>;
75
+
76
+ const ReportReceivedEventSchema = z.looseObject({
77
+ ...webhookEnvelopeShape,
78
+ type: z.literal('report.received'),
79
+ /** §10.6: "optional confirmation of receipt and merge" — §10.4's response. */
80
+ data: CreateReportResponseSchema,
81
+ });
82
+
83
+ const CaseCreatedEventSchema = z.looseObject({
84
+ ...webhookEnvelopeShape,
85
+ type: z.literal('case.created'),
86
+ data: z.looseObject({ caseId: IdentifierSchema }),
87
+ });
88
+
89
+ const CaseEscalatedEventSchema = z.looseObject({
90
+ ...webhookEnvelopeShape,
91
+ type: z.literal('case.escalated'),
92
+ data: z.looseObject({ caseId: IdentifierSchema }),
93
+ });
94
+
95
+ const CaseDecidedEventSchema = z.looseObject({
96
+ ...webhookEnvelopeShape,
97
+ type: z.literal('case.decided'),
98
+ data: z.looseObject({ caseId: IdentifierSchema, decision: DecisionSchema }),
99
+ });
100
+
101
+ /**
102
+ * §10.6: "a later revision replaces the previous decision".
103
+ *
104
+ * The carried decision must therefore name what it superseded. `DecisionSchema`
105
+ * already requires that of any revision past the first; requiring it again here
106
+ * is what stops a correction from carrying revision 1.
107
+ */
108
+ const DecisionCorrectedEventSchema = z.looseObject({
109
+ ...webhookEnvelopeShape,
110
+ type: z.literal('decision.corrected'),
111
+ data: z.looseObject({
112
+ caseId: IdentifierSchema,
113
+ decision: DecisionSchema.refine(
114
+ (decision) => decision.supersedesDecisionId !== undefined,
115
+ { message: 'a corrected decision must supersede the decision it replaces' },
116
+ ),
117
+ }),
118
+ });
119
+
120
+ const AppealCreatedEventSchema = z.looseObject({
121
+ ...webhookEnvelopeShape,
122
+ type: z.literal('appeal.created'),
123
+ data: z.looseObject({ caseId: IdentifierSchema, appealId: IdentifierSchema }),
124
+ });
125
+
126
+ const AppealDecidedEventSchema = z.looseObject({
127
+ ...webhookEnvelopeShape,
128
+ type: z.literal('appeal.decided'),
129
+ data: z.looseObject({
130
+ caseId: IdentifierSchema,
131
+ appealId: IdentifierSchema,
132
+ decision: DecisionSchema,
133
+ }),
134
+ });
135
+
136
+ const CaseClosedEventSchema = z.looseObject({
137
+ ...webhookEnvelopeShape,
138
+ type: z.literal('case.closed'),
139
+ data: z.looseObject({ caseId: IdentifierSchema }),
140
+ });
141
+
142
+ /**
143
+ * The eight events of §10.6, discriminated on `type`.
144
+ *
145
+ * Only `case.decided` has its payload specified in the plan (§10.7). The other
146
+ * seven carry the case they are about and whatever identifies the object that
147
+ * moved; they are loose, so filling them in later is additive and needs no
148
+ * version bump (§10.11).
149
+ */
150
+ export const KnownWebhookEventSchema = z.discriminatedUnion('type', [
151
+ ReportReceivedEventSchema,
152
+ CaseCreatedEventSchema,
153
+ CaseEscalatedEventSchema,
154
+ CaseDecidedEventSchema,
155
+ DecisionCorrectedEventSchema,
156
+ AppealCreatedEventSchema,
157
+ AppealDecidedEventSchema,
158
+ CaseClosedEventSchema,
159
+ ]);
160
+ export type KnownWebhookEvent = z.infer<typeof KnownWebhookEventSchema>;
161
+
162
+ /** §10.8 headers, in their canonical casing. HTTP header names are case-insensitive; look them up accordingly. */
163
+ export const WEBHOOK_EVENT_ID_HEADER = 'X-CrowdSource-Event-Id';
164
+ export const WEBHOOK_TIMESTAMP_HEADER = 'X-CrowdSource-Timestamp';
165
+ export const WEBHOOK_SIGNATURE_HEADER = 'X-CrowdSource-Signature';
166
+
167
+ /** The signature scheme prefix, as in `v1=<hex>`. */
168
+ export const WEBHOOK_SIGNATURE_VERSION = 'v1';
169
+
170
+ /** §10.8: reject timestamps more than five minutes away from now. */
171
+ export const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
172
+
173
+ /** Unix seconds, as the header carries them. */
174
+ export const WebhookTimestampHeaderSchema = z
175
+ .string()
176
+ .regex(/^[0-9]{1,15}$/, 'must be unix seconds');
177
+
178
+ /** `v1=<64 lowercase hex>` — HMAC-SHA256 of the signed payload. */
179
+ export const WebhookSignatureHeaderSchema = z
180
+ .string()
181
+ .regex(/^v1=[0-9a-f]{64}$/, 'must be "v1=" followed by 64 lowercase hex characters');
182
+
183
+ /**
184
+ * The exact bytes both sides sign: `timestamp + "." + rawBody` (§10.8).
185
+ *
186
+ * This lives in the contract, not in the signer or the verifier, because a
187
+ * disagreement between those two about what gets signed is invisible until
188
+ * every delivery starts failing — or, far worse, until a signature validates
189
+ * over bytes that are not the ones the receiver goes on to parse. There is no
190
+ * cryptography here and no transport; the HMAC belongs to the backend's signer
191
+ * and the SDK's middleware.
192
+ *
193
+ * `timestamp` is the header value VERBATIM. Re-deriving it from a parsed number
194
+ * is the mistake this signature exists to catch, and the receiver must verify
195
+ * over exactly what arrived.
196
+ */
197
+ export function buildWebhookSignedPayload(timestamp: string, rawBody: string): string {
198
+ return `${timestamp}.${rawBody}`;
199
+ }
200
+
201
+ /**
202
+ * §10.9's backoff, in seconds after the initial attempt.
203
+ *
204
+ * Published behaviour a tenant plans around, so it belongs to the contract
205
+ * rather than to the delivery worker. After the last one the delivery is
206
+ * `dead_letter`, the tenant is alerted, and replay is manual.
207
+ */
208
+ export const WEBHOOK_RETRY_SCHEDULE_SECONDS: readonly number[] = Object.freeze([
209
+ 30,
210
+ 120,
211
+ 900,
212
+ 3_600,
213
+ 21_600,
214
+ 86_400,
215
+ ]);