@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.
- package/README.md +129 -0
- package/dist/case-envelope.d.ts +1130 -0
- package/dist/case-envelope.d.ts.map +1 -0
- package/dist/case-envelope.js +383 -0
- package/dist/case-envelope.js.map +1 -0
- package/dist/decisions.d.ts +353 -0
- package/dist/decisions.d.ts.map +1 -0
- package/dist/decisions.js +198 -0
- package/dist/decisions.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/json-schema.d.ts +43 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +83 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/policies.d.ts +286 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +178 -0
- package/dist/policies.js.map +1 -0
- package/dist/primitives.d.ts +185 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +231 -0
- package/dist/primitives.js.map +1 -0
- package/dist/reputation-events.d.ts +349 -0
- package/dist/reputation-events.d.ts.map +1 -0
- package/dist/reputation-events.js +128 -0
- package/dist/reputation-events.js.map +1 -0
- package/dist/resources.d.ts +484 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +436 -0
- package/dist/resources.js.map +1 -0
- package/dist/reviews.d.ts +276 -0
- package/dist/reviews.d.ts.map +1 -0
- package/dist/reviews.js +144 -0
- package/dist/reviews.js.map +1 -0
- package/dist/taxonomy.d.ts +266 -0
- package/dist/taxonomy.d.ts.map +1 -0
- package/dist/taxonomy.js +282 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/webhooks.d.ts +604 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +192 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +56 -0
- package/src/case-envelope.ts +433 -0
- package/src/decisions.ts +216 -0
- package/src/index.ts +45 -0
- package/src/json-schema.ts +89 -0
- package/src/policies.ts +203 -0
- package/src/primitives.ts +283 -0
- package/src/reputation-events.ts +144 -0
- package/src/resources.ts +489 -0
- package/src/reviews.ts +159 -0
- package/src/taxonomy.ts +313 -0
- package/src/webhooks.ts +215 -0
package/src/resources.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resources and relations — the universal content unit (§5.2–§5.7).
|
|
3
|
+
*
|
|
4
|
+
* "Applications send data, never executable code" (§5.2). Everything in this
|
|
5
|
+
* module follows from that one sentence:
|
|
6
|
+
*
|
|
7
|
+
* * The resource union is CLOSED and discriminated on `type`, and each
|
|
8
|
+
* variant declares exactly which fields it may carry. There is no free-form
|
|
9
|
+
* branch, so there is no place to put a rendering instruction.
|
|
10
|
+
* * Every object is `.strict()`. In the outbound direction an unknown field
|
|
11
|
+
* is harmless forward compatibility (§10.11); inbound it is not. A field
|
|
12
|
+
* that is silently dropped is context an application believes it sent and a
|
|
13
|
+
* jury never sees — which is how a case gets decided on incomplete material
|
|
14
|
+
* — and a field that is silently kept is a rendering input nobody reviewed.
|
|
15
|
+
* §10.11 makes exactly this exception: unknown fields must not break
|
|
16
|
+
* clients "except where the schema forbids them for safety".
|
|
17
|
+
* * A media type must agree with its resource type, so an `image` cannot
|
|
18
|
+
* declare `text/html` and be handed to something that trusts the label.
|
|
19
|
+
* * §5.7 custom payloads are bounded JSON with no `$`-prefixed keys, which
|
|
20
|
+
* removes every JSON Schema reference mechanism at once — `$ref` to a
|
|
21
|
+
* remote document IS the "remote component" §5.7 forbids, and resolving one
|
|
22
|
+
* is an SSRF against CrowdSource itself.
|
|
23
|
+
*
|
|
24
|
+
* What is deliberately NOT here: any filter on the CONTENT of text fields. The
|
|
25
|
+
* material under review is hostile by definition — a harassment report quotes
|
|
26
|
+
* the harassment, a phishing report quotes the phishing link. A lexical
|
|
27
|
+
* blocklist would reject the evidence and protect nothing, because the contract
|
|
28
|
+
* has no field whose value is ever interpreted as markup or code.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
CONTRACT_LIMITS,
|
|
35
|
+
CustomPayloadSchema,
|
|
36
|
+
HttpUrlSchema,
|
|
37
|
+
IdentifierSchema,
|
|
38
|
+
LanguageTagSchema,
|
|
39
|
+
MetadataBagSchema,
|
|
40
|
+
MimeTypeSchema,
|
|
41
|
+
Sha256DigestSchema,
|
|
42
|
+
TimestampSchema,
|
|
43
|
+
} from './primitives';
|
|
44
|
+
import { PolicyVersionSchema } from './policies';
|
|
45
|
+
import { SensitivityHintSchema } from './taxonomy';
|
|
46
|
+
|
|
47
|
+
/** A resource id, unique within one envelope (§5.2 `id`). */
|
|
48
|
+
export const ResourceIdSchema = IdentifierSchema;
|
|
49
|
+
export type ResourceId = z.infer<typeof ResourceIdSchema>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A pseudonymous reference to an actor, resolved within one envelope.
|
|
53
|
+
*
|
|
54
|
+
* Never a real identity: §13.5 requires a pseudonymous principal wherever one
|
|
55
|
+
* suffices. `principalBindings` is the only place a ref is tied to anything,
|
|
56
|
+
* and even there it is tied to a binding proof id, not to a name.
|
|
57
|
+
*/
|
|
58
|
+
export const PrincipalRefSchema = IdentifierSchema;
|
|
59
|
+
export type PrincipalRef = z.infer<typeof PrincipalRefSchema>;
|
|
60
|
+
|
|
61
|
+
/** §5.2 `role`: why this resource is in the case. */
|
|
62
|
+
export const RESOURCE_ROLES = [
|
|
63
|
+
'subject',
|
|
64
|
+
'attachment',
|
|
65
|
+
'context',
|
|
66
|
+
'parent',
|
|
67
|
+
'quoted',
|
|
68
|
+
'evidence',
|
|
69
|
+
'metadata',
|
|
70
|
+
'policy_context',
|
|
71
|
+
] as const;
|
|
72
|
+
export const ResourceRoleSchema = z.enum(RESOURCE_ROLES);
|
|
73
|
+
export type ResourceRole = z.infer<typeof ResourceRoleSchema>;
|
|
74
|
+
|
|
75
|
+
/** §5.3 standard resource types. */
|
|
76
|
+
export const RESOURCE_TYPES = [
|
|
77
|
+
'text',
|
|
78
|
+
'image',
|
|
79
|
+
'video',
|
|
80
|
+
'audio',
|
|
81
|
+
'document',
|
|
82
|
+
'link',
|
|
83
|
+
'profile',
|
|
84
|
+
'conversation',
|
|
85
|
+
'listing',
|
|
86
|
+
'location',
|
|
87
|
+
'metadata',
|
|
88
|
+
'custom',
|
|
89
|
+
] as const;
|
|
90
|
+
export const ResourceTypeSchema = z.enum(RESOURCE_TYPES);
|
|
91
|
+
export type ResourceType = z.infer<typeof ResourceTypeSchema>;
|
|
92
|
+
|
|
93
|
+
/** §5.3 text formatting. Never HTML: `markdown_subset` is a subset for a reason. */
|
|
94
|
+
export const TEXT_FORMATTINGS = ['plain', 'markdown_subset'] as const;
|
|
95
|
+
export const TextFormattingSchema = z.enum(TEXT_FORMATTINGS);
|
|
96
|
+
export type TextFormatting = z.infer<typeof TextFormattingSchema>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A reference to bytes held outside the envelope (§5.2 `asset`).
|
|
100
|
+
*
|
|
101
|
+
* Exactly one of `uploadId` and `url` — never both, never neither. Two
|
|
102
|
+
* locations for one piece of evidence means two answers to "what exactly did
|
|
103
|
+
* the jury look at", and §5.6 requires the case to pin the exact version that
|
|
104
|
+
* was reported. `sha256` is required for the same reason: it is what makes the
|
|
105
|
+
* answer verifiable after the application has deleted the original.
|
|
106
|
+
*/
|
|
107
|
+
export const AssetRefSchema = z
|
|
108
|
+
.strictObject({
|
|
109
|
+
uploadId: IdentifierSchema.optional(),
|
|
110
|
+
url: HttpUrlSchema.optional(),
|
|
111
|
+
mimeType: MimeTypeSchema,
|
|
112
|
+
sha256: Sha256DigestSchema,
|
|
113
|
+
sizeBytes: z.number().int().positive().optional(),
|
|
114
|
+
width: z.number().int().positive().optional(),
|
|
115
|
+
height: z.number().int().positive().optional(),
|
|
116
|
+
durationSeconds: z.number().positive().optional(),
|
|
117
|
+
})
|
|
118
|
+
.superRefine((asset, ctx) => {
|
|
119
|
+
const hasUpload = asset.uploadId !== undefined;
|
|
120
|
+
const hasUrl = asset.url !== undefined;
|
|
121
|
+
if (hasUpload === hasUrl) {
|
|
122
|
+
ctx.addIssue({
|
|
123
|
+
code: 'custom',
|
|
124
|
+
path: ['uploadId'],
|
|
125
|
+
message: 'an asset must reference exactly one of uploadId or url',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
export type AssetRef = z.infer<typeof AssetRefSchema>;
|
|
130
|
+
|
|
131
|
+
const mediaTypeMismatch = (
|
|
132
|
+
ctx: z.RefinementCtx,
|
|
133
|
+
mimeType: string,
|
|
134
|
+
allowedPrefixes: readonly string[],
|
|
135
|
+
): void => {
|
|
136
|
+
if (!allowedPrefixes.some((prefix) => mimeType.startsWith(prefix))) {
|
|
137
|
+
ctx.addIssue({
|
|
138
|
+
code: 'custom',
|
|
139
|
+
path: ['asset', 'mimeType'],
|
|
140
|
+
message: `must be one of ${allowedPrefixes.join(', ')}* for this resource type`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const resourceBaseShape = {
|
|
146
|
+
id: ResourceIdSchema,
|
|
147
|
+
role: ResourceRoleSchema,
|
|
148
|
+
/** §5.2: BCP 47 "where applicable" — a text of unknown language is real. */
|
|
149
|
+
language: LanguageTagSchema.optional(),
|
|
150
|
+
/** §5.2: the moment of the ORIGINAL object, not of the report. */
|
|
151
|
+
createdAt: TimestampSchema.optional(),
|
|
152
|
+
authorPrincipalRef: PrincipalRefSchema.optional(),
|
|
153
|
+
/** §5.2: a prior classification of exposure, never shown as a verdict. */
|
|
154
|
+
sensitivity: SensitivityHintSchema.optional(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `sha256` is required on every resource whose content travels inline, and
|
|
159
|
+
* optional on asset-backed ones where `asset.sha256` carries it instead.
|
|
160
|
+
*
|
|
161
|
+
* That split is not a preference; it is what both reference envelopes do.
|
|
162
|
+
* Appendix A's `res_post` and `res_parent` carry a top-level digest and its
|
|
163
|
+
* `res_image` carries the digest on the asset only. §5.6 asks for "SHA 256 of
|
|
164
|
+
* each resource", so one of the two is always present.
|
|
165
|
+
*/
|
|
166
|
+
const inlineResourceShape = { ...resourceBaseShape, sha256: Sha256DigestSchema };
|
|
167
|
+
const assetResourceShape = { ...resourceBaseShape, sha256: Sha256DigestSchema.optional() };
|
|
168
|
+
|
|
169
|
+
const TextResourceSchema = z.strictObject({
|
|
170
|
+
...inlineResourceShape,
|
|
171
|
+
type: z.literal('text'),
|
|
172
|
+
data: z.strictObject({
|
|
173
|
+
text: z.string().min(1).max(CONTRACT_LIMITS.TEXT_RESOURCE_MAX_LENGTH),
|
|
174
|
+
formatting: TextFormattingSchema.optional(),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const ImageResourceSchema = z
|
|
179
|
+
.strictObject({
|
|
180
|
+
...assetResourceShape,
|
|
181
|
+
type: z.literal('image'),
|
|
182
|
+
asset: AssetRefSchema,
|
|
183
|
+
})
|
|
184
|
+
.superRefine((resource, ctx) => mediaTypeMismatch(ctx, resource.asset.mimeType, ['image/']));
|
|
185
|
+
|
|
186
|
+
const VideoResourceSchema = z
|
|
187
|
+
.strictObject({
|
|
188
|
+
...assetResourceShape,
|
|
189
|
+
type: z.literal('video'),
|
|
190
|
+
asset: AssetRefSchema,
|
|
191
|
+
data: z
|
|
192
|
+
.strictObject({
|
|
193
|
+
timeRange: z
|
|
194
|
+
.strictObject({
|
|
195
|
+
startSeconds: z.number().nonnegative(),
|
|
196
|
+
endSeconds: z.number().positive(),
|
|
197
|
+
})
|
|
198
|
+
.refine((range) => range.endSeconds > range.startSeconds, {
|
|
199
|
+
message: 'endSeconds must be greater than startSeconds',
|
|
200
|
+
path: ['endSeconds'],
|
|
201
|
+
})
|
|
202
|
+
.optional(),
|
|
203
|
+
})
|
|
204
|
+
.optional(),
|
|
205
|
+
})
|
|
206
|
+
.superRefine((resource, ctx) => {
|
|
207
|
+
mediaTypeMismatch(ctx, resource.asset.mimeType, ['video/']);
|
|
208
|
+
if (resource.asset.durationSeconds === undefined) {
|
|
209
|
+
ctx.addIssue({
|
|
210
|
+
code: 'custom',
|
|
211
|
+
path: ['asset', 'durationSeconds'],
|
|
212
|
+
message: 'a video asset must declare durationSeconds',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const AudioResourceSchema = z
|
|
218
|
+
.strictObject({
|
|
219
|
+
...assetResourceShape,
|
|
220
|
+
type: z.literal('audio'),
|
|
221
|
+
asset: AssetRefSchema,
|
|
222
|
+
data: z
|
|
223
|
+
.strictObject({
|
|
224
|
+
transcript: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
|
|
225
|
+
})
|
|
226
|
+
.optional(),
|
|
227
|
+
})
|
|
228
|
+
.superRefine((resource, ctx) => {
|
|
229
|
+
mediaTypeMismatch(ctx, resource.asset.mimeType, ['audio/']);
|
|
230
|
+
if (resource.asset.durationSeconds === undefined) {
|
|
231
|
+
ctx.addIssue({
|
|
232
|
+
code: 'custom',
|
|
233
|
+
path: ['asset', 'durationSeconds'],
|
|
234
|
+
message: 'an audio asset must declare durationSeconds',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const DocumentResourceSchema = z
|
|
240
|
+
.strictObject({
|
|
241
|
+
...assetResourceShape,
|
|
242
|
+
type: z.literal('document'),
|
|
243
|
+
asset: AssetRefSchema,
|
|
244
|
+
data: z.strictObject({
|
|
245
|
+
title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
|
|
246
|
+
extractedText: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
|
|
247
|
+
}),
|
|
248
|
+
})
|
|
249
|
+
.superRefine((resource, ctx) =>
|
|
250
|
+
mediaTypeMismatch(ctx, resource.asset.mimeType, ['application/', 'text/']),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const LinkResourceSchema = z.strictObject({
|
|
254
|
+
...inlineResourceShape,
|
|
255
|
+
type: z.literal('link'),
|
|
256
|
+
data: z.strictObject({
|
|
257
|
+
url: HttpUrlSchema,
|
|
258
|
+
title: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
|
|
259
|
+
resolvedHost: z
|
|
260
|
+
.string()
|
|
261
|
+
.max(253)
|
|
262
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/, 'must be a hostname')
|
|
263
|
+
.optional(),
|
|
264
|
+
snapshot: z.string().max(CONTRACT_LIMITS.EXTRACTED_TEXT_MAX_LENGTH).optional(),
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const ProfileResourceSchema = z.strictObject({
|
|
269
|
+
...inlineResourceShape,
|
|
270
|
+
type: z.literal('profile'),
|
|
271
|
+
data: z.strictObject({
|
|
272
|
+
/**
|
|
273
|
+
* Every field is optional on purpose. A federated or unresolved actor
|
|
274
|
+
* routinely has no display name, and requiring one would push applications
|
|
275
|
+
* into synthesising a name — the opposite of what the profile-identity rule
|
|
276
|
+
* in the ecosystem instructions asks for.
|
|
277
|
+
*/
|
|
278
|
+
displayName: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
|
|
279
|
+
bio: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
|
|
280
|
+
/** An `image` resource in the same envelope. */
|
|
281
|
+
avatarRef: ResourceIdSchema.optional(),
|
|
282
|
+
claims: MetadataBagSchema.optional(),
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const ConversationResourceSchema = z.strictObject({
|
|
287
|
+
...inlineResourceShape,
|
|
288
|
+
type: z.literal('conversation'),
|
|
289
|
+
data: z.strictObject({
|
|
290
|
+
/**
|
|
291
|
+
* Ordered (§5.3). §13.5 asks for five messages around the incident rather
|
|
292
|
+
* than the whole thread, which is why this is a bounded list of ids the
|
|
293
|
+
* application chose, not a pointer to a conversation.
|
|
294
|
+
*/
|
|
295
|
+
messageResourceIds: z
|
|
296
|
+
.array(ResourceIdSchema)
|
|
297
|
+
.min(1)
|
|
298
|
+
.max(CONTRACT_LIMITS.CONVERSATION_MESSAGES_MAX),
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const ListingResourceSchema = z
|
|
303
|
+
.strictObject({
|
|
304
|
+
...inlineResourceShape,
|
|
305
|
+
type: z.literal('listing'),
|
|
306
|
+
data: z.strictObject({
|
|
307
|
+
title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
|
|
308
|
+
description: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
|
|
309
|
+
price: z.number().finite().nonnegative().optional(),
|
|
310
|
+
currency: z
|
|
311
|
+
.string()
|
|
312
|
+
.regex(/^[A-Z]{3}$/, 'must be an ISO 4217 alphabetic code')
|
|
313
|
+
.optional(),
|
|
314
|
+
sellerRef: PrincipalRefSchema.optional(),
|
|
315
|
+
mediaRefs: z.array(ResourceIdSchema).max(CONTRACT_LIMITS.LISTING_MEDIA_REFS_MAX).optional(),
|
|
316
|
+
}),
|
|
317
|
+
})
|
|
318
|
+
.superRefine((resource, ctx) => {
|
|
319
|
+
const hasPrice = resource.data.price !== undefined;
|
|
320
|
+
const hasCurrency = resource.data.currency !== undefined;
|
|
321
|
+
if (hasPrice !== hasCurrency) {
|
|
322
|
+
ctx.addIssue({
|
|
323
|
+
code: 'custom',
|
|
324
|
+
path: ['data', hasPrice ? 'currency' : 'price'],
|
|
325
|
+
message: 'price and currency must travel together',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/** Two decimal places ≈ 1.1 km — coarse enough not to locate a person. */
|
|
331
|
+
const COARSE_COORDINATE_DECIMALS = 2;
|
|
332
|
+
|
|
333
|
+
const isCoarse = (value: number): boolean =>
|
|
334
|
+
Number.isInteger(value * 10 ** COARSE_COORDINATE_DECIMALS);
|
|
335
|
+
|
|
336
|
+
const LocationResourceSchema = z
|
|
337
|
+
.strictObject({
|
|
338
|
+
...inlineResourceShape,
|
|
339
|
+
type: z.literal('location'),
|
|
340
|
+
data: z.strictObject({
|
|
341
|
+
label: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
|
|
342
|
+
latitude: z.number().min(-90).max(90).optional(),
|
|
343
|
+
longitude: z.number().min(-180).max(180).optional(),
|
|
344
|
+
}),
|
|
345
|
+
})
|
|
346
|
+
.superRefine((resource, ctx) => {
|
|
347
|
+
const { label, latitude, longitude } = resource.data;
|
|
348
|
+
if (label === undefined && latitude === undefined && longitude === undefined) {
|
|
349
|
+
ctx.addIssue({
|
|
350
|
+
code: 'custom',
|
|
351
|
+
path: ['data'],
|
|
352
|
+
message: 'a location must carry a label, coordinates, or both',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if ((latitude === undefined) !== (longitude === undefined)) {
|
|
356
|
+
ctx.addIssue({
|
|
357
|
+
code: 'custom',
|
|
358
|
+
path: ['data', latitude === undefined ? 'latitude' : 'longitude'],
|
|
359
|
+
message: 'latitude and longitude must travel together',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* §5.3 says "coarse location or place label" and §13.5 requires precise
|
|
364
|
+
* location to be redacted before community review. Precision is a property
|
|
365
|
+
* of the number itself, so the contract can refuse the precise value rather
|
|
366
|
+
* than trusting every downstream renderer to round it.
|
|
367
|
+
*/
|
|
368
|
+
if (latitude !== undefined && !isCoarse(latitude)) {
|
|
369
|
+
ctx.addIssue({
|
|
370
|
+
code: 'custom',
|
|
371
|
+
path: ['data', 'latitude'],
|
|
372
|
+
message: `must be coarse: at most ${COARSE_COORDINATE_DECIMALS} decimal places`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (longitude !== undefined && !isCoarse(longitude)) {
|
|
376
|
+
ctx.addIssue({
|
|
377
|
+
code: 'custom',
|
|
378
|
+
path: ['data', 'longitude'],
|
|
379
|
+
message: `must be coarse: at most ${COARSE_COORDINATE_DECIMALS} decimal places`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const MetadataResourceSchema = z.strictObject({
|
|
385
|
+
...inlineResourceShape,
|
|
386
|
+
type: z.literal('metadata'),
|
|
387
|
+
data: MetadataBagSchema.refine((bag) => Object.keys(bag).length >= 1, {
|
|
388
|
+
message: 'a metadata resource must carry at least one field',
|
|
389
|
+
}),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const CustomResourceSchema = z.strictObject({
|
|
393
|
+
...inlineResourceShape,
|
|
394
|
+
type: z.literal('custom'),
|
|
395
|
+
/** The schema the application registered for this shape (§5.7). */
|
|
396
|
+
schemaId: IdentifierSchema,
|
|
397
|
+
/**
|
|
398
|
+
* Validated twice: structurally here, then against the registered JSON Schema
|
|
399
|
+
* at ingress. The registered schema decides what the fields MEAN; this decides
|
|
400
|
+
* that they are fields at all.
|
|
401
|
+
*/
|
|
402
|
+
payload: CustomPayloadSchema,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* One resource (§5.2).
|
|
407
|
+
*
|
|
408
|
+
* A discriminated union rather than one object with everything optional: the
|
|
409
|
+
* latter would accept an `image` with inline text and no asset, which is not a
|
|
410
|
+
* resource anybody can review.
|
|
411
|
+
*/
|
|
412
|
+
export const ResourceSchema = z.discriminatedUnion('type', [
|
|
413
|
+
TextResourceSchema,
|
|
414
|
+
ImageResourceSchema,
|
|
415
|
+
VideoResourceSchema,
|
|
416
|
+
AudioResourceSchema,
|
|
417
|
+
DocumentResourceSchema,
|
|
418
|
+
LinkResourceSchema,
|
|
419
|
+
ProfileResourceSchema,
|
|
420
|
+
ConversationResourceSchema,
|
|
421
|
+
ListingResourceSchema,
|
|
422
|
+
LocationResourceSchema,
|
|
423
|
+
MetadataResourceSchema,
|
|
424
|
+
CustomResourceSchema,
|
|
425
|
+
]);
|
|
426
|
+
export type Resource = z.infer<typeof ResourceSchema>;
|
|
427
|
+
|
|
428
|
+
/** §5.5 relation types. */
|
|
429
|
+
export const RELATION_TYPES = [
|
|
430
|
+
'has_attachment',
|
|
431
|
+
'replies_to',
|
|
432
|
+
'quotes',
|
|
433
|
+
'authored_by',
|
|
434
|
+
'contextualizes',
|
|
435
|
+
'previous_version',
|
|
436
|
+
'contains',
|
|
437
|
+
'refers_to',
|
|
438
|
+
] as const;
|
|
439
|
+
export const RelationTypeSchema = z.enum(RELATION_TYPES);
|
|
440
|
+
export type RelationType = z.infer<typeof RelationTypeSchema>;
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Relation types whose `to` names a principal rather than a resource.
|
|
444
|
+
*
|
|
445
|
+
* §5.5 defines `authored_by` as "a resource was created by a principal", so its
|
|
446
|
+
* target is a `principalRef`. Every other relation in the table joins two
|
|
447
|
+
* resources. No example in the plan exercises `authored_by`, so this reading is
|
|
448
|
+
* recorded here rather than assumed: it is what makes §5.5's "the backend must
|
|
449
|
+
* validate that every referenced id exists in the envelope" checkable at all,
|
|
450
|
+
* since the two id spaces are separate.
|
|
451
|
+
*/
|
|
452
|
+
export const PRINCIPAL_TARGETED_RELATION_TYPES = ['authored_by'] as const;
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A relation (§5.5).
|
|
456
|
+
*
|
|
457
|
+
* `from` and `to` are only shape-checked here. Whether they RESOLVE is decided
|
|
458
|
+
* by `CaseEnvelopeSchema`, which is the smallest scope that can see both the
|
|
459
|
+
* resource list and the principal list.
|
|
460
|
+
*/
|
|
461
|
+
export const RelationSchema = z.strictObject({
|
|
462
|
+
from: ResourceIdSchema,
|
|
463
|
+
type: RelationTypeSchema,
|
|
464
|
+
to: IdentifierSchema,
|
|
465
|
+
});
|
|
466
|
+
export type Relation = z.infer<typeof RelationSchema>;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A custom resource schema an application registers (§5.7).
|
|
470
|
+
*
|
|
471
|
+
* `jsonSchema` reuses the custom-payload grammar, which forbids `$`-prefixed
|
|
472
|
+
* keys. That single restriction removes `$ref`, `$dynamicRef`, `$recursiveRef`
|
|
473
|
+
* and `$id` in one move — every mechanism by which a registered schema could
|
|
474
|
+
* point at a document CrowdSource would then have to fetch. §5.7's "no remote
|
|
475
|
+
* components" is not a rendering rule alone; a remote `$ref` is also an SSRF
|
|
476
|
+
* with a tenant holding the pen.
|
|
477
|
+
*
|
|
478
|
+
* The cost is `$defs`, `$schema` and `$comment`, which flat custom-field
|
|
479
|
+
* schemas do not need. Recursive tenant schemas are not supported and are not
|
|
480
|
+
* meant to be.
|
|
481
|
+
*/
|
|
482
|
+
export const ResourceSchemaRegistrationSchema = z.strictObject({
|
|
483
|
+
schemaId: IdentifierSchema,
|
|
484
|
+
version: PolicyVersionSchema,
|
|
485
|
+
title: z.string().min(1).max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH),
|
|
486
|
+
description: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
|
|
487
|
+
jsonSchema: CustomPayloadSchema,
|
|
488
|
+
});
|
|
489
|
+
export type ResourceSchemaRegistration = z.infer<typeof ResourceSchemaRegistrationSchema>;
|
package/src/reviews.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one reviewer submits (§9.2, §9.3) and how they step away (§4.1).
|
|
3
|
+
*
|
|
4
|
+
* §9.2 splits the form in two — describe the material, then evaluate it against
|
|
5
|
+
* a rule — to reduce anchoring on the reporter's category and to let the same
|
|
6
|
+
* description be reused under different policies. §9.3 then gives one flat
|
|
7
|
+
* result object. Both are true at once: a `ReviewFinding` carries the step-one
|
|
8
|
+
* fields (`code`, `resourceIds`, `severity`, `confidence`) and the step-two
|
|
9
|
+
* fields (`policyRuleIds`), and the enclosing submission carries the step-two
|
|
10
|
+
* verdict (`outcome`, `recommendedActions`). The split is a property of the
|
|
11
|
+
* interface, not of the payload, so it is documented here rather than nested.
|
|
12
|
+
*
|
|
13
|
+
* The submission is `.strict()`, and that is a safety decision, not tidiness.
|
|
14
|
+
* A review belongs to an assignment the server issued; the assignment id comes
|
|
15
|
+
* from the route, the case and the reviewer come from the assignment. If this
|
|
16
|
+
* object tolerated a `caseId`, an `assignmentId` or a `reviewerId`, the day
|
|
17
|
+
* somebody read one of them would be the day a reviewer could vote on a case
|
|
18
|
+
* they were never drawn for — against "nobody chooses the case they review".
|
|
19
|
+
* Strict makes that field impossible rather than merely unused.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
|
|
24
|
+
import { CONTRACT_LIMITS, UnitIntervalSchema } from './primitives';
|
|
25
|
+
import { PolicyRuleIdSchema } from './policies';
|
|
26
|
+
import { ResourceIdSchema } from './resources';
|
|
27
|
+
import {
|
|
28
|
+
FindingContextSchema,
|
|
29
|
+
RecommendedActionSchema,
|
|
30
|
+
SeveritySchema,
|
|
31
|
+
TaxonomyCodeSchema,
|
|
32
|
+
} from './taxonomy';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a single reviewer can conclude.
|
|
36
|
+
*
|
|
37
|
+
* Narrower than §9.6's decision outcomes on purpose. `inconclusive` is what the
|
|
38
|
+
* consensus engine reports when a panel does not agree — a single reviewer
|
|
39
|
+
* cannot fail to agree with themselves, and "the absence of consensus is
|
|
40
|
+
* neither guilt nor innocence" only holds if `inconclusive` is produced by the
|
|
41
|
+
* engine and never voted for. `duplicate`, `escalated` and `superseded` are
|
|
42
|
+
* likewise case-level states, not opinions about material. The plan never
|
|
43
|
+
* enumerates review outcomes; this is that gap resolved toward the invariant.
|
|
44
|
+
*/
|
|
45
|
+
export const REVIEW_OUTCOMES = [
|
|
46
|
+
'violation',
|
|
47
|
+
'no_violation',
|
|
48
|
+
'insufficient_context',
|
|
49
|
+
'content_unavailable',
|
|
50
|
+
] as const;
|
|
51
|
+
export const ReviewOutcomeSchema = z.enum(REVIEW_OUTCOMES);
|
|
52
|
+
export type ReviewOutcome = z.infer<typeof ReviewOutcomeSchema>;
|
|
53
|
+
|
|
54
|
+
/** §9.5 feeds this into `contextFactor`: 1.0 if sufficient, 0.5 otherwise. */
|
|
55
|
+
export const CONTEXT_SUFFICIENCIES = ['sufficient', 'insufficient'] as const;
|
|
56
|
+
export const ContextSufficiencySchema = z.enum(CONTEXT_SUFFICIENCIES);
|
|
57
|
+
export type ContextSufficiency = z.infer<typeof ContextSufficiencySchema>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One structured finding from one reviewer (§9.3).
|
|
61
|
+
*
|
|
62
|
+
* `resourceIds` is required and non-empty: §9.4 makes the affected resource one
|
|
63
|
+
* of the dimensions consensus is measured on, so a finding that does not say
|
|
64
|
+
* what it is about cannot be agreed with or disagreed with.
|
|
65
|
+
*
|
|
66
|
+
* `policyRuleIds` is optional: step one of the form can complete without step
|
|
67
|
+
* two — a reviewer may classify material accurately and find no rule that
|
|
68
|
+
* covers it, which is exactly the `no_violation`-with-findings case §6.2
|
|
69
|
+
* describes.
|
|
70
|
+
*
|
|
71
|
+
* `context` is §9.2's and §9.4's "excepción", written as §6.2 writes it: beside
|
|
72
|
+
* the code and the severity, because "artistic nudity" is a different
|
|
73
|
+
* description of the material from "nudity" rather than a different verdict
|
|
74
|
+
* about it. Optional, and absence means no exception applies.
|
|
75
|
+
*
|
|
76
|
+
* `confidence` communicates quality and triggers escalation (§9.5). It never
|
|
77
|
+
* weights the vote.
|
|
78
|
+
*/
|
|
79
|
+
export const ReviewFindingSchema = z.strictObject({
|
|
80
|
+
code: TaxonomyCodeSchema,
|
|
81
|
+
resourceIds: z.array(ResourceIdSchema).min(1).max(CONTRACT_LIMITS.RESOURCE_REFS_PER_FINDING_MAX),
|
|
82
|
+
severity: SeveritySchema,
|
|
83
|
+
context: FindingContextSchema.optional(),
|
|
84
|
+
confidence: UnitIntervalSchema,
|
|
85
|
+
policyRuleIds: z.array(PolicyRuleIdSchema).max(CONTRACT_LIMITS.POLICY_RULE_IDS_MAX).optional(),
|
|
86
|
+
});
|
|
87
|
+
export type ReviewFinding = z.infer<typeof ReviewFindingSchema>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The body of `POST /v1/reviewer/assignments/{id}/reviews` — §9.3's "result of
|
|
91
|
+
* a review".
|
|
92
|
+
*
|
|
93
|
+
* `recommendedActions` is a list of action tokens here, where a decision's
|
|
94
|
+
* `recommendedActions` are objects that name their target resources. That
|
|
95
|
+
* difference is in the plan (§9.3 versus Appendix B) and is kept: a reviewer
|
|
96
|
+
* recommends a course of action, and the consensus process is what binds an
|
|
97
|
+
* agreed recommendation to the specific resources it applies to.
|
|
98
|
+
*/
|
|
99
|
+
export const ReviewSubmissionSchema = z
|
|
100
|
+
.strictObject({
|
|
101
|
+
outcome: ReviewOutcomeSchema,
|
|
102
|
+
contextSufficiency: ContextSufficiencySchema,
|
|
103
|
+
findings: z.array(ReviewFindingSchema).max(CONTRACT_LIMITS.FINDINGS_MAX),
|
|
104
|
+
recommendedActions: z
|
|
105
|
+
.array(RecommendedActionSchema)
|
|
106
|
+
.max(CONTRACT_LIMITS.RECOMMENDED_ACTIONS_MAX),
|
|
107
|
+
/**
|
|
108
|
+
* §13.5 and the "sensitive content never reaches logs" invariant apply to
|
|
109
|
+
* this field as much as to the evidence: a note is free text a human wrote
|
|
110
|
+
* while looking at the material, so it is bounded here and must be treated
|
|
111
|
+
* as case content everywhere downstream — never logged, never attested.
|
|
112
|
+
*/
|
|
113
|
+
notes: z.string().max(CONTRACT_LIMITS.LONG_TEXT_MAX_LENGTH).optional(),
|
|
114
|
+
})
|
|
115
|
+
.superRefine((review, ctx) => {
|
|
116
|
+
if (review.outcome === 'insufficient_context' && review.contextSufficiency !== 'insufficient') {
|
|
117
|
+
ctx.addIssue({
|
|
118
|
+
code: 'custom',
|
|
119
|
+
path: ['contextSufficiency'],
|
|
120
|
+
message: 'an outcome of insufficient_context requires contextSufficiency "insufficient"',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (review.outcome === 'violation' && review.findings.length === 0) {
|
|
124
|
+
ctx.addIssue({
|
|
125
|
+
code: 'custom',
|
|
126
|
+
path: ['findings'],
|
|
127
|
+
message: 'a violation outcome requires at least one finding',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
export type ReviewSubmission = z.infer<typeof ReviewSubmissionSchema>;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* §4.1: a reviewer may declare a conflict, a language they do not have,
|
|
135
|
+
* material too sensitive for them, or context they cannot obtain — "without
|
|
136
|
+
* penalty". These four are the plan's own list, made structured as §10.3 asks.
|
|
137
|
+
*/
|
|
138
|
+
export const RECUSAL_REASONS = [
|
|
139
|
+
'conflict_of_interest',
|
|
140
|
+
'language',
|
|
141
|
+
'too_sensitive',
|
|
142
|
+
'insufficient_context',
|
|
143
|
+
] as const;
|
|
144
|
+
export const RecusalReasonSchema = z.enum(RECUSAL_REASONS);
|
|
145
|
+
export type RecusalReason = z.infer<typeof RecusalReasonSchema>;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The body of `POST /v1/reviewer/assignments/{id}/recuse`.
|
|
149
|
+
*
|
|
150
|
+
* Strict for the same reason as the submission, and with no free-text field
|
|
151
|
+
* beyond a short optional note: a recusal is routing information, and the more
|
|
152
|
+
* it can say about the case, the more it becomes a channel for case content to
|
|
153
|
+
* leak into operational data.
|
|
154
|
+
*/
|
|
155
|
+
export const RecusalSubmissionSchema = z.strictObject({
|
|
156
|
+
reason: RecusalReasonSchema,
|
|
157
|
+
note: z.string().max(CONTRACT_LIMITS.SHORT_TEXT_MAX_LENGTH).optional(),
|
|
158
|
+
});
|
|
159
|
+
export type RecusalSubmission = z.infer<typeof RecusalSubmissionSchema>;
|