diff --git a/Makefile b/Makefile index d0a1af8e..c292711d 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,7 @@ build: src/ cloudformation/ docs/ yarn -D VITE_BUILD_HASH=$(GIT_HASH) yarn build cp -r src/api/resources/ dist/api/resources + rm -rf dist/lambda/sqs sam build --template-file cloudformation/main.yml local: diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index c468b871..abb3c1a2 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -12,10 +12,14 @@ Parameters: AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$ SesEmailDomain: Type: String + SqsQueueArn: + Type: String Resources: ApiLambdaIAMRole: Type: AWS::IAM::Role Properties: + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole AssumeRolePolicyDocument: Version: '2012-10-17' Statement: @@ -41,6 +45,14 @@ Resources: ses:Recipients: - "*@illinois.edu" PolicyName: ses-membership + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Action: + - sqs:SendMessage + Effect: Allow + Resource: !Ref SqsQueueArn + PolicyName: lambda-sqs - PolicyDocument: Version: '2012-10-17' Statement: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 09e64220..295d10a8 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -22,6 +22,14 @@ Parameters: Default: false Type: String AllowedValues: [true, false] + SqsLambdaTimeout: + Description: How long the SQS lambda is permitted to run (in seconds) + Default: 300 + Type: Number + SqsMessageTimeout: + Description: MessageVisibilityTimeout for the SQS Lambda queue (should be at least 6xSqsLambdaTimeout) + Default: 1800 + Type: Number Conditions: IsProd: !Equals [!Ref RunEnvironment, 'prod'] @@ -74,6 +82,7 @@ Resources: RunEnvironment: !Ref RunEnvironment LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda SesEmailDomain: !FindInMap [General, !Ref RunEnvironment, SesDomain] + SqsQueueArn: !GetAtt AppSQSQueues.Outputs.MainQueueArn AppLogGroups: Type: AWS::Serverless::Application @@ -83,6 +92,14 @@ Resources: LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda LogRetentionDays: !FindInMap [General, !Ref RunEnvironment, LogRetentionDays] + AppSQSQueues: + Type: AWS::Serverless::Application + Properties: + Location: ./sqs.yml + Parameters: + QueueName: !Sub ${ApplicationPrefix}-sqs + MessageTimeout: !Ref SqsMessageTimeout + IcalDomainProxy: Type: AWS::Serverless::Application Properties: @@ -149,6 +166,40 @@ Resources: Path: /{proxy+} Method: ANY + AppSqsLambdaFunction: + Type: AWS::Serverless::Function + DependsOn: + - AppLogGroups + Properties: + Architectures: [arm64] + CodeUri: ../dist/sqsConsumer + AutoPublishAlias: live + Runtime: nodejs22.x + Description: !Sub "${ApplicationFriendlyName} SQS Lambda" + FunctionName: !Sub ${ApplicationPrefix}-sqs-lambda + Handler: index.handler + MemorySize: 512 + Role: !GetAtt AppSecurityRoles.Outputs.MainFunctionRoleArn + Timeout: !Ref SqsLambdaTimeout + LoggingConfig: + LogGroup: !Sub /aws/lambda/${ApplicationPrefix}-lambda + Environment: + Variables: + RunEnvironment: !Ref RunEnvironment + VpcConfig: + Ipv6AllowedForDualStack: !If [ShouldAttachVpc, True, !Ref AWS::NoValue] + SecurityGroupIds: !If [ShouldAttachVpc, !FindInMap [EnvironmentToCidr, !Ref RunEnvironment, SecurityGroupIds], !Ref AWS::NoValue] + SubnetIds: !If [ShouldAttachVpc, !FindInMap [EnvironmentToCidr, !Ref RunEnvironment, SubnetIds], !Ref AWS::NoValue] + + SQSLambdaEventMapping: + Type: AWS::Lambda::EventSourceMapping + Properties: + BatchSize: 5 + EventSourceArn: !GetAtt AppSQSQueues.Outputs.MainQueueArn + FunctionName: !Sub ${ApplicationPrefix}-sqs-lambda + FunctionResponseTypes: + - ReportBatchItemFailures + IamGroupRolesTable: Type: 'AWS::DynamoDB::Table' DeletionPolicy: "Retain" @@ -348,6 +399,23 @@ Resources: - Name: 'ApiName' Value: !Sub ${ApplicationPrefix}-gateway + + AppDLQMessagesAlarm: + Type: 'AWS::CloudWatch::Alarm' + Condition: IsProd + Properties: + AlarmName: !Sub ${ApplicationPrefix}-sqs-dlq + AlarmDescription: 'Items are present in the application DLQ, meaning some messages failed to process.' + Namespace: 'AWS/SQS' + MetricName: 'ApproximateNumberOfMessagesVisible' + Statistic: 'Sum' + Period: '60' + EvaluationPeriods: '1' + ComparisonOperator: 'GreaterThanThreshold' + Threshold: '0' + AlarmActions: + - !Ref AlertSNSArn + APILambdaPermission: Type: AWS::Lambda::Permission Properties: diff --git a/cloudformation/sqs.yml b/cloudformation/sqs.yml new file mode 100644 index 00000000..6689e706 --- /dev/null +++ b/cloudformation/sqs.yml @@ -0,0 +1,40 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Stack SQS Queues +Transform: AWS::Serverless-2016-10-31 +Parameters: + QueueName: + Type: String + AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$ + MessageTimeout: + Type: Number +Resources: + AppDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub ${QueueName}-dlq + VisibilityTimeout: !Ref MessageTimeout + AppQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + VisibilityTimeout: !Ref MessageTimeout + RedrivePolicy: + deadLetterTargetArn: + Fn::GetAtt: + - "AppDLQ" + - "Arn" + maxReceiveCount: 3 + +Outputs: + MainQueueArn: + Description: Main Queue Arn + Value: + Fn::GetAtt: + - AppQueue + - Arn + DLQArn: + Description: Dead-letter Queue Arn + Value: + Fn::GetAtt: + - AppDLQ + - Arn diff --git a/package.json b/package.json index 16c772bf..a49ba8eb 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,13 @@ "scripts": { "build": "yarn workspaces run build && yarn lockfile-manage", "dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", - "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/lambda/ && cp src/api/package.lambda.json dist/lambda/package.json && rm package-lock.json", + "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/lambda/ && cp package-lock.json dist/sqsConsumer/ && cp src/api/package.lambda.json dist/lambda/package.json && cp src/api/package.lambda.json dist/sqsConsumer/package.json && rm package-lock.json", "prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts", "prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts", "lint": "yarn workspaces run lint", "prepare": "node .husky/install.mjs || true", "typecheck": "yarn workspaces run typecheck", - "test:unit": "vitest run tests/unit --config tests/unit/vitest.config.ts && yarn workspace infra-core-ui run test:unit", + "test:unit": "cross-env RunEnvironment='dev' vitest run tests/unit --config tests/unit/vitest.config.ts && yarn workspace infra-core-ui run test:unit", "test:unit-ui": "yarn test:unit --ui", "test:unit-watch": "vitest tests/unit", "test:live": "vitest tests/live", @@ -39,7 +39,7 @@ "@typescript-eslint/parser": "^8.0.1", "@vitejs/plugin-react": "^4.3.1", "@vitest/ui": "^2.0.5", - "aws-sdk-client-mock": "^4.0.1", + "aws-sdk-client-mock": "^4.1.0", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "esbuild": "^0.23.0", @@ -81,4 +81,4 @@ "resolutions": { "pdfjs-dist": "^4.8.69" } -} +} \ No newline at end of file diff --git a/src/api/build.js b/src/api/build.js index f24c8100..cb6dff64 100644 --- a/src/api/build.js +++ b/src/api/build.js @@ -1,39 +1,56 @@ import esbuild from "esbuild"; import { resolve } from "path"; + +const commonParams = { + bundle: true, + format: "esm", + minify: true, + outExtension: { ".js": ".mjs" }, + loader: { + ".png": "file", + ".pkpass": "file", + ".json": "file", + }, // File loaders + target: "es2022", // Target ES2022 + sourcemap: false, + platform: "node", + external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"], + alias: { + 'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js') + }, + banner: { + js: ` + import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + `.trim(), + }, // Banner for compatibility with CommonJS +} esbuild .build({ - entryPoints: ["api/lambda.js"], // Entry file - bundle: true, - format: "esm", - minify: true, + ...commonParams, + entryPoints: ["api/lambda.js"], outdir: "../../dist/lambda/", - outExtension: { ".js": ".mjs" }, - loader: { - ".png": "file", - ".pkpass": "file", - ".json": "file", - }, // File loaders - target: "es2022", // Target ES2022 - sourcemap: false, - platform: "node", - external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"], - alias: { - 'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js') - }, - banner: { - js: ` - import path from 'path'; - import { fileURLToPath } from 'url'; - import { createRequire as topLevelCreateRequire } from 'module'; - const require = topLevelCreateRequire(import.meta.url); - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - `.trim(), - }, // Banner for compatibility with CommonJS + external: [...commonParams.external, "sqs/*"], + }) + .then(() => console.log("API server build completed successfully!")) + .catch((error) => { + console.error("API server build failed:", error); + process.exit(1); + }); + + esbuild + .build({ + ...commonParams, + entryPoints: ["api/sqs/index.js", "api/sqs/driver.js"], + outdir: "../../dist/sqsConsumer/", }) - .then(() => console.log("Build completed successfully!")) + .then(() => console.log("SQS consumer build completed successfully!")) .catch((error) => { - console.error("Build failed:", error); + console.error("SQS consumer build failed:", error); process.exit(1); }); diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 6524c53b..547fe505 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -21,6 +21,8 @@ import { } from "../../common/types/iam.js"; import { FastifyInstance } from "fastify"; import { UserProfileDataBase } from "common/types/msGraphApi.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed @@ -28,15 +30,13 @@ function validateGroupId(groupId: string): boolean { } export async function getEntraIdToken( - fastify: FastifyInstance, + clients: { smClient: SecretsManagerClient; dynamoClient: DynamoDBClient }, clientId: string, scopes: string[] = ["https://graph.microsoft.com/.default"], ) { const secretApiConfig = - (await getSecretValue( - fastify.secretsManagerClient, - genericConfig.ConfigSecretName, - )) || {}; + (await getSecretValue(clients.smClient, genericConfig.ConfigSecretName)) || + {}; if ( !secretApiConfig.entra_id_private_key || !secretApiConfig.entra_id_thumbprint @@ -50,7 +50,7 @@ export async function getEntraIdToken( "base64", ).toString("utf8"); const cachedToken = await getItemFromCache( - fastify.dynamoClient, + clients.dynamoClient, "entra_id_access_token", ); if (cachedToken) { @@ -80,7 +80,7 @@ export async function getEntraIdToken( date.setTime(date.getTime() - 30000); if (result?.accessToken) { await insertItemIntoCache( - fastify.dynamoClient, + clients.dynamoClient, "entra_id_access_token", { token: result?.accessToken }, date, diff --git a/src/api/functions/mobileWallet.ts b/src/api/functions/mobileWallet.ts index b16f99b3..7cf73882 100644 --- a/src/api/functions/mobileWallet.ts +++ b/src/api/functions/mobileWallet.ts @@ -1,5 +1,10 @@ import { getSecretValue } from "../plugins/auth.js"; -import { genericConfig, SecretConfig } from "../../common/config.js"; +import { + ConfigType, + genericConfig, + GenericConfigType, + SecretConfig, +} from "../../common/config.js"; import { InternalServerError, UnauthorizedError, @@ -12,6 +17,9 @@ import strip from "../resources/MembershipPass.pkpass/strip.png"; import pass from "../resources/MembershipPass.pkpass/pass.js"; import { PKPass } from "passkit-generator"; import { promises as fs } from "fs"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { RunEnvironment } from "common/roles.js"; +import pino from "pino"; function trim(s: string) { return (s || "").replace(/^\s+|\s+$/g, ""); @@ -25,9 +33,11 @@ function convertName(name: string): string { } export async function issueAppleWalletMembershipCard( - app: FastifyInstance, - request: FastifyRequest, + clients: { smClient: SecretsManagerClient }, + environmentConfig: ConfigType, + runEnvironment: RunEnvironment, email: string, + logger: pino.Logger, name?: string, ) { if (!email.endsWith("@illinois.edu")) { @@ -37,7 +47,7 @@ export async function issueAppleWalletMembershipCard( }); } const secretApiConfig = (await getSecretValue( - app.secretsManagerClient, + clients.smClient, genericConfig.ConfigSecretName, )) as SecretConfig; if (!secretApiConfig) { @@ -57,7 +67,7 @@ export async function issueAppleWalletMembershipCard( secretApiConfig.apple_signing_cert_base64, "base64", ).toString("utf-8"); - pass["passTypeIdentifier"] = app.environmentConfig["PasskitIdentifier"]; + pass["passTypeIdentifier"] = environmentConfig["PasskitIdentifier"]; const pkpass = new PKPass( { @@ -73,13 +83,13 @@ export async function issueAppleWalletMembershipCard( }, { // logoText: app.runEnvironment === "dev" ? "INVALID Membership Pass" : "Membership Pass", - serialNumber: app.environmentConfig["PasskitSerialNumber"], + serialNumber: environmentConfig["PasskitSerialNumber"], }, ); pkpass.setBarcodes({ altText: email.split("@")[0], format: "PKBarcodeFormatPDF417", - message: app.runEnvironment === "dev" ? `INVALID${email}INVALID` : email, + message: runEnvironment === "dev" ? `INVALID${email}INVALID` : email, }); const iat = new Date().toLocaleDateString("en-US", { day: "2-digit", @@ -93,7 +103,7 @@ export async function issueAppleWalletMembershipCard( value: convertName(name), }); } - if (app.runEnvironment === "prod") { + if (runEnvironment === "prod") { pkpass.backFields.push({ label: "Verification URL", key: "iss", @@ -109,7 +119,7 @@ export async function issueAppleWalletMembershipCard( pkpass.backFields.push({ label: "Pass Created On", key: "iat", value: iat }); pkpass.backFields.push({ label: "Membership ID", key: "id", value: email }); const buffer = pkpass.getAsBuffer(); - request.log.info( + logger.info( { type: "audit", actor: email, target: email }, "Created membership verification pass", ); diff --git a/src/api/index.ts b/src/api/index.ts index ebe512b6..6a378a55 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,7 +21,6 @@ import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; -import { SESClient } from "@aws-sdk/client-ses"; import mobileWalletRoute from "./routes/mobileWallet.js"; dotenv.config(); @@ -37,10 +36,6 @@ async function init() { region: genericConfig.AwsRegion, }); - const sesClient = new SESClient({ - region: genericConfig.AwsRegion, - }); - const app: FastifyInstance = fastify({ logger: { level: process.env.LOG_LEVEL || "info", @@ -88,7 +83,6 @@ async function init() { app.nodeCache = new NodeCache({ checkperiod: 30 }); app.dynamoClient = dynamoClient; app.secretsManagerClient = secretsManagerClient; - app.sesClient = sesClient; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; diff --git a/src/api/package.json b/src/api/package.json index 765b03b7..96a61a2d 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -18,6 +18,7 @@ "@aws-sdk/client-dynamodb": "^3.624.0", "@aws-sdk/client-secrets-manager": "^3.624.0", "@aws-sdk/client-ses": "^3.734.0", + "@aws-sdk/client-sqs": "^3.738.0", "@aws-sdk/client-sts": "^3.726.0", "@aws-sdk/util-dynamodb": "^3.624.0", "@azure/msal-node": "^2.16.1", @@ -25,6 +26,9 @@ "@fastify/aws-lambda": "^5.0.0", "@fastify/caching": "^9.0.1", "@fastify/cors": "^10.0.1", + "@middy/core": "^6.0.0", + "@middy/event-normalizer": "^6.0.0", + "@middy/sqs-partial-batch-failure": "^6.0.0", "@touch4it/ical-timezones": "^1.9.0", "base64-arraybuffer": "^1.0.2", "discord.js": "^14.15.3", @@ -39,13 +43,16 @@ "moment-timezone": "^0.5.45", "node-cache": "^5.1.2", "passkit-generator": "^3.3.1", + "pino": "^9.6.0", "pluralize": "^8.0.0", + "uuid": "^11.0.5", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.2", "zod-validation-error": "^3.3.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.0", + "@types/aws-lambda": "^8.10.147", "nodemon": "^3.1.9" } } diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 8c6a77e5..15de1c56 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -160,7 +160,10 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const emails = request.body.emails; const entraIdToken = await getEntraIdToken( - fastify, + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }, fastify.environmentConfig.AadValidClientId, ); if (!entraIdToken) { @@ -247,7 +250,10 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( - fastify, + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }, fastify.environmentConfig.AadValidClientId, ); const addResults = await Promise.allSettled( @@ -371,7 +377,10 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( - fastify, + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }, fastify.environmentConfig.AadValidClientId, ); const response = await listGroupMembers(entraIdToken, groupId); diff --git a/src/api/routes/mobileWallet.ts b/src/api/routes/mobileWallet.ts index 0a8083b6..343563d5 100644 --- a/src/api/routes/mobileWallet.ts +++ b/src/api/routes/mobileWallet.ts @@ -1,21 +1,31 @@ import { FastifyPluginAsync } from "fastify"; -import { issueAppleWalletMembershipCard } from "../functions/mobileWallet.js"; import { - EntraFetchError, + InternalServerError, UnauthenticatedError, - UnauthorizedError, ValidationError, } from "../../common/errors/index.js"; -import { generateMembershipEmailCommand } from "../functions/ses.js"; import { z } from "zod"; -import { getEntraIdToken, getUserProfile } from "../functions/entraId.js"; import { checkPaidMembership } from "../functions/membership.js"; +import { + AvailableSQSFunctions, + SQSPayload, +} from "../../common/types/sqsMessage.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { genericConfig } from "../../common/config.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +const queuedResponseJsonSchema = zodToJsonSchema( + z.object({ + queueId: z.string().uuid(), + }), +); const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { fastify.post<{ Querystring: { email: string } }>( "/membership", { schema: { + response: { 202: queuedResponseJsonSchema }, querystring: { type: "object", properties: { @@ -53,37 +63,36 @@ const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { message: `${request.query.email} is not a paid member.`, }); } - const entraIdToken = await getEntraIdToken( - fastify, - fastify.environmentConfig.AadValidClientId, - ); - - const userProfile = await getUserProfile( - entraIdToken, - request.query.email, - ); - - const item = await issueAppleWalletMembershipCard( - fastify, - request, - request.query.email, - userProfile.displayName, - ); - const emailCommand = generateMembershipEmailCommand( - request.query.email, - `membership@${fastify.environmentConfig.EmailDomain}`, - item, + const sqsPayload: SQSPayload = + { + function: AvailableSQSFunctions.EmailMembershipPass, + metadata: { + initiator: "public", + reqId: request.id, + }, + payload: { + email: request.query.email, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), ); - if ( - fastify.runEnvironment === "dev" && - request.query.email === "testinguser@illinois.edu" - ) { - return reply - .status(202) - .send({ message: "OK (skipped sending email)" }); + if (!result.MessageId) { + request.log.error(result); + throw new InternalServerError({ + message: "Could not add job to queue.", + }); } - await fastify.sesClient.send(emailCommand); - reply.status(202).send({ message: "OK" }); + request.log.info(`Queued job to SQS with message ID ${result.MessageId}`); + reply.status(202).send({ queueId: result.MessageId }); }, ); }; diff --git a/src/api/sqs/driver.ts b/src/api/sqs/driver.ts new file mode 100644 index 00000000..faf682b3 --- /dev/null +++ b/src/api/sqs/driver.ts @@ -0,0 +1,26 @@ +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { environmentConfig, genericConfig } from "common/config.js"; +import { parseSQSPayload } from "common/types/sqsMessage.js"; + +const queueUrl = environmentConfig["dev"].SqsQueueUrl; +const sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, +}); + +const payload = parseSQSPayload({ + function: "ping", + payload: {}, + metadata: { + reqId: "1", + initiator: "dsingh14@illinois.edu", + }, +}); +if (!payload) { + throw new Error("not valid"); +} +const command = new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: JSON.stringify(payload), +}); + +await sqsClient.send(command); diff --git a/src/api/sqs/handlers.ts b/src/api/sqs/handlers.ts new file mode 100644 index 00000000..50ef3f14 --- /dev/null +++ b/src/api/sqs/handlers.ts @@ -0,0 +1,57 @@ +import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; +import { + currentEnvironmentConfig, + runEnvironment, + SQSHandlerFunction, +} from "./index.js"; +import { + getEntraIdToken, + getUserProfile, +} from "../../api/functions/entraId.js"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { environmentConfig, genericConfig } from "../../common/config.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { issueAppleWalletMembershipCard } from "../../api/functions/mobileWallet.js"; +import { generateMembershipEmailCommand } from "../../api/functions/ses.js"; +import { SESClient } from "@aws-sdk/client-ses"; + +export const emailMembershipPassHandler: SQSHandlerFunction< + AvailableSQSFunctions.EmailMembershipPass +> = async (payload, _metadata, logger) => { + const email = payload.email; + const commonConfig = { region: genericConfig.AwsRegion }; + const clients = { + smClient: new SecretsManagerClient(commonConfig), + dynamoClient: new DynamoDBClient(commonConfig), + }; + const entraIdToken = await getEntraIdToken( + clients, + currentEnvironmentConfig.AadValidClientId, + ); + const userProfile = await getUserProfile(entraIdToken, email); + const pkpass = await issueAppleWalletMembershipCard( + clients, + environmentConfig[runEnvironment], + runEnvironment, + email, + logger, + userProfile.displayName, + ); + const emailCommand = generateMembershipEmailCommand( + email, + `membership@${environmentConfig[runEnvironment].EmailDomain}`, + pkpass, + ); + if (runEnvironment === "dev" && email === "testinguser@illinois.edu") { + return; + } + const sesClient = new SESClient(commonConfig); + return await sesClient.send(emailCommand); +}; + +export const pingHandler: SQSHandlerFunction< + AvailableSQSFunctions.Ping +> = async (payload, metadata, logger) => { + logger.error("Not implemented yet!"); + return; +}; diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts new file mode 100644 index 00000000..95328255 --- /dev/null +++ b/src/api/sqs/index.ts @@ -0,0 +1,76 @@ +import middy from "@middy/core"; +import eventNormalizerMiddleware from "@middy/event-normalizer"; +import sqsPartialBatchFailure from "@middy/sqs-partial-batch-failure"; +import { Context, SQSEvent } from "aws-lambda"; +import { + parseSQSPayload, + sqsPayloadSchemas, + AvailableSQSFunctions, + SQSMessageMetadata, + AnySQSPayload, +} from "../../common/types/sqsMessage.js"; +import { logger } from "./logger.js"; +import { z, ZodError } from "zod"; +import pino from "pino"; +import { emailMembershipPassHandler, pingHandler } from "./handlers.js"; +import { ValidationError } from "../../common/errors/index.js"; +import { RunEnvironment } from "../../common/roles.js"; +import { environmentConfig } from "../../common/config.js"; + +export type SQSFunctionPayloadTypes = { + [K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction; +}; + +export type SQSHandlerFunction = ( + payload: z.infer<(typeof sqsPayloadSchemas)[T]>["payload"], + metadata: SQSMessageMetadata, + logger: pino.Logger, +) => Promise; + +const handlers: SQSFunctionPayloadTypes = { + [AvailableSQSFunctions.EmailMembershipPass]: emailMembershipPassHandler, + [AvailableSQSFunctions.Ping]: pingHandler, +}; +export const runEnvironment = process.env.RunEnvironment as RunEnvironment; +export const currentEnvironmentConfig = environmentConfig[runEnvironment]; + +export const handler = middy() + .use(eventNormalizerMiddleware()) + .use(sqsPartialBatchFailure()) + .handler((event: SQSEvent, context: Context, { signal }) => { + const recordsPromises = event.Records.map(async (record, index) => { + try { + let parsedBody = parseSQSPayload(record.body); + if (parsedBody instanceof ZodError) { + logger.error( + { sqsMessageId: record.messageId }, + parsedBody.toString(), + ); + throw new ValidationError({ + message: "Could not parse SQS payload", + }); + } + parsedBody = parsedBody as AnySQSPayload; + const childLogger = logger.child({ + sqsMessageId: record.messageId, + metadata: parsedBody.metadata, + function: parsedBody.function, + }); + const func = handlers[parsedBody.function] as SQSHandlerFunction< + typeof parsedBody.function + >; + childLogger.info(`Starting handler for ${parsedBody.function}...`); + const result = func( + parsedBody.payload, + parsedBody.metadata, + childLogger, + ); + childLogger.info(`Finished handler for ${parsedBody.function}.`); + return result; + } catch (e: any) { + logger.error({ sqsMessageId: record.messageId }, e.toString()); + throw e; + } + }); + return Promise.allSettled(recordsPromises); + }); diff --git a/src/api/sqs/logger.ts b/src/api/sqs/logger.ts new file mode 100644 index 00000000..4991cb22 --- /dev/null +++ b/src/api/sqs/logger.ts @@ -0,0 +1,2 @@ +import { pino } from "pino"; +export const logger = pino().child({ context: "sqsHandler" }); diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 252b40bf..20ee16aa 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -5,7 +5,8 @@ import { ConfigType } from "../common/config.js"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; -import { SESClient } from "@aws-sdk/client-ses"; +import { SQSClient } from "@aws-sdk/client-sqs"; + declare module "fastify" { interface FastifyInstance { authenticate: ( @@ -26,7 +27,7 @@ declare module "fastify" { environmentConfig: ConfigType; nodeCache: NodeCache; dynamoClient: DynamoDBClient; - sesClient: SESClient; + sqsClient?: SQSClient; secretsManagerClient: SecretsManagerClient; } interface FastifyRequest { diff --git a/src/common/config.ts b/src/common/config.ts index 3800c37f..75bcb4b9 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -16,9 +16,10 @@ export type ConfigType = { PasskitSerialNumber: string; MembershipApiEndpoint: string; EmailDomain: string; + SqsQueueUrl: string; }; -type GenericConfigType = { +export type GenericConfigType = { EventsDynamoTableName: string; CacheDynamoTableName: string; ConfigSecretName: string; @@ -74,6 +75,7 @@ const environmentConfig: EnvironmentConfigType = { PasskitSerialNumber: "0", MembershipApiEndpoint: "https://infra-membership-api.aws.qa.acmuiuc.org/api/v1/checkMembership", EmailDomain: "aws.qa.acmuiuc.org", + SqsQueueUrl: "https://sqs.us-east-1.amazonaws.com/427040638965/infra-core-api-sqs" }, prod: { AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, @@ -88,6 +90,7 @@ const environmentConfig: EnvironmentConfigType = { PasskitSerialNumber: "0", MembershipApiEndpoint: "https://infra-membership-api.aws.acmuiuc.org/api/v1/checkMembership", EmailDomain: "acm.illinois.edu", + SqsQueueUrl: "https://sqs.us-east-1.amazonaws.com/298118738376/infra-core-api-sqs" } }; diff --git a/src/common/types/sqsMessage.ts b/src/common/types/sqsMessage.ts new file mode 100644 index 00000000..524d9853 --- /dev/null +++ b/src/common/types/sqsMessage.ts @@ -0,0 +1,58 @@ +import { z, ZodError, ZodType } from "zod"; + +export enum AvailableSQSFunctions { + Ping = "ping", + EmailMembershipPass = "emailMembershipPass", +} + +const sqsMessageMetadataSchema = z.object({ + reqId: z.string().min(1), + initiator: z.string().min(1), +}); + +export type SQSMessageMetadata = z.infer; + +const baseSchema = z.object({ + metadata: sqsMessageMetadataSchema, +}); + +const createSQSSchema = >( + func: T, + payloadSchema: P +) => + baseSchema.extend({ + function: z.literal(func), + payload: payloadSchema, + }); + +export const sqsPayloadSchemas = { + [AvailableSQSFunctions.Ping]: createSQSSchema(AvailableSQSFunctions.Ping, z.object({})), + [AvailableSQSFunctions.EmailMembershipPass]: createSQSSchema( + AvailableSQSFunctions.EmailMembershipPass, + z.object({ email: z.string().email() }) + ), +} as const; + +export const sqsPayloadSchema = z.discriminatedUnion( + "function", + [ + sqsPayloadSchemas[AvailableSQSFunctions.Ping], + sqsPayloadSchemas[AvailableSQSFunctions.EmailMembershipPass], + ] as const +); + + +export type SQSPayload = z.infer< + (typeof sqsPayloadSchemas)[T] +>; + +export type AnySQSPayload = z.infer; + +export function parseSQSPayload(json: unknown): AnySQSPayload | ZodError { + const parsed = sqsPayloadSchema.safeParse(json); + if (parsed.success) { + return parsed.data; + } else { + return parsed.error; + } +} diff --git a/tests/unit/mobileWallet.test.ts b/tests/unit/mobileWallet.test.ts index 6fb20c92..7921ced1 100644 --- a/tests/unit/mobileWallet.test.ts +++ b/tests/unit/mobileWallet.test.ts @@ -1,12 +1,17 @@ -import { afterAll, expect, test, beforeEach, vi } from "vitest"; +import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; import init from "../../src/api/index.js"; -import { describe } from "node:test"; import { EntraFetchError } from "../../src/common/errors/index.js"; +import { + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import { mockClient } from "aws-sdk-client-mock"; -import { issueAppleWalletMembershipCard } from "../../src/api/functions/mobileWallet.js"; -import { SendRawEmailCommand, SESClient } from "@aws-sdk/client-ses"; +import { secretJson } from "./secret.testdata.js"; +import { v4 as uuidv4 } from "uuid"; -const sesMock = mockClient(SESClient); +const smMock = mockClient(SecretsManagerClient); +const sqsMock = mockClient(SQSClient); vi.mock("../../src/api/functions/membership.js", () => { return { @@ -43,14 +48,6 @@ vi.mock("../../src/api/functions/entraId.js", () => { }; }); -vi.mock("../../src/api/functions/mobileWallet.js", () => { - return { - issueAppleWalletMembershipCard: vi.fn().mockImplementation(async () => { - return new ArrayBuffer(); - }), - }; -}); - const app = await init(); describe("Mobile wallet pass issuance", async () => { test("Test that passes will not be issued for non-emails", async () => { @@ -70,13 +67,15 @@ describe("Mobile wallet pass issuance", async () => { await response.json(); }); test("Test that passes will be issued for members", async () => { - sesMock.on(SendRawEmailCommand).resolvesOnce({}).rejects(); + const queueId = uuidv4(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); const response = await app.inject({ method: "POST", url: "/api/v1/mobileWallet/membership?email=valid@illinois.edu", }); expect(response.statusCode).toBe(202); - expect(issueAppleWalletMembershipCard).toHaveBeenCalledOnce(); + const body = await response.json(); + expect(body).toEqual({ queueId }); }); afterAll(async () => { await app.close(); @@ -84,5 +83,8 @@ describe("Mobile wallet pass issuance", async () => { beforeEach(() => { (app as any).nodeCache.flushAll(); vi.clearAllMocks(); + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); }); }); diff --git a/yarn.lock b/yarn.lock index 02fc88e3..0166b7b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -199,6 +199,53 @@ "@smithy/util-waiter" "^4.0.2" tslib "^2.6.2" +"@aws-sdk/client-sqs@^3.738.0": + version "3.738.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sqs/-/client-sqs-3.738.0.tgz#7a0021937ecba7c32feec07bf51becea00581cb0" + integrity sha512-wGxZNV0m0NM+IXub61vkwoq3KD88joG45aYpS4+2ADnZLxnUe/Md1AH+ZMznIv5ZAppCXso7S0Tis3LLK85IGw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.734.0" + "@aws-sdk/credential-provider-node" "3.738.0" + "@aws-sdk/middleware-host-header" "3.734.0" + "@aws-sdk/middleware-logger" "3.734.0" + "@aws-sdk/middleware-recursion-detection" "3.734.0" + "@aws-sdk/middleware-sdk-sqs" "3.734.0" + "@aws-sdk/middleware-user-agent" "3.734.0" + "@aws-sdk/region-config-resolver" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@aws-sdk/util-endpoints" "3.734.0" + "@aws-sdk/util-user-agent-browser" "3.734.0" + "@aws-sdk/util-user-agent-node" "3.734.0" + "@smithy/config-resolver" "^4.0.1" + "@smithy/core" "^3.1.1" + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/hash-node" "^4.0.1" + "@smithy/invalid-dependency" "^4.0.1" + "@smithy/md5-js" "^4.0.1" + "@smithy/middleware-content-length" "^4.0.1" + "@smithy/middleware-endpoint" "^4.0.2" + "@smithy/middleware-retry" "^4.0.3" + "@smithy/middleware-serde" "^4.0.1" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/protocol-http" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.3" + "@smithy/util-defaults-mode-node" "^4.0.3" + "@smithy/util-endpoints" "^3.0.1" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@aws-sdk/client-sso-oidc@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.721.0.tgz#a53b954e5b0112cd253d82b0f68264827e7d36ca" @@ -754,6 +801,24 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@3.738.0": + version "3.738.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.738.0.tgz#0a470fc4d2e791c26da57261b8b14f07de43cd74" + integrity sha512-3MuREsazwBxghKb2sQQHvie+uuK4dX4/ckFYiSoffzJQd0YHxaGxf8cr4NOSCQCUesWu8D3Y0SzlnHGboVSkpA== + dependencies: + "@aws-sdk/credential-provider-env" "3.734.0" + "@aws-sdk/credential-provider-http" "3.734.0" + "@aws-sdk/credential-provider-ini" "3.734.0" + "@aws-sdk/credential-provider-process" "3.734.0" + "@aws-sdk/credential-provider-sso" "3.734.0" + "@aws-sdk/credential-provider-web-identity" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/credential-provider-imds" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@3.716.0": version "3.716.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.716.0.tgz#a8a7b9416cb28c0e2ef601a2713342533619ce4c" @@ -973,6 +1038,18 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@aws-sdk/middleware-sdk-sqs@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.734.0.tgz#65282e8312ae2c6d9c1387533c587c950b71b8af" + integrity sha512-WetobEBbOFt4WutMYNnhkqNG8FDU9ZTLQ7gY0tGdhUKzHo0h/k9TPRZc8WUeKqacZ7gMWMNOjY251izockqWsQ== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/util-hex-encoding" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@aws-sdk/middleware-user-agent@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.721.0.tgz#2a5fbfb63d42a79b4f4b9d94e5aefa66b4e57ddd" @@ -2186,6 +2263,28 @@ dependencies: "@types/mdx" "^2.0.0" +"@middy/core@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@middy/core/-/core-6.0.0.tgz#f1ecbe9809816cc8f5d08a69311861f0103387d6" + integrity sha512-EFsvMkyFfaIu3Uzye26w2NzycwMAbh/99XlhRH9p240y/YKA0nlQ7itE8y7lBJOJ/clj1qu9evQeLfaiWhJAFg== + +"@middy/event-normalizer@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@middy/event-normalizer/-/event-normalizer-6.0.0.tgz#9415cc1bbce466f54f16acd3f0bf8bf2f005efa2" + integrity sha512-JUHCrTME9fQR3ipSc65KW+DLEz/cqfQ63+j9BzHj2SRm6aSAjQIl/br4hds/0VMdMWQKiwL7nEEfWAGpqX6Ddw== + dependencies: + "@middy/util" "6.0.0" + +"@middy/sqs-partial-batch-failure@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@middy/sqs-partial-batch-failure/-/sqs-partial-batch-failure-6.0.0.tgz#c844611c5f9e0c8c5b4358bb8f0b7788676708f2" + integrity sha512-/8uhEibYvoJz2K83hgHFv2wCUxHuR3jGE7vqDL7PrQ4UtgQIJaFqvyKUzn8K+kfrV33EMtkSmI4SoZ3dbzwKWA== + +"@middy/util@6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@middy/util/-/util-6.0.0.tgz#dddada0cfa40dcdfc0b41bd116a58ae14a3212a8" + integrity sha512-V2/gJ4wE6TtMJNAnUTm3VRdgNyLI6zdNLy3MzhrJOwxiUslG1OSShE1IUYR0cmzMOm5w/Y2p3+OIRXRqKUVHYQ== + "@napi-rs/canvas-android-arm64@0.1.65": version "0.1.65" resolved "https://registry.yarnpkg.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.65.tgz#b42f2a2f67cb32ad6669e53561987d58384e791f" @@ -2665,6 +2764,15 @@ dependencies: tslib "^2.6.2" +"@smithy/md5-js@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.0.1.tgz#d7622e94dc38ecf290876fcef04369217ada8f07" + integrity sha512-HLZ647L27APi6zXkZlzSFZIjpo8po45YiyjMGJZM3gyDY8n7dPGdmxIIljLm4gPt/7rRvutLTTkYJpZVfG5r+A== + dependencies: + "@smithy/types" "^4.1.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@smithy/middleware-content-length@^3.0.13": version "3.0.13" resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-3.0.13.tgz#6e08fe52739ac8fb3996088e0f8837e4b2ea187f" @@ -3731,6 +3839,11 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== +"@types/aws-lambda@^8.10.147": + version "8.10.147" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.147.tgz#dc5c89aa32f47a9b35e52c32630545c83afa6f2f" + integrity sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew== + "@types/babel__core@^7.18.0", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -4537,7 +4650,7 @@ avvio@^9.0.0: "@fastify/error" "^4.0.0" fastq "^1.17.1" -aws-sdk-client-mock@^4.0.1: +aws-sdk-client-mock@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz#ae1950b2277f8e65f9a039975d79ff9fffab39e3" integrity sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw== @@ -8082,7 +8195,7 @@ pino-std-serializers@^7.0.0: resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^9.0.0: +pino@^9.0.0, pino@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/pino/-/pino-9.6.0.tgz#6bc628159ba0cc81806d286718903b7fc6b13169" integrity sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg== @@ -9953,6 +10066,11 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uuid@^11.0.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62" + integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"