diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 92c7a13372..71e1c0e4cb 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -10,7 +10,7 @@ import { nanoId } from "@cap/database/helpers"; import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { dub, userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; +import { AwsCredentials, S3Buckets } from "@cap/web-backend"; import { type Folder, type Organisation, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -60,10 +60,9 @@ async function getVideoUploadPresignedUrl({ if (distributionId) { const cloudfront = new CloudFrontClient({ region: serverEnv().CAP_AWS_REGION || "us-east-1", - credentials: { - accessKeyId: serverEnv().CAP_AWS_ACCESS_KEY || "", - secretAccessKey: serverEnv().CAP_AWS_SECRET_KEY || "", - }, + credentials: await runPromise( + Effect.map(AwsCredentials, (c) => c.credentials), + ), }); const pathToInvalidate = "/" + fileKey; diff --git a/apps/web/app/api/upload/[...route]/signed.ts b/apps/web/app/api/upload/[...route]/signed.ts index 6c34a77890..ffd46e4c9a 100644 --- a/apps/web/app/api/upload/[...route]/signed.ts +++ b/apps/web/app/api/upload/[...route]/signed.ts @@ -6,7 +6,7 @@ import type { PresignedPost } from "@aws-sdk/s3-presigned-post"; import { db, updateIfDefined } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; +import { AwsCredentials, S3Buckets } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; import { zValidator } from "@hono/zod-validator"; import { and, eq } from "drizzle-orm"; @@ -73,10 +73,9 @@ app.post( const cloudfront = new CloudFrontClient({ region: serverEnv().CAP_AWS_REGION || "us-east-1", - credentials: { - accessKeyId: serverEnv().CAP_AWS_ACCESS_KEY || "", - secretAccessKey: serverEnv().CAP_AWS_SECRET_KEY || "", - }, + credentials: await runPromise( + Effect.map(AwsCredentials, (c) => c.credentials), + ), }); const pathToInvalidate = "/" + fileKey; diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index d4e54b692c..eca18c5b8a 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -115,9 +115,10 @@ export const POST = async (req: Request) => { console.log("Webhook received"); const buf = await req.text(); const sig = req.headers.get("Stripe-Signature") as string; - const webhookSecret = serverEnv().VERCEL_ENV === "production" - ? serverEnv().STRIPE_WEBHOOK_SECRET_LIVE - : serverEnv().STRIPE_WEBHOOK_SECRET_TEST; + const webhookSecret = + serverEnv().VERCEL_ENV === "production" + ? serverEnv().STRIPE_WEBHOOK_SECRET_LIVE + : serverEnv().STRIPE_WEBHOOK_SECRET_TEST; let event: Stripe.Event; try { diff --git a/apps/web/lib/server.ts b/apps/web/lib/server.ts index adef04d2a2..c4badc574b 100644 --- a/apps/web/lib/server.ts +++ b/apps/web/lib/server.ts @@ -2,6 +2,7 @@ import "server-only"; import { decrypt } from "@cap/database/crypto"; import { + AwsCredentials, Database, Folders, HttpAuthMiddlewareLive, @@ -104,6 +105,7 @@ export const Dependencies = Layer.mergeAll( SpacesPolicy.Default, OrganisationsPolicy.Default, Spaces.Default, + AwsCredentials.Default, WorkflowRpcLive, layerTracer, ).pipe( diff --git a/infra/sst-env.d.ts b/infra/sst-env.d.ts index ba2fdf30c1..676bd17b83 100644 --- a/infra/sst-env.d.ts +++ b/infra/sst-env.d.ts @@ -5,10 +5,48 @@ declare module "sst" { export interface Resource { - DATABASE_URL: { + AuroraDB: { + clusterArn: string; + database: string; + host: string; + password: string; + port: number; + reader: string; + secretArn: string; + type: "sst.aws.Aurora"; + username: string; + }; + CAP_AWS_ACCESS_KEY: { + type: "sst.sst.Secret"; + value: string; + }; + CAP_AWS_SECRET_KEY: { + type: "sst.sst.Secret"; + value: string; + }; + DATABASE_URL_MYSQL: { type: "sst.sst.Secret"; value: string; }; + GITHUB_PAT: { + type: "sst.sst.Secret"; + value: string; + }; + MyApi: { + type: "sst.aws.ApiGatewayV2"; + url: string; + }; + Runner: { + service: string; + type: "sst.aws.Service"; + }; + ShardManager: { + service: string; + type: "sst.aws.Service"; + }; + Vpc: { + type: "sst.aws.Vpc"; + }; } } /// diff --git a/infra/sst.config.ts b/infra/sst.config.ts index 88f17fdb36..ddf7800fac 100644 --- a/infra/sst.config.ts +++ b/infra/sst.config.ts @@ -45,13 +45,16 @@ export default $config({ const secrets = Secrets(); // const planetscale = Planetscale(); - const recordingsBucket = new aws.s3.BucketV2("RecordingsBucket"); + const recordingsBucket = new aws.s3.BucketV2( + "RecordingsBucket", + {}, + { retainOnDelete: true }, + ); const vercelVariables = [ { key: "NEXT_PUBLIC_AXIOM_TOKEN", value: AXIOM_API_TOKEN }, { key: "NEXT_PUBLIC_AXIOM_DATASET", value: AXIOM_DATASET }, { key: "CAP_AWS_BUCKET", value: recordingsBucket.bucket }, - { key: "NEXT_PUBLIC_CAP_AWS_BUCKET", value: recordingsBucket.bucket }, { key: "DATABASE_URL", value: secrets.DATABASE_URL_MYSQL.value }, ]; @@ -60,21 +63,21 @@ export default $config({ // status: "Enabled", // }); - // const cloudfrontDistribution = aws.cloudfront.getDistributionOutput({ - // id: "E36XSZEM0VIIYB", - // }); + const cloudfrontDistribution = + $app.stage === "production" + ? aws.cloudfront.getDistributionOutput({ id: "E36XSZEM0VIIYB" }) + : null; const vercelUser = new aws.iam.User("VercelUser", { forceDestroy: false }); const vercelProject = vercel.getProjectOutput({ name: "cap-web" }); - if (webUrl) { + if (webUrl) vercelVariables.push( { key: "WEB_URL", value: webUrl }, { key: "NEXT_PUBLIC_WEB_URL", value: webUrl }, { key: "NEXTAUTH_URL", value: webUrl }, ); - } // vercelEnvVar("VercelCloudfrontEnv", { // key: "CAP_CLOUDFRONT_DISTRIBUTION_ID", @@ -89,16 +92,14 @@ export default $config({ return { aud, url, - provider: await aws.iam - .getOpenIdConnectProvider({ url: `https://${url}` }) - .catch( - () => - new aws.iam.OpenIdConnectProvider( + provider: + $app.stage === "production" || $app.stage === "staging" + ? aws.iam.getOpenIdConnectProviderOutput({ url: `https://${url}` }) + : new aws.iam.OpenIdConnectProvider( "VercelAWSOIDC", { url: `https://${url}`, clientIdLists: [aud] }, { retainOnDelete: true }, ), - ), }; })(); @@ -118,7 +119,7 @@ export default $config({ }, StringLike: { [`${oidc.url}:sub`]: [ - `owner:${VERCEL_TEAM_SLUG}:project:*:environment:staging`, + `owner:${VERCEL_TEAM_SLUG}:project:*:environment:${$app.stage}`, ], }, }, @@ -128,40 +129,57 @@ export default $config({ inlinePolicies: [ { name: "VercelAWSAccessPolicy", - policy: recordingsBucket.arn.apply((arn) => + policy: $resolve([ + recordingsBucket.arn, + cloudfrontDistribution?.arn, + ] as const).apply(([bucketArn, cloudfrontArn]) => JSON.stringify({ Version: "2012-10-17", Statement: [ { Effect: "Allow", Action: ["s3:*"], - Resource: `${arn}/*`, + Resource: `${bucketArn}/*`, }, { Effect: "Allow", Action: ["s3:*"], - Resource: `${arn}`, + Resource: bucketArn, }, - ], + cloudfrontArn && { + Effect: "Allow", + Action: ["cloudfront:CreateInvalidation"], + Resource: cloudfrontArn, + }, + ].filter(Boolean), }), ), }, ], }); - const workflowCluster = await WorkflowCluster(recordingsBucket, secrets); + const workflowCluster = + $app.stage === "staging" + ? await WorkflowCluster(recordingsBucket, secrets) + : null; if ($app.stage === "staging" || $app.stage === "production") { [ ...vercelVariables, - { key: "WORKFLOWS_RPC_URL", value: workflowCluster.api.url }, - { + workflowCluster && { + key: "WORKFLOWS_RPC_URL", + value: workflowCluster.api.url, + }, + workflowCluster && { key: "WORKFLOWS_RPC_SECRET", value: secrets.WORKFLOWS_RPC_SECRET.result, }, { key: "VERCEL_AWS_ROLE_ARN", value: vercelAwsAccessRole.arn }, - ].map( - (v) => + ] + .filter(Boolean) + .forEach((_v) => { + const v = _v as NonNullable; + new vercel.ProjectEnvironmentVariable(`VercelEnv${v.key}`, { ...v, projectId: vercelProject.id, @@ -171,8 +189,8 @@ export default $config({ : undefined, targets: $app.stage === "staging" ? undefined : ["preview", "production"], - }), - ); + }); + }); } // DiscordBot(); @@ -193,21 +211,6 @@ function Secrets() { type Secrets = ReturnType; -// function Planetscale() { -// const org = planetscale.getOrganizationOutput({ name: "cap" }); -// const db = planetscale.getDatabaseOutput({ -// name: "cap-production", -// organization: org.name, -// }); -// const branch = planetscale.getBranchOutput({ -// name: $app.stage === "production" ? "main" : "staging", -// database: db.name, -// organization: org.name, -// }); - -// return { org, db, branch }; -// } - // function DiscordBot() { // new sst.cloudflare.Worker("DiscordBotScript", { // handler: "../apps/discord-bot/src/index.ts", diff --git a/packages/web-backend/src/Aws.ts b/packages/web-backend/src/Aws.ts new file mode 100644 index 0000000000..3fc945840e --- /dev/null +++ b/packages/web-backend/src/Aws.ts @@ -0,0 +1,43 @@ +import { fromContainerMetadata, fromSSO } from "@aws-sdk/credential-providers"; +import type { + AwsCredentialIdentity, + AwsCredentialIdentityProvider, +} from "@smithy/types"; +import { awsCredentialsProvider } from "@vercel/functions/oidc"; +import { Config, Effect, Option } from "effect"; + +export class AwsCredentials extends Effect.Service()( + "AwsCredentials", + { + effect: Effect.gen(function* () { + let credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider; + + const accessKeys = yield* Config.option( + Config.all([ + Config.string("CAP_AWS_ACCESS_KEY"), + Config.string("CAP_AWS_SECRET_KEY"), + ]), + ); + const vercelAwsRole = yield* Config.option( + Config.string("VERCEL_AWS_ROLE_ARN"), + ); + + if (Option.isSome(accessKeys)) { + const [accessKeyId, secretAccessKey] = accessKeys.value; + yield* Effect.log("Using CAP_AWS_ACCESS_KEY and CAP_AWS_SECRET_KEY"); + credentials = { accessKeyId, secretAccessKey }; + } else if (Option.isSome(vercelAwsRole)) { + yield* Effect.log("Using VERCEL_AWS_ROLE_ARN"); + credentials = awsCredentialsProvider({ roleArn: vercelAwsRole.value }); + } else if (process.env.NODE_ENV === "development") { + yield* Effect.log("Using AWS_DEFAULT_PROFILE"); + credentials = fromSSO({ profile: process.env.AWS_DEFAULT_PROFILE }); + } else { + yield* Effect.log("Falling back to ECS metadata"); + credentials = fromContainerMetadata(); + } + + return { credentials }; + }), + }, +) {} diff --git a/packages/web-backend/src/S3Buckets/index.ts b/packages/web-backend/src/S3Buckets/index.ts index 0ba4f99c76..240e3c8a5b 100644 --- a/packages/web-backend/src/S3Buckets/index.ts +++ b/packages/web-backend/src/S3Buckets/index.ts @@ -3,9 +3,9 @@ import * as CloudFrontPresigner from "@aws-sdk/cloudfront-signer"; import { fromContainerMetadata, fromSSO } from "@aws-sdk/credential-providers"; import { decrypt } from "@cap/database/crypto"; import type { S3Bucket, User } from "@cap/web-domain"; -import { awsCredentialsProvider } from "@vercel/functions/oidc"; import { Config, Effect, Layer, Option } from "effect"; +import { AwsCredentials } from "../Aws.ts"; import { Database } from "../Database.ts"; import { createS3BucketAccess } from "./S3BucketAccess.ts"; import { S3BucketClientProvider } from "./S3BucketClientProvider.ts"; @@ -14,6 +14,7 @@ import { S3BucketsRepo } from "./S3BucketsRepo.ts"; export class S3Buckets extends Effect.Service()("S3Buckets", { effect: Effect.gen(function* () { const repo = yield* S3BucketsRepo; + const { credentials } = yield* AwsCredentials; const defaultConfigs = { publicEndpoint: yield* Config.string("S3_PUBLIC_ENDPOINT").pipe( @@ -25,33 +26,7 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { Config.option, ), region: yield* Config.string("CAP_AWS_REGION"), - credentials: yield* Config.option( - Config.all([ - Config.string("CAP_AWS_ACCESS_KEY"), - Config.string("CAP_AWS_SECRET_KEY"), - ]).pipe( - Config.map(([accessKeyId, secretAccessKey]) => ({ - accessKeyId, - secretAccessKey, - })), - Config.orElse(() => - Config.string("VERCEL_AWS_ROLE_ARN").pipe( - Config.map((arn) => awsCredentialsProvider({ roleArn: arn })), - ), - ), - ), - ).pipe( - Effect.flatMap( - Effect.catchTag("NoSuchElementException", () => - Effect.succeed( - process.env.NODE_ENV === "development" - ? fromSSO({ profile: process.env.AWS_DEFAULT_PROFILE }) - : fromContainerMetadata(), - ), - ), - ), - ), - + credentials, forcePathStyle: Option.getOrNull( yield* Config.boolean("S3_PATH_STYLE").pipe(Config.option), @@ -205,7 +180,11 @@ export class S3Buckets extends Effect.Service()("S3Buckets", { ), }; }), - dependencies: [S3BucketsRepo.Default, Database.Default], + dependencies: [ + S3BucketsRepo.Default, + Database.Default, + AwsCredentials.Default, + ], }) { static getBucketAccess = (bucketId: Option.Option) => Effect.flatMap(S3Buckets, (b) => diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index fca94d4440..0898b86376 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -1,4 +1,5 @@ export * from "./Auth.ts"; +export * from "./Aws.ts"; export * from "./Database.ts"; export { Folders } from "./Folders/index.ts"; export { HttpLive } from "./Http/Live.ts";