Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 422 lines 15 kB view raw
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}