Skip to content

Commit

Permalink
Zod Validations Refactoring (#1230)
Browse files Browse the repository at this point in the history
  • Loading branch information
ukrocks007 authored Apr 5, 2024
1 parent 5d22973 commit a2c0141
Show file tree
Hide file tree
Showing 9 changed files with 229 additions and 31 deletions.
8 changes: 1 addition & 7 deletions components/webhook/EventTypes.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { eventTypes } from '@/lib/common';
import React, { ReactElement } from 'react';
import type { WebookFormSchema } from 'types';

const eventTypes = [
'member.created',
'member.removed',
'invitation.created',
'invitation.removed',
];

const EventTypes = ({
onChange,
values,
Expand Down
16 changes: 16 additions & 0 deletions lib/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export const passwordPolicies = {
minLength: 8,
};

// List of events used to create webhook endpoint
export const eventTypes = [
'member.created',
'member.removed',
'invitation.created',
'invitation.removed',
];

export const maxLengthPolicies = {
name: 104,
nameShortDisplay: 20,
Expand All @@ -29,4 +37,12 @@ export const maxLengthPolicies = {
apiKeyName: 64,
webhookDescription: 100,
webhookEndpoint: 2083,
memberId: 64,
eventType: 50,
eventTypes: eventTypes.length,
endpointId: 64,
inviteToken: 64,
expiredToken: 64,
invitationId: 64,
sendViaEmail: 10,
};
143 changes: 141 additions & 2 deletions lib/zod/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ const slug = z
);

const image = z
.string()
.string({
required_error: 'Avatar is required',
invalid_type_error: 'Avatar must be a string',
})
.url('Enter a valid URL')
.refine(
(imageUri) => imageUri.startsWith('data:image/'),
Expand Down Expand Up @@ -146,7 +149,11 @@ const expiredToken = z
required_error: 'Expired token is required',
invalid_type_error: 'Expired token must be a string',
})
.min(1, 'Expired token is required');
.min(1, 'Expired token is required')
.max(
maxLengthPolicies.expiredToken,
`Expired token should have at most ${maxLengthPolicies.expiredToken} characters`
);

const sessionId = z
.string({
Expand All @@ -170,6 +177,95 @@ const recaptchaToken = z.string({
invalid_type_error: 'Recaptcha token must be a string',
});

const sentViaEmailString = z
.string()
.max(
maxLengthPolicies.sendViaEmail,
`Send via email should be at most ${maxLengthPolicies.sendViaEmail} characters`
)
.refine((value) => value === 'true' || !value || value === 'false', {
message: 'sentViaEmail must be a string "true" or "false" or empty',
});

const invitationId = z
.string({
required_error: 'Invitation id is required',
invalid_type_error: 'Invitation id must be a string',
})
.min(1, 'Invitation id is required')
.max(
maxLengthPolicies.invitationId,
`Invitation id should be at most ${maxLengthPolicies.invitationId} characters`
);

const endpointId = z
.string({
required_error: 'Endpoint id is required',
invalid_type_error: 'Endpoint id must be a string',
})
.min(1, `Endpoint id is required`)
.max(
maxLengthPolicies.endpointId,
`Endpoint id should be at most ${maxLengthPolicies.endpointId} characters`
);

const eventTypes = z
.array(
z
.string({
invalid_type_error: 'Event type must be a string',
required_error: 'Event type is required',
})
.min(1)
.max(
maxLengthPolicies.eventType,
`Event type should be at most ${maxLengthPolicies.eventType} characters`
)
)
.min(1, 'At least one event type is required')
.max(maxLengthPolicies.eventTypes, 'Too many event types');

const url = z
.string({
invalid_type_error: 'URL must be a string',
})
.url('Enter a valid URL')
.min(1, 'URL is required')
.max(
maxLengthPolicies.domain,
`URL should have at most ${maxLengthPolicies.domain} characters`
)
.refine((url) => {
if (url) {
if (url.startsWith('https://') || url.startsWith('http://')) {
return true;
}
}
return false;
});

const inviteToken = z
.string({
required_error: 'Invite token is required',
invalid_type_error: 'Invite token must be a string',
})
.min(1, 'Invite token is required')
.max(
maxLengthPolicies.inviteToken,
`Invite token should be at most ${maxLengthPolicies.inviteToken} characters`
);

const memberId = z
.string({
required_error: 'Member id is required',
invalid_type_error: 'Member id must be a string',
})
.min(1)
.max(
maxLengthPolicies.memberId,
`Member id should be at most ${maxLengthPolicies.memberId} characters`
);

export const createApiKeySchema = z.object({
name: teamName,
});
Expand Down Expand Up @@ -261,3 +357,46 @@ export const checkoutSessionSchema = z.object({
price: priceId,
quantity: quantity.optional(),
});

export const updateMemberSchema = z.object({
role,
memberId,
});

export const acceptInvitationSchema = z.object({
inviteToken,
});

export const getInvitationSchema = z.object({
token: inviteToken,
});

export const webhookEndpointSchema = z.object({
name,
url,
eventTypes,
});

export const updateWebhookEndpointSchema = webhookEndpointSchema.extend({
endpointId,
});

export const getInvitationsSchema = z.object({
sentViaEmail: sentViaEmailString,
});

export const deleteInvitationSchema = z.object({
id: invitationId,
});

export const getWebhookSchema = z.object({
endpointId,
});

export const deleteWebhookSchema = z.object({
webhookId: endpointId,
});

export const deleteMemberSchema = z.object({
memberId,
});
7 changes: 3 additions & 4 deletions models/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,9 @@ export const throwIfNoTeamAccess = async (
throw new Error('Unauthorized');
}

const teamMember = await getTeamMember(
session.user.id,
req.query.slug as string
);
const { slug } = validateWithSchema(teamSlugSchema, req.query);

const teamMember = await getTeamMember(session.user.id, slug);

if (!teamMember) {
throw new Error('You do not have access to this team');
Expand Down
6 changes: 5 additions & 1 deletion pages/api/invitations/[token].ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getInvitation, isInvitationExpired } from 'models/invitation';
import type { NextApiRequest, NextApiResponse } from 'next';
import { recordMetric } from '@/lib/metrics';
import { ApiError } from '@/lib/errors';
import { getInvitationSchema, validateWithSchema } from '@/lib/zod';

export default async function handler(
req: NextApiRequest,
Expand Down Expand Up @@ -29,7 +30,10 @@ export default async function handler(

// Get the invitation by token
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { token } = req.query as { token: string };
const { token } = validateWithSchema(
getInvitationSchema,
req.query as { token: string }
);

const invitation = await getInvitation({ token });

Expand Down
23 changes: 19 additions & 4 deletions pages/api/teams/[slug]/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import { recordMetric } from '@/lib/metrics';
import { extractEmailDomain, isEmailAllowed } from '@/lib/email/utils';
import { Invitation, Role } from '@prisma/client';
import { countTeamMembers } from 'models/teamMember';
import { inviteViaEmailSchema, validateWithSchema } from '@/lib/zod';
import {
acceptInvitationSchema,
deleteInvitationSchema,
getInvitationsSchema,
inviteViaEmailSchema,
validateWithSchema,
} from '@/lib/zod';

export default async function handler(
req: NextApiRequest,
Expand Down Expand Up @@ -196,7 +202,10 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_invitation', 'read');

const { sentViaEmail } = req.query as { sentViaEmail: string };
const { sentViaEmail } = validateWithSchema(
getInvitationsSchema,
req.query as { sentViaEmail: string }
);

const invitations = await getInvitations(
teamMember.teamId,
Expand All @@ -213,7 +222,10 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_invitation', 'delete');

const { id } = req.query as { id: string };
const { id } = validateWithSchema(
deleteInvitationSchema,
req.query as { id: string }
);

const invitation = await getInvitation({ id });

Expand Down Expand Up @@ -245,7 +257,10 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {

// Accept an invitation to an organization
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const { inviteToken } = req.body as { inviteToken: string };
const { inviteToken } = validateWithSchema(
acceptInvitationSchema,
req.body as { inviteToken: string }
);

const invitation = await getInvitation({ token: inviteToken });

Expand Down
15 changes: 13 additions & 2 deletions pages/api/teams/[slug]/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { recordMetric } from '@/lib/metrics';
import { countTeamMembers, updateTeamMember } from 'models/teamMember';
import { validateUpdateRole } from '@/lib/rbac';
import {
deleteMemberSchema,
updateMemberSchema,
validateWithSchema,
} from '@/lib/zod';

export default async function handler(
req: NextApiRequest,
Expand Down Expand Up @@ -64,7 +69,10 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_member', 'delete');

const { memberId } = req.query as { memberId: string };
const { memberId } = validateWithSchema(
deleteMemberSchema,
req.query as { memberId: string }
);

const teamMemberRemoved = await removeTeamMember(teamMember.teamId, memberId);

Expand Down Expand Up @@ -141,7 +149,10 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_member', 'update');

const { memberId, role } = req.body as { memberId: string; role: Role };
const { memberId, role } = validateWithSchema(
updateMemberSchema,
req.body as { memberId: string; role: Role }
);

await validateUpdateRole(memberId, teamMember);

Expand Down
26 changes: 18 additions & 8 deletions pages/api/teams/[slug]/webhooks/[endpointId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { EndpointIn } from 'svix';
import { recordMetric } from '@/lib/metrics';
import env from '@/lib/env';
import {
getWebhookSchema,
updateWebhookEndpointSchema,
validateWithSchema,
} from '@/lib/zod';

export default async function handler(
req: NextApiRequest,
Expand Down Expand Up @@ -45,9 +50,12 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_webhook', 'read');

const { endpointId } = req.query as {
endpointId: string;
};
const { endpointId } = validateWithSchema(
getWebhookSchema,
req.query as {
endpointId: string;
}
);

const app = await findOrCreateApp(teamMember.team.name, teamMember.team.id);

Expand All @@ -67,11 +75,13 @@ const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const teamMember = await throwIfNoTeamAccess(req, res);
throwIfNotAllowed(teamMember, 'team_webhook', 'update');

const { endpointId } = req.query as {
endpointId: string;
};

const { name, url, eventTypes } = req.body;
const { name, url, eventTypes, endpointId } = validateWithSchema(
updateWebhookEndpointSchema,
{
...req.query,
...req.body,
}
);

const app = await findOrCreateApp(teamMember.team.name, teamMember.team.id);

Expand Down
Loading

0 comments on commit a2c0141

Please sign in to comment.