Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1/* eslint-disable import/no-restricted-paths */
2// Normally, code outside the graphql folder (which should only hold
3// GQL-specific code) shouldn't import from the GQL folder. However, the
4// IntegrationApi, OrgApi, and UserApi are currently implemented inside the GQL
5// folder (even though the logic in them really ought to be in a
6// transport-agnostic service in the services folder), so, for now, this file
7// has to import just those files from the graphql folder.
8import { type IntegrationErrorType } from '../graphql/datasources/IntegrationApi.js';
9import { type OrgErrorType } from '../graphql/datasources/OrgApi.js';
10import {
11 type SignUpErrorType,
12 type UserErrorType,
13} from '../graphql/datasources/UserApi.js';
14/* eslint-enable import/no-restricted-paths */
15import { type ManualReviewToolServiceErrorType } from '../services/manualReviewToolService/index.js';
16import { type ModerationConfigErrorType } from '../services/moderationConfigService/index.js';
17import { type PartialItemsServiceErrorType } from '../services/partialItemsService/index.js';
18import { type ReportingServiceErrorType } from '../services/reportingService/index.js';
19import { filterNullOrUndefined } from './collections.js';
20import { safePick } from './misc.js';
21
22// Keep a master list of our error types, for convenience. These are types
23// for our exposed errors, which are sent in both REST and GraphQL responses.
24// The types are root-relative URLs usable by clients for classifying this error.
25// This list is gonna get huge, but we'll figure out how to break it up later;
26// for now, we'll group some errors by model, and put generic ones at the end.
27export enum ErrorType {
28 // Content Type/Content Submission Errors
29 UnrecognizedContentType = '/errors/unrecognized-content-type',
30 ContentInvalidForContentType = '/errors/content-invalid-for-content-type',
31
32 // To replace the above once we migrate the submission endpoint
33 DataInvalidForItemType = '/errors/data-invalid-for-item-type',
34 FieldRolesInvalidForItemType = '/errors/field-roles-invalid-for-item-type',
35 AttemptingToDeleteDefaultUserType = 'errors/attempting-to-delete-default-user-type',
36
37 // Rule + Rule evaluation Errors
38 AttemptingToMutateActiveRule = '/errors/attempting-to-mutate-active-rule',
39 InvalidMatchingValues = '/errors/invalid-matching-values',
40 PermanentSignalError = '/errors/permanent-signal-error',
41
42 // Signing Key Pair Errors
43 SigningKeyPairAlreadyExists = '/errors/signing-key-pair-already-exists',
44 SigningKeyPairNotFound = '/errors/signing-key-pair-not-found',
45
46 NotFound = '/errors/not-found',
47 InvalidUserInput = '/errors/invalid-user-input',
48
49 // Conflict is for any time the resource is in a state that makes it unable to
50 // process the request. UniqueViolation and ConcurrencyConflict are specific
51 // types of conflicts.
52 Conflict = '/errors/conflict',
53 UniqueViolation = '/errors/unique-violation',
54 ConcurrencyConflict = '/errors/concurrent-update-conflict',
55
56 InternalServerError = '/errors/internal-server-error',
57
58 // authz
59 Unauthenticated = '/errors/authentication-failed-or-missing',
60 Unauthorized = '/errors/authorization-failed',
61}
62
63// A key that can be added to any SerializableError error object to indicate
64// that the error is safe to send to the client as-is (i.e., that it doesn't
65// contain any sensitive implementation info that needs to be removed first for
66// security). CoopError instances are marked as safe by default (since their
67// message is populated manually, rather than coming from any libraries we're calling.)
68const safeErrorKey = Symbol();
69
70export type SafeErrorKey = typeof safeErrorKey;
71
72// Derived from https://jsonapi.org/format/1.1/#error-objects, with some changes.
73// Provides a uniform set of properties for all the errors we serialize, whether
74// via GraphQL or traditional HTTP.
75export type SerializableError = {
76 status: number;
77 type: ErrorType[];
78 // A short, human-readable summary of the problem that SHOULD NOT change
79 // from occurrence to occurrence of the problem, except for localization.
80 title: string;
81
82 // A pointer to the input data that is the primary source of the problem.
83 // This is a JSON pointer. On HTTP requests, it's a pointer into the request
84 // body. For GraphQL, it's a pointer into an object formed by wrapping up all
85 // the arguments (keyed by argument name). For example, if we have
86 // `input CreateXInput { name: String! }`, and a mutation like:
87 // `createX(input: CreateXInput!): X`, and the name is the issue, then the
88 // pointer would be `/input/name`.
89 pointer?: string;
90
91 // A human-readable explanation specific to this occurrence of the problem.
92 detail?: string;
93 // The id of the request that caused the error. It might be useful to surface
94 // this to users, so they can tell it to us if we're helping them debug a
95 // failure.
96 requestId?: string;
97
98 [safeErrorKey]?: true;
99};
100
101// The props that vary per error instance (from HttpError + cause).
102export type ErrorInstanceData = Omit<
103 SerializableError,
104 'status' | 'title' | 'type'
105> & {
106 cause?: unknown;
107 type?: ErrorType[];
108 shouldErrorSpan: boolean;
109};
110
111/**
112 * A class that represents errors that have a standard set of fields and that
113 * are considered safe to expose to end users (i.e., because their fields don't
114 * contain implementation details that we'd like to keep private).
115 *
116 * This class is called CoopError because it should be used for errors
117 * created directly from our code, which we know can be safely serialized,
118 * unlike errors thrown by our dependencies/third-party libraries, which could
119 * have secret details in them.
120 *
121 * NB: DO NOT SUBCLASS THIS. Instead, create a function that returns a
122 * CoopError instance, with a more specific `name` property (if needed).
123 * The reason for this is that our sanitizeError must be able to produce a
124 * sanitized error that has the `cause` field removed (or, in the future,
125 * possibly recursively sanitized instead). To remove `cause`, without mutating
126 * the error in place, `sanitizeError` needs to clone the original error. If the
127 * error were a CoopError subclass, then there'd be no generic way to clone
128 * it that wouldn't break the prototype chain (i.e., the clone would no longer
129 * pass as an `instanceof CoopErrorSubclass` check). A simple clone approach
130 * w/ object spread would lose even the Error/CoopError parts of the chain,
131 * while a `CoopError.clone` method in the base class still wouldn't work,
132 * as it wouldn't know the signature of the child class's constructor, so it
133 * couldn't construct a subclass instance (plus, even if we defined a signature
134 * as part of the informal contract, TS wouldn't type check that). Therefore,
135 * we'd have to define a `clone` method in each child class, which ends up with
136 * _a lot_ of boilerplate, and some potential bugs.
137 *
138 * By not extending CoopError, and instead representing the "subclass" as
139 * just a `name` field (using a string union literal type to prevent typos,
140 * because NAME IS SERIALIZED/PUBLIC), we can avoid all this nonsense about
141 * preserving class identity (and the risk of a subclass overriding some
142 * parent-class-relevant behavior), which is a perfect example of why dynamic
143 * languages w/ structural typing of dictionary-like plain data can be so nice.
144 * Unfortunately, we can't take this structural approach all the way to its
145 * logical conclusion, because we do want to have our errors ultimately be
146 * instances of the built-in `Error` class, and having one class level below
147 * that lets us enforce some nice things (e.g., the `new.target` check below.)
148 * But, besides that, we want no subclassing.
149 */
150export class CoopError<Name extends CoopErrorName = CoopErrorName>
151 extends Error
152 implements SerializableError
153{
154 public readonly status: number;
155 public readonly type: ErrorType[];
156 public readonly title: string;
157 public override readonly name: Name;
158 public readonly [safeErrorKey] = true;
159 // This is used to indicate whether or not we want this error to be considered
160 // an error in the corresponding OTel span generated by SafeTracer.
161 public readonly shouldErrorSpan: boolean;
162
163 public readonly pointer?: string = undefined;
164 public readonly detail?: string = undefined;
165 public readonly requestId?: string = undefined;
166
167 constructor(
168 data: SerializableError & {
169 cause?: unknown;
170 name: Name;
171 shouldErrorSpan: boolean;
172 },
173 ) {
174 if (new.target !== CoopError) {
175 throw new Error(
176 'Cannot subclass CoopError. See comment above this class.',
177 );
178 }
179
180 const {
181 cause,
182 title,
183 status,
184 type,
185 detail,
186 name,
187 shouldErrorSpan,
188 ...errDataRest
189 } = data;
190
191 super(title + (detail ? ` ${detail}` : ''), { cause });
192
193 this.status = status;
194 this.type = type;
195 this.title = title;
196 this.name = name;
197 this.detail = detail;
198 this.shouldErrorSpan = shouldErrorSpan;
199
200 Object.assign(this, errDataRest);
201 Error.captureStackTrace(this, CoopError);
202 }
203
204 clone() {
205 return this.cloneWith({});
206 }
207
208 cloneWith(overrides: Partial<ErrorInstanceData>): CoopError<Name> {
209 return new CoopError({
210 ...this,
211 type: [...this.type],
212 // copy `cause` explicitly since it's not enumerable
213 // (i.e., won't be picked up by `...this`)
214 cause: this.cause,
215 ...overrides,
216 });
217 }
218
219 /**
220 * Customize the JSON serialization, mostly to exclude `name` (which hasn't
221 * been returned historically in REST responses [though GQL has exposed it]
222 * and needn't be part of that contract -- that's what type is for), but also
223 * to hide `cause` and any other properties that might be added unintentionally.
224 */
225 toJSON(): SerializableError {
226 return safePick(this, [
227 'status',
228 'type',
229 'title',
230 'pointer',
231 'detail',
232 'requestId',
233 ]);
234 }
235}
236
237// List of all our coop errors.
238//
239// NB: these names are serialized in GQL as the __typename, and in HTTP
240// responses, so DON'T CHANGE THEM lightly.
241//
242// TODO: figure out some system for when to add a CoopErrorName vs. a new
243// ErrorType value.
244export type CoopErrorName =
245 // fallback/default name
246 | 'CoopError'
247 // rule engine errors
248 | 'SignalPermanentError'
249 | 'DerivedFieldPermanentError'
250 // signing key errors
251 | 'SigningKeyPairAlreadyExists'
252 | 'SigningKeyPairNotFound'
253 // errors from different services
254 | PartialItemsServiceErrorType
255 | ModerationConfigErrorType
256 | ManualReviewToolServiceErrorType
257 | ReportingServiceErrorType
258 // generic errors
259 | 'NotFoundError'
260 | 'InternalServerError'
261 | 'BadRequestError'
262 | 'UnauthorizedError'
263 // gql mutation errors
264 | UserErrorType
265 | IntegrationErrorType
266 | OrgErrorType
267 | SignUpErrorType;
268
269export function isCoopError(it: unknown): it is CoopError {
270 return it instanceof CoopError;
271}
272
273export function isCoopErrorOfType<T extends CoopErrorName>(
274 it: unknown,
275 nameOrNames: T | T[],
276): it is CoopError<T> {
277 return (
278 isCoopError(it) &&
279 (Array.isArray(nameOrNames)
280 ? (nameOrNames satisfies T[] as CoopErrorName[]).includes(it.name)
281 : nameOrNames === it.name)
282 );
283}
284
285// Some generic CoopErrors
286export const makeSignalPermanentError = (
287 title: string,
288 data: ErrorInstanceData,
289) =>
290 new CoopError({
291 ...data,
292 status: 500,
293 type: [...(data.type ?? []), ErrorType.PermanentSignalError],
294 title,
295 name: 'SignalPermanentError' as const,
296 });
297
298export const makeDerivedFieldPermanentError = (
299 title: string,
300 data: ErrorInstanceData,
301) =>
302 new CoopError({
303 ...data,
304 status: 500,
305 type: [...(data.type ?? []), ErrorType.InternalServerError],
306 title,
307 name: 'DerivedFieldPermanentError',
308 });
309
310export const makeNotFoundError = (title: string, data: ErrorInstanceData) =>
311 new CoopError({
312 ...data,
313 status: 404,
314 type: [...(data.type ?? []), ErrorType.NotFound],
315 title,
316 name: 'NotFoundError',
317 });
318
319export const makeUnauthorizedError = (title: string, data: ErrorInstanceData) =>
320 new CoopError({
321 ...data,
322 status: 403,
323 type: [...(data.type ?? []), ErrorType.Unauthorized],
324 title,
325 name: 'UnauthorizedError',
326 });
327
328export const makeInternalServerError = (
329 title: string,
330 data: ErrorInstanceData,
331) =>
332 new CoopError({
333 ...data,
334 status: 500,
335 type: [...(data.type ?? []), ErrorType.InternalServerError],
336 title,
337 name: 'InternalServerError',
338 });
339
340export const makeBadRequestError = (title: string, data: ErrorInstanceData) =>
341 new CoopError({
342 ...data,
343 status: 400,
344 type: [...(data.type ?? []), ErrorType.InvalidUserInput],
345 title,
346 name: 'BadRequestError',
347 });
348
349const exposeUnsafeErrorDetails =
350 process.env.EXPOSE_SENSITIVE_IMPLEMENTATION_DETAILS_IN_ERRORS === 'true';
351
352export const sanitizeError = exposeUnsafeErrorDetails
353 ? // In local dev, when exposeUnsafeErrorDetails is true, sanitizeError
354 // includes the full error as-is in its result, and just does a minimal
355 // transformation, adding some props to satisfy the SerializableError type.
356 (err: unknown): SerializableError =>
357 typeof err !== 'object'
358 ? { title: String(err), status: 500, type: [] }
359 : err instanceof CoopError
360 ? err
361 : { title: String(err), status: 500, type: [], ...err }
362 : (err: unknown) => {
363 // eslint-disable-next-line no-console
364 console.error('Sanitizing error:', err);
365 if (isSafeError(err)) {
366 if ((err satisfies object as { cause?: unknown }).cause == null) {
367 return err;
368 } else {
369 const clone = err instanceof CoopError ? err.clone() : { ...err };
370 delete (clone satisfies object as { cause?: unknown }).cause;
371 return clone;
372 }
373 } else {
374 // eslint-disable-next-line no-console
375 console.error('Unknown error:', err);
376 return makeInternalServerError('Unknown error', {
377 shouldErrorSpan: true,
378 });
379 }
380 };
381
382function isSerializableError(it: unknown): it is SerializableError {
383 return Boolean(
384 typeof it === 'object' &&
385 it &&
386 'status' in it &&
387 'type' in it &&
388 'title' in it,
389 );
390}
391
392function isSafeError(
393 it: unknown,
394): it is SerializableError & { [safeErrorKey]: true } {
395 return isSerializableError(it) && Boolean(it[safeErrorKey]);
396}
397
398export function getMessageFromAggregateError(it: AggregateError): string {
399 return filterNullOrUndefined(
400 it.errors.map((it) =>
401 it instanceof AggregateError
402 ? getMessageFromAggregateError(it)
403 : it instanceof CoopError
404 ? it.title + (it.detail ? `: ${it.detail}` : '')
405 : it instanceof Error
406 ? it.message
407 : undefined,
408 ),
409 ).join('\n');
410}
411
412export function getErrorsFromAggregateError(
413 it: AggregateError,
414): (Error | CoopError)[] {
415 return it.errors.flatMap((it) =>
416 it instanceof AggregateError
417 ? getErrorsFromAggregateError(it)
418 : it instanceof Error
419 ? [it]
420 : [],
421 );
422}