userIdOrMe support on all yup validations

This commit is contained in:
Konstantin Wohlwend 2024-09-24 19:42:10 -07:00
parent 70a1e65723
commit f1bffee8b9
4 changed files with 69 additions and 42 deletions

View File

@ -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<T extends CrudTypeOf<any>, K extends Capitalize<CrudlOperation>> = K extends keyof T["Admin"] ? T["Admin"][K] : void;
@ -155,9 +155,8 @@ export function createCrudHandlers<
adminSchemas,
invoke: async (options: { params: yup.InferType<PS> | Partial<yup.InferType<PS>>, query: yup.InferType<QS>, 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<T>(obj: unknown, schema: yup.ISchema<T>, name: string): Promise<T> {
async function validate<T>(obj: unknown, schema: yup.ISchema<T>, currentUser: UsersCrud["Admin"]["Read"] | null, validationDescription: string): Promise<T> {
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")}

View File

@ -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<T, MSQ = SmartRequest> =
async function validate<T>(obj: SmartRequest, schema: yup.Schema<T>, req: NextRequest | null): Promise<T> {
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 });

View File

@ -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.`,
);
}
}

View File

@ -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<S extends yup.ISchema<any>>(
schema: S,
obj: unknown,
options?: yup.ValidateOptions & { currentUserId?: string | null }
): Promise<yup.InferType<S>> {
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' } });