Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
createMemo,
createResource,
For,
ParentProps,
type ParentProps,
Show,
} from "solid-js";
import { createStore, reconcile } from "solid-js/store";
Expand Down
8 changes: 0 additions & 8 deletions infra/sst-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,6 @@ declare module "sst" {
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;
Expand Down
148 changes: 96 additions & 52 deletions infra/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ const GITHUB_ORG = "CapSoftware";
const GITHUB_REPO = "Cap";
const GITHUB_APP_ID = "1196731";

const VERCEL_PROJECT_NAME = "cap-web";
const VERCEL_TEAM_SLUG = "mc-ilroy";
const VERCEL_TEAM_ID = "team_vbZRU7UW78rpKKIj4c9PfFAC";

const CLOUDFLARE_ACCOUNT_ID = "3de2dd633194481d80f68f55257bdbaa";
const AXIOM_API_TOKEN = "xaat-c0704be6-e942-4935-b068-3b491d7cc00f";
const AXIOM_DATASET = "cap-otel";

const parsedStage = () => {
if ($app.stage === "staging") return { variant: "staging" } as const;
if ($app.stage === "production") return { variant: "production" } as const;
if ($app.stage.startsWith("git-branch-"))
return {
variant: "git-branch",
branch: $app.stage.slice("git-branch-".length),
} as const;
throw new Error("Unsupported stage");
};

export default $config({
app(input) {
return {
Expand All @@ -37,11 +49,14 @@ export default $config({
};
},
async run() {
const stage = parsedStage();
const WEB_URLS: Record<string, string> = {
production: "https://cap.so",
staging: "https://staging.cap.so",
};
const webUrl = WEB_URLS[$app.stage];
const webUrl =
WEB_URLS[stage.variant] ??
`https://${VERCEL_PROJECT_NAME}-git-${stage.branch}-${VERCEL_TEAM_SLUG}.vercel.app`;
const secrets = Secrets();
// const planetscale = Planetscale();

Expand All @@ -51,26 +66,48 @@ export default $config({
{ retainOnDelete: true },
);

new aws.s3.BucketCorsConfigurationV2("RecordingsBucketCors", {
bucket: recordingsBucket.bucket,
corsRules: [
{
allowedHeaders: ["*"],
allowedMethods: ["GET", "POST"],
allowedOrigins:
stage.variant === "production"
? [
"https://cap.so",
"https://cap.link",
"https://v.cap.so",
"https://dyk2p776s2gx5.cloudfront.net",
]
: ["http://localhost:*", "https://*.vercel.app", webUrl],
exposeHeaders: [],
},
],
});
Comment on lines +69 to +87
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Invalid CORS wildcard patterns.

The CORS allowedOrigins configuration uses wildcard patterns (http://localhost:* and https://*.vercel.app) that are not valid in AWS S3 CORS configuration. S3 only supports exact origins or the special * value for all origins. These patterns will cause CORS validation errors at runtime.

Apply this fix to use valid CORS origins:

 					allowedOrigins:
 						stage.variant === "production"
 							? [
 									"https://cap.so",
 									"https://cap.link",
 									"https://v.cap.so",
 									"https://dyk2p776s2gx5.cloudfront.net",
 								]
-							: ["http://localhost:*", "https://*.vercel.app", webUrl],
+							: [
+									"http://localhost:3000",
+									"http://localhost:3001",
+									webUrl,
+								],

Note: You'll need to explicitly list each localhost port you use. For Vercel preview deployments, the webUrl already includes the specific preview URL. If you need to allow multiple Vercel domains, you must list them explicitly or consider using a more permissive CORS policy for non-production stages.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
new aws.s3.BucketCorsConfigurationV2("RecordingsBucketCors", {
bucket: recordingsBucket.bucket,
corsRules: [
{
allowedHeaders: ["*"],
allowedMethods: ["GET", "POST"],
allowedOrigins:
stage.variant === "production"
? [
"https://cap.so",
"https://cap.link",
"https://v.cap.so",
"https://dyk2p776s2gx5.cloudfront.net",
]
: ["http://localhost:*", "https://*.vercel.app", webUrl],
exposeHeaders: [],
},
],
});
new aws.s3.BucketCorsConfigurationV2("RecordingsBucketCors", {
bucket: recordingsBucket.bucket,
corsRules: [
{
allowedHeaders: ["*"],
allowedMethods: ["GET", "POST"],
allowedOrigins:
stage.variant === "production"
? [
"https://cap.so",
"https://cap.link",
"https://v.cap.so",
"https://dyk2p776s2gx5.cloudfront.net",
]
: [
"http://localhost:3000",
"http://localhost:3001",
webUrl,
],
exposeHeaders: [],
},
],
});
🤖 Prompt for AI Agents
In infra/sst.config.ts around lines 69 to 87, the S3 CORS allowedOrigins
currently contains invalid wildcard patterns (e.g., "http://localhost:*" and
"https://*.vercel.app") which AWS S3 rejects; replace those patterns with only
exact origin strings or the single "*" wildcard. Concretely, enumerate each
localhost origin you need (e.g., "http://localhost:3000"), include the explicit
webUrl and any specific Vercel preview URLs you want to allow, and for
non-production stages either list all known origins explicitly or use "*" (if
acceptable for your security model) instead of wildcard patterns. Ensure the
final corsRules.allowedOrigins array contains only exact origins or "*" so the
CORS configuration validates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not true, a single wildcard is allowed anywhere in each origin

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

AWS S3 CORS configuration wildcard support in allowedOrigins

💡 Result:

Short answer: S3 supports wildcards in AllowedOrigin — you may use a single * in a domain (e.g. http://.example.com) and you can also use "" to allow all origins. Only one wildcard character is permitted per AllowedOrigin. [1][2]

Sources:

  • AWS S3 CORS documentation (Elements of a CORS configuration). [1]
  • PutBucketCors / API docs (examples showing AllowedOrigin = "*"). [2]

@Brendonovich You're absolutely right, and I apologize for the incorrect information. AWS S3 CORS does indeed support a single wildcard character per AllowedOrigin, so patterns like http://localhost:* and https://*.vercel.app are completely valid.[1][2]

The CORS configuration in your PR is correct as-is. Thank you for the correction!


🐰💭


✏️ Learnings added
Learnt from: Brendonovich
PR: CapSoftware/Cap#1199
File: infra/sst.config.ts:69-87
Timestamp: 2025-10-15T10:03:20.558Z
Learning: AWS S3 CORS configuration supports wildcard patterns in AllowedOrigin entries. A single wildcard character (*) is permitted per AllowedOrigin, allowing patterns like `http://localhost:*` or `https://*.example.com`. The special value "*" can also be used to allow all origins.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


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: "DATABASE_URL", value: secrets.DATABASE_URL_MYSQL.value },
];

// new aws.s3.BucketAccelerateConfigurationV2("RecordingsBucketAcceleration", {
// bucket: recordingsBucket.id,
// status: "Enabled",
// });
new aws.s3.BucketAccelerateConfigurationV2("RecordingsBucketAcceleration", {
bucket: recordingsBucket.id,
status: "Enabled",
});

const cloudfrontDistribution =
$app.stage === "production"
stage.variant === "production"
? aws.cloudfront.getDistributionOutput({ id: "E36XSZEM0VIIYB" })
: null;

const vercelUser = new aws.iam.User("VercelUser", { forceDestroy: false });

const vercelProject = vercel.getProjectOutput({ name: "cap-web" });
const vercelProject = vercel.getProjectOutput({
name: VERCEL_PROJECT_NAME,
});

if (webUrl)
vercelVariables.push(
Expand All @@ -92,14 +129,9 @@ export default $config({
return {
aud,
url,
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 },
),
provider: aws.iam.getOpenIdConnectProviderOutput({
url: `https://${url}`,
}),
};
})();

Expand All @@ -119,7 +151,7 @@ export default $config({
},
StringLike: {
[`${oidc.url}:sub`]: [
`owner:${VERCEL_TEAM_SLUG}:project:*:environment:${$app.stage}`,
`owner:${VERCEL_TEAM_SLUG}:project:*:environment:${stage.variant === "git-branch" ? "preview" : stage.variant}`,
],
},
},
Expand Down Expand Up @@ -159,39 +191,51 @@ export default $config({
});

const workflowCluster =
$app.stage === "staging"
stage.variant === "staging"
? await WorkflowCluster(recordingsBucket, secrets)
: null;

if ($app.stage === "staging" || $app.stage === "production") {
[
...vercelVariables,
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 },
]
.filter(Boolean)
.forEach((_v) => {
const v = _v as NonNullable<typeof _v>;

new vercel.ProjectEnvironmentVariable(`VercelEnv${v.key}`, {
[
...vercelVariables,
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 },
]
.filter(Boolean)
.forEach((_v) => {
const v = _v as NonNullable<typeof _v>;

new vercel.ProjectEnvironmentVariable(
`VercelEnv${v.key}`,
{
...v,
projectId: vercelProject.id,
customEnvironmentIds:
$app.stage === "staging"
stage.variant === "staging"
? ["env_CFbtmnpsI11e4o8X5UD8MZzxELQi"]
: undefined,
targets:
$app.stage === "staging" ? undefined : ["preview", "production"],
});
});
}
stage.variant === "production"
? ["production"]
: stage.variant === "staging"
? ["development", "preview"]
: stage.variant === "git-branch"
? ["preview"]
: undefined,
gitBranch:
stage.variant === "git-branch" ? stage.branch : undefined,
comment:
"This var is being managed by SST, do not edit or delete it via the Vercel dashboard",
},
{ deleteBeforeReplace: true },
);
});

// DiscordBot();
},
Expand All @@ -200,9 +244,8 @@ export default $config({
function Secrets() {
return {
DATABASE_URL_MYSQL: new sst.Secret("DATABASE_URL_MYSQL"),
CAP_AWS_ACCESS_KEY: new sst.Secret("CAP_AWS_ACCESS_KEY"),
CAP_AWS_SECRET_KEY: new sst.Secret("CAP_AWS_SECRET_KEY"),
GITHUB_PAT: new sst.Secret("GITHUB_PAT"),
GITHUB_PAT:
$app.stage === "staging" ? new sst.Secret("GITHUB_PAT") : undefined,
WORKFLOWS_RPC_SECRET: new random.RandomString("WORKFLOWS_RPC_SECRET", {
length: 48,
}),
Expand Down Expand Up @@ -312,15 +355,16 @@ async function WorkflowCluster(bucket: aws.s3.BucketV2, secrets: Secrets) {
"GHCRCredentialsSecret",
);

new aws.secretsmanager.SecretVersion("GHCRCredentialsSecretVersion", {
secretId: ghcrCredentialsSecret.id,
secretString: secrets.GITHUB_PAT.value.apply((password) =>
JSON.stringify({
username: "brendonovich",
password,
}),
),
});
if (secrets.GITHUB_PAT)
new aws.secretsmanager.SecretVersion("GHCRCredentialsSecretVersion", {
secretId: ghcrCredentialsSecret.id,
secretString: secrets.GITHUB_PAT.value.apply((password) =>
JSON.stringify({
username: "brendonovich",
password,
}),
),
});

const ghcrCredentialsTransform = {
taskRole(args) {
Expand Down
39 changes: 23 additions & 16 deletions packages/web-backend/src/Aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ export class AwsCredentials extends Effect.Service<AwsCredentials>()(
"AwsCredentials",
{
effect: Effect.gen(function* () {
let credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider;

const accessKeys = yield* Config.option(
Config.all([
Config.string("CAP_AWS_ACCESS_KEY"),
Expand All @@ -22,20 +20,29 @@ export class AwsCredentials extends Effect.Service<AwsCredentials>()(
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();
}
const credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider =
yield* Effect.gen(function* () {
if (Option.isSome(vercelAwsRole)) {
yield* Effect.log("Using VERCEL_AWS_ROLE_ARN");
return awsCredentialsProvider({ roleArn: vercelAwsRole.value });
}

if (Option.isSome(accessKeys)) {
const [accessKeyId, secretAccessKey] = accessKeys.value;
yield* Effect.log(
"Using CAP_AWS_ACCESS_KEY and CAP_AWS_SECRET_KEY",
);
return { accessKeyId, secretAccessKey };
}

if (process.env.NODE_ENV === "development") {
yield* Effect.log("Using AWS_DEFAULT_PROFILE");
return fromSSO({ profile: process.env.AWS_DEFAULT_PROFILE });
}

yield* Effect.log("Falling back to ECS metadata");
return fromContainerMetadata();
});

return { credentials };
}),
Expand Down