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";