From f1bffee8b982944eb20b9e3f9a4a4aed631b0e96 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 24 Sep 2024 19:42:10 -0700 Subject: [PATCH] userIdOrMe support on all yup validations --- .../src/route-handlers/crud-handler.tsx | 18 +++--- .../src/route-handlers/smart-request.tsx | 36 ++---------- .../route-handlers/smart-route-handler.tsx | 2 +- packages/stack-shared/src/schema-fields.ts | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 42 deletions(-) diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index 503cbf749..f2569a7b9 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -9,7 +9,7 @@ import { deindent, typedToLowercase } from "@stackframe/stack-shared/dist/utils/ import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { SmartRequestAuth } from "./smart-request"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; type GetAdminKey, K extends Capitalize> = K extends keyof T["Admin"] ? T["Admin"][K] : void; @@ -155,9 +155,8 @@ export function createCrudHandlers< adminSchemas, invoke: async (options: { params: yup.InferType | Partial>, query: yup.InferType, data: any, auth: SmartRequestAuth }) => { const actualParamsSchema = typedIncludes(["List", "Create"], crudOperation) ? paramsSchema.partial() : paramsSchema; - const paramsValidated = await validate(options.params, actualParamsSchema, "Params validation"); - - const adminData = await validate(options.data, adminSchemas.input, "Input validation"); + const paramsValidated = await validate(options.params, actualParamsSchema, options.auth.user ?? null, "Params validation"); + const adminData = await validate(options.data, adminSchemas.input, options.auth.user ?? null, "Input validation"); await optionsAsPartial.onPrepare?.({ params: paramsValidated, @@ -172,8 +171,8 @@ export function createCrudHandlers< query: options.query, }); - const resultAdminValidated = await validate(result, adminSchemas.output, "Result admin validation"); - const resultAccessValidated = await validate(resultAdminValidated, accessSchemas.output, `Result ${accessType} validation`); + const resultAdminValidated = await validate(result, adminSchemas.output, options.auth.user ?? null, "Result admin validation"); + const resultAccessValidated = await validate(resultAdminValidated, accessSchemas.output, options.auth.user ?? null, `Result ${accessType} validation`); return resultAccessValidated; }, @@ -270,17 +269,18 @@ export class CrudHandlerInvocationError extends Error { } } -async function validate(obj: unknown, schema: yup.ISchema, name: string): Promise { +async function validate(obj: unknown, schema: yup.ISchema, currentUser: UsersCrud["Admin"]["Read"] | null, validationDescription: string): Promise { try { - return await schema.validate(obj, { + return await yupValidate(schema, obj, { abortEarly: false, stripUnknown: true, + currentUserId: currentUser?.id ?? null, }); } catch (error) { if (error instanceof yup.ValidationError) { throw new StackAssertionError( deindent` - ${name} failed in CRUD handler. + ${validationDescription} failed in CRUD handler. Errors: ${error.errors.join("\n")} diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index 7aa0f637b..007a5f2f7 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -7,7 +7,7 @@ import { decodeAccessToken } from "@/lib/tokens"; import { KnownErrors } from "@stackframe/stack-shared"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { ReplaceFieldWithOwnUserId, StackAdaptSentinel } from "@stackframe/stack-shared/dist/schema-fields"; +import { ReplaceFieldWithOwnUserId, StackAdaptSentinel, yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; import { groupBy, typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { deepPlainClone } from "@stackframe/stack-shared/dist/utils/objects"; @@ -51,43 +51,15 @@ export type MergeSmartRequest = async function validate(obj: SmartRequest, schema: yup.Schema, req: NextRequest | null): Promise { try { - return await schema.validate(obj, { + return await yupValidate(schema, obj, { abortEarly: false, context: { noUnknownPathPrefixes: ["body", "query", "params"], }, + currentUserId: obj.auth?.user?.id ?? null, }); } catch (error) { - if (error instanceof ReplaceFieldWithOwnUserId) { - // parse yup path - let pathRemaining = error.path; - const fieldPath = []; - while (pathRemaining.length > 0) { - if (pathRemaining.startsWith("[")) { - const index = pathRemaining.indexOf("]"); - if (index < 0) throw new StackAssertionError("Invalid path"); - fieldPath.push(JSON.parse(pathRemaining.slice(1, index))); - pathRemaining = pathRemaining.slice(index + 1); - } else { - let dotIndex = pathRemaining.indexOf("."); - if (dotIndex === -1) dotIndex = pathRemaining.length; - fieldPath.push(pathRemaining.slice(0, dotIndex)); - pathRemaining = pathRemaining.slice(dotIndex + 1); - } - } - - const newObj = deepPlainClone(obj); - let it = newObj; - for (const field of fieldPath.slice(0, -1)) { - if (!Object.prototype.hasOwnProperty.call(it, field)) { - throw new StackAssertionError(`Segment ${field} of path ${error.path} not found in object`); - } - it = (it as any)[field]; - } - (it as any)[fieldPath[fieldPath.length - 1]] = obj.auth?.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); - - return await validate(newObj, schema, req); - } else if (error instanceof yup.ValidationError) { + if (error instanceof yup.ValidationError) { if (req === null) { // we weren't called by a HTTP request, so it must be a logical error in a manual invocation throw new StackAssertionError("Request validation failed", {}, { cause: error }); diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 3e7a2cf97..ea0e06412 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -16,7 +16,7 @@ class InternalServerError extends StatusError { constructor(error: unknown) { super( StatusError.InternalServerError, - ["development", "test"].includes(getNodeEnvironment()) ? `Internal Server Error. The error message follows, but will be stripped in production. ${error}` : `Something went wrong. Please make sure the data you entered is correct.`, + ["development", "test"].includes(getNodeEnvironment()) ? `Internal Server Error. The error message follows, but will be stripped in production. ${(error as any)?.stack ?? error}` : `Something went wrong. Please make sure the data you entered is correct.`, ); } } diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 8bcc5554f..117218760 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -1,9 +1,62 @@ import * as yup from "yup"; +import { KnownErrors } from "."; import { isBase64 } from "./utils/bytes"; import { StackAssertionError } from "./utils/errors"; import { allProviders } from "./utils/oauth"; +import { deepPlainClone, omit } from "./utils/objects"; import { isUuid } from "./utils/uuids"; +export async function yupValidate>( + schema: S, + obj: unknown, + options?: yup.ValidateOptions & { currentUserId?: string | null } +): Promise> { + try { + return await schema.validate(obj, { + ...omit(options ?? {}, ['currentUserId']), + context: { + ...options?.context, + stackAllowUserIdMe: options?.currentUserId !== undefined, + }, + }); + } catch (error) { + if (error instanceof ReplaceFieldWithOwnUserId) { + const currentUserId = options?.currentUserId; + if (!currentUserId) throw new KnownErrors.CannotGetOwnUserWithoutUser(); + + // parse yup path + let pathRemaining = error.path; + const fieldPath = []; + while (pathRemaining.length > 0) { + if (pathRemaining.startsWith("[")) { + const index = pathRemaining.indexOf("]"); + if (index < 0) throw new StackAssertionError("Invalid path"); + fieldPath.push(JSON.parse(pathRemaining.slice(1, index))); + pathRemaining = pathRemaining.slice(index + 1); + } else { + let dotIndex = pathRemaining.indexOf("."); + if (dotIndex === -1) dotIndex = pathRemaining.length; + fieldPath.push(pathRemaining.slice(0, dotIndex)); + pathRemaining = pathRemaining.slice(dotIndex + 1); + } + } + + const newObj = deepPlainClone(obj); + let it = newObj; + for (const field of fieldPath.slice(0, -1)) { + if (!Object.prototype.hasOwnProperty.call(it, field)) { + throw new StackAssertionError(`Segment ${field} of path ${error.path} not found in object`); + } + it = (it as any)[field]; + } + (it as any)[fieldPath[fieldPath.length - 1]] = currentUserId; + + return await yupValidate(schema, newObj, options); + } + throw error; + } +} + const _idDescription = (identify: string) => `The unique identifier of this ${identify}`; const _displayNameDescription = (identify: string) => `Human-readable ${identify} display name. This is not a unique identifier.`; const _clientMetaDataDescription = (identify: string) => `Client metadata. Used as a data store, accessible from the client side. Do not store information that should not be exposed to the client.`; @@ -190,6 +243,8 @@ export const userIdOrMeSchema = yupString().uuid().transform(v => { if (v === "me") return userIdMeSentinelUuid; else return v; }).test((v, context) => { + if (!("stackAllowUserIdMe" in (context.options.context ?? {}))) throw new StackAssertionError('userIdOrMeSchema is not allowed in this context. Make sure you\'re using yupValidate from schema-fields.ts to validate, instead of schema.validate(...).'); + if (!context.options.context?.stackAllowUserIdMe) throw new StackAssertionError('userIdOrMeSchema is not allowed in this context. Make sure you\'re passing in the currentUserId option in yupValidate.'); if (v === userIdMeSentinelUuid) throw new ReplaceFieldWithOwnUserId(context.path); return true; }).meta({ openapiField: { description: 'The ID of the user, or the special value `me` for the currently authenticated user', exampleValue: '3241a285-8329-4d69-8f3d-316e08cf140c' } });