From b474e823ce912a0c5e15f109e99034fe182312b0 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 27 Sep 2026 14:46:04 -0400 Subject: [PATCH] feat: CORS enable button...ish still now understanding this --- .../adapter-aws/AwsApiGatewayAdapter.test.ts | 73 ++++++++++- .../src/adapter-aws/AwsApiGatewayAdapter.ts | 115 ++++++++++++++++- .../src/adapter-aws/AwsStorageAdapter.test.ts | 36 ++++++ .../api/src/adapter-aws/AwsStorageAdapter.ts | 68 +++++++++- packages/api/src/cloud-spi/types.ts | 22 ++++ packages/api/src/routes/clouds.test.ts | 55 ++++++++ packages/api/src/routes/clouds.ts | 73 +++++++++++ .../api/src/service/CloudProxyService.test.ts | 29 +++++ packages/api/src/service/CloudProxyService.ts | 27 ++++ packages/frontend/src/api/api.ts | 40 +++++- packages/frontend/src/api/cloudProxyClient.ts | 67 ++++++++++ .../components/ApiGatewayRoutePanel.test.tsx | 60 +++++++++ .../src/components/ApiGatewayRoutePanel.tsx | 122 ++++++++++++++++-- .../src/components/DynamicResourceView.tsx | 21 +-- .../components/S3BucketWebsitePanel.test.tsx | 54 ++++++++ .../src/components/S3BucketWebsitePanel.tsx | 108 ++++++++++++++++ packages/frontend/src/types/resource.ts | 4 + 17 files changed, 943 insertions(+), 31 deletions(-) create mode 100644 packages/api/src/service/CloudProxyService.test.ts create mode 100644 packages/frontend/src/components/S3BucketWebsitePanel.test.tsx create mode 100644 packages/frontend/src/components/S3BucketWebsitePanel.tsx diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts index 283f541..e7f8d1d 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -11,7 +11,9 @@ import { GetResourcesCommand, GetStagesCommand, PutIntegrationCommand, + PutIntegrationResponseCommand, PutMethodCommand, + PutMethodResponseCommand, type APIGatewayClient, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' @@ -280,8 +282,8 @@ describe('AwsApiGatewayAdapter', () => { await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({ routes: [ - {resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello'}, - {resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello'}, + {resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello', integrationType: null}, + {resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello', integrationType: null}, ], stages: ['dev', 'prod'], }) @@ -308,6 +310,73 @@ describe('AwsApiGatewayAdapter', () => { expect((lambdaCommands[0] as RemovePermissionCommand).input).toMatchObject({FunctionName: 'hello', StatementId: 'apigateway-a1b2c3-dev-POST-hello'}) }) + test('enables CORS for non-proxy methods, creates OPTIONS, and reports proxy methods', async () => { + const commands: unknown[] = [] + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + commands.push(command) + if (command instanceof GetMethodCommand && command.input.httpMethod === 'GET') { + return { + methodResponses: {200: { + responseParameters: {'method.response.header.X-Existing': true}, + responseModels: {'application/json': 'ExistingModel'}, + }}, + methodIntegration: {type: 'HTTP', integrationResponses: {200: { + responseParameters: {'method.response.header.X-Existing': "'existing'"}, + responseTemplates: {'application/json': '$input.body'}, + }}}, + } + } + if (command instanceof GetMethodCommand && command.input.httpMethod === 'POST') { + return {methodIntegration: {type: 'AWS_PROXY'}} + } + if (command instanceof GetMethodCommand && command.input.httpMethod === 'OPTIONS') { + return { + methodResponses: {200: { + responseParameters: {'method.response.header.Access-Control-Allow-Origin': true}, + responseModels: {'application/json': 'OptionsModel'}, + }}, + methodIntegration: {timeoutInMillis: 29000, integrationResponses: {200: { + responseTemplates: {'text/plain': 'existing template'}, + }}}, + } + } + return {} + })) + + await expect(adapter.enableApiGatewayCors('a1b2c3', { + resourceId: 'resource456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], + })).resolves.toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']}) + + expect(commands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([ + 'GetMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationResponseCommand', 'GetMethodCommand', 'GetMethodCommand', + 'PutMethodCommand', 'PutMethodResponseCommand', 'PutIntegrationCommand', 'PutIntegrationResponseCommand', 'CreateDeploymentCommand', + ]) + expect((commands[1] as PutMethodResponseCommand).input.responseParameters).toEqual({ + 'method.response.header.X-Existing': true, + 'method.response.header.Access-Control-Allow-Origin': false, + 'method.response.header.Access-Control-Allow-Headers': false, + 'method.response.header.Access-Control-Allow-Methods': false, + }) + expect((commands[1] as PutMethodResponseCommand).input.responseModels).toEqual({'application/json': 'ExistingModel'}) + expect((commands[2] as PutIntegrationResponseCommand).input.responseParameters).toMatchObject({ + 'method.response.header.X-Existing': "'existing'", + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Headers': "'Content-Type'", + 'method.response.header.Access-Control-Allow-Methods': "'GET,POST,OPTIONS'", + }) + expect((commands[2] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'application/json': '$input.body'}) + expect((commands[5] as PutMethodCommand).input).toMatchObject({httpMethod: 'OPTIONS', authorizationType: 'NONE'}) + expect((commands[6] as PutMethodResponseCommand).input).toMatchObject({ + responseParameters: {'method.response.header.Access-Control-Allow-Origin': true}, + responseModels: {'application/json': 'OptionsModel'}, + }) + expect((commands[7] as PutIntegrationCommand).input).toMatchObject({ + type: 'MOCK', requestTemplates: {'application/json': '{"statusCode": 200}'}, timeoutInMillis: 29000, + }) + expect((commands[8] as PutIntegrationResponseCommand).input.responseTemplates).toEqual({'text/plain': 'existing template'}) + expect((commands[9] as CreateDeploymentCommand).input).toMatchObject({stageName: 'dev'}) + }) + test('returns the AWS API Gateway schema', () => { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts index 8376d10..95f85cf 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -10,8 +10,13 @@ import { GetResourcesCommand, GetStagesCommand, PutIntegrationCommand, + PutIntegrationResponseCommand, PutMethodCommand, + PutMethodResponseCommand, type APIGatewayClient, + type Integration, + type IntegrationResponse, + type MethodResponse, type RestApi, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' @@ -19,7 +24,7 @@ import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLamb import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' import {ValidationError} from '../cloud-spi/errors' import {awsEndpoint} from '../service/runtimeProbe' -import type {ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' +import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' export class AwsApiGatewayAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const @@ -200,6 +205,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { path: resource.path, method, functionName: lambdaFunctionName(result.methodIntegration?.uri), + integrationType: result.methodIntegration?.type ?? null, }) } } @@ -245,6 +251,93 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { })) } + async enableApiGatewayCors(apiId: string, input: ApiGatewayCorsInput): Promise { + const resourceId = stringValue(input.resourceId) + const stage = stageName(input.stage) + const origin = stringValue(input.origin) + const headers = stringValue(input.headers) + const methods = [...new Set(input.methods.map((method) => method.toUpperCase()))] + if (!resourceId) throw new ValidationError('resourceId is required') + if (!origin) throw new ValidationError('origin is required') + if (!headers) throw new ValidationError('headers are required') + if (methods.length === 0 || methods.some((method) => !CORS_METHODS.includes(method))) { + throw new ValidationError('methods must contain one or more of GET, POST, PUT, DELETE, or OPTIONS') + } + + const corsParameters = corsResponseParameters(origin, headers, methods) + const configuredMethods: string[] = [] + const proxyMethods: string[] = [] + for (const method of methods) { + if (method === 'OPTIONS') continue + const route = await this.apiGateway.send(new GetMethodCommand({restApiId: apiId, resourceId, httpMethod: method})) + if (route.methodIntegration?.type === 'AWS_PROXY') { + proxyMethods.push(method) + continue + } + const responseCodes = Object.keys(route.methodResponses ?? {200: {}}) + for (const statusCode of responseCodes) { + await this.apiGateway.send(new PutMethodResponseCommand({ + restApiId: apiId, resourceId, httpMethod: method, statusCode, + responseParameters: { + ...route.methodResponses?.[statusCode]?.responseParameters, + ...corsMethodResponseParameters(), + }, + responseModels: route.methodResponses?.[statusCode]?.responseModels, + })) + } + const integrationResponses = route.methodIntegration?.integrationResponses ?? {200: {}} + for (const [statusCode, response] of Object.entries(integrationResponses)) { + await this.apiGateway.send(new PutIntegrationResponseCommand({ + restApiId: apiId, resourceId, httpMethod: method, statusCode, + selectionPattern: response.selectionPattern, + responseParameters: {...response.responseParameters, ...corsParameters}, + responseTemplates: response.responseTemplates, + contentHandling: response.contentHandling, + })) + } + configuredMethods.push(method) + } + + let existingOptionsMethodResponse: MethodResponse | undefined + let existingOptionsIntegration: Integration | undefined + let existingOptionsIntegrationResponse: IntegrationResponse | undefined + try { + const optionsRoute = await this.apiGateway.send(new GetMethodCommand({ + restApiId: apiId, resourceId, httpMethod: 'OPTIONS', + })) + existingOptionsMethodResponse = optionsRoute.methodResponses?.['200'] + existingOptionsIntegration = optionsRoute.methodIntegration + existingOptionsIntegrationResponse = optionsRoute.methodIntegration?.integrationResponses?.['200'] + } catch (error) { + if (!hasHttpStatus(error, 404)) throw error + } + await this.apiGateway.send(new PutMethodCommand({restApiId: apiId, resourceId, httpMethod: 'OPTIONS', authorizationType: 'NONE'})) + await this.apiGateway.send(new PutMethodResponseCommand({ + restApiId: apiId, resourceId, httpMethod: 'OPTIONS', statusCode: '200', + responseParameters: { + ...corsMethodResponseParameters(), + ...existingOptionsMethodResponse?.responseParameters, + }, + responseModels: existingOptionsMethodResponse?.responseModels, + })) + await this.apiGateway.send(new PutIntegrationCommand({ + restApiId: apiId, resourceId, httpMethod: 'OPTIONS', type: 'MOCK', + requestTemplates: {'application/json': '{"statusCode": 200}'}, + timeoutInMillis: existingOptionsIntegration?.timeoutInMillis, + })) + await this.apiGateway.send(new PutIntegrationResponseCommand({ + restApiId: apiId, resourceId, httpMethod: 'OPTIONS', statusCode: '200', + responseParameters: corsParameters, + responseTemplates: existingOptionsIntegrationResponse?.responseTemplates, + selectionPattern: existingOptionsIntegrationResponse?.selectionPattern, + contentHandling: existingOptionsIntegrationResponse?.contentHandling, + })) + await this.apiGateway.send(new CreateDeploymentCommand({ + restApiId: apiId, stageName: stage, description: `Enable CORS for resource ${resourceId}`, + })) + return {configuredMethods, proxyMethods} + } + private async rootResource(apiId: string) { let position: string | undefined do { @@ -277,6 +370,26 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { } } +const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + +function corsMethodResponseParameters(): Record { + return { + 'method.response.header.Access-Control-Allow-Origin': false, + 'method.response.header.Access-Control-Allow-Headers': false, + 'method.response.header.Access-Control-Allow-Methods': false, + } +} + +function corsResponseParameters(origin: string, headers: string, methods: string[]): Record { + const requestedMethods = new Set([...methods, 'OPTIONS']) + const allowedMethods = CORS_METHODS.filter((method) => requestedMethods.has(method)) + return { + 'method.response.header.Access-Control-Allow-Origin': `'${origin}'`, + 'method.response.header.Access-Control-Allow-Headers': `'${headers}'`, + 'method.response.header.Access-Control-Allow-Methods': `'${allowedMethods.join(',')}'`, + } +} + function routePathPart(path: string): string { const value = stringValue(path).replace(/^\//, '') if (!/^[A-Za-z0-9._~-]+$/.test(value)) { diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts index 85e73e7..4bdc568 100644 --- a/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts @@ -1,9 +1,12 @@ import {describe, expect, test} from 'bun:test' import { GetBucketNotificationConfigurationCommand, + GetBucketLocationCommand, GetBucketTaggingCommand, + GetBucketWebsiteCommand, ListBucketsCommand, PutBucketNotificationConfigurationCommand, + PutBucketWebsiteCommand, type S3Client, } from '@aws-sdk/client-s3' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' @@ -29,6 +32,7 @@ function fakeS3(overrides: Partial<{ if (overrides.taggingError) throw overrides.taggingError return {TagSet: overrides.tagSet ?? [{Key: 'Environment', Value: 'dev'}]} } + if (command instanceof GetBucketLocationCommand) return {LocationConstraint: 'us-west-2'} throw new Error(`Unexpected command: ${command?.constructor?.name}`) }, } as unknown as S3Client @@ -104,6 +108,38 @@ describe('AwsStorageAdapter', () => { expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs']) }) + test('gets normalized website configuration and maps no website configuration to null', async () => { + const s3 = { + send: async (command: unknown) => { + if (command instanceof GetBucketWebsiteCommand) { + return {RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}} + } + throw new Error(`Unexpected command: ${command?.constructor?.name}`) + }, + } as unknown as S3Client + const adapter = new AwsStorageAdapter(s3) + + expect(await adapter.getS3BucketWebsite('website-bucket')).toEqual({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}) + + const absent = new Error('No website configuration') + absent.name = 'NoSuchWebsiteConfiguration' + const missing = new AwsStorageAdapter({send: async () => { throw absent }} as unknown as S3Client) + expect(await missing.getS3BucketWebsite('website-bucket')).toBeNull() + }) + + test('puts mutually exclusive S3 website configuration shapes', async () => { + const sent: unknown[] = [] + const s3 = {send: async (command: unknown) => { sent.push(command); return {} }} as unknown as S3Client + const adapter = new AwsStorageAdapter(s3) + + await adapter.putS3BucketWebsite('website-bucket', {mode: 'index', indexDocument: 'index.html', errorDocument: 'error.html'}) + await adapter.putS3BucketWebsite('website-bucket', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}) + + const [index, redirect] = sent as PutBucketWebsiteCommand[] + expect(index.input.WebsiteConfiguration).toEqual({IndexDocument: {Suffix: 'index.html'}, ErrorDocument: {Key: 'error.html'}}) + expect(redirect.input.WebsiteConfiguration).toEqual({RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}}) + }) + test('adds an S3 Lambda trigger without replacing other bucket notifications', async () => { const sent: unknown[] = [] const s3 = { diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.ts index 31c1ad9..920dd0c 100644 --- a/packages/api/src/adapter-aws/AwsStorageAdapter.ts +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.ts @@ -4,15 +4,19 @@ import { CopyObjectCommand, CreateBucketCommand, DeleteBucketCommand, + DeleteBucketWebsiteCommand, DeleteObjectCommand, type Event, GetBucketTaggingCommand, GetBucketNotificationConfigurationCommand, GetObjectCommand, + GetBucketLocationCommand, + GetBucketWebsiteCommand, ListBucketsCommand, ListObjectsV2Command, PutObjectCommand, PutBucketNotificationConfigurationCommand, + PutBucketWebsiteCommand, type S3Client, } from '@aws-sdk/client-s3' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' @@ -28,6 +32,7 @@ import type { StorageObjectList, S3LambdaTriggerInput, S3LambdaTriggerOverview, + S3BucketWebsiteConfiguration, } from '../cloud-spi/types' export class AwsStorageAdapter implements CloudServiceAdapter { @@ -49,20 +54,20 @@ export class AwsStorageAdapter implements CloudServiceAdapter { const buckets = filterBucketsBySearch(res.Buckets ?? [], query.search) return Promise.all(buckets.map(async (bucket): Promise => { - const {tags, tagsUnavailable} = await this.bucketTags(bucket.Name ?? '') + const [tagResult, region] = await Promise.all([this.bucketTags(bucket.Name ?? ''), this.bucketRegion(bucket.Name ?? '')]) return { id: bucket.Name ?? '', name: bucket.Name ?? '', cloud: 'aws', service: 'storage', type: 'bucket', - region: null, + region, createdAt: bucket.CreationDate?.toISOString() ?? null, metadata: { provider: 'aws', storageService: 's3', - tags, - tagsUnavailable, + tags: tagResult.tags, + tagsUnavailable: tagResult.tagsUnavailable, }, } })) @@ -87,6 +92,16 @@ export class AwsStorageAdapter implements CloudServiceAdapter { } } + private async bucketRegion(bucketName: string): Promise { + if (!bucketName) return null + try { + const response = await this.s3.send(new GetBucketLocationCommand({Bucket: bucketName})) + return response.LocationConstraint === 'EU' ? 'eu-west-1' : response.LocationConstraint ?? 'us-east-1' + } catch { + return null + } + } + async get(id: string): Promise { const resources = await this.list() return resources.find((resource) => resource.id === id) ?? null @@ -199,6 +214,51 @@ export class AwsStorageAdapter implements CloudServiceAdapter { } } + async getS3BucketWebsite(bucketName: string): Promise { + try { + const response = await this.s3.send(new GetBucketWebsiteCommand({Bucket: bucketName})) + if (response.RedirectAllRequestsTo?.HostName) { + return { + mode: 'redirect', + hostName: response.RedirectAllRequestsTo.HostName, + protocol: response.RedirectAllRequestsTo.Protocol ?? null, + } + } + if (response.IndexDocument?.Suffix) { + return { + mode: 'index', + indexDocument: response.IndexDocument.Suffix, + errorDocument: response.ErrorDocument?.Key ?? null, + } + } + return null + } catch (error) { + if (error instanceof Error && error.name === 'NoSuchWebsiteConfiguration') return null + throw error + } + } + + async putS3BucketWebsite(bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise { + await this.s3.send(new PutBucketWebsiteCommand({ + Bucket: bucketName, + WebsiteConfiguration: configuration.mode === 'index' + ? { + IndexDocument: {Suffix: configuration.indexDocument}, + ...(configuration.errorDocument ? {ErrorDocument: {Key: configuration.errorDocument}} : {}), + } + : { + RedirectAllRequestsTo: { + HostName: configuration.hostName, + ...(configuration.protocol ? {Protocol: configuration.protocol} : {}), + }, + }, + })) + } + + async deleteS3BucketWebsite(bucketName: string): Promise { + await this.s3.send(new DeleteBucketWebsiteCommand({Bucket: bucketName})) + } + async listObjects(resourceId: string, prefix = ''): Promise { const res = await this.s3.send(new ListObjectsV2Command({ Bucket: resourceId, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index b67a027..72171a4 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -508,6 +508,7 @@ export interface ApiGatewayRouteOverview { path: string method: string functionName: string | null + integrationType: string | null } export interface ApiGatewayRoutesResult { @@ -522,6 +523,19 @@ export interface ApiGatewayDeleteRouteInput { stage: string } +export interface ApiGatewayCorsInput { + resourceId: string + stage: string + origin: string + headers: string + methods: string[] +} + +export interface ApiGatewayCorsResult { + configuredMethods: string[] + proxyMethods: string[] +} + export interface S3LambdaTriggerInput { functionName: string events: string[] @@ -538,6 +552,10 @@ export interface S3LambdaTriggerOverview { suffix: string | null } +export type S3BucketWebsiteConfiguration = + | {mode: 'index'; indexDocument: string; errorDocument: string | null} + | {mode: 'redirect'; hostName: string; protocol: 'http' | 'https' | null} + export interface EventBridgeLambdaTriggerInput { ruleName: string scheduleExpression: string @@ -663,9 +681,13 @@ export interface CloudServiceAdapter { testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise listApiGatewayRoutes?(apiId: string): Promise deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise + enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise listS3LambdaTriggers?(bucketName: string): Promise deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise + getS3BucketWebsite?(bucketName: string): Promise + putS3BucketWebsite?(bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise + deleteS3BucketWebsite?(bucketName: string): Promise createEventBridgeLambdaTrigger?(input: EventBridgeLambdaTriggerInput): Promise listEventBridgeLambdaTriggers?(functionName: string): Promise deleteEventBridgeLambdaTrigger?(functionName: string, triggerId: string): Promise diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 23e1b30..20cce13 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -145,6 +145,61 @@ describe('EventBridge rule routes', () => { }) }) +describe('API Gateway CORS routes', () => { + test('dispatches resource-scoped CORS configuration through the API Gateway adapter', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'apigateway', + enableApiGatewayCors: async (apiId, input) => { + calls.push({apiId, input}) + return {configuredMethods: ['GET'], proxyMethods: ['POST']} + }, + })]) + + const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/cors', { + method: 'POST', headers: {'content-type': 'application/json'}, + body: JSON.stringify({resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST']}), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']}) + expect(calls).toEqual([{apiId: 'api-123', input: { + resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], + }}]) + }) +}) + +describe('S3 website routes', () => { + test('routes normalized website configuration lifecycle requests through the storage adapter', async () => { + const calls: Array<{operation: string; args: unknown[]}> = [] + const adapter = mockAdapter('aws', { + getS3BucketWebsite: async (...args) => { + calls.push({operation: 'get', args}) + return {mode: 'index', indexDocument: 'index.html', errorDocument: null} + }, + putS3BucketWebsite: async (...args) => { calls.push({operation: 'put', args}) }, + deleteS3BucketWebsite: async (...args) => { calls.push({operation: 'delete', args}) }, + }) + const app = appWithRoutes([adapter]) + + const get = await app.request('/api/clouds/aws/services/storage/resources/assets/website') + const put = await app.request('/api/clouds/aws/services/storage/resources/assets/website', { + method: 'PUT', headers: {'content-type': 'application/json'}, + body: JSON.stringify({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}), + }) + const remove = await app.request('/api/clouds/aws/services/storage/resources/assets/website', {method: 'DELETE'}) + + expect(await get.json()).toEqual({mode: 'index', indexDocument: 'index.html', errorDocument: null}) + expect(put.status).toBe(200) + expect(remove.status).toBe(200) + expect(calls).toEqual([ + {operation: 'get', args: ['assets']}, + {operation: 'put', args: ['assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}]}, + {operation: 'delete', args: ['assets']}, + ]) + }) +}) + describe('SNS subscription routes', () => { test('routes subscription lifecycle requests through the Notifications adapter', async () => { const calls: Array<{operation: string; args: unknown[]}> = [] diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 52bf0b1..dab2cf4 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -5,6 +5,7 @@ import type { CloudServiceType, CreateDatabaseSnapshotInput, KmsEncryptionAlgorithm, + S3BucketWebsiteConfiguration, SqlConnectionInput, } from '../cloud-spi/types' import {clampLimit, type PageQuery} from '../cloud-spi/childCollections' @@ -554,6 +555,22 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/apigateway/resources/:id/cors', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + const result = await svc(c).enableApiGatewayCors(cloud, c.req.param('id'), { + resourceId: typeof input.resourceId === 'string' ? input.resourceId : '', + stage: typeof input.stage === 'string' ? input.stage : '', + origin: typeof input.origin === 'string' ? input.origin : '', + headers: typeof input.headers === 'string' ? input.headers : '', + methods: Array.isArray(input.methods) ? input.methods.filter((method): method is string => typeof method === 'string') : [], + }) + return c.json(result) + }) + }) + app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) @@ -602,6 +619,32 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.get('/:cloud/services/storage/resources/:id/website', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).getS3BucketWebsite(cloud, c.req.param('id')))) + }) + + app.put('/:cloud/services/storage/resources/:id/website', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await jsonBody<{mode?: unknown; indexDocument?: unknown; errorDocument?: unknown; hostName?: unknown; protocol?: unknown}>(c) + const configuration = s3WebsiteConfiguration(input) + await svc(c).putS3BucketWebsite(cloud, c.req.param('id'), configuration) + return c.json({ok: true}) + }) + }) + + app.delete('/:cloud/services/storage/resources/:id/website', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteS3BucketWebsite(cloud, c.req.param('id')) + return c.json({ok: true}) + }) + }) + app.get('/:cloud/services/serverless/resources/:id/eventbridge-triggers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) @@ -1052,6 +1095,36 @@ function isCloudProvider(value: string): value is CloudProvider { return value === 'aws' || value === 'azure' || value === 'gcp' } +function s3WebsiteConfiguration(input: {mode?: unknown; indexDocument?: unknown; errorDocument?: unknown; hostName?: unknown; protocol?: unknown}): S3BucketWebsiteConfiguration { + if (input.mode === 'index') { + if (typeof input.indexDocument !== 'string' || !input.indexDocument.trim()) { + throw new ValidationError('indexDocument is required for index website hosting') + } + if (input.errorDocument !== undefined && typeof input.errorDocument !== 'string' && input.errorDocument !== null) { + throw new ValidationError('errorDocument must be a string or null') + } + return { + mode: 'index', + indexDocument: input.indexDocument.trim(), + errorDocument: typeof input.errorDocument === 'string' && input.errorDocument.trim() ? input.errorDocument.trim() : null, + } + } + if (input.mode === 'redirect') { + if (typeof input.hostName !== 'string' || !input.hostName.trim()) { + throw new ValidationError('hostName is required for redirect website hosting') + } + if (input.protocol !== undefined && input.protocol !== null && input.protocol !== 'http' && input.protocol !== 'https') { + throw new ValidationError('protocol must be http, https, or null') + } + return { + mode: 'redirect', + hostName: input.hostName.trim(), + protocol: input.protocol === 'http' || input.protocol === 'https' ? input.protocol : null, + } + } + throw new ValidationError('mode must be index or redirect') +} + async function jsonBody(c: Context): Promise { try { const body = await c.req.json() diff --git a/packages/api/src/service/CloudProxyService.test.ts b/packages/api/src/service/CloudProxyService.test.ts new file mode 100644 index 0000000..d481f66 --- /dev/null +++ b/packages/api/src/service/CloudProxyService.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, test} from 'bun:test' +import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' +import type {CloudServiceAdapter} from '../cloud-spi/types' +import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' +import {CloudProxyService} from './CloudProxyService' + +describe('CloudProxyService API Gateway CORS', () => { + test('forwards resource-scoped CORS configuration to the API Gateway adapter', async () => { + const calls: unknown[] = [] + const adapter: CloudServiceAdapter = { + cloud: 'aws', service: 'apigateway', schema: awsApiGatewaySchema, + list: async () => [], get: async () => null, create: async () => ({ + id: 'api', name: 'api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: null, metadata: {}, + }), delete: async () => {}, + enableApiGatewayCors: async (apiId, input) => { + calls.push({apiId, input}) + return {configuredMethods: ['GET'], proxyMethods: ['POST']} + }, + } + const service = new CloudProxyService(new CloudAdapterRegistry([adapter])) + + await expect(service.enableApiGatewayCors('aws', 'api-123', { + resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], + })).resolves.toEqual({configuredMethods: ['GET'], proxyMethods: ['POST']}) + expect(calls).toEqual([{apiId: 'api-123', input: { + resourceId: 'resource-456', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], + }}]) + }) +}) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index f35d9bd..14dd801 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -26,8 +26,11 @@ import type { ApiGatewayTestRouteResult, ApiGatewayRoutesResult, ApiGatewayDeleteRouteInput, + ApiGatewayCorsInput, + ApiGatewayCorsResult, S3LambdaTriggerInput, S3LambdaTriggerOverview, + S3BucketWebsiteConfiguration, EventBridgeLambdaTriggerInput, EventBridgeLambdaTriggerOverview, EventBridgeRuleInput, @@ -280,6 +283,12 @@ export class CloudProxyService { await adapter.deleteApiGatewayRoute(apiId, input) } + async enableApiGatewayCors(cloud: CloudProvider, apiId: string, input: ApiGatewayCorsInput): Promise { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (!adapter.enableApiGatewayCors) throw new NotSupportedError(`API Gateway CORS is not supported for ${cloud}`) + return adapter.enableApiGatewayCors(apiId, input) + } + async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise { const adapter = this.requireAdapter(cloud, 'storage') if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`) @@ -298,6 +307,24 @@ export class CloudProxyService { await adapter.deleteS3LambdaTrigger(bucketName, triggerId) } + async getS3BucketWebsite(cloud: CloudProvider, bucketName: string): Promise { + const adapter = this.requireAdapter(cloud, 'storage') + if (!adapter.getS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`) + return adapter.getS3BucketWebsite(bucketName) + } + + async putS3BucketWebsite(cloud: CloudProvider, bucketName: string, configuration: S3BucketWebsiteConfiguration): Promise { + const adapter = this.requireAdapter(cloud, 'storage') + if (!adapter.putS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`) + await adapter.putS3BucketWebsite(bucketName, configuration) + } + + async deleteS3BucketWebsite(cloud: CloudProvider, bucketName: string): Promise { + const adapter = this.requireAdapter(cloud, 'storage') + if (!adapter.deleteS3BucketWebsite) throw new NotSupportedError(`S3 website hosting is not supported for ${cloud}`) + await adapter.deleteS3BucketWebsite(bucketName) + } + async createEventBridgeLambdaTrigger(cloud: CloudProvider, input: EventBridgeLambdaTriggerInput): Promise { const adapter = this.requireAdapter(cloud, 'serverless') if (!adapter.createEventBridgeLambdaTrigger) throw new NotSupportedError(`EventBridge Lambda triggers are not supported for ${cloud}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index e395548..0c9f375 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -31,11 +31,15 @@ export const apiEndpointKeys = { decrypt: "clouds.services.resources.decrypt", createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list", - deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete", + deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete", + enableApiGatewayCors: "clouds.services.resources.apigateway.cors.enable", testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create", listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list", - deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete", + deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete", + getS3BucketWebsite: "clouds.services.resources.storage.website.get", + putS3BucketWebsite: "clouds.services.resources.storage.website.put", + deleteS3BucketWebsite: "clouds.services.resources.storage.website.delete", createEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.create", listEventBridgeLambdaTriggers: "clouds.services.resources.serverless.eventbridge-triggers.list", deleteEventBridgeLambdaTrigger: "clouds.services.resources.serverless.eventbridge-trigger.delete", @@ -389,6 +393,14 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.enableApiGatewayCors, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/cors", + method: "POST", + telemetry: { service: "apigateway" }, + }, + ], [ apiEndpointKeys.clouds.resources.createS3LambdaTrigger, { @@ -413,6 +425,30 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "storage" }, }, ], + [ + apiEndpointKeys.clouds.resources.getS3BucketWebsite, + { + path: "/clouds/:cloud/services/storage/resources/:id/website", + method: "GET", + telemetry: { service: "storage" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.putS3BucketWebsite, + { + path: "/clouds/:cloud/services/storage/resources/:id/website", + method: "PUT", + telemetry: { service: "storage" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.deleteS3BucketWebsite, + { + path: "/clouds/:cloud/services/storage/resources/:id/website", + method: "DELETE", + telemetry: { service: "storage" }, + }, + ], [ apiEndpointKeys.clouds.resources.createEventBridgeLambdaTrigger, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 53f2a8f..5604eda 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -35,6 +35,7 @@ import type { SqlQueryResult, SqlTable, StorageObjectList, + S3BucketWebsiteConfiguration, } from "@/types/resource"; import type { ServiceSchema } from "@/types/schema"; import { getAccountId } from "@/lib/accountStore"; @@ -267,6 +268,7 @@ export interface ApiGatewayRouteOverview { path: string; method: string; functionName: string | null; + integrationType: string | null; } export interface ApiGatewayRoutesResult { @@ -281,6 +283,19 @@ export interface DeleteApiGatewayRouteInput { stage: string; } +export interface EnableApiGatewayCorsInput { + resourceId: string; + stage: string; + origin: string; + headers: string; + methods: string[]; +} + +export interface ApiGatewayCorsResult { + configuredMethods: string[]; + proxyMethods: string[]; +} + export interface CreateS3LambdaTriggerInput { functionName: string; events: string[]; @@ -402,6 +417,20 @@ export async function deleteApiGatewayRoute( ); } +export async function enableApiGatewayCors( + cloud: CloudProvider, + apiId: string, + input: EnableApiGatewayCorsInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.enableApiGatewayCors, + requestOptions(cloud, "apigateway", {signal, body: input}), + {cloud, id: apiId}, + ); + return res.data; +} + export async function testApiGatewayRoute( cloud: CloudProvider, apiId: string, @@ -456,6 +485,44 @@ export async function deleteS3LambdaTrigger( ); } +export async function getS3BucketWebsite( + cloud: CloudProvider, + bucketName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.getS3BucketWebsite, + requestOptions(cloud, "storage", {signal}), + {cloud, id: bucketName}, + ); + return res.data; +} + +export async function putS3BucketWebsite( + cloud: CloudProvider, + bucketName: string, + configuration: S3BucketWebsiteConfiguration, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.putS3BucketWebsite, + requestOptions(cloud, "storage", {signal, body: configuration}), + {cloud, id: bucketName}, + ); +} + +export async function deleteS3BucketWebsite( + cloud: CloudProvider, + bucketName: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.deleteS3BucketWebsite, + requestOptions(cloud, "storage", {signal}), + {cloud, id: bucketName}, + ); +} + export async function createSnsSubscription( cloud: CloudProvider, topicArn: string, diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx index 855cb4e..00eb239 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx @@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource' const apiMocks = vi.hoisted(() => ({ createApiGatewayLambdaRoute: vi.fn(), deleteApiGatewayRoute: vi.fn(), + enableApiGatewayCors: vi.fn(), listApiGatewayRoutes: vi.fn(), listCloudResources: vi.fn(), queryLogs: vi.fn(), @@ -54,6 +55,39 @@ test('creates and displays a deployed Lambda proxy route', async () => { expect(await screen.findByText('Route deployed')).toBeInTheDocument() }) +test('displays and copies the selected API stage base invoke URL', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) + const user = userEvent.setup() + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + const baseInvokeUrl = 'http://floci.test:4566/execute-api/api-123/dev' + expect(screen.getByText(baseInvokeUrl)).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: 'Copy Base invoke URL'})) + expect(writeText).toHaveBeenCalledWith(baseInvokeUrl) +}) + +test('uses the browser host for a Docker bridge runtime endpoint', () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + expect(screen.getByText('http://localhost:4566/execute-api/api-123/dev')).toBeInTheDocument() +}) + test('sends a route test request and displays its response', async () => { apiMocks.listCloudResources.mockResolvedValue([lambda]) apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) @@ -144,3 +178,29 @@ test('confirms before deleting a deployed route', async () => { resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev', })) }) + +test('enables CORS for one resource and shows AWS proxy guidance', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({ + routes: [ + {resourceId: 'resource-123', path: '/orders', method: 'GET', functionName: null, integrationType: 'HTTP'}, + {resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler', integrationType: 'AWS_PROXY'}, + ], stages: ['dev'], + }) + apiMocks.enableApiGatewayCors.mockResolvedValue({configuredMethods: ['GET'], proxyMethods: ['POST']}) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + render() + + await user.click(await screen.findByRole('button', {name: 'Enable CORS'})) + expect(screen.getByDisplayValue('*')).toBeInTheDocument() + expect(screen.getByLabelText('GET')).toBeChecked() + expect(screen.getByLabelText('POST')).toBeChecked() + await user.click(screen.getByRole('button', {name: 'Confirm enable CORS'})) + + await waitFor(() => expect(apiMocks.enableApiGatewayCors).toHaveBeenCalledWith('aws', 'api-123', { + resourceId: 'resource-123', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'], + })) + expect(await screen.findByText(/AWS_PROXY methods need their backend/)).toBeInTheDocument() + expect(screen.getByText(/Access-Control-Allow-Origin/)).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index 384c415..100d4d3 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -1,18 +1,20 @@ import {useEffect, useState} from 'react' import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' +import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' import type {CloudProvider} from '@/types/cloud' import type {CloudResource} from '@/types/resource' +import {CopyValueButton} from './CopyValueButton' import {LogsQueryPanel} from './LogsQueryPanel' interface ApiGatewayRoutePanelProps { cloud: CloudProvider resource?: CloudResource runtimeReachable: boolean + runtimeEndpoint?: string } -export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) { +export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) { const [path, setPath] = useState('/hello') const [method, setMethod] = useState('GET') const [stage, setStage] = useState('dev') @@ -24,6 +26,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat const [bodyFormatError, setBodyFormatError] = useState(null) const [logsRefreshKey, setLogsRefreshKey] = useState(0) const [deleteConfirmation, setDeleteConfirmation] = useState(null) + const [corsConfirmation, setCorsConfirmation] = useState<{resourceId: string; path: string} | null>(null) + const [corsOrigin, setCorsOrigin] = useState('*') + const [corsHeaders, setCorsHeaders] = useState('Content-Type') + const [corsMethods, setCorsMethods] = useState([]) + const [corsProxyMethods, setCorsProxyMethods] = useState([]) const queryClient = useQueryClient() const functionsQuery = useQuery({ queryKey: ['cloud-resources', cloud, 'serverless'], @@ -48,6 +55,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat setBodyFormatError(null) setLogsRefreshKey(0) setDeleteConfirmation(null) + setCorsConfirmation(null) + setCorsOrigin('*') + setCorsHeaders('Content-Type') + setCorsMethods([]) + setCorsProxyMethods([]) }, [resource?.id]) const routeMutation = useMutation({ @@ -73,6 +85,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}) }, }) + const corsMutation = useMutation({ + mutationFn: () => enableApiGatewayCors(cloud, resource!.id, { + resourceId: corsConfirmation!.resourceId, + stage, + origin: corsOrigin, + headers: corsHeaders, + methods: corsMethods, + }), + onSuccess: (result) => { + setCorsProxyMethods(result.proxyMethods) + setCorsConfirmation(null) + queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}) + }, + }) if (!resource || resource.service !== 'apigateway') { return ( @@ -89,6 +115,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody) + const endpoint = invokeEndpoint(runtimeEndpoint) + const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null return (
@@ -108,6 +136,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat )}
+ {baseInvokeUrl &&
+ Base invoke URL +
+ {baseInvokeUrl} + +
+
}
{ event.preventDefault() if (canCreate) routeMutation.mutate() @@ -161,12 +196,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat {routesQuery.isLoading &&

Loading deployed routes...

} {routesQuery.isError &&

{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}

} {routesQuery.data?.routes.length === 0 &&

No deployed methods yet. Create and deploy a route above.

} - {routesQuery.data && routesQuery.data.routes.length > 0 && ( -
- {routesQuery.data.routes.map((route) => { + {routesQuery.data && routesQuery.data.routes.length > 0 && ( +
+ {routesQuery.data.routes.map((route) => { const selected = route.path === path && route.method === method const key = `${route.resourceId}-${route.method}` - return
+ const resourceMethods = routesQuery.data.routes + .filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method)) + .map((item) => item.method) + const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route) + return
- {deleteConfirmation === key ?
+ {route.integrationType ?? 'No integration'}{route.functionName ? ` ยท ${route.functionName}` : ''} + + {isFirstForResource && } + {deleteConfirmation === key ?
Delete {route.method}? -
: } -
+
: } + {corsConfirmation?.resourceId === route.resourceId && isFirstForResource &&
+ Enable CORS for {corsConfirmation.path} + + +
Allowed methods{resourceMethods.map((value) => )}
+ Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}. + + + {corsMutation.isError &&

{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}

} +
} +
})} -
- )} -
+
+ )} + {corsProxyMethods.length > 0 && } +
@@ -299,6 +354,45 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat ) } +const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + +function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) { + return
+ AWS_PROXY methods need their backend to return CORS headers: {methods.join(', ')}. +
{`import "net/http"
+
+func handler(w http.ResponseWriter, r *http.Request) {
+    w.Header().Set("Access-Control-Allow-Origin", "${origin}")
+    w.Header().Set("Access-Control-Allow-Headers", "${headers}")
+    w.Header().Set("Access-Control-Allow-Methods", "${methods.join(',')},OPTIONS")
+    if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK); return }
+    // Handle the proxy request.
+}`}
+
+} + +function invokeEndpoint(runtimeEndpoint?: string): string | null { + if (!runtimeEndpoint) return null + try { + const endpoint = new URL(runtimeEndpoint) + if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') { + endpoint.hostname = window.location.hostname + } + return endpoint.toString().replace(/\/$/, '') + } catch { + return runtimeEndpoint.replace(/\/$/, '') + } +} + +function isDockerBridgeAddress(hostname: string): boolean { + const octets = hostname.split('.').map(Number) + return octets.length === 4 + && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) + && octets[0] === 172 + && octets[1] >= 16 + && octets[1] <= 31 +} + function parseHeaders(value: string): Record { let parsed: unknown try { diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 0ba9b54..cd310e7 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -20,6 +20,7 @@ import { DynamicFormRenderer } from "@/components/DynamicFormRenderer"; import { ResourceInspector } from "@/components/ResourceInspector"; import { ResourceTable } from "@/components/ResourceTable"; import { StorageObjectBrowser } from "@/components/StorageObjectBrowser"; +import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel"; import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel"; import { NetworkingPanel } from "@/components/NetworkingPanel"; import { getPath } from "@/lib/resourcePath"; @@ -848,14 +849,17 @@ export function DynamicResourceView({ )}
{service === "storage" && ( - + <> + + {cloud === "aws" && } + )} {service === "ecr" && cloud === "aws" && ( @@ -912,6 +916,7 @@ export function DynamicResourceView({ cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} + runtimeEndpoint={cloudStatus?.endpoint ?? undefined} /> )} {service === "events" && ( diff --git a/packages/frontend/src/components/S3BucketWebsitePanel.test.tsx b/packages/frontend/src/components/S3BucketWebsitePanel.test.tsx new file mode 100644 index 0000000..8c43312 --- /dev/null +++ b/packages/frontend/src/components/S3BucketWebsitePanel.test.tsx @@ -0,0 +1,54 @@ +import {render, screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {expect, test, vi} from 'vitest' +import {S3BucketWebsitePanel} from './S3BucketWebsitePanel' + +const api = vi.hoisted(() => ({ + getS3BucketWebsite: vi.fn(), + putS3BucketWebsite: vi.fn(), + deleteS3BucketWebsite: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) + +const bucket = {id: 'assets', name: 'assets', cloud: 'aws' as const, service: 'storage' as const, type: 'bucket', region: 'us-west-2', createdAt: null, metadata: {}} + +function renderPanel() { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +test('loads website configuration, shows its regional endpoint, and saves the index-only AWS shape', async () => { + api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'home.html', errorDocument: 'error.html'}) + api.putS3BucketWebsite.mockResolvedValue(undefined) + const user = userEvent.setup() + renderPanel() + + expect(await screen.findByDisplayValue('home.html')).toBeVisible() + expect(screen.getByText('http://assets.s3-website-us-west-2.localhost:4566')).toBeVisible() + await user.clear(screen.getByLabelText(/Error document/)) + await user.click(screen.getByRole('button', {name: 'Save changes'})) + + await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'index', indexDocument: 'home.html', errorDocument: null})) +}) + +test('switches to redirect mode and confirms disable before deleting', async () => { + api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'index.html', errorDocument: null}) + api.putS3BucketWebsite.mockResolvedValue(undefined) + api.deleteS3BucketWebsite.mockResolvedValue(undefined) + const user = userEvent.setup() + renderPanel() + + await screen.findByText('Enabled') + await user.selectOptions(screen.getByLabelText('Hosting mode'), 'redirect') + await user.type(screen.getByLabelText('Redirect host name'), 'www.example.com') + await user.selectOptions(screen.getByLabelText('Redirect protocol'), 'https') + await user.click(screen.getByRole('button', {name: 'Save changes'})) + await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'})) + + await user.click(screen.getByRole('button', {name: 'Disable website hosting'})) + expect(screen.getByText('Disable static website hosting?')).toBeVisible() + await user.click(screen.getByRole('button', {name: 'Disable'})) + await waitFor(() => expect(api.deleteS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets')) +}) diff --git a/packages/frontend/src/components/S3BucketWebsitePanel.tsx b/packages/frontend/src/components/S3BucketWebsitePanel.tsx new file mode 100644 index 0000000..eb862e6 --- /dev/null +++ b/packages/frontend/src/components/S3BucketWebsitePanel.tsx @@ -0,0 +1,108 @@ +import {useEffect, useState} from 'react' +import {Globe, Loader2, RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {deleteS3BucketWebsite, getS3BucketWebsite, putS3BucketWebsite} from '@/api/cloudProxyClient' +import {useAccountId} from '@/lib/accountStore' +import type {CloudResource, S3BucketWebsiteConfiguration} from '@/types/resource' + +interface S3BucketWebsitePanelProps { + resource?: CloudResource + runtimeReachable: boolean +} + +export function S3BucketWebsitePanel({resource, runtimeReachable}: S3BucketWebsitePanelProps) { + const queryClient = useQueryClient() + const accountId = useAccountId() + const [mode, setMode] = useState<'index' | 'redirect'>('index') + const [indexDocument, setIndexDocument] = useState('index.html') + const [errorDocument, setErrorDocument] = useState('') + const [hostName, setHostName] = useState('') + const [protocol, setProtocol] = useState<'http' | 'https' | ''>('') + const [confirmDisable, setConfirmDisable] = useState(false) + const queryKey = ['s3-bucket-website', accountId, resource?.id] + const websiteQuery = useQuery({ + queryKey, + queryFn: ({signal}) => getS3BucketWebsite('aws', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + const configuration = websiteQuery.data + if (!configuration) { + setMode('index') + setIndexDocument('index.html') + setErrorDocument('') + setHostName('') + setProtocol('') + return + } + setMode(configuration.mode) + if (configuration.mode === 'index') { + setIndexDocument(configuration.indexDocument) + setErrorDocument(configuration.errorDocument ?? '') + } else { + setHostName(configuration.hostName) + setProtocol(configuration.protocol ?? '') + } + }, [websiteQuery.data, resource?.id]) + + const saveMutation = useMutation({ + mutationFn: (configuration: S3BucketWebsiteConfiguration) => putS3BucketWebsite('aws', resource?.id ?? '', configuration), + onSuccess: () => { + void queryClient.invalidateQueries({queryKey}) + }, + }) + const deleteMutation = useMutation({ + mutationFn: () => deleteS3BucketWebsite('aws', resource?.id ?? ''), + onSuccess: () => { + setConfirmDisable(false) + void queryClient.setQueryData(queryKey, null) + void queryClient.invalidateQueries({queryKey}) + }, + }) + + if (!resource || resource.service !== 'storage' || resource.type !== 'bucket') return null + const endpoint = `http://${resource.name}.s3-website-${resource.region ?? 'us-east-1'}.localhost:4566` + const configuration = mode === 'index' + ? {mode, indexDocument: indexDocument.trim(), errorDocument: errorDocument.trim() || null} + : {mode, hostName: hostName.trim(), protocol: protocol || null} + const canSave = !saveMutation.isPending && (mode === 'index' ? Boolean(configuration.indexDocument) : Boolean(configuration.hostName)) + + return
+
+
+

Static website hosting

+

{websiteQuery.data ? 'Enabled' : 'Not configured'}

+

Website endpoint: {endpoint}

+
+ +
+ {websiteQuery.isLoading &&

Loading website configuration...

} + {websiteQuery.isError &&

{websiteQuery.error instanceof Error ? websiteQuery.error.message : 'Unable to load website configuration.'}

} + { + event.preventDefault() + if (canSave) saveMutation.mutate(configuration) + }}> + + {mode === 'index' ? <> + + + : <> + + + } + + + {saveMutation.isError &&

{saveMutation.error instanceof Error ? saveMutation.error.message : 'Unable to save website configuration.'}

} + {websiteQuery.data && (confirmDisable ?
Disable static website hosting?
: )} + {deleteMutation.isError &&

{deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Unable to disable website hosting.'}

} +
+} diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 611f564..fccddd2 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -53,6 +53,10 @@ export interface StorageObjectList { objects: StorageObject[] } +export type S3BucketWebsiteConfiguration = + | {mode: 'index'; indexDocument: string; errorDocument: string | null} + | {mode: 'redirect'; hostName: string; protocol: 'http' | 'https' | null} + export interface CosmosContainer { id: string name: string