@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,192 @@
1
+ "use strict";
2
+ /**
3
+ * Outbound webhooks (§10.6–§10.9) and the signature contract (§10.8).
4
+ *
5
+ * §10.11 asks for two behaviours that pull in opposite directions: unknown
6
+ * EVENTS must be ignored safely, and unknown FIELDS must not break clients.
7
+ * This module gives each its own schema rather than compromising on one.
8
+ *
9
+ * * `WebhookEventEnvelopeSchema` validates only what every event has —
10
+ * identity, type, timing, tenant — and leaves `data` opaque. A receiver
11
+ * verifies the signature, records the event id for idempotency, and ignores
12
+ * what it does not recognise, without a schema update ever being the reason
13
+ * a delivery fails.
14
+ * * `KnownWebhookEventSchema` is the discriminated union of the eight events
15
+ * §10.6 defines, for the branch that actually handles one.
16
+ *
17
+ * Everything here is `.loose()`. These payloads travel from CrowdSource to a
18
+ * tenant, so an unknown field is a newer server, not an attack; stripping it
19
+ * would silently discard data from a receiver that persists `event.data` for
20
+ * later processing — which is precisely what §10.8's "respond 2xx quickly and
21
+ * queue the processing" tells receivers to do.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.WEBHOOK_RETRY_SCHEDULE_SECONDS = exports.WebhookSignatureHeaderSchema = exports.WebhookTimestampHeaderSchema = exports.WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = exports.WEBHOOK_SIGNATURE_VERSION = exports.WEBHOOK_SIGNATURE_HEADER = exports.WEBHOOK_TIMESTAMP_HEADER = exports.WEBHOOK_EVENT_ID_HEADER = exports.KnownWebhookEventSchema = exports.WebhookEventEnvelopeSchema = exports.AnyWebhookEventTypeSchema = exports.WebhookEventTypeSchema = exports.WEBHOOK_EVENT_TYPES = void 0;
25
+ exports.buildWebhookSignedPayload = buildWebhookSignedPayload;
26
+ const zod_1 = require("zod");
27
+ const case_envelope_1 = require("./case-envelope");
28
+ const decisions_1 = require("./decisions");
29
+ const primitives_1 = require("./primitives");
30
+ /** §10.6. */
31
+ exports.WEBHOOK_EVENT_TYPES = [
32
+ 'report.received',
33
+ 'case.created',
34
+ 'case.escalated',
35
+ 'case.decided',
36
+ 'decision.corrected',
37
+ 'appeal.created',
38
+ 'appeal.decided',
39
+ 'case.closed',
40
+ ];
41
+ exports.WebhookEventTypeSchema = zod_1.z.enum(exports.WEBHOOK_EVENT_TYPES);
42
+ /**
43
+ * Any event type, including ones this version of the contract does not know.
44
+ *
45
+ * Shape-checked but not enumerated, so that "unknown events must be ignored
46
+ * safely" is something a receiver can DO rather than something it is told about
47
+ * after its parse has already thrown.
48
+ */
49
+ exports.AnyWebhookEventTypeSchema = zod_1.z
50
+ .string()
51
+ .min(3)
52
+ .max(64)
53
+ .regex(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/, 'must be a dotted lowercase event type');
54
+ const webhookEnvelopeShape = {
55
+ id: primitives_1.IdentifierSchema,
56
+ createdAt: primitives_1.TimestampSchema,
57
+ organizationId: primitives_1.IdentifierSchema,
58
+ applicationId: primitives_1.IdentifierSchema,
59
+ };
60
+ /**
61
+ * The envelope every delivery shares (§10.7), with `data` left opaque.
62
+ *
63
+ * Parse with this first. `id` is the idempotency key §10.8 requires receivers
64
+ * to store; `type` decides whether there is anything to do.
65
+ */
66
+ exports.WebhookEventEnvelopeSchema = zod_1.z.looseObject({
67
+ ...webhookEnvelopeShape,
68
+ type: exports.AnyWebhookEventTypeSchema,
69
+ data: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
70
+ });
71
+ const ReportReceivedEventSchema = zod_1.z.looseObject({
72
+ ...webhookEnvelopeShape,
73
+ type: zod_1.z.literal('report.received'),
74
+ /** §10.6: "optional confirmation of receipt and merge" — §10.4's response. */
75
+ data: case_envelope_1.CreateReportResponseSchema,
76
+ });
77
+ const CaseCreatedEventSchema = zod_1.z.looseObject({
78
+ ...webhookEnvelopeShape,
79
+ type: zod_1.z.literal('case.created'),
80
+ data: zod_1.z.looseObject({ caseId: primitives_1.IdentifierSchema }),
81
+ });
82
+ const CaseEscalatedEventSchema = zod_1.z.looseObject({
83
+ ...webhookEnvelopeShape,
84
+ type: zod_1.z.literal('case.escalated'),
85
+ data: zod_1.z.looseObject({ caseId: primitives_1.IdentifierSchema }),
86
+ });
87
+ const CaseDecidedEventSchema = zod_1.z.looseObject({
88
+ ...webhookEnvelopeShape,
89
+ type: zod_1.z.literal('case.decided'),
90
+ data: zod_1.z.looseObject({ caseId: primitives_1.IdentifierSchema, decision: decisions_1.DecisionSchema }),
91
+ });
92
+ /**
93
+ * §10.6: "a later revision replaces the previous decision".
94
+ *
95
+ * The carried decision must therefore name what it superseded. `DecisionSchema`
96
+ * already requires that of any revision past the first; requiring it again here
97
+ * is what stops a correction from carrying revision 1.
98
+ */
99
+ const DecisionCorrectedEventSchema = zod_1.z.looseObject({
100
+ ...webhookEnvelopeShape,
101
+ type: zod_1.z.literal('decision.corrected'),
102
+ data: zod_1.z.looseObject({
103
+ caseId: primitives_1.IdentifierSchema,
104
+ decision: decisions_1.DecisionSchema.refine((decision) => decision.supersedesDecisionId !== undefined, { message: 'a corrected decision must supersede the decision it replaces' }),
105
+ }),
106
+ });
107
+ const AppealCreatedEventSchema = zod_1.z.looseObject({
108
+ ...webhookEnvelopeShape,
109
+ type: zod_1.z.literal('appeal.created'),
110
+ data: zod_1.z.looseObject({ caseId: primitives_1.IdentifierSchema, appealId: primitives_1.IdentifierSchema }),
111
+ });
112
+ const AppealDecidedEventSchema = zod_1.z.looseObject({
113
+ ...webhookEnvelopeShape,
114
+ type: zod_1.z.literal('appeal.decided'),
115
+ data: zod_1.z.looseObject({
116
+ caseId: primitives_1.IdentifierSchema,
117
+ appealId: primitives_1.IdentifierSchema,
118
+ decision: decisions_1.DecisionSchema,
119
+ }),
120
+ });
121
+ const CaseClosedEventSchema = zod_1.z.looseObject({
122
+ ...webhookEnvelopeShape,
123
+ type: zod_1.z.literal('case.closed'),
124
+ data: zod_1.z.looseObject({ caseId: primitives_1.IdentifierSchema }),
125
+ });
126
+ /**
127
+ * The eight events of §10.6, discriminated on `type`.
128
+ *
129
+ * Only `case.decided` has its payload specified in the plan (§10.7). The other
130
+ * seven carry the case they are about and whatever identifies the object that
131
+ * moved; they are loose, so filling them in later is additive and needs no
132
+ * version bump (§10.11).
133
+ */
134
+ exports.KnownWebhookEventSchema = zod_1.z.discriminatedUnion('type', [
135
+ ReportReceivedEventSchema,
136
+ CaseCreatedEventSchema,
137
+ CaseEscalatedEventSchema,
138
+ CaseDecidedEventSchema,
139
+ DecisionCorrectedEventSchema,
140
+ AppealCreatedEventSchema,
141
+ AppealDecidedEventSchema,
142
+ CaseClosedEventSchema,
143
+ ]);
144
+ /** §10.8 headers, in their canonical casing. HTTP header names are case-insensitive; look them up accordingly. */
145
+ exports.WEBHOOK_EVENT_ID_HEADER = 'X-CrowdSource-Event-Id';
146
+ exports.WEBHOOK_TIMESTAMP_HEADER = 'X-CrowdSource-Timestamp';
147
+ exports.WEBHOOK_SIGNATURE_HEADER = 'X-CrowdSource-Signature';
148
+ /** The signature scheme prefix, as in `v1=<hex>`. */
149
+ exports.WEBHOOK_SIGNATURE_VERSION = 'v1';
150
+ /** §10.8: reject timestamps more than five minutes away from now. */
151
+ exports.WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300;
152
+ /** Unix seconds, as the header carries them. */
153
+ exports.WebhookTimestampHeaderSchema = zod_1.z
154
+ .string()
155
+ .regex(/^[0-9]{1,15}$/, 'must be unix seconds');
156
+ /** `v1=<64 lowercase hex>` — HMAC-SHA256 of the signed payload. */
157
+ exports.WebhookSignatureHeaderSchema = zod_1.z
158
+ .string()
159
+ .regex(/^v1=[0-9a-f]{64}$/, 'must be "v1=" followed by 64 lowercase hex characters');
160
+ /**
161
+ * The exact bytes both sides sign: `timestamp + "." + rawBody` (§10.8).
162
+ *
163
+ * This lives in the contract, not in the signer or the verifier, because a
164
+ * disagreement between those two about what gets signed is invisible until
165
+ * every delivery starts failing — or, far worse, until a signature validates
166
+ * over bytes that are not the ones the receiver goes on to parse. There is no
167
+ * cryptography here and no transport; the HMAC belongs to the backend's signer
168
+ * and the SDK's middleware.
169
+ *
170
+ * `timestamp` is the header value VERBATIM. Re-deriving it from a parsed number
171
+ * is the mistake this signature exists to catch, and the receiver must verify
172
+ * over exactly what arrived.
173
+ */
174
+ function buildWebhookSignedPayload(timestamp, rawBody) {
175
+ return `${timestamp}.${rawBody}`;
176
+ }
177
+ /**
178
+ * §10.9's backoff, in seconds after the initial attempt.
179
+ *
180
+ * Published behaviour a tenant plans around, so it belongs to the contract
181
+ * rather than to the delivery worker. After the last one the delivery is
182
+ * `dead_letter`, the tenant is alerted, and replay is manual.
183
+ */
184
+ exports.WEBHOOK_RETRY_SCHEDULE_SECONDS = Object.freeze([
185
+ 30,
186
+ 120,
187
+ 900,
188
+ 3600,
189
+ 21600,
190
+ 86400,
191
+ ]);
192
+ //# sourceMappingURL=webhooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.js","sourceRoot":"","sources":["../src/webhooks.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@oxyhq/crowdsource-contracts",
3
+ "version": "0.1.0",
4
+ "description": "Versioned CrowdSource contracts: case envelope, resources, taxonomy, policies, reviews, decisions, webhooks and reputation events",
5
+ "type": "commonjs",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsc --watch",
19
+ "clean": "rm -rf dist",
20
+ "lint": "tsc --noEmit --noUnusedLocals --noUnusedParameters && tsc -p tsconfig.test.json",
21
+ "test": "node ../../node_modules/vitest/vitest.mjs run",
22
+ "test:watch": "node ../../node_modules/vitest/vitest.mjs",
23
+ "prepublishOnly": "bun run build"
24
+ },
25
+ "keywords": [
26
+ "crowdsource",
27
+ "moderation",
28
+ "contracts",
29
+ "typescript"
30
+ ],
31
+ "author": "OxyHQ",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/OxyHQ/CrowdSource",
36
+ "directory": "packages/contracts"
37
+ },
38
+ "homepage": "https://github.com/OxyHQ/CrowdSource/tree/main/packages/contracts#readme",
39
+ "bugs": "https://github.com/OxyHQ/CrowdSource/issues",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.9.5",
48
+ "typescript": "^5.9.3",
49
+ "vitest": "^4.1.10"
50
+ },
51
+ "files": [
52
+ "dist/**/*",
53
+ "src/**/*",
54
+ "!src/**/__tests__/**"
55
+ ]
56
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * The Case Envelope (§5.1) — the universal content contract.
3
+ *
4
+ * §5 opens by naming the mistake this contract exists to avoid: designing
5
+ * moderation around `post`, `comment`, `room` or `product`. Nothing below knows
6
+ * what a post is. An envelope is resources, relations, pseudonymous principals,
7
+ * allegations, a policy reference and privacy terms; what the material happens
8
+ * to be called in the application that sent it lives in `subject.type`, which
9
+ * is a namespaced label, not a branch in this file.
10
+ *
11
+ * Two rules govern the whole module.
12
+ *
13
+ * **Everything inbound is strict.** An unknown key on an envelope is either a
14
+ * typo — in which case the application believes it sent context that the jury
15
+ * will never see — or a field somebody hopes will reach the reviewer's screen.
16
+ * Both are rejected loudly. §10.11's "unknown fields must not break clients"
17
+ * governs what CrowdSource SENDS; see `decisions.ts` and `webhooks.ts` for the
18
+ * other half of that rule.
19
+ *
20
+ * **References resolve inside the envelope.** §5.5 requires the backend to
21
+ * check that every referenced id exists. That check lives here, in the schema,
22
+ * because it is a property of the document and nothing downstream can restate
23
+ * it as reliably. It is extended past `relations` to every in-envelope
24
+ * reference — `subject.primaryResourceId`, `allegation.resourceIds`,
25
+ * `authorPrincipalRef`, conversation members, listing media and seller, profile
26
+ * avatar — since a dangling id is the same defect wherever it appears.
27
+ */
28
+
29
+ import { z } from 'zod';
30
+
31
+ import {
32
+ CONTRACT_LIMITS,
33
+ ExternalIdSchema,
34
+ HttpUrlSchema,
35
+ IdentifierSchema,
36
+ MetadataBagSchema,
37
+ TimestampSchema,
38
+ } from './primitives';
39
+ import { CasePolicyRefSchema } from './policies';
40
+ import {
41
+ PRINCIPAL_TARGETED_RELATION_TYPES,
42
+ PrincipalRefSchema,
43
+ RelationSchema,
44
+ ResourceIdSchema,
45
+ ResourceSchema,
46
+ } from './resources';
47
+ import { SensitivityHintSchema, TaxonomyCodeSchema } from './taxonomy';
48
+
49
+ /**
50
+ * The contract version carried inside the payload.
51
+ *
52
+ * §10.11 keeps this separate from the `/v1` route version on purpose: the HTTP
53
+ * surface and the document shape evolve at different speeds, and an additive
54
+ * change to either bumps neither.
55
+ */
56
+ export const CASE_ENVELOPE_SCHEMA_VERSION = 'crowdsource.case.v1';
57
+
58
+ export const CaseEnvelopeSchemaVersionSchema = z.literal(CASE_ENVELOPE_SCHEMA_VERSION);
59
+
60
+ /** §5.4 namespaced subject types. */
61
+ export const STANDARD_SUBJECT_TYPES = [
62
+ 'social.post',
63
+ 'social.comment',
64
+ 'chat.message',
65
+ 'chat.conversation',
66
+ 'identity.profile',
67
+ 'commerce.listing',
68
+ 'commerce.review',
69
+ 'forum.thread',
70
+ 'video.live_segment',
71
+ 'document.file',
72
+ 'gaming.username',
73
+ ] as const;
74
+
75
+ /**
76
+ * A subject type: one of §5.4's standard types, or an application's own
77
+ * `custom.<organization>.<object_type>`.
78
+ *
79
+ * The custom branch is a pattern rather than an open string so a tenant's
80
+ * private vocabulary is always visibly namespaced and can never collide with a
81
+ * standard type or be mistaken for one.
82
+ */
83
+ export const SubjectTypeSchema = z.union([
84
+ z.enum(STANDARD_SUBJECT_TYPES),
85
+ z
86
+ .string()
87
+ .max(CONTRACT_LIMITS.IDENTIFIER_MAX_LENGTH)
88
+ .regex(
89
+ /^custom\.[a-z0-9][a-z0-9_-]*\.[a-z0-9][a-z0-9_-]*$/,
90
+ 'a custom subject type must be "custom.<organization>.<object_type>"',
91
+ ),
92
+ ]);
93
+ export type SubjectType = z.infer<typeof SubjectTypeSchema>;
94
+
95
+ /** §5.1 / Appendix A `subject`. */
96
+ export const CaseSubjectSchema = z.strictObject({
97
+ externalId: ExternalIdSchema,
98
+ type: SubjectTypeSchema,
99
+ primaryResourceId: ResourceIdSchema,
100
+ /** Where the application's own users see the object. Never fetched by a jury. */
101
+ permalink: HttpUrlSchema.optional(),
102
+ });
103
+ export type CaseSubject = z.infer<typeof CaseSubjectSchema>;
104
+
105
+ /**
106
+ * §3 principal kinds.
107
+ *
108
+ * The plan defines a principal in prose — "an Oxy user, a local user, an
109
+ * organization, a bot or a federated actor" — and only ever writes `oxy_user`
110
+ * in an example. These are those five in the plan's own order; the tokens are
111
+ * this contract's, the concepts are the plan's.
112
+ */
113
+ export const PRINCIPAL_TYPES = [
114
+ 'oxy_user',
115
+ 'local_user',
116
+ 'organization',
117
+ 'bot',
118
+ 'federated_actor',
119
+ ] as const;
120
+ export const PrincipalTypeSchema = z.enum(PRINCIPAL_TYPES);
121
+ export type PrincipalType = z.infer<typeof PrincipalTypeSchema>;
122
+
123
+ /**
124
+ * §5.1 `principalBindings` — proof that a pseudonymous ref corresponds to a
125
+ * verifiable identity.
126
+ *
127
+ * `bindingProofId` is REQUIRED for `oxy_user` and optional for every other
128
+ * principal type. That is the "no binding proof, no Oxy Trust effect" invariant
129
+ * made structural at the earliest possible point: an Oxy identity asserted
130
+ * without a proof cannot be expressed. The other four types can never move Oxy
131
+ * reputation anyway — they have no Oxy identity to move — and requiring a proof
132
+ * from them would lock out every tenant whose users are not Oxy users, which is
133
+ * the multi-tenant case this whole product exists for.
134
+ */
135
+ export const PrincipalBindingSchema = z
136
+ .strictObject({
137
+ principalRef: PrincipalRefSchema,
138
+ type: PrincipalTypeSchema,
139
+ /** The application's own id for the actor. Pseudonymous to the jury. */
140
+ externalPrincipalId: ExternalIdSchema.optional(),
141
+ bindingProofId: IdentifierSchema.optional(),
142
+ boundAt: TimestampSchema.optional(),
143
+ })
144
+ .superRefine((binding, ctx) => {
145
+ if (binding.type === 'oxy_user' && binding.bindingProofId === undefined) {
146
+ ctx.addIssue({
147
+ code: 'custom',
148
+ path: ['bindingProofId'],
149
+ message: 'an oxy_user binding must carry a bindingProofId',
150
+ });
151
+ }
152
+ });
153
+ export type PrincipalBinding = z.infer<typeof PrincipalBindingSchema>;
154
+
155
+ /**
156
+ * §5.1 `allegations` — what the reporter claims, not what is true.
157
+ *
158
+ * §9.1 requires the jury to see this as an unverified allegation, and §6.2
159
+ * shows the allegation and the finding diverging as the normal case. Only
160
+ * `code` is required: §5.8's example carries nothing else.
161
+ */
162
+ export const AllegationSchema = z.strictObject({
163
+ code: TaxonomyCodeSchema,
164
+ resourceIds: z
165
+ .array(ResourceIdSchema)
166
+ .max(CONTRACT_LIMITS.RESOURCE_REFS_PER_FINDING_MAX)
167
+ .optional(),
168
+ reporterPrincipalRef: PrincipalRefSchema.optional(),
169
+ details: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
170
+ });
171
+ export type Allegation = z.infer<typeof AllegationSchema>;
172
+
173
+ /**
174
+ * The environment the report came from, as the APPLICATION sees it.
175
+ *
176
+ * `staging` is deliberately absent. The plan's §12.4 three-environment model is
177
+ * not what this ecosystem runs — CrowdSource has one deployment, and
178
+ * tenant-facing sandboxing is an application-trust state inside it. A report
179
+ * from a tenant's own pre-production is a sandbox report or it is a real one.
180
+ */
181
+ export const SOURCE_ENVIRONMENTS = ['production', 'sandbox'] as const;
182
+ export const SourceEnvironmentSchema = z.enum(SOURCE_ENVIRONMENTS);
183
+
184
+ /** Appendix A `source`. */
185
+ export const CaseSourceSchema = z.strictObject({
186
+ environment: SourceEnvironmentSchema,
187
+ submittedAt: TimestampSchema,
188
+ });
189
+ export type CaseSource = z.infer<typeof CaseSourceSchema>;
190
+
191
+ /** §5.1 / Appendix A `privacy`. */
192
+ export const CasePrivacySchema = z.strictObject({
193
+ /** §13.6 defaults to 30 days after a final decision, configurable by policy. */
194
+ retentionDays: z.number().int().positive().max(CONTRACT_LIMITS.RETENTION_DAYS_MAX),
195
+ /**
196
+ * Whether a community jury may see this at all. `false` routes the case to a
197
+ * specialist pool (§7.5); it is not a preference the triage may override
198
+ * upwards.
199
+ */
200
+ allowCommunityReview: z.boolean(),
201
+ containsPersonalData: z.boolean().optional(),
202
+ sensitivityHint: SensitivityHintSchema.optional(),
203
+ });
204
+ export type CasePrivacy = z.infer<typeof CasePrivacySchema>;
205
+
206
+ /**
207
+ * §5.1 / Appendix A `urgency` — an input to triage, never to a verdict.
208
+ *
209
+ * `hint` stays an open lowercase token for the same reason as
210
+ * `sensitivityHint`: the plan names exactly one value (`normal`) and §7.4 makes
211
+ * clear the authoritative priority is computed server-side from several
212
+ * signals, of which this is one. Closing the list is a product decision that
213
+ * has not been made.
214
+ */
215
+ export const CaseUrgencySchema = z.strictObject({
216
+ hint: z
217
+ .string()
218
+ .min(1)
219
+ .max(40)
220
+ .regex(/^[a-z][a-z0-9_]*$/, 'must be a lowercase token'),
221
+ /** How many people the material reached, as the application counts it. */
222
+ reach: z.number().int().nonnegative().optional(),
223
+ activeDistribution: z.boolean().optional(),
224
+ });
225
+ export type CaseUrgency = z.infer<typeof CaseUrgencySchema>;
226
+
227
+ const caseEnvelopeShape = {
228
+ schemaVersion: CaseEnvelopeSchemaVersionSchema,
229
+ /**
230
+ * An assertion by the caller, never the source of tenancy.
231
+ *
232
+ * `applicationId` comes from the credential (Appendix F). This field exists
233
+ * so a mismatch can be DETECTED — the ingress route compares it to the
234
+ * credential-derived id and rejects a disagreement rather than trusting
235
+ * either side silently. No schema can enforce that; it is stated here so the
236
+ * rule travels with the field it constrains.
237
+ */
238
+ applicationId: IdentifierSchema,
239
+ externalReportId: ExternalIdSchema,
240
+ source: CaseSourceSchema.optional(),
241
+ subject: CaseSubjectSchema,
242
+ principalBindings: z
243
+ .array(PrincipalBindingSchema)
244
+ .max(CONTRACT_LIMITS.PRINCIPAL_BINDINGS_PER_ENVELOPE_MAX),
245
+ resources: z.array(ResourceSchema).min(1).max(CONTRACT_LIMITS.RESOURCES_PER_ENVELOPE_MAX),
246
+ relations: z.array(RelationSchema).max(CONTRACT_LIMITS.RELATIONS_PER_ENVELOPE_MAX),
247
+ allegations: z.array(AllegationSchema).min(1).max(CONTRACT_LIMITS.ALLEGATIONS_PER_ENVELOPE_MAX),
248
+ policy: CasePolicyRefSchema,
249
+ privacy: CasePrivacySchema,
250
+ urgency: CaseUrgencySchema.optional(),
251
+ metadata: MetadataBagSchema.optional(),
252
+ };
253
+
254
+ /**
255
+ * The Case Envelope.
256
+ *
257
+ * `principalBindings`, `resources`, `relations` and `allegations` are all
258
+ * required keys, matching §5.1's declared root structure and both worked
259
+ * examples; `resources` and `allegations` additionally require at least one
260
+ * entry, because an envelope with nothing to look at or no claim to evaluate is
261
+ * not a case. `source`, `urgency` and `metadata` are optional — §5.8 omits all
262
+ * three, Appendix A carries all three.
263
+ */
264
+ export const CaseEnvelopeSchema = z
265
+ .strictObject(caseEnvelopeShape)
266
+ .superRefine((envelope, ctx) => {
267
+ const resourceIds = new Set<string>();
268
+ envelope.resources.forEach((resource, index) => {
269
+ if (resourceIds.has(resource.id)) {
270
+ ctx.addIssue({
271
+ code: 'custom',
272
+ path: ['resources', index, 'id'],
273
+ message: `duplicate resource id "${resource.id}"`,
274
+ });
275
+ }
276
+ resourceIds.add(resource.id);
277
+ });
278
+
279
+ const principalRefs = new Set<string>();
280
+ envelope.principalBindings.forEach((binding, index) => {
281
+ if (principalRefs.has(binding.principalRef)) {
282
+ ctx.addIssue({
283
+ code: 'custom',
284
+ path: ['principalBindings', index, 'principalRef'],
285
+ message: `duplicate principalRef "${binding.principalRef}"`,
286
+ });
287
+ }
288
+ principalRefs.add(binding.principalRef);
289
+ });
290
+
291
+ const requireResource = (id: string, path: (string | number)[]): void => {
292
+ if (!resourceIds.has(id)) {
293
+ ctx.addIssue({
294
+ code: 'custom',
295
+ path,
296
+ message: `no resource in this envelope has id "${id}"`,
297
+ });
298
+ }
299
+ };
300
+ const requirePrincipal = (ref: string, path: (string | number)[]): void => {
301
+ if (!principalRefs.has(ref)) {
302
+ ctx.addIssue({
303
+ code: 'custom',
304
+ path,
305
+ message: `no principal binding in this envelope has principalRef "${ref}"`,
306
+ });
307
+ }
308
+ };
309
+
310
+ requireResource(envelope.subject.primaryResourceId, ['subject', 'primaryResourceId']);
311
+ const primary = envelope.resources.find(
312
+ (resource) => resource.id === envelope.subject.primaryResourceId,
313
+ );
314
+ if (primary !== undefined && primary.role !== 'subject') {
315
+ ctx.addIssue({
316
+ code: 'custom',
317
+ path: ['subject', 'primaryResourceId'],
318
+ message: `the primary resource must have role "subject", not "${primary.role}"`,
319
+ });
320
+ }
321
+
322
+ envelope.resources.forEach((resource, index) => {
323
+ if (resource.authorPrincipalRef !== undefined) {
324
+ requirePrincipal(resource.authorPrincipalRef, ['resources', index, 'authorPrincipalRef']);
325
+ }
326
+ switch (resource.type) {
327
+ case 'profile':
328
+ if (resource.data.avatarRef !== undefined) {
329
+ requireResource(resource.data.avatarRef, ['resources', index, 'data', 'avatarRef']);
330
+ }
331
+ break;
332
+ case 'conversation':
333
+ resource.data.messageResourceIds.forEach((messageId, messageIndex) => {
334
+ requireResource(messageId, [
335
+ 'resources',
336
+ index,
337
+ 'data',
338
+ 'messageResourceIds',
339
+ messageIndex,
340
+ ]);
341
+ });
342
+ break;
343
+ case 'listing':
344
+ if (resource.data.sellerRef !== undefined) {
345
+ requirePrincipal(resource.data.sellerRef, ['resources', index, 'data', 'sellerRef']);
346
+ }
347
+ resource.data.mediaRefs?.forEach((mediaId, mediaIndex) => {
348
+ requireResource(mediaId, ['resources', index, 'data', 'mediaRefs', mediaIndex]);
349
+ });
350
+ break;
351
+ default:
352
+ break;
353
+ }
354
+ });
355
+
356
+ const seenRelations = new Set<string>();
357
+ envelope.relations.forEach((relation, index) => {
358
+ requireResource(relation.from, ['relations', index, 'from']);
359
+ if (PRINCIPAL_TARGETED_RELATION_TYPES.some((type) => type === relation.type)) {
360
+ requirePrincipal(relation.to, ['relations', index, 'to']);
361
+ } else {
362
+ requireResource(relation.to, ['relations', index, 'to']);
363
+ if (relation.from === relation.to) {
364
+ ctx.addIssue({
365
+ code: 'custom',
366
+ path: ['relations', index, 'to'],
367
+ message: 'a resource cannot relate to itself',
368
+ });
369
+ }
370
+ }
371
+ const key = `${relation.from}${relation.type}${relation.to}`;
372
+ if (seenRelations.has(key)) {
373
+ ctx.addIssue({
374
+ code: 'custom',
375
+ path: ['relations', index],
376
+ message: 'duplicate relation',
377
+ });
378
+ }
379
+ seenRelations.add(key);
380
+ });
381
+
382
+ envelope.allegations.forEach((allegation, index) => {
383
+ allegation.resourceIds?.forEach((resourceId, resourceIndex) => {
384
+ requireResource(resourceId, ['allegations', index, 'resourceIds', resourceIndex]);
385
+ });
386
+ if (allegation.reporterPrincipalRef !== undefined) {
387
+ requirePrincipal(allegation.reporterPrincipalRef, [
388
+ 'allegations',
389
+ index,
390
+ 'reporterPrincipalRef',
391
+ ]);
392
+ }
393
+ });
394
+ });
395
+ export type CaseEnvelope = z.infer<typeof CaseEnvelopeSchema>;
396
+
397
+ /** §3.2 report states. */
398
+ export const REPORT_STATUSES = ['received', 'merged', 'invalid', 'withdrawn', 'closed'] as const;
399
+ export const ReportStatusSchema = z.enum(REPORT_STATUSES);
400
+ export type ReportStatus = z.infer<typeof ReportStatusSchema>;
401
+
402
+ /**
403
+ * `POST /v1/reports` (§10.4).
404
+ *
405
+ * The request repeats `externalReportId` outside the envelope, so the two must
406
+ * agree — a mismatch means the idempotency key and the document disagree about
407
+ * which report this is, and §12.7's `application_id + external_report_id`
408
+ * uniqueness would then be enforced against the wrong value.
409
+ */
410
+ export const CreateReportRequestSchema = z
411
+ .strictObject({
412
+ externalReportId: ExternalIdSchema,
413
+ envelope: CaseEnvelopeSchema,
414
+ })
415
+ .superRefine((request, ctx) => {
416
+ if (request.externalReportId !== request.envelope.externalReportId) {
417
+ ctx.addIssue({
418
+ code: 'custom',
419
+ path: ['envelope', 'externalReportId'],
420
+ message: 'externalReportId must match the envelope',
421
+ });
422
+ }
423
+ });
424
+ export type CreateReportRequest = z.infer<typeof CreateReportRequestSchema>;
425
+
426
+ /** The `202 Accepted` body of §10.4. Loose: it travels outbound (§10.11). */
427
+ export const CreateReportResponseSchema = z.looseObject({
428
+ reportId: IdentifierSchema,
429
+ caseId: IdentifierSchema,
430
+ status: ReportStatusSchema,
431
+ merged: z.boolean(),
432
+ });
433
+ export type CreateReportResponse = z.infer<typeof CreateReportResponseSchema>;