Compare commits

..

11 Commits

Author SHA1 Message Date
Zakaria 6586f48539 feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s
2026-10-24 14:10:16 -04:00
Zakaria 5c3c73c582 Merge pull request 'feat(api): add Route 53 hosted zones and DNS records to the cloud explorer' (#15) from feat/route53 into main
CI / checks (push) Successful in 25m44s
Reviewed-on: #15
2026-10-24 17:10:52 +00:00
Zakaria 2b4d4b146d feat(api): add Route 53 hosted zones and DNS records to the cloud explorer
CI / checks (pull_request) Successful in 25m40s
2026-10-24 13:07:57 -04:00
Zakaria ac58bfc98b Merge pull request 'test(api): stop tests leaking module mocks and env into later files' (#14) from fix/ci-test-isolation into main
CI / checks (push) Successful in 25m29s
Reviewed-on: #14
2026-10-24 15:36:52 +00:00
Zakaria 51a0a60a73 test(api): stop tests leaking module mocks and env into later files
CI / checks (pull_request) Successful in 25m28s
CI failed on a different set of tests each run because bun runs every test
file in one process and the file order differs between machines:

- the secretsmanager route test replaced the whole ../aws module, so files
  loaded after it lost awsClients, lambda and defaultAccountId (API Gateway,
  SES)
- the Lightsail tests restored FLOCI_ENDPOINT by assigning undefined, which
  stores the string "undefined" (runtime diagnostics)
- the auth tests used full-strength argon2 for ~30 sign-ins and timed out on
  slower runners; the OIDC cleanup test cached a discovery document without
  userinfo_endpoint for the shared issuer

Checked with 20 runs under bun test --randomize.
2026-10-24 11:30:46 -04:00
Zakaria a5c9de25d0 Merge pull request 'feat(api): show a user pool's Lambda triggers' (#13) from feat/cognito-app-client-settings into main
CI / checks (push) Has been cancelled
Reviewed-on: #13
2026-10-24 15:22:35 +00:00
Zakaria 238bb1a3d5 feat(frontend): hosted sign-in and OAuth settings for Cognito app clients
CI / checks (pull_request) Successful in 26m7s
2026-10-24 11:17:42 -04:00
Zakaria d411f632c9 feat(api): show a user pool's Lambda triggers 2026-10-24 02:07:18 -04:00
Zakaria 954e2e8a47 Merge pull request 'feat(frontend): newest emails first, mailbox above the SES tools' (#12) from feat/mailbox-newest-first into main
CI / checks (push) Failing after 12m26s
Reviewed-on: #12
2026-10-24 06:02:06 +00:00
Zakaria ce444f0851 feat(frontend): newest emails first, mailbox above the SES tools
CI / checks (pull_request) Failing after 12m24s
2026-10-24 02:01:14 -04:00
Zakaria 18c1f5067f Merge pull request 'feat(api): show the verification code in the SES mailbox' (#10) from feat/emails-page into main
CI / checks (push) Has been cancelled
Reviewed-on: #10
2026-10-24 05:29:58 +00:00
42 changed files with 2640 additions and 67 deletions
+2
View File
@@ -78,6 +78,8 @@ cd packages/api && bun run scripts/service-matrix.ts
| Networking | Networking | Yes (list) | No | No | | Networking | Networking | Yes (list) | No | No |
| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No | | Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | | Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
| Networking | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No |
| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
+2
View File
@@ -13,6 +13,7 @@
"@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudfront": "^3.1146.0",
"@aws-sdk/client-cloudwatch": "^3.1076.0", "@aws-sdk/client-cloudwatch": "^3.1076.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-codebuild": "^3.1076.0", "@aws-sdk/client-codebuild": "^3.1076.0",
@@ -36,6 +37,7 @@
"@aws-sdk/client-lightsail": "^3.1076.0", "@aws-sdk/client-lightsail": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-route-53": "^3.1146.0",
"@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sesv2": "^3.1076.0", "@aws-sdk/client-sesv2": "^3.1076.0",
@@ -0,0 +1,182 @@
import {describe, expect, test} from 'bun:test'
import {
CreateDistributionCommand,
CreateInvalidationCommand,
DeleteDistributionCommand,
GetDistributionCommand,
GetDistributionConfigCommand,
ListDistributionsCommand,
ListInvalidationsCommand,
UpdateDistributionCommand,
type CloudFrontClient,
type DistributionConfig,
} from '@aws-sdk/client-cloudfront'
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
import {AwsCdnAdapter} from './AwsCdnAdapter'
import {ConflictError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
}
return {client, sent}
}
function awsError(name: string, status = 400): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
}
const ID = 'E7CPM0P0G0VC8D'
const origin = {Id: 's3-site', DomainName: 'site.s3.amazonaws.com', S3OriginConfig: {OriginAccessIdentity: ''}}
const config = {
CallerReference: 'r', Comment: 'Docs site', Enabled: true, DefaultRootObject: 'index.html',
Origins: {Quantity: 1, Items: [origin]},
DefaultCacheBehavior: {TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', AllowedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
CacheBehaviors: {Quantity: 1, Items: [{PathPattern: '/api/*', TargetOriginId: 'custom-api', ViewerProtocolPolicy: 'https-only', AllowedMethods: {Quantity: 7, Items: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']}}]},
} as DistributionConfig
const distribution = {Id: ID, ARN: `arn:aws:cloudfront::000000000000:distribution/${ID}`, DomainName: 'e7cpm0p0g0vc8d.cloudfront.net', Status: 'Deployed', LastModifiedTime: new Date('2026-10-24T18:00:00Z'), DistributionConfig: config}
function adapter(cloudfront: (command: object) => SendResult | Promise<SendResult>, s3: (command: object) => SendResult | Promise<SendResult> = () => ({})) {
const cf = stub(cloudfront)
const bucket = stub(s3)
return {adapter: new AwsCdnAdapter(cf.client as unknown as CloudFrontClient, bucket.client as unknown as S3Client, 'http://floci:4566'), sent: cf.sent, s3Sent: bucket.sent}
}
describe('AwsCdnAdapter distributions', () => {
test('lists distributions with their local URL, origins and description, and filters them', async () => {
const {adapter: cdn} = adapter((command) => command instanceof ListDistributionsCommand
? {DistributionList: {Items: [{...distribution, Enabled: true, Comment: 'Docs site', Origins: config.Origins}], IsTruncated: false}}
: {})
const [listed] = await cdn.list()
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
})
test('describes behaviours and recent invalidations, and treats a missing distribution as absent', async () => {
const {adapter: cdn} = adapter((command) => {
if (command instanceof GetDistributionCommand && command.input.Id === ID) return {Distribution: distribution, ETag: 'e1'}
if (command instanceof GetDistributionCommand) throw awsError('NoSuchDistribution', 404)
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: [{Id: 'I1', Status: 'Completed', CreateTime: new Date('2026-10-24T18:05:00Z')}]}}
return {}
})
const described = await cdn.get(ID)
expect(described?.metadata).toMatchObject({
defaultRootObject: 'index.html',
behaviors: [
{pathPattern: '/api/*', originId: 'custom-api', viewerProtocol: 'https-only', methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']},
{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'allow-all', methods: ['GET', 'HEAD']},
],
invalidations: [{id: 'I1', status: 'Completed', createdAt: '2026-10-24T18:05:00.000Z', paths: []}],
})
expect(await cdn.get('EMISSING')).toBeNull()
})
test('creates a cached website from a bucket that exists', async () => {
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
: {})
await cdn.create({values: {originType: 's3', bucket: 'site', defaultRootObject: '/index.html', comment: 'Docs'}})
expect(s3Sent[0]).toBeInstanceOf(HeadBucketCommand)
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
expect(created).toMatchObject({Enabled: true, Comment: 'Docs', DefaultRootObject: 'index.html', Origins: {Quantity: 1, Items: [{Id: 's3-site', DomainName: 'site.s3.amazonaws.com'}]}})
expect(created.DefaultCacheBehavior).toMatchObject({TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', AllowedMethods: {Quantity: 2}})
expect(created.DefaultCacheBehavior?.OriginRequestPolicyId).toBeUndefined()
})
test('creates an uncached API in front of a custom origin, forwarding the viewer request', async () => {
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
: {})
await cdn.create({values: {originType: 'custom', originDomain: 'API.example.com', originProtocol: 'http-only', originPort: '8080', allowedMethods: 'all', viewerProtocol: 'redirect-to-https'}})
expect(s3Sent).toHaveLength(0)
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
expect(created.Origins?.Items?.[0]).toMatchObject({Id: 'custom-api.example.com', DomainName: 'api.example.com', CustomOriginConfig: {HTTPPort: 8080, HTTPSPort: 8080, OriginProtocolPolicy: 'http-only'}})
expect(created.DefaultCacheBehavior).toMatchObject({
ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: {Quantity: 7},
CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', OriginRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac',
})
})
test('refuses a missing bucket and invalid settings before creating anything', async () => {
const {adapter: cdn, sent} = adapter(() => ({}), () => { throw awsError('NotFound', 404) })
await expect(cdn.create({values: {originType: 's3', bucket: 'nope'}})).rejects.toThrow(/no S3 bucket named nope/)
await expect(cdn.create({values: {originType: 's3', bucket: 'Bad_Name'}})).rejects.toThrow(ValidationError)
await expect(cdn.create({values: {originType: 'custom', originDomain: 'not a host'}})).rejects.toThrow(ValidationError)
await expect(cdn.create({values: {originType: 'custom', originDomain: 'api.example.com', originPort: '70000'}})).rejects.toThrow(/originPort/)
await expect(cdn.create({values: {originType: 'ftp'}})).rejects.toThrow(/originType/)
expect(sent.some((command) => command instanceof CreateDistributionCommand)).toBe(false)
})
test('disables an enabled distribution, then deletes it with the new ETag', async () => {
const {adapter: cdn, sent} = adapter((command) => {
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'Deployed'}, ETag: 'e2'}
return {}
})
await cdn.delete(ID)
const update = sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand
expect(update.input).toMatchObject({Id: ID, IfMatch: 'e1', DistributionConfig: {Enabled: false, Comment: 'Docs site'}})
expect((sent.find((command) => command instanceof DeleteDistributionCommand) as DeleteDistributionCommand).input).toEqual({Id: ID, IfMatch: 'e2'})
})
test('says to try again while CloudFront is still deploying the disable', async () => {
const {adapter: cdn, sent} = adapter((command) => {
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'InProgress'}, ETag: 'e2'}
return {}
})
await expect(cdn.delete(ID)).rejects.toThrow(ConflictError)
expect(sent.some((command) => command instanceof DeleteDistributionCommand)).toBe(false)
})
})
describe('AwsCdnAdapter actions', () => {
test('enables or disables a distribution, keeping the rest of its configuration', async () => {
const {adapter: cdn, sent} = adapter((command) => {
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
if (command instanceof GetDistributionCommand) return {Distribution: {...distribution, DistributionConfig: {...config, Enabled: false}}, ETag: 'e2'}
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: []}}
return {}
})
const disabled = await cdn.setDistributionEnabled(ID, false)
expect(disabled.metadata.enabled).toBe(false)
expect((sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand).input.DistributionConfig).toEqual({...config, Enabled: false})
// The stored configuration is already enabled: nothing to change.
const before = sent.length
await cdn.setDistributionEnabled(ID, true)
expect(sent.slice(before).some((command) => command instanceof UpdateDistributionCommand)).toBe(false)
})
test('invalidates paths that start with a slash', async () => {
const {adapter: cdn, sent} = adapter((command) => command instanceof CreateInvalidationCommand
? {Invalidation: {Id: 'I2', Status: 'Completed', CreateTime: new Date('2026-10-24T18:10:00Z'), InvalidationBatch: command.input.InvalidationBatch}}
: {})
expect(await cdn.createDistributionInvalidation(ID, [' /* ', '/index.html', '/*'])).toEqual({id: 'I2', status: 'Completed', createdAt: '2026-10-24T18:10:00.000Z', paths: ['/*', '/index.html']})
await expect(cdn.createDistributionInvalidation(ID, ['index.html'])).rejects.toThrow(/must start with \//)
await expect(cdn.createDistributionInvalidation(ID, [])).rejects.toThrow(/at least one path/)
expect(sent.filter((command) => command instanceof CreateInvalidationCommand)).toHaveLength(1)
})
})
@@ -0,0 +1,300 @@
import {
CreateDistributionCommand,
CreateInvalidationCommand,
DeleteDistributionCommand,
GetDistributionCommand,
GetDistributionConfigCommand,
ListDistributionsCommand,
ListInvalidationsCommand,
UpdateDistributionCommand,
type CacheBehavior,
type CloudFrontClient,
type DefaultCacheBehavior,
type Distribution,
type DistributionConfig,
type DistributionSummary,
type Origin,
} from '@aws-sdk/client-cloudfront'
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
import {
CDN_ALLOWED_METHODS,
CDN_BUCKET_PATTERN,
CDN_ORIGIN_DOMAIN_PATTERN,
CDN_ORIGIN_PROTOCOLS,
CDN_ORIGIN_TYPES,
CDN_VIEWER_PROTOCOLS,
awsCdnSchema,
} from '../cloud-spi/cdnSchema'
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, DistributionInvalidation, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
/** AWS managed cache and origin request policies, which CloudFront and the runtime both know by ID. */
const CACHING_OPTIMIZED = '658327ea-f89d-4fab-a63d-7e88639e58f6'
const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad'
const ALL_VIEWER_EXCEPT_HOST_HEADER = 'b689b0a8-53d0-40ab-baf2-68738e2966ac'
const READ_METHODS = ['GET', 'HEAD']
const ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']
/** CloudFront's limit on paths in one invalidation that are not wildcards. */
const MAX_INVALIDATION_PATHS = 3000
const RECENT_INVALIDATIONS = 5
/**
* Talks to CloudFront distributions, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - A distribution is `Deployed` as soon as it is created or updated; AWS takes minutes.
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
* (409), as on AWS, so delete disables it first.
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost.floci.io` on the
* runtime's port, a name that resolves to 127.0.0.1 without any hosts-file entry.
*/
export class AwsCdnAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'cdn' as const
constructor(
private readonly cloudfront: CloudFrontClient,
private readonly s3: S3Client,
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
) {}
schema(): ServiceSchema {
return awsCdnSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const summaries: DistributionSummary[] = []
let marker: string | undefined
do {
const res = await this.cloudfront.send(new ListDistributionsCommand({...(marker ? {Marker: marker} : {})}))
summaries.push(...(res.DistributionList?.Items ?? []))
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined
} while (marker)
const search = query.search?.trim().toLowerCase()
return summaries.map((summary) => this.toResource(summary, summary.Enabled ?? false, summary.Comment, summary.Origins?.Items))
.filter((resource) => !search || [resource.id, resource.name, ...stringList(resource.metadata.originDomains)].some((value) => value.toLowerCase().includes(search)))
}
async get(id: string): Promise<CloudResource | null> {
let distribution: Distribution | undefined
try {
distribution = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).Distribution
} catch (error) {
if (errorName(error) === 'NoSuchDistribution') return null
throw error
}
if (!distribution) return null
const config = distribution.DistributionConfig
const invalidations = await this.recentInvalidations(id)
const resource = this.toResource(distribution, config?.Enabled ?? false, config?.Comment, config?.Origins?.Items)
resource.metadata = {
...resource.metadata,
defaultRootObject: config?.DefaultRootObject || null,
aliases: config?.Aliases?.Items ?? [],
behaviors: behaviorsOf(config),
invalidations,
}
return resource
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const originType = oneOf(values.originType ?? 's3', CDN_ORIGIN_TYPES, 'originType')
const viewerProtocol = oneOf(values.viewerProtocol || 'allow-all', CDN_VIEWER_PROTOCOLS, 'viewerProtocol')
const methods = oneOf(values.allowedMethods || 'read', CDN_ALLOWED_METHODS, 'allowedMethods')
const comment = text(values.comment)
if (comment.length > 128) throw new ValidationError('comment must be at most 128 characters')
const defaultRootObject = text(values.defaultRootObject).replace(/^\/+/, '')
const origin = originType === 's3' ? await this.s3Origin(text(values.bucket)) : customOrigin(values)
const allowed = methods === 'all' ? ALL_METHODS : READ_METHODS
const behavior: DefaultCacheBehavior = {
TargetOriginId: origin.Id,
ViewerProtocolPolicy: viewerProtocol,
AllowedMethods: {Quantity: allowed.length, Items: allowed as never, CachedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
Compress: true,
// A website is cached; an API, which sees every method, is not, and gets the viewer's request.
CachePolicyId: methods === 'all' ? CACHING_DISABLED : CACHING_OPTIMIZED,
...(methods === 'all' && originType === 'custom' ? {OriginRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER} : {}),
}
const res = await this.cloudfront.send(new CreateDistributionCommand({
DistributionConfig: {
CallerReference: crypto.randomUUID(),
Comment: comment,
Enabled: true,
DefaultRootObject: defaultRootObject || undefined,
Origins: {Quantity: 1, Items: [origin]},
DefaultCacheBehavior: behavior,
},
}))
if (!res.Distribution) throw new ValidationError('CloudFront did not return the new distribution')
const config = res.Distribution.DistributionConfig
return this.toResource(res.Distribution, config?.Enabled ?? true, config?.Comment, config?.Origins?.Items)
}
/** CloudFront deletes only a disabled distribution, so an enabled one is disabled first. */
async delete(id: string): Promise<void> {
let etag = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).ETag
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
if (current.DistributionConfig?.Enabled) {
const disabled = await this.cloudfront.send(new UpdateDistributionCommand({
Id: id,
IfMatch: current.ETag,
DistributionConfig: {...current.DistributionConfig, Enabled: false},
}))
if (disabled.Distribution?.Status !== 'Deployed') {
throw new ConflictError('The distribution is now disabled. CloudFront deletes it once the change is deployed: try again when its status is Deployed')
}
etag = disabled.ETag
}
try {
await this.cloudfront.send(new DeleteDistributionCommand({Id: id, IfMatch: etag}))
} catch (error) {
if (errorName(error) === 'DistributionNotDisabled') {
throw new ConflictError('CloudFront is still disabling the distribution: try again when its status is Deployed')
}
throw error
}
}
async setDistributionEnabled(id: string, enabled: boolean): Promise<CloudResource> {
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
if (!current.DistributionConfig) throw new NotFoundError(`Distribution ${id} was not found`)
if (current.DistributionConfig.Enabled !== enabled) {
await this.cloudfront.send(new UpdateDistributionCommand({
Id: id,
IfMatch: current.ETag,
DistributionConfig: {...current.DistributionConfig, Enabled: enabled},
}))
}
const resource = await this.get(id)
if (!resource) throw new NotFoundError(`Distribution ${id} was not found`)
return resource
}
async createDistributionInvalidation(id: string, paths: string[]): Promise<DistributionInvalidation> {
if (!Array.isArray(paths)) throw new ValidationError('paths must be a list')
const items = [...new Set(paths.map((path) => String(path).trim()).filter(Boolean))]
if (items.length === 0) throw new ValidationError('Add at least one path, such as /* or /index.html')
if (items.length > MAX_INVALIDATION_PATHS) throw new ValidationError(`An invalidation holds at most ${MAX_INVALIDATION_PATHS} paths`)
const invalid = items.find((path) => !path.startsWith('/'))
if (invalid) throw new ValidationError(`${invalid} must start with /`)
const res = await this.cloudfront.send(new CreateInvalidationCommand({
DistributionId: id,
InvalidationBatch: {CallerReference: crypto.randomUUID(), Paths: {Quantity: items.length, Items: items}},
}))
return {
id: res.Invalidation?.Id ?? '',
status: res.Invalidation?.Status ?? 'InProgress',
createdAt: res.Invalidation?.CreateTime?.toISOString() ?? null,
paths: res.Invalidation?.InvalidationBatch?.Paths?.Items ?? items,
}
}
private async s3Origin(bucket: string): Promise<Origin> {
if (!new RegExp(CDN_BUCKET_PATTERN).test(bucket)) throw new ValidationError('bucket must be the name of an existing S3 bucket')
try {
await this.s3.send(new HeadBucketCommand({Bucket: bucket}))
} catch (error) {
if (['NotFound', 'NoSuchBucket'].includes(errorName(error))) throw new ValidationError(`There is no S3 bucket named ${bucket}`)
throw error
}
return {Id: `s3-${bucket}`, DomainName: `${bucket}.s3.amazonaws.com`, S3OriginConfig: {OriginAccessIdentity: ''}}
}
private async recentInvalidations(id: string): Promise<DistributionInvalidation[]> {
const res = await this.cloudfront.send(new ListInvalidationsCommand({DistributionId: id, MaxItems: RECENT_INVALIDATIONS}))
return (res.InvalidationList?.Items ?? []).map((invalidation) => ({
id: invalidation.Id ?? '',
status: invalidation.Status ?? '',
createdAt: invalidation.CreateTime?.toISOString() ?? null,
paths: [],
}))
}
private toResource(distribution: Pick<Distribution, 'Id' | 'DomainName' | 'Status' | 'LastModifiedTime' | 'ARN'>, enabled: boolean, comment: string | undefined, origins: Origin[] | undefined): CloudResource {
const id = distribution.Id ?? ''
return {
id,
name: distribution.DomainName ?? id,
cloud: 'aws',
service: 'cdn',
type: 'distribution',
region: null,
createdAt: distribution.LastModifiedTime?.toISOString() ?? null,
status: distribution.Status ?? null,
metadata: {
arn: distribution.ARN ?? null,
domainName: distribution.DomainName ?? null,
viewerUrl: this.viewerUrl(id),
enabled,
comment: comment || null,
originDomains: (origins ?? []).map((origin) => origin.DomainName ?? ''),
origins: (origins ?? []).map((origin) => ({
id: origin.Id ?? '',
domainName: origin.DomainName ?? '',
kind: origin.S3OriginConfig ? 's3' : 'custom',
protocol: origin.CustomOriginConfig?.OriginProtocolPolicy ?? null,
})),
},
}
}
/** Where a browser on this machine reaches the distribution through the runtime. */
private viewerUrl(id: string): string {
const port = new URL(this.endpoint).port || '4566'
return `http://${id.toLowerCase()}.cloudfront.localhost.floci.io:${port}/`
}
}
function customOrigin(values: Record<string, unknown>): Origin {
const domain = text(values.originDomain).toLowerCase()
if (!new RegExp(CDN_ORIGIN_DOMAIN_PATTERN).test(domain)) throw new ValidationError('originDomain must be a host name such as api.example.com')
const protocol = oneOf(values.originProtocol || 'https-only', CDN_ORIGIN_PROTOCOLS, 'originProtocol')
const port = text(values.originPort)
const portNumber = port ? Number(port) : null
if (portNumber !== null && (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535)) throw new ValidationError('originPort must be between 1 and 65535')
return {
Id: `custom-${domain}`,
DomainName: domain,
CustomOriginConfig: {
HTTPPort: portNumber ?? 80,
HTTPSPort: portNumber ?? 443,
OriginProtocolPolicy: protocol,
OriginSslProtocols: {Quantity: 1, Items: ['TLSv1.2']},
},
}
}
function behaviorsOf(config: DistributionConfig | undefined) {
const describe = (behavior: DefaultCacheBehavior | CacheBehavior, pathPattern: string) => ({
pathPattern,
originId: behavior.TargetOriginId ?? '',
viewerProtocol: behavior.ViewerProtocolPolicy ?? '',
methods: behavior.AllowedMethods?.Items ?? READ_METHODS,
})
return [
...(config?.CacheBehaviors?.Items ?? []).map((behavior) => describe(behavior, behavior.PathPattern ?? '')),
...(config?.DefaultCacheBehavior ? [describe(config.DefaultCacheBehavior, 'Default (*)')] : []),
]
}
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
function errorName(error: unknown): string {
return (error as {name?: string} | null)?.name ?? ''
}
@@ -0,0 +1,174 @@
import {describe, expect, test} from 'bun:test'
import {
ChangeResourceRecordSetsCommand,
CreateHostedZoneCommand,
DeleteHostedZoneCommand,
GetHostedZoneCommand,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
type ResourceRecordSet,
type Route53Client,
} from '@aws-sdk/client-route-53'
import {AwsDnsAdapter} from './AwsDnsAdapter'
import {ConflictError, ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubRoute53(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as Route53Client
return {client, sent}
}
function awsError(name: string, status = 400): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
}
const ZONE_ID = 'ZG9PAE0M9KRWJ4F'
const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}}
const apex = [
{Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]},
{Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]},
]
function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) {
return stubRoute53((command) => {
const extra = more(command)
if (extra) return extra
if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false}
return {}
})
}
describe('AwsDnsAdapter hosted zones', () => {
test('lists zones by bare ID and filters by name', async () => {
const {client} = stubRoute53((command) => {
if (command instanceof ListHostedZonesCommand) {
return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false}
}
return {}
})
const adapter = new AwsDnsAdapter(client)
const zones = await adapter.list()
expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']])
expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE'])
})
test('describes a zone with its name servers, and treats a missing zone as absent', async () => {
const {client} = stubRoute53((command) => {
if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}}
throw awsError('NoSuchHostedZone', 404)
})
const adapter = new AwsDnsAdapter(client)
expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']})
expect(await adapter.get('ZMISSING')).toBeNull()
})
test('creates a public zone, or a private one in a VPC', async () => {
const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand
? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
: {})
const adapter = new AwsDnsAdapter(client)
expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test')
await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}})
const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[]
expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}})
expect(publicZone.input.VPC).toBeUndefined()
expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}})
await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError)
})
test('explains that a zone with records cannot be deleted', async () => {
const {client} = stubRoute53((command) => {
if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty')
return {}
})
await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError)
})
})
describe('AwsDnsAdapter records', () => {
test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => {
const {client} = zoneStub([
...apex,
{Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]},
{Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}},
{Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]},
])
const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID)
expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue'])
expect(items[0].body).toMatchObject({managed: true, editable: true})
expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true})
expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false})
expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false})
})
test('pages with an opaque cursor built from Route 53\'s next record', async () => {
const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName
? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'}
: undefined)
const store = new AwsDnsAdapter(client).items
const first = await store.listItems(ZONE_ID, {limit: 2})
expect(first.nextCursor).toBeTruthy()
await store.listItems(ZONE_ID, {cursor: first.nextCursor!})
const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand
expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'})
await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError)
})
test('upserts a record, naming it inside the zone and quoting TXT text', async () => {
const {client, sent} = zoneStub()
const store = new AwsDnsAdapter(client).items
expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A')
await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'})
await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']})
const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand)
.map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0])
expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}})
expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]})
expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'})
})
test('applies Route 53\'s value rules before calling it', async () => {
const {client, sent} = zoneStub()
const store = new AwsDnsAdapter(client).items
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/)
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/)
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/)
await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/)
await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/)
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/)
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/)
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/)
expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false)
})
test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => {
const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]}
const {client, sent} = zoneStub([stored])
const store = new AwsDnsAdapter(client).items
await store.deleteItem!(ZONE_ID, 'www.example.test|A')
const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]
expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored})
await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError)
await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/)
})
})
@@ -0,0 +1,293 @@
import {isIPv4, isIPv6} from 'node:net'
import {
ChangeResourceRecordSetsCommand,
CreateHostedZoneCommand,
DeleteHostedZoneCommand,
GetHostedZoneCommand,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
type HostedZone,
type ResourceRecordSet,
type Route53Client,
type RRType,
} from '@aws-sdk/client-route-53'
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
import {DNS_RECORD_TYPES, DNS_ZONE_NAME_PATTERN, awsDnsSchema} from '../cloud-spi/dnsSchema'
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
/** Route 53 caps ListResourceRecordSets pages at 300 and ListHostedZones at 100. */
const RECORD_PAGE_LIMIT = 300
const ZONE_PAGE_LIMIT = 100
/** Route 53's TTL range. */
const MAX_TTL = 2_147_483_647
/**
* Talks to Route 53 hosted zones, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Zone IDs come back as `/hostedzone/Z…`; the bare ID is accepted everywhere, so
* resources are keyed by it.
* - A new zone holds the apex NS and SOA records, and `DeleteHostedZone` refuses
* with `HostedZoneNotEmpty` (400) while any other record remains, as on AWS.
* - A missing zone is `NoSuchHostedZone` with HTTP 404.
* - The runtime stores record values without checking them (an A record of
* `not-an-ip`, unquoted TXT text), so the AWS rules are applied here.
*
* Records are the zone's child items, keyed by name, type and set identifier,
* which is what makes a record set unique in Route 53.
*/
export class AwsDnsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'dns' as const
readonly items: ItemStoreAdapter
constructor(private readonly route53: Route53Client, private readonly region = 'us-east-1') {
this.items = new RecordStore(route53, (id) => this.zoneName(id))
}
schema(): ServiceSchema {
return awsDnsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const zones: HostedZone[] = []
let marker: string | undefined
do {
const res = await this.route53.send(new ListHostedZonesCommand({MaxItems: ZONE_PAGE_LIMIT, ...(marker ? {Marker: marker} : {})}))
zones.push(...(res.HostedZones ?? []))
marker = res.IsTruncated ? res.NextMarker : undefined
} while (marker)
const search = query.search?.trim().toLowerCase()
return zones.map((zone) => toResource(zone))
.filter((zone) => !search || zone.name.toLowerCase().includes(search) || zone.id.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.route53.send(new GetHostedZoneCommand({Id: zoneId(id)}))
return res.HostedZone ? toResource(res.HostedZone, res.DelegationSet?.NameServers, res.VPCs?.map((vpc) => `${vpc.VPCId} (${vpc.VPCRegion})`)) : null
} catch (error) {
if (errorName(error) === 'NoSuchHostedZone') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = typeof input.values.name === 'string' ? input.values.name.trim().toLowerCase() : ''
if (!new RegExp(DNS_ZONE_NAME_PATTERN).test(name)) throw new ValidationError('name must be a domain name such as example.com')
const comment = typeof input.values.comment === 'string' ? input.values.comment.trim() : ''
const vpcId = typeof input.values.vpcId === 'string' ? input.values.vpcId.trim() : ''
if (vpcId && !/^vpc-[0-9a-f]{8,17}$/.test(vpcId)) throw new ValidationError('vpcId must be a VPC ID such as vpc-0abc1234')
const res = await this.route53.send(new CreateHostedZoneCommand({
Name: name,
CallerReference: crypto.randomUUID(),
HostedZoneConfig: {Comment: comment || undefined, PrivateZone: Boolean(vpcId)},
...(vpcId ? {VPC: {VPCId: vpcId, VPCRegion: this.region as never}} : {}),
}))
if (!res.HostedZone) throw new ValidationError(`Route 53 did not return the hosted zone ${name}`)
return toResource(res.HostedZone, res.DelegationSet?.NameServers, vpcId ? [`${vpcId} (${this.region})`] : undefined)
}
async delete(id: string): Promise<void> {
try {
await this.route53.send(new DeleteHostedZoneCommand({Id: zoneId(id)}))
} catch (error) {
if (errorName(error) === 'HostedZoneNotEmpty') {
throw new ConflictError('Delete the zone\'s records first: Route 53 only deletes a hosted zone that holds just its NS and SOA records')
}
throw error
}
}
private async zoneName(id: string): Promise<string> {
const zone = await this.get(id)
if (!zone) throw new NotFoundError(`Hosted zone ${id} was not found`)
return zone.name
}
}
class RecordStore implements ItemStoreAdapter {
constructor(private readonly route53: Route53Client, private readonly zoneName: (id: string) => Promise<string>) {}
async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const start = decodeCursor(page.cursor)
const res = await this.route53.send(new ListResourceRecordSetsCommand({
HostedZoneId: zoneId(resourceId),
MaxItems: Math.min(page.limit ?? RECORD_PAGE_LIMIT, RECORD_PAGE_LIMIT),
...(start ? {StartRecordName: start.name, StartRecordType: start.type as RRType, StartRecordIdentifier: start.identifier} : {}),
}))
const zone = await this.zoneName(resourceId)
return {
items: (res.ResourceRecordSets ?? []).map((record) => toRecordItem(record, zone)),
nextCursor: res.IsTruncated && res.NextRecordName
? encodeCursor({name: res.NextRecordName, type: res.NextRecordType ?? '', identifier: res.NextRecordIdentifier})
: null,
}
}
/** Creates the record set, or replaces the one with the same name and type (UPSERT). */
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
const zone = await this.zoneName(resourceId)
const record = recordFromInput(body, zone)
await this.route53.send(new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId(resourceId),
ChangeBatch: {Changes: [{Action: 'UPSERT', ResourceRecordSet: record}]},
}))
return toRecordItem(record, zone)
}
/** Route 53 deletes a record set only when given it exactly, so the stored set is read first. */
async deleteItem(resourceId: string, itemId: string): Promise<void> {
const key = parseRecordId(itemId)
const zone = await this.zoneName(resourceId)
if (key.name === zone && (key.type === 'NS' || key.type === 'SOA')) {
throw new ConflictError(`The zone's own ${key.type} record cannot be deleted`)
}
const res = await this.route53.send(new ListResourceRecordSetsCommand({
HostedZoneId: zoneId(resourceId),
StartRecordName: key.name,
StartRecordType: key.type as RRType,
...(key.identifier ? {StartRecordIdentifier: key.identifier} : {}),
MaxItems: 1,
}))
const stored = res.ResourceRecordSets?.find((record) => recordId(record) === itemId)
if (!stored) throw new NotFoundError(`Record ${key.type} ${key.name} was not found`)
await this.route53.send(new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId(resourceId),
ChangeBatch: {Changes: [{Action: 'DELETE', ResourceRecordSet: stored}]},
}))
}
}
function toResource(zone: HostedZone, nameServers?: string[], vpcs?: string[]): CloudResource {
const id = zoneId(zone.Id ?? '')
const privateZone = zone.Config?.PrivateZone ?? false
return {
id,
name: withoutDot(zone.Name ?? id),
cloud: 'aws',
service: 'dns',
type: 'hosted-zone',
region: null,
createdAt: null,
status: null,
metadata: {
visibility: privateZone ? 'private' : 'public',
recordCount: zone.ResourceRecordSetCount ?? null,
comment: zone.Config?.Comment ?? null,
...(nameServers ? {nameServers} : {}),
...(vpcs ? {vpcs} : {}),
},
}
}
function toRecordItem(record: ResourceRecordSet, zone: string): ChildItem {
const name = withoutDot(unescapeName(record.Name ?? ''))
return {
id: recordId(record),
collectionId: null,
timestamp: null,
body: {
name,
type: record.Type ?? '',
ttl: record.TTL ?? null,
values: (record.ResourceRecords ?? []).map((value) => value.Value ?? ''),
alias: record.AliasTarget ? {dnsName: withoutDot(record.AliasTarget.DNSName ?? ''), hostedZoneId: record.AliasTarget.HostedZoneId ?? '', evaluateTargetHealth: record.AliasTarget.EvaluateTargetHealth ?? false} : null,
setIdentifier: record.SetIdentifier ?? null,
// The zone's own NS and SOA records, and routing-policy sets this console cannot edit faithfully.
managed: (name === zone && (record.Type === 'NS' || record.Type === 'SOA')),
editable: !record.AliasTarget && !record.SetIdentifier,
},
metadata: {},
}
}
/** Validates a simple record the way Route 53 does and returns it as a record set. */
function recordFromInput(body: Record<string, unknown>, zone: string): ResourceRecordSet {
const type = typeof body.type === 'string' ? body.type.trim().toUpperCase() : ''
if (!(DNS_RECORD_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${DNS_RECORD_TYPES.join(', ')}`)
const name = qualifiedName(typeof body.name === 'string' ? body.name : '', zone)
const ttl = Number(body.ttl ?? 300)
if (!Number.isInteger(ttl) || ttl < 0 || ttl > MAX_TTL) throw new ValidationError('ttl must be a whole number of seconds')
const rawValues = Array.isArray(body.values) ? body.values : typeof body.values === 'string' ? body.values.split('\n') : []
const values = rawValues.map((value) => String(value).trim()).filter(Boolean)
if (values.length === 0) throw new ValidationError('Add at least one value')
if (type === 'CNAME' && values.length > 1) throw new ValidationError('A CNAME record has exactly one value')
if (type === 'CNAME' && name === zone) throw new ValidationError('The zone apex cannot have a CNAME record; use an A or alias record')
for (const value of values) {
if (type === 'A' && !isIPv4(value)) throw new ValidationError(`${value} is not an IPv4 address`)
if (type === 'AAAA' && !isIPv6(value)) throw new ValidationError(`${value} is not an IPv6 address`)
if (type === 'MX' && !/^\d{1,5}\s+\S+$/.test(value)) throw new ValidationError(`MX values are a priority and a mail server, such as "10 mail.${zone}"`)
}
return {
Name: name,
Type: type as RRType,
TTL: ttl,
ResourceRecords: values.map((value) => ({Value: type === 'TXT' || type === 'SPF' ? quoted(value) : value})),
}
}
/** "www", "www.example.com" and "@" all name a record in example.com. */
function qualifiedName(input: string, zone: string): string {
const name = withoutDot(input.trim().toLowerCase())
if (!name || name === '@') return zone
const full = name === zone || name.endsWith(`.${zone}`) ? name : `${name}.${zone}`
if (!/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(full) || full.length > 253) {
throw new ValidationError(`${input} is not a valid record name`)
}
return full
}
/** Route 53 wants each TXT string quoted; text the user already quoted is kept as written. */
function quoted(value: string): string {
if (value.startsWith('"') && value.endsWith('"') && value.length > 1) return value
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
function recordId(record: Pick<ResourceRecordSet, 'Name' | 'Type' | 'SetIdentifier'>): string {
return [withoutDot(unescapeName(record.Name ?? '')), record.Type ?? '', ...(record.SetIdentifier ? [record.SetIdentifier] : [])].join('|')
}
function parseRecordId(id: string): {name: string; type: string; identifier?: string} {
const [name, type, ...rest] = id.split('|')
if (!name || !type) throw new ValidationError('Record id must be name|type')
return {name, type, ...(rest.length ? {identifier: rest.join('|')} : {})}
}
function encodeCursor(cursor: {name: string; type: string; identifier?: string}): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
}
function decodeCursor(cursor: string | undefined): {name: string; type: string; identifier?: string} | null {
if (!cursor) return null
try {
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as {name?: unknown; type?: unknown; identifier?: unknown}
if (typeof value.name !== 'string' || typeof value.type !== 'string') throw new Error('bad cursor')
return {name: value.name, type: value.type, ...(typeof value.identifier === 'string' ? {identifier: value.identifier} : {})}
} catch {
throw new ValidationError('cursor is not valid for this hosted zone')
}
}
/** Route 53 returns `*` as `\052` and other special characters as octal escapes. */
function unescapeName(name: string): string {
return name.replace(/\\(\d{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8)))
}
function zoneId(id: string): string {
return id.replace(/^\/hostedzone\//, '')
}
function withoutDot(name: string): string {
return name.endsWith('.') ? name.slice(0, -1) : name
}
function errorName(error: unknown): string {
return (error as {name?: string} | null)?.name ?? ''
}
@@ -16,7 +16,9 @@ import {AwsLightsailAdapter} from './AwsLightsailAdapter'
const originalEndpoint = process.env.FLOCI_ENDPOINT const originalEndpoint = process.env.FLOCI_ENDPOINT
afterEach(() => { afterEach(() => {
process.env.FLOCI_ENDPOINT = originalEndpoint // Assigning undefined would store the string "undefined" for every later test file.
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
else process.env.FLOCI_ENDPOINT = originalEndpoint
}) })
function stubLightsail() { function stubLightsail() {
@@ -59,6 +59,18 @@ describe('AwsSesAdapter', () => {
expect(codes).toEqual(['869472', '4410', null]) expect(codes).toEqual(['869472', '4410', null])
}) })
test('lists the newest email first', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [
{Id: 'old', Timestamp: '2026-10-23T21:12:41.732Z'},
{Id: 'newest', Timestamp: '2026-10-24T05:00:00.000Z'},
{Id: 'middle', Timestamp: '2026-10-23T21:56:04.866Z'},
],
}))
expect((await adapter.list()).map((resource) => resource.id)).toEqual(['newest', 'middle', 'old'])
})
test('filters messages by subject, sender, and recipients', async () => { test('filters messages by subject, sender, and recipients', async () => {
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({ const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
messages: [ messages: [
@@ -47,7 +47,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
async list(query: ResourceQuery = {}): Promise<CloudResource[]> { async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const messages = await this.fetchInbox() const messages = await this.fetchInbox()
const resources = messages.map(messageToResource) const resources = messages.map(messageToResource).sort(newestFirst)
return filterBySearch(resources, query.search) return filterBySearch(resources, query.search)
} }
@@ -137,6 +137,10 @@ function findCode(body: string | null | undefined): string | null {
return body?.match(CODE_PATTERN)?.[1] ?? null return body?.match(CODE_PATTERN)?.[1] ?? null
} }
function newestFirst(a: CloudResource, b: CloudResource): number {
return (b.createdAt ?? '').localeCompare(a.createdAt ?? '')
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase() const normalized = search?.trim().toLowerCase()
if (!normalized) return resources if (!normalized) return resources
@@ -23,6 +23,7 @@ import {
ListUserPoolClientsCommand, ListUserPoolClientsCommand,
ListUserPoolsCommand, ListUserPoolsCommand,
ListUsersCommand, ListUsersCommand,
UpdateUserPoolClientCommand,
type CognitoIdentityProviderClient, type CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider' } from '@aws-sdk/client-cognito-identity-provider'
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
@@ -125,9 +126,31 @@ describe('AwsUserPoolsAdapter', () => {
mfaConfiguration: 'OFF', mfaConfiguration: 'OFF',
deletionProtection: 'INACTIVE', deletionProtection: 'INACTIVE',
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
lambdaTriggers: [],
}) })
}) })
test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => {
const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}`
const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: {
PreTokenGeneration: arn('claims'),
PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'},
PreSignUp: `${arn('signup')}:live`,
CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'},
KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1',
}}}
return {}
})
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
expect(resource?.metadata.lambdaTriggers).toEqual([
{trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null},
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'},
{trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'},
])
})
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => { test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
const {client} = stubCognito((command) => { const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
@@ -387,3 +410,63 @@ describe('AwsUserPoolsAdapter user actions and groups', () => {
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'}) .toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
}) })
}) })
describe('app client OAuth settings', () => {
const CLIENT_ID = '40808d60e188483d876bbbf646'
const stored = {
UserPoolId: POOL_ID, ClientId: CLIENT_ID, ClientName: 'notes-demo-web',
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
SupportedIdentityProviders: ['COGNITO'],
}
function clientStub() {
return stubCognito((command) => {
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: stored}
if (command instanceof UpdateUserPoolClientCommand) return {UserPoolClient: {...stored, ...command.input}}
return {}
})
}
test('reads a client with no OAuth set up', async () => {
const settings = await new AwsUserPoolsAdapter(clientStub().client).describeUserPoolClient(POOL_ID, CLIENT_ID)
expect(settings).toEqual({
clientId: CLIENT_ID, clientName: 'notes-demo-web', hasSecret: false,
explicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO'],
})
})
test('turns on the code flow and sends the client\'s other settings back unchanged', async () => {
const {client, sent} = clientStub()
const settings = await new AwsUserPoolsAdapter(client).updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {
callbackUrls: ['http://localhost:4600/api/auth/callback', ' http://localhost:4600/api/auth/callback '],
logoutUrls: ['http://localhost:4600/'],
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
})
const update = sent.find((command) => command instanceof UpdateUserPoolClientCommand) as UpdateUserPoolClientCommand
expect(update.input).toMatchObject({
ClientName: 'notes-demo-web', ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
CallbackURLs: ['http://localhost:4600/api/auth/callback'], LogoutURLs: ['http://localhost:4600/'],
AllowedOAuthFlows: ['code'], AllowedOAuthScopes: ['openid', 'email'], AllowedOAuthFlowsUserPoolClient: true,
SupportedIdentityProviders: ['COGNITO'],
})
expect(settings.oauthFlows).toEqual(['code'])
})
test('applies Cognito\'s rules before calling it', async () => {
const {client, sent} = clientStub()
const adapter = new AwsUserPoolsAdapter(client)
const valid = {callbackUrls: ['https://app.example/callback'], logoutUrls: [], oauthFlows: ['code' as const], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['http://app.example/callback']})).rejects.toThrow(/https/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['https://app.example/#done']})).rejects.toThrow(/fragment/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: []})).rejects.toThrow(/callback URL/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthScopes: []})).rejects.toThrow(/scope/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['code', 'client_credentials']})).rejects.toThrow(ValidationError)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['client_credentials']})).rejects.toThrow(/secret/)
expect(sent.some((command) => command instanceof UpdateUserPoolClientCommand)).toBe(false)
})
})
@@ -24,11 +24,13 @@ import {
ListUserPoolClientsCommand, ListUserPoolClientsCommand,
ListUserPoolsCommand, ListUserPoolsCommand,
ListUsersCommand, ListUsersCommand,
UpdateUserPoolClientCommand,
type AttributeType, type AttributeType,
type CognitoIdentityProviderClient, type CognitoIdentityProviderClient,
type GroupType, type GroupType,
type PasswordPolicyType, type PasswordPolicyType,
type UserPoolClientDescription, type UserPoolClientDescription,
type UserPoolClientType,
type UserPoolDescriptionType, type UserPoolDescriptionType,
type UserPoolType, type UserPoolType,
type UserType, type UserType,
@@ -38,6 +40,7 @@ import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import { import {
USER_POOL_GROUP_NAME_PATTERN, USER_POOL_GROUP_NAME_PATTERN,
USER_POOL_NAME_PATTERN, USER_POOL_NAME_PATTERN,
USER_POOL_OAUTH_FLOWS,
USER_POOL_PASSWORD_POLICIES, USER_POOL_PASSWORD_POLICIES,
USER_POOL_SIGN_IN, USER_POOL_SIGN_IN,
awsUserPoolsSchema, awsUserPoolsSchema,
@@ -49,7 +52,10 @@ import type {
CreateUserPoolGroupInput, CreateUserPoolGroupInput,
ResourceQuery, ResourceQuery,
ServiceSchema, ServiceSchema,
UpdateUserPoolClientOAuthInput,
UserPoolClientSettings,
UserPoolGroup, UserPoolGroup,
UserPoolOAuthFlow,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolUserAction, UserPoolUserAction,
@@ -285,6 +291,54 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
} }
} }
async describeUserPoolClient(poolId: string, clientId: string): Promise<UserPoolClientSettings> {
return toClientSettings(await this.fetchClient(poolId, clientId))
}
/**
* Changes the settings the hosted login and OAuth endpoints use. UpdateUserPoolClient
* resets every setting it is not given, so the client's other settings are sent back as they are.
*/
async updateUserPoolClientOAuth(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
const settings = validateOAuthSettings(input)
const current = await this.fetchClient(poolId, clientId)
if (settings.oauthFlows.includes('client_credentials') && !current.ClientSecret) {
throw new ValidationError('The client_credentials flow needs an app client with a secret')
}
const res = await this.cognito.send(new UpdateUserPoolClientCommand({
UserPoolId: poolId,
ClientId: clientId,
ClientName: current.ClientName,
RefreshTokenValidity: current.RefreshTokenValidity,
AccessTokenValidity: current.AccessTokenValidity,
IdTokenValidity: current.IdTokenValidity,
TokenValidityUnits: current.TokenValidityUnits,
ReadAttributes: current.ReadAttributes,
WriteAttributes: current.WriteAttributes,
ExplicitAuthFlows: current.ExplicitAuthFlows,
DefaultRedirectURI: current.DefaultRedirectURI && settings.callbackUrls.includes(current.DefaultRedirectURI) ? current.DefaultRedirectURI : undefined,
AnalyticsConfiguration: current.AnalyticsConfiguration,
PreventUserExistenceErrors: current.PreventUserExistenceErrors,
EnableTokenRevocation: current.EnableTokenRevocation,
EnablePropagateAdditionalUserContextData: current.EnablePropagateAdditionalUserContextData,
AuthSessionValidity: current.AuthSessionValidity,
RefreshTokenRotation: current.RefreshTokenRotation,
CallbackURLs: settings.callbackUrls,
LogoutURLs: settings.logoutUrls,
AllowedOAuthFlows: settings.oauthFlows,
AllowedOAuthScopes: settings.oauthScopes,
AllowedOAuthFlowsUserPoolClient: settings.oauthFlows.length > 0,
SupportedIdentityProviders: settings.identityProviders,
}))
return toClientSettings(res.UserPoolClient ?? current)
}
private async fetchClient(poolId: string, clientId: string): Promise<UserPoolClientType> {
const res = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: requiredString(clientId, 'clientId')}))
if (!res.UserPoolClient) throw new RuntimeError(`Cognito returned no app client ${clientId}`)
return res.UserPoolClient
}
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> { private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
return listGroupNamesForUser(this.cognito, poolId, username) return listGroupNamesForUser(this.cognito, poolId, username)
} }
@@ -460,10 +514,103 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us
mfaConfiguration: pool.MfaConfiguration ?? null, mfaConfiguration: pool.MfaConfiguration ?? null,
deletionProtection: pool.DeletionProtection ?? null, deletionProtection: pool.DeletionProtection ?? null,
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})), appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig),
}, },
} }
} }
// Labels as the AWS console names them, in the order a sign-up and sign-in reach them.
const LAMBDA_TRIGGERS = [
['PreSignUp', 'Pre sign-up'],
['UserMigration', 'User migration'],
['CustomMessage', 'Custom message'],
['PostConfirmation', 'Post confirmation'],
['PreAuthentication', 'Pre authentication'],
['DefineAuthChallenge', 'Define auth challenge'],
['CreateAuthChallenge', 'Create auth challenge'],
['VerifyAuthChallengeResponse', 'Verify auth challenge response'],
['PostAuthentication', 'Post authentication'],
['PreTokenGeneration', 'Pre token generation'],
['CustomEmailSender', 'Custom email sender'],
['CustomSMSSender', 'Custom SMS sender'],
] as const
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] {
if (!config) return []
return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => {
// PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field.
const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig
: trigger === 'CustomEmailSender' ? config.CustomEmailSender
: trigger === 'CustomSMSSender' ? config.CustomSMSSender
: undefined
const functionArn = versioned?.LambdaArn ?? config[trigger]
if (typeof functionArn !== 'string' || !functionArn) return []
return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}]
})
}
function toClientSettings(client: UserPoolClientType): UserPoolClientSettings {
return {
clientId: client.ClientId ?? '',
clientName: client.ClientName ?? '',
hasSecret: Boolean(client.ClientSecret),
explicitAuthFlows: client.ExplicitAuthFlows ?? [],
callbackUrls: client.CallbackURLs ?? [],
logoutUrls: client.LogoutURLs ?? [],
oauthFlows: client.AllowedOAuthFlowsUserPoolClient ? (client.AllowedOAuthFlows ?? []).filter(isOAuthFlow) : [],
oauthScopes: client.AllowedOAuthScopes ?? [],
identityProviders: client.SupportedIdentityProviders ?? [],
}
}
function isOAuthFlow(value: string): value is UserPoolOAuthFlow {
return (USER_POOL_OAUTH_FLOWS as readonly string[]).includes(value)
}
/** Cognito's own rules for these settings, checked here so the form can say what is wrong. */
function validateOAuthSettings(input: UpdateUserPoolClientOAuthInput): UpdateUserPoolClientOAuthInput {
const callbackUrls = urlList(input?.callbackUrls, 'Callback URL')
const logoutUrls = urlList(input.logoutUrls, 'Sign-out URL')
const oauthFlows = stringList(input.oauthFlows, 'oauthFlows')
const unknown = oauthFlows.find((flow) => !isOAuthFlow(flow))
if (unknown) throw new ValidationError(`Unknown OAuth flow ${unknown}`)
if (oauthFlows.includes('client_credentials') && oauthFlows.length > 1) {
throw new ValidationError('client_credentials cannot be combined with the code or implicit flows')
}
if ((oauthFlows.includes('code') || oauthFlows.includes('implicit')) && callbackUrls.length === 0) {
throw new ValidationError('Add at least one callback URL for the code or implicit flow')
}
const oauthScopes = stringList(input.oauthScopes, 'oauthScopes')
if (oauthFlows.length > 0 && oauthScopes.length === 0) throw new ValidationError('Choose at least one OAuth scope')
const identityProviders = stringList(input.identityProviders, 'identityProviders')
return {callbackUrls, logoutUrls, oauthFlows: oauthFlows as UserPoolOAuthFlow[], oauthScopes, identityProviders}
}
function stringList(value: unknown, field: string): string[] {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new ValidationError(`${field} must be a list of strings`)
return [...new Set(value.map((entry: string) => entry.trim()).filter(Boolean))]
}
// Cognito accepts https URLs, and http only for localhost; fragments are never allowed.
function urlList(value: unknown, label: string): string[] {
return stringList(value, label).map((entry) => {
let url: URL
try {
url = new URL(entry)
} catch {
throw new ValidationError(`${label} ${entry} is not a valid URL`)
}
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
throw new ValidationError(`${label} ${entry} must use https (http is allowed only for localhost)`)
}
if (url.hash || entry.includes('#')) throw new ValidationError(`${label} ${entry} must not contain a fragment`)
return entry
})
}
function toUserItem( function toUserItem(
username: string | undefined, username: string | undefined,
attributes: AttributeType[] | undefined, attributes: AttributeType[] | undefined,
+5 -2
View File
@@ -13,7 +13,9 @@ const realFetch = globalThis.fetch
let passwordHash: string let passwordHash: string
beforeAll(async () => { beforeAll(async () => {
passwordHash = await Bun.password.hash('correct-horse-battery') // Cheap parameters keep the many sign-in attempts below fast on slow CI runners; verification
// reads the parameters from the hash, so the code under test is unchanged.
passwordHash = await Bun.password.hash('correct-horse-battery', {algorithm: 'argon2id', memoryCost: 1024, timeCost: 1})
}) })
afterEach(() => { afterEach(() => {
@@ -239,7 +241,8 @@ describe('auth state cleanup', () => {
test('caps pending OIDC requests and sweeps expired state', async () => { test('caps pending OIDC requests and sweeps expired state', async () => {
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'})) const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
const hono = app(config) const hono = app(config)
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch // The same document as mockIdp: discovery is cached per issuer for the rest of the run.
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})) as unknown as typeof fetch
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`) for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
expect(authStateSizes().oidcRequests).toBe(1_000) expect(authStateSizes().oidcRequests).toBe(1_000)
+6
View File
@@ -31,6 +31,8 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
import { LightsailClient } from "@aws-sdk/client-lightsail"; import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -93,6 +95,8 @@ export type AwsClients = {
elasticache: ElastiCacheClient; elasticache: ElastiCacheClient;
lightsail: LightsailClient; lightsail: LightsailClient;
cognito: CognitoIdentityProviderClient; cognito: CognitoIdentityProviderClient;
route53: Route53Client;
cloudfront: CloudFrontClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -139,6 +143,8 @@ function buildClients(accountId: string): AwsClients {
elasticache: new ElastiCacheClient(base), elasticache: new ElastiCacheClient(base),
lightsail: new LightsailClient(base), lightsail: new LightsailClient(base),
cognito: new CognitoIdentityProviderClient(base), cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base),
cloudfront: new CloudFrontClient(base),
}; };
} }
+131
View File
@@ -0,0 +1,131 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
export const CDN_ORIGIN_TYPES = ['s3', 'custom'] as const
export const CDN_VIEWER_PROTOCOLS = ['redirect-to-https', 'allow-all', 'https-only'] as const
export const CDN_ORIGIN_PROTOCOLS = ['https-only', 'http-only', 'match-viewer'] as const
export const CDN_ALLOWED_METHODS = ['read', 'all'] as const
/** An S3 bucket name as S3 allows it for new buckets. */
export const CDN_BUCKET_PATTERN = '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$'
/** A host name, optionally with a port, as a custom origin's DomainName. */
export const CDN_ORIGIN_DOMAIN_PATTERN = '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$'
const distributionColumns: TableColumnSchema[] = [
{name: 'id', label: 'Distribution ID', format: 'code'},
{name: 'viewerUrl', label: 'Local URL', path: 'metadata.viewerUrl', format: 'code'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'enabled', label: 'Enabled', path: 'metadata.enabled', format: 'boolean'},
{name: 'origins', label: 'Origins', path: 'metadata.originDomains', format: 'list'},
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
]
const distributionFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCdnSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'cdn',
displayName: 'Amazon CloudFront distributions',
fields: [
{
name: 'originType',
label: 'Origin',
type: 'select',
required: true,
defaultValue: 's3',
options: [{label: 'S3 bucket', value: 's3'}, {label: 'Custom origin (a web server or API)', value: 'custom'}],
},
{
name: 'bucket',
label: 'S3 bucket',
type: 'text',
required: false,
requiredWhen: {field: 'originType', equals: 's3'},
visibleWhen: {field: 'originType', values: ['s3']},
validation: {pattern: CDN_BUCKET_PATTERN, message: 'Use the name of an existing bucket'},
},
{
name: 'originDomain',
label: 'Origin domain',
type: 'text',
required: false,
requiredWhen: {field: 'originType', equals: 'custom'},
visibleWhen: {field: 'originType', values: ['custom']},
description: 'For example api.example.com. A local server needs FLOCI_SERVICES_CLOUDFRONT_ALLOWED_PRIVATE_ORIGIN_HOSTS on the runtime.',
validation: {pattern: CDN_ORIGIN_DOMAIN_PATTERN, message: 'Use a host name such as api.example.com'},
},
{
name: 'originProtocol',
label: 'Origin protocol',
type: 'select',
required: false,
defaultValue: 'https-only',
visibleWhen: {field: 'originType', values: ['custom']},
options: [
{label: 'HTTPS only', value: 'https-only'},
{label: 'HTTP only', value: 'http-only'},
{label: 'Match the viewer', value: 'match-viewer'},
],
},
{
name: 'originPort',
label: 'Origin port',
type: 'text',
required: false,
visibleWhen: {field: 'originType', values: ['custom']},
description: 'Leave empty for 443 (HTTPS) and 80 (HTTP).',
validation: {pattern: '^\\d{1,5}$', message: 'Use a port number'},
},
{
name: 'defaultRootObject',
label: 'Default root object',
type: 'text',
required: false,
defaultValue: 'index.html',
description: 'What the distribution serves for a request to /.',
},
{
name: 'viewerProtocol',
label: 'Viewer protocol',
type: 'select',
required: false,
defaultValue: 'allow-all',
options: [
{label: 'HTTP and HTTPS', value: 'allow-all'},
{label: 'Redirect HTTP to HTTPS', value: 'redirect-to-https'},
{label: 'HTTPS only', value: 'https-only'},
],
},
{
name: 'allowedMethods',
label: 'Allowed methods',
type: 'select',
required: false,
defaultValue: 'read',
options: [
{label: 'GET and HEAD (a website or files)', value: 'read'},
{label: 'All methods (an API)', value: 'all'},
],
},
{name: 'comment', label: 'Description', type: 'text', required: false},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List distributions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Disable and delete distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect distribution', enabled: true, status: 'available', runtimeRequired: true},
],
distributionActions: [
{name: 'setEnabled', label: 'Enable or disable a distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createInvalidation', label: 'Invalidate cached paths', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: distributionFilters,
columns: distributionColumns,
}
}
export function cdnSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsCdnSchema() : null
}
+64
View File
@@ -0,0 +1,64 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** A domain name of dot-separated labels, with or without the trailing dot. */
export const DNS_ZONE_NAME_PATTERN = '^(?=.{1,253}\\.?$)([a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.)*[a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.?$'
/** The record types Route 53 accepts in ChangeResourceRecordSets. */
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const
const zoneColumns: TableColumnSchema[] = [
{name: 'name', label: 'Domain'},
{name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'},
{name: 'recordCount', label: 'Records', path: 'metadata.recordCount'},
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
{name: 'id', label: 'Hosted zone ID', format: 'code'},
]
const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsDnsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'dns',
displayName: 'Amazon Route 53 hosted zones',
fields: [
{
name: 'name',
label: 'Domain name',
type: 'text',
required: true,
description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.',
validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'},
},
{name: 'comment', label: 'Description', type: 'text', required: false},
{
name: 'vpcId',
label: 'VPC ID (private zone)',
type: 'text',
required: false,
description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.',
validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true},
],
itemActions: [
{name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true},
{name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: zoneFilters,
columns: zoneColumns,
}
}
export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsDnsSchema() : null
}
@@ -239,6 +239,20 @@ export const SERVICE_CATALOG = {
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer. // AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'}, routeByCloud: {aws: '/secretsmanager'},
}, },
cdn: {
displayName: 'CDN',
displayNameByCloud: {aws: 'CloudFront'},
iconKey: 'cdn',
group: 'Networking',
order: 50,
},
dns: {
displayName: 'DNS',
displayNameByCloud: {aws: 'Route 53'},
iconKey: 'dns',
group: 'Networking',
order: 40,
},
userpools: { userpools: {
displayName: 'User Directories', displayName: 'User Directories',
displayNameByCloud: {aws: 'Cognito'}, displayNameByCloud: {aws: 'Cognito'},
+38
View File
@@ -178,6 +178,9 @@ export type UserPoolActionName =
| 'createGroup' | 'createGroup'
| 'deleteGroup' | 'deleteGroup'
| 'testSignIn' | 'testSignIn'
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {
@@ -222,6 +225,7 @@ export interface ServiceSchema {
databaseActions?: CapabilitySchema<DatabaseActionName>[] databaseActions?: CapabilitySchema<DatabaseActionName>[]
kubernetesActions?: CapabilitySchema<KubernetesActionName>[] kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[] userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
// Child levels advertise separately from the resource level because a // Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection — // store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event. // CloudWatch Logs creates streams but cannot delete an event.
@@ -453,6 +457,36 @@ export interface UserPoolSignInTestInput {
} }
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ /** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
export interface DistributionInvalidation {
id: string
status: string
createdAt: string | null
paths: string[]
}
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
/** The app client settings that decide how the hosted login and OAuth endpoints treat it. */
export interface UserPoolClientSettings {
clientId: string
clientName: string
hasSecret: boolean
explicitAuthFlows: string[]
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export interface UpdateUserPoolClientOAuthInput {
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export type UserPoolSignInTestResult = export type UserPoolSignInTestResult =
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>} | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
| {outcome: 'challenge'; challengeName: string} | {outcome: 'challenge'; challengeName: string}
@@ -970,6 +1004,10 @@ export interface CloudServiceAdapter {
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void> deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */ /** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void> clearEmailInbox?(): Promise<void>
// AppConfig nested resources, keyed by the ids the provider needs. Inspect // AppConfig nested resources, keyed by the ids the provider needs. Inspect
@@ -81,6 +81,8 @@ export function awsUserPoolsSchema(): ServiceSchema {
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true}, {name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true}, {name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true}, {name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
{name: 'describeClient', label: 'Show app client settings', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateClientOAuth', label: 'Change hosted sign-in and OAuth settings', enabled: true, status: 'available', runtimeRequired: true},
], ],
}, },
filters: userPoolFilters, filters: userPoolFilters,
@@ -92,6 +94,10 @@ export function awsUserPoolsSchema(): ServiceSchema {
} }
} }
export const USER_POOL_OAUTH_FLOWS = ['code', 'implicit', 'client_credentials'] as const
/** Scopes every pool offers; resource servers can add their own. */
export const USER_POOL_STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin'] as const
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null { export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsUserPoolsSchema() : null return cloud === 'aws' ? awsUserPoolsSchema() : null
} }
+18 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy' import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog' import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/** /**
* Guards the schema-to-implementation contract for every registered adapter. * Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,11 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile', deleteFargateProfile: 'deleteKubernetesFargateProfile',
} }
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
setEnabled: 'setDistributionEnabled',
createInvalidation: 'createDistributionInvalidation',
}
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = { const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
updateUser: 'updateUserPoolUser', updateUser: 'updateUserPoolUser',
setUserGroups: 'setUserPoolUserGroups', setUserGroups: 'setUserPoolUserGroups',
@@ -54,6 +59,8 @@ const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudService
createGroup: 'createUserPoolGroup', createGroup: 'createUserPoolGroup',
deleteGroup: 'deleteUserPoolGroup', deleteGroup: 'deleteUserPoolGroup',
testSignIn: 'testUserPoolSignIn', testSignIn: 'testUserPoolSignIn',
describeClient: 'describeUserPoolClient',
updateClientOAuth: 'updateUserPoolClientOAuth',
} }
const registry = createCloudAdapterRegistry() const registry = createCloudAdapterRegistry()
@@ -139,6 +146,15 @@ describe('registered adapters honour their schema', () => {
`${label} advertises ${capability.name} as available but has no ${String(method)}()`, `${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function') ).toBe('function')
} }
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
}) })
test(`${label} child collections match their advertised capabilities`, () => { test(`${label} child collections match their advertised capabilities`, () => {
@@ -152,6 +168,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.databaseActions ?? []), ...(adapter.schema().capabilities?.databaseActions ?? []),
...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []),
] ]
+4
View File
@@ -48,6 +48,8 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -98,6 +100,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsElastiCacheAdapter(clients.elasticache), new AwsElastiCacheAdapter(clients.elasticache),
new AwsLightsailAdapter(clients.lightsail), new AwsLightsailAdapter(clients.lightsail),
new AwsUserPoolsAdapter(clients.cognito), new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+48
View File
@@ -17,6 +17,7 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema'
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -747,6 +748,53 @@ describe('cloud schema routes', () => {
]) ])
}) })
test('reads and updates an app client\'s OAuth settings', async () => {
const settings = {clientId: 'c1', clientName: 'web', hasSecret: false, explicitAuthFlows: [], callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'userpools',
schema: awsUserPoolsSchema,
describeUserPoolClient: async () => settings,
updateUserPoolClientOAuth: async (poolId, clientId, input) => {
calls.push([poolId, clientId, input])
return {...settings, ...input}
},
})])
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x/clients/c1'
const input = {callbackUrls: ['http://localhost:4600/cb'], logoutUrls: [], oauthFlows: ['code'], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
expect(await (await app.request(base)).json()).toEqual(settings)
const updated = await app.request(`${base}/oauth`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify(input)})
expect(updated.status).toBe(200)
expect((await updated.json()).oauthFlows).toEqual(['code'])
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
})
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'cdn',
schema: awsCdnSchema,
setDistributionEnabled: async (id, enabled) => {
calls.push(['enabled', id, enabled])
return {id, name: 'd.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: null, metadata: {enabled}}
},
createDistributionInvalidation: async (id, paths) => {
calls.push(['invalidate', id, paths])
return {id: 'I1', status: 'Completed', createdAt: null, paths}
},
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/cdn/resources/E123'
const enabled = await app.request(`${base}/enabled`, json('PUT', {enabled: false}))
expect(enabled.status).toBe(200)
expect((await enabled.json()).metadata.enabled).toBe(false)
const invalidated = await app.request(`${base}/invalidations`, json('POST', {paths: ['/*']}))
expect(invalidated.status).toBe(201)
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
})
test('clears the SES mailbox through the email adapter', async () => { test('clears the SES mailbox through the email adapter', async () => {
let cleared = false let cleared = false
const app = appWithRoutes([mockAdapter('aws', { const app = appWithRoutes([mockAdapter('aws', {
+34
View File
@@ -447,6 +447,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json()))) return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
}) })
app.get('/:cloud/services/userpools/resources/:id/clients/:clientId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeUserPoolClient(cloud, c.req.param('id'), c.req.param('clientId'))))
})
app.put('/:cloud/services/userpools/resources/:id/clients/:clientId/oauth', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
})
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{enabled?: boolean}>()
return c.json(await svc(c).setDistributionEnabled(cloud, c.req.param('id'), body.enabled as boolean))
})
})
app.post('/:cloud/services/cdn/resources/:id/invalidations', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{paths?: string[]}>()
return c.json(await svc(c).createDistributionInvalidation(cloud, c.req.param('id'), body.paths as string[]), 201)
})
})
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -1,13 +1,16 @@
import {beforeEach, describe, expect, mock, test} from 'bun:test' import {beforeEach, describe, expect, mock, test} from 'bun:test'
// The secretsmanager router imports the `secretsManager` singleton directly from // The secretsmanager router imports the `secretsManager` singleton directly from
// `../aws`, so we replace that module with a stub whose `send()` is driven per // `../aws`, so we replace that export with a stub whose `send()` is driven per
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures // test. Bun keeps a module mock for the rest of the run, so every other export
// stays real: test files that load `../aws` after this one still get it whole. `responder` returns the fake AWS SDK response and `lastCommand` captures
// the command that was dispatched so we can assert on its input shape. // the command that was dispatched so we can assert on its input shape.
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
const realAws = await import('../aws')
mock.module('../aws', () => ({ mock.module('../aws', () => ({
...realAws,
secretsManager: { secretsManager: {
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) { async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
lastCommand = command lastCommand = command
@@ -81,6 +81,9 @@ import type {
UserPoolGroup, UserPoolGroup,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
} from '../cloud-spi/types' } from '../cloud-spi/types'
import type { import type {
@@ -831,6 +834,30 @@ export class CloudProxyService {
return adapter.testUserPoolSignIn(poolId, input) return adapter.testUserPoolSignIn(poolId, input)
} }
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'cdn')
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
return adapter.setDistributionEnabled(distributionId, enabled)
}
async createDistributionInvalidation(cloud: CloudProvider, distributionId: string, paths: string[]): Promise<DistributionInvalidation> {
const adapter = this.requireAdapter(cloud, 'cdn')
if (!adapter.createDistributionInvalidation) throw new NotSupportedError(`Invalidations are not supported for ${cloud}/cdn`)
return adapter.createDistributionInvalidation(distributionId, paths)
}
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
return adapter.describeUserPoolClient(poolId, clientId)
}
async updateUserPoolClientOAuth(cloud: CloudProvider, poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.updateUserPoolClientOAuth) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
return adapter.updateUserPoolClientOAuth(poolId, clientId, input)
}
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> { async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
const adapter = this.requireAdapter(cloud, 'k8s') const adapter = this.requireAdapter(cloud, 'k8s')
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`) if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
@@ -5,7 +5,9 @@ const originalEndpoint = process.env.FLOCI_ENDPOINT
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
afterEach(() => { afterEach(() => {
process.env.FLOCI_ENDPOINT = originalEndpoint // Assigning undefined would store the string "undefined" for every later test file.
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
else process.env.FLOCI_ENDPOINT = originalEndpoint
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
mock.restore() mock.restore()
}) })
+36
View File
@@ -130,6 +130,10 @@ export const apiEndpointKeys = {
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"}, messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
moveTasks: "clouds.services.messaging.move-tasks", moveTasks: "clouds.services.messaging.move-tasks",
}, },
cdn: {
enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations",
},
userpools: { userpools: {
userAction: "clouds.services.userpools.users.action", userAction: "clouds.services.userpools.users.action",
userGroups: "clouds.services.userpools.users.groups", userGroups: "clouds.services.userpools.users.groups",
@@ -139,6 +143,10 @@ export const apiEndpointKeys = {
delete: "clouds.services.userpools.groups.delete", delete: "clouds.services.userpools.groups.delete",
}, },
signInTest: "clouds.services.userpools.sign-in-test", signInTest: "clouds.services.userpools.sign-in-test",
client: {
get: "clouds.services.userpools.clients.get",
updateOAuth: "clouds.services.userpools.clients.update-oauth",
},
}, },
k8s: { k8s: {
nodegroups: { nodegroups: {
@@ -882,6 +890,34 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", method: "POST",
}, },
], ],
[
apiEndpointKeys.clouds.cdn.enabled,
{
path: "/clouds/:cloud/services/cdn/resources/:id/enabled",
method: "PUT",
},
],
[
apiEndpointKeys.clouds.cdn.invalidations,
{
path: "/clouds/:cloud/services/cdn/resources/:id/invalidations",
method: "POST",
},
],
[
apiEndpointKeys.clouds.userpools.client.get,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId",
method: "GET",
},
],
[
apiEndpointKeys.clouds.userpools.client.updateOAuth,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId/oauth",
method: "PUT",
},
],
[ [
apiEndpointKeys.clouds.k8s.nodegroups.list, apiEndpointKeys.clouds.k8s.nodegroups.list,
{ {
@@ -31,6 +31,9 @@ import type {
UserPoolGroup, UserPoolGroup,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
LogsInsightsQueryInput, LogsInsightsQueryInput,
LogsInsightsQueryResult, LogsInsightsQueryResult,
@@ -1474,6 +1477,60 @@ export async function testUserPoolSignIn(
return res.data; return res.data;
} }
export async function setDistributionEnabled(
cloud: CloudProvider,
distributionId: string,
enabled: boolean,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, { enabled: boolean }>(
apiEndpointKeys.clouds.cdn.enabled,
requestOptions(cloud, "cdn", { body: { enabled } }),
{ cloud, id: distributionId },
);
return res.data;
}
export async function createDistributionInvalidation(
cloud: CloudProvider,
distributionId: string,
paths: string[],
): Promise<DistributionInvalidation> {
const res = await apiClient.call<DistributionInvalidation, { paths: string[] }>(
apiEndpointKeys.clouds.cdn.invalidations,
requestOptions(cloud, "cdn", { body: { paths } }),
{ cloud, id: distributionId },
);
return res.data;
}
export async function describeUserPoolClient(
cloud: CloudProvider,
poolId: string,
clientId: string,
signal?: AbortSignal,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings>(
apiEndpointKeys.clouds.userpools.client.get,
requestOptions(cloud, "userpools", { signal }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function updateUserPoolClientOAuth(
cloud: CloudProvider,
poolId: string,
clientId: string,
input: UpdateUserPoolClientOAuthInput,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings, UpdateUserPoolClientOAuthInput>(
apiEndpointKeys.clouds.userpools.client.updateOAuth,
requestOptions(cloud, "userpools", { body: input }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function putFlatItem( export async function putFlatItem(
cloud: CloudProvider, cloud: CloudProvider,
service: CloudServiceType, service: CloudServiceType,
@@ -0,0 +1,71 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CloudFrontPanel} from './CloudFrontPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
setDistributionEnabled: vi.fn(),
createDistributionInvalidation: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/'
const distribution: CloudResource = {
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
region: null, createdAt: null, status: 'Deployed',
metadata: {
viewerUrl: VIEWER_URL, domainName: 'e7cpm0p0g0vc8d.cloudfront.net', enabled: true, defaultRootObject: 'index.html',
origins: [{id: 's3-site', domainName: 'site.s3.amazonaws.com', kind: 's3', protocol: null}],
behaviors: [{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'redirect-to-https', methods: ['GET', 'HEAD']}],
invalidations: [{id: 'I1', status: 'Completed', createdAt: null, paths: []}],
},
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudFrontPanel resource={distribution} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue(distribution)
api.setDistributionEnabled.mockImplementation(async (_cloud, _id, enabled) => ({...distribution, metadata: {...distribution.metadata, enabled}}))
api.createDistributionInvalidation.mockImplementation(async (_cloud, _id, paths) => ({id: 'I2', status: 'Completed', createdAt: null, paths}))
})
test('opens the distribution locally and shows its origins and behaviours', async () => {
renderPanel()
expect(await screen.findByRole('link', {name: /Open/})).toHaveAttribute('href', VIEWER_URL)
expect(screen.getByText('site.s3.amazonaws.com')).toBeInTheDocument()
expect(screen.getByText('Redirect to HTTPS')).toBeInTheDocument()
expect(screen.getByText('I1')).toBeInTheDocument()
})
test('disables the distribution', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Disable'}))
expect(api.setDistributionEnabled).toHaveBeenCalledWith('aws', distribution.id, false)
expect(await screen.findByText(/Distribution disabled/)).toBeInTheDocument()
})
test('invalidates the paths entered, one per line', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Create invalidation'})
const field = within(form).getByLabelText(/Paths/)
await user.clear(field)
await user.type(field, '/index.html{enter}/assets/*')
await user.click(within(form).getByRole('button', {name: 'Create invalidation'}))
expect(api.createDistributionInvalidation).toHaveBeenCalledWith('aws', distribution.id, ['/index.html', '/assets/*'])
expect(await screen.findByText(/Invalidation I2 completed for \/index.html, \/assets\/\*/)).toBeInTheDocument()
})
@@ -0,0 +1,124 @@
import {ExternalLink, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {createDistributionInvalidation, getCloudResource, setDistributionEnabled} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Origin = {id: string; domainName: string; kind: string; protocol: string | null}
type Behavior = {pathPattern: string; originId: string; viewerProtocol: string; methods: string[]}
type Invalidation = {id: string; status: string; createdAt: string | null}
const VIEWER_PROTOCOL: Record<string, string> = {
'allow-all': 'HTTP and HTTPS',
'redirect-to-https': 'Redirect to HTTPS',
'https-only': 'HTTPS only',
}
export function CloudFrontPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [paths, setPaths] = useState('/*')
const [notice, setNotice] = useState('')
const distributionKey = ['cdn-distribution', accountId, resource?.id]
// The table row is a list summary; behaviours and invalidations come from the full distribution.
const distributionQuery = useQuery({
queryKey: distributionKey,
queryFn: ({signal}) => getCloudResource('aws', 'cdn', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setPaths('/*')
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: distributionKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'cdn']}),
])
const enabledMutation = useMutation({
mutationFn: (enabled: boolean) => setDistributionEnabled('aws', resource?.id ?? '', enabled),
onSuccess: (updated) => {
setNotice(updated.metadata.enabled ? 'Distribution enabled.' : 'Distribution disabled. It no longer answers viewer requests.')
return refresh()
},
})
const invalidationMutation = useMutation({
mutationFn: () => createDistributionInvalidation('aws', resource?.id ?? '', paths.split('\n')),
onSuccess: (invalidation) => {
setNotice(`Invalidation ${invalidation.id} ${invalidation.status === 'Completed' ? 'completed' : 'started'} for ${invalidation.paths.join(', ')}.`)
return refresh()
},
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a distribution</h3><p>Choose a distribution to open it, change its state or invalidate cached files.</p></div></section>
}
const distribution = distributionQuery.data ?? resource
const metadata = distribution.metadata
const viewerUrl = typeof metadata.viewerUrl === 'string' ? metadata.viewerUrl : ''
const enabled = metadata.enabled === true
const origins = list<Origin>(metadata.origins)
const behaviors = list<Behavior>(metadata.behaviors)
const invalidations = list<Invalidation>(metadata.invalidations)
const pending = enabledMutation.isPending || invalidationMutation.isPending
const disabled = !runtimeReachable || pending
const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Distribution ${distribution.id}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Distribution</p><span className="muted">{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); enabledMutation.mutate(!enabled) }}>{enabled ? 'Disable' : 'Enable'}</button>
<button className="button" type="button" disabled={!runtimeReachable || distributionQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{distributionQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{viewerUrl && <div className="cdn-viewer">
<span className="copy-value mono">{viewerUrl}<CopyValueButton value={viewerUrl} label="Local URL"/></span>
<a className="button" href={viewerUrl} target="_blank" rel="noreferrer"><ExternalLink size={13}/> Open</a>
<small className="muted">Served by TheCloud on this machine. On AWS the same distribution answers at <code>{String(metadata.domainName ?? distribution.name)}</code>.</small>
</div>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Origins</p></div></div>
{origins.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Origin</th><th>Domain</th><th>Type</th></tr></thead><tbody>{origins.map((origin) => (
<tr key={origin.id}><td className="mono">{origin.id}</td><td className="mono">{origin.domainName}</td><td>{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}</td></tr>
))}</tbody></table></div>}
{behaviors.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Cache behaviours</p><span className="muted">The first path pattern that matches a request decides how it is served.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Path pattern</th><th>Origin</th><th>Viewer protocol</th><th>Methods</th></tr></thead><tbody>{behaviors.map((behavior) => (
<tr key={behavior.pathPattern}><td className="mono">{behavior.pathPattern}</td><td className="mono">{behavior.originId}</td><td>{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}</td><td>{behavior.methods.join(', ')}</td></tr>
))}</tbody></table></div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Invalidations</p><span className="muted">Clear cached copies so the next request fetches them from the origin.</span></div></div>
<form className="dynamic-form" aria-label="Create invalidation" onSubmit={(event) => { event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}>
<label className="dynamic-field dynamic-field--span"><span>Paths</span>
<textarea className="textarea code-textarea small" rows={2} value={paths} onChange={(event) => setPaths(event.target.value)} disabled={disabled} required/>
<small className="muted">One per line, each starting with /. Use /* for everything.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !paths.trim()}>{invalidationMutation.isPending ? 'Invalidating' : 'Create invalidation'}</button></div>
</form>
{invalidations.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Invalidation</th><th>Status</th><th>Created</th></tr></thead><tbody>{invalidations.map((invalidation) => (
<tr key={invalidation.id}><td className="mono">{invalidation.id}</td><td><code>{invalidation.status}</code></td><td>{formatDateTime(invalidation.createdAt) ?? '—'}</td></tr>
))}</tbody></table></div>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
function list<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
@@ -0,0 +1,145 @@
import {X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeUserPoolClient, updateUserPoolClientOAuth} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {UserPoolClientSettings, UserPoolOAuthFlow} from '@/types/resource'
type Props = {
poolId: string
clientId: string
runtimeReachable: boolean
onClose: () => void
}
const FLOWS: {flow: UserPoolOAuthFlow; label: string; hint: string}[] = [
{flow: 'code', label: 'Authorization code', hint: 'Recommended for web and mobile apps, with PKCE.'},
{flow: 'implicit', label: 'Implicit', hint: 'Legacy: tokens come back in the URL.'},
{flow: 'client_credentials', label: 'Client credentials', hint: 'Machine-to-machine. Needs a client secret.'},
]
const STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin']
const COGNITO = 'COGNITO'
type Draft = {callbackUrls: string; logoutUrls: string; oauthFlows: UserPoolOAuthFlow[]; oauthScopes: string[]; identityProviders: string[]}
export function CognitoAppClientSettings({poolId, clientId, runtimeReachable, onClose}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const clientKey = ['cognito-client', accountId, poolId, clientId]
const [draft, setDraft] = useState<Draft | null>(null)
const [notice, setNotice] = useState('')
const clientQuery = useQuery({
queryKey: clientKey,
queryFn: ({signal}) => describeUserPoolClient('aws', poolId, clientId, signal),
enabled: runtimeReachable,
})
const client = clientQuery.data
useEffect(() => {
if (client) setDraft(draftFrom(client))
}, [client])
const saveMutation = useMutation({
mutationFn: (value: Draft) => updateUserPoolClientOAuth('aws', poolId, clientId, {
callbackUrls: lines(value.callbackUrls),
logoutUrls: lines(value.logoutUrls),
oauthFlows: value.oauthFlows,
oauthScopes: value.oauthScopes,
identityProviders: value.identityProviders,
}),
onSuccess: (saved) => {
queryClient.setQueryData(clientKey, saved)
setNotice(saved.oauthFlows.includes('code') && saved.identityProviders.includes(COGNITO)
? 'Saved. Apps using this client can now send users to the hosted login page.'
: 'Saved.')
},
})
const disabled = !runtimeReachable || saveMutation.isPending
const scopes = draft ? [...STANDARD_SCOPES, ...draft.oauthScopes.filter((scope) => !STANDARD_SCOPES.includes(scope))] : STANDARD_SCOPES
const otherProviders = draft?.identityProviders.filter((provider) => provider !== COGNITO) ?? []
const changed = Boolean(client && draft && JSON.stringify(normalized(draft)) !== JSON.stringify(normalized(draftFrom(client))))
const update = (patch: Partial<Draft>) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) }
const toggle = <T extends string>(list: T[], value: T, on: boolean) => on ? [...list.filter((entry) => entry !== value), value] : list.filter((entry) => entry !== value)
const error = clientQuery.error ?? saveMutation.error
return <section className="cognito-user" aria-label={`App client ${client?.clientName || clientId}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">App client</p><strong>{client?.clientName || clientId}</strong></div>
<button className="icon-btn" type="button" title="Close" aria-label="Close app client settings" onClick={onClose}><X size={14}/></button>
</div>
{clientQuery.isLoading && <p className="muted padded">Loading app client</p>}
{client && draft && <form className="cognito-user-grid" aria-label="Hosted sign-in settings" onSubmit={(event) => { event.preventDefault(); saveMutation.mutate(draft) }}>
<dl className="cognito-facts">
<dt>Client ID</dt><dd className="mono">{client.clientId}</dd>
<dt>Client secret</dt><dd>{client.hasSecret ? 'Yes' : 'No (public client)'}</dd>
<dt>Sign-in flows</dt><dd>{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => <code key={flow} className="cognito-flow">{flow.replace(/^ALLOW_/, '')}</code>) : '—'}</dd>
</dl>
<div>
<p className="eyebrow">Hosted sign-in</p>
<label className="cognito-check">
<input type="checkbox" checked={draft.identityProviders.includes(COGNITO)} disabled={disabled} onChange={(event) => update({identityProviders: toggle(draft.identityProviders, COGNITO, event.target.checked)})}/>
Hosted login page for this pool's users
</label>
{otherProviders.length > 0 && <p className="muted compact-text">Also signs in with: {otherProviders.map((provider) => <code key={provider}>{provider}</code>)}</p>}
<p className="eyebrow">OAuth flows</p>
{FLOWS.map(({flow, label, hint}) => (
<label key={flow} className="cognito-check" title={hint}>
<input type="checkbox" checked={draft.oauthFlows.includes(flow)} disabled={disabled || (flow === 'client_credentials' && !client.hasSecret)} onChange={(event) => update({oauthFlows: toggle(draft.oauthFlows, flow, event.target.checked)})}/>
{label} <small className="muted">{hint}</small>
</label>
))}
</div>
<div>
<p className="eyebrow">Scopes</p>
{scopes.map((scope) => (
<label key={scope} className="cognito-check">
<input type="checkbox" checked={draft.oauthScopes.includes(scope)} disabled={disabled} onChange={(event) => update({oauthScopes: toggle(draft.oauthScopes, scope, event.target.checked)})}/>
<code>{scope}</code>
</label>
))}
</div>
<div className="cognito-client-urls">
<label className="dynamic-field"><span>Callback URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.callbackUrls} disabled={disabled} placeholder="http://localhost:3000/api/auth/callback" onChange={(event) => update({callbackUrls: event.target.value})}/>
<small className="muted">One per line. Where Cognito sends users back with a code. https, or http for localhost.</small>
</label>
<label className="dynamic-field"><span>Sign-out URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.logoutUrls} disabled={disabled} placeholder="http://localhost:3000/" onChange={(event) => update({logoutUrls: event.target.value})}/>
<small className="muted">One per line. Allowed <code>logout_uri</code> values after sign-out.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{saveMutation.isPending ? 'Saving' : 'Save settings'}</button></div>
</div>
</form>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
function draftFrom(client: UserPoolClientSettings): Draft {
return {
callbackUrls: client.callbackUrls.join('\n'),
logoutUrls: client.logoutUrls.join('\n'),
oauthFlows: client.oauthFlows,
oauthScopes: client.oauthScopes,
identityProviders: client.identityProviders,
}
}
function normalized(draft: Draft) {
return {
callbackUrls: lines(draft.callbackUrls),
logoutUrls: lines(draft.logoutUrls),
oauthFlows: [...draft.oauthFlows].sort(),
oauthScopes: [...draft.oauthScopes].sort(),
identityProviders: [...draft.identityProviders].sort(),
}
}
function lines(value: string): string[] {
return value.split('\n').map((line) => line.trim()).filter(Boolean)
}
@@ -0,0 +1,31 @@
import {ScrollText, Zap} from 'lucide-react'
import type {CloudResource} from '@/types/resource'
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
export function CognitoTriggersSection({pool}: {pool?: CloudResource}) {
const triggers = triggersOf(pool)
return <>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Lambda triggers</p><span className="muted">Functions Cognito runs during sign-up, sign-in and token issue</span></div>
</div>
{pool && triggers.length === 0 && <p className="muted padded">This pool runs no Lambda triggers.</p>}
{triggers.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Trigger</th><th>Function</th><th>Version</th><th/></tr></thead><tbody>{triggers.map((trigger) => (
<tr key={trigger.trigger}>
<td>{trigger.label}</td>
<td><code title={trigger.functionArn}>{trigger.functionName}</code></td>
<td>{trigger.version ? <code>{trigger.version}</code> : '—'}</td>
<td className="cognito-row-actions">
<a className="button" href={`/cloud-explorer/aws/serverless?resource=${encodeURIComponent(trigger.functionName)}`} aria-label={`Open function ${trigger.functionName}`}><Zap size={13}/> Function</a>
<a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${trigger.functionName}`)}`} aria-label={`Open logs for ${trigger.functionName}`}><ScrollText size={13}/> Logs</a>
</td>
</tr>
))}</tbody></table></div>}
</>
}
function triggersOf(pool?: CloudResource): LambdaTrigger[] {
const triggers = pool?.metadata.lambdaTriggers
if (!Array.isArray(triggers)) return []
return triggers.filter((trigger): trigger is LambdaTrigger => typeof trigger?.functionName === 'string' && typeof trigger?.label === 'string')
}
@@ -17,6 +17,8 @@ const api = vi.hoisted(() => ({
createUserPoolGroup: vi.fn(), createUserPoolGroup: vi.fn(),
deleteUserPoolGroup: vi.fn(), deleteUserPoolGroup: vi.fn(),
testUserPoolSignIn: vi.fn(), testUserPoolSignIn: vi.fn(),
describeUserPoolClient: vi.fn(),
updateUserPoolClientOAuth: vi.fn(),
})) }))
vi.mock('@/api/cloudProxyClient', () => api) vi.mock('@/api/cloudProxyClient', () => api)
@@ -64,6 +66,50 @@ test('shows the app client id and the pool users', async () => {
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything()) expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
}) })
test('lists the pool\'s Lambda triggers with links to the function and its logs', async () => {
api.getCloudResource.mockResolvedValue({...pool('email'), metadata: {...pool('email').metadata, lambdaTriggers: [
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: 'arn:aws:lambda:us-east-1:000000000000:function:notes-demo-pre-token', functionName: 'notes-demo-pre-token', version: null},
]}})
renderPanel()
expect(await screen.findByText('Pre token generation')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'Open function notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless?resource=notes-demo-pre-token')
expect(screen.getByRole('link', {name: 'Open logs for notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fnotes-demo-pre-token')
})
test('says when a pool has no Lambda triggers', async () => {
api.getCloudResource.mockResolvedValue(pool('email'))
renderPanel()
expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument()
})
test('turns on hosted sign-in for an app client', async () => {
const user = userEvent.setup()
const settings = {clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client', hasSecret: false, explicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
api.getCloudResource.mockResolvedValue(pool('email'))
api.describeUserPoolClient.mockResolvedValue(settings)
api.updateUserPoolClientOAuth.mockImplementation(async (_cloud, _pool, _client, input) => ({...settings, ...input}))
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Manage app client orders-users-client'}))
const form = await screen.findByRole('form', {name: 'Hosted sign-in settings'})
expect(within(form).getByRole('checkbox', {name: /Client credentials/})).toBeDisabled()
await user.click(within(form).getByRole('checkbox', {name: /Authorization code/}))
await user.click(within(form).getByRole('checkbox', {name: 'openid'}))
await user.click(within(form).getByRole('checkbox', {name: 'email'}))
await user.type(within(form).getByLabelText(/Callback URLs/), 'http://localhost:4600/api/auth/callback')
await user.type(within(form).getByLabelText(/Sign-out URLs/), 'http://localhost:4600/')
await user.click(within(form).getByRole('button', {name: 'Save settings'}))
expect(api.updateUserPoolClientOAuth).toHaveBeenCalledWith('aws', summary.id, 'a8e5c7d38d3b4a1a97c9d822de', {
callbackUrls: ['http://localhost:4600/api/auth/callback'], logoutUrls: ['http://localhost:4600/'],
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
})
expect(await screen.findByText(/can now send users to the hosted login page/)).toBeInTheDocument()
})
test('creates an email user who can sign in immediately', async () => { test('creates an email user who can sign in immediately', async () => {
const user = userEvent.setup() const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('email')) api.getCloudResource.mockResolvedValue(pool('email'))
@@ -2,8 +2,10 @@ import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
import {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
import {CognitoSignInTest} from '@/components/CognitoSignInTest' import {CognitoSignInTest} from '@/components/CognitoSignInTest'
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CognitoUserDetail} from '@/components/CognitoUserDetail'
import {CopyValueButton} from '@/components/CopyValueButton' import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
@@ -24,6 +26,7 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [permanent, setPermanent] = useState(true) const [permanent, setPermanent] = useState(true)
const [openUser, setOpenUser] = useState<string | null>(null) const [openUser, setOpenUser] = useState<string | null>(null)
const [openClient, setOpenClient] = useState<string | null>(null)
const usersKey = ['cognito-users', accountId, resource?.id] const usersKey = ['cognito-users', accountId, resource?.id]
const groupsKey = ['cognito-groups', accountId, resource?.id] const groupsKey = ['cognito-groups', accountId, resource?.id]
@@ -93,9 +96,16 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div> <div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
</div> </div>
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>} {poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => ( {appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th><th/></tr></thead><tbody>{appClients.map((client) => (
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr> <tr key={client.clientId}>
<td>{client.clientName || '—'}</td>
<td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td>
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openClient === client.clientId} aria-label={`Manage app client ${client.clientName || client.clientId}`} onClick={() => setOpenClient(openClient === client.clientId ? null : client.clientId)}>Manage</button></td>
</tr>
))}</tbody></table></div>} ))}</tbody></table></div>}
{openClient && <CognitoAppClientSettings key={openClient} poolId={resource.id} clientId={openClient} runtimeReachable={runtimeReachable} onClose={() => setOpenClient(null)}/>}
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/> <CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
@@ -65,6 +65,8 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -632,7 +634,6 @@ export function DynamicResourceView({
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`} className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
> >
<section className="resource-main"> <section className="resource-main">
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
{isAwsDatabase && ( {isAwsDatabase && (
<div className="drawer-tabs" style={{ marginBottom: 12 }}> <div className="drawer-tabs" style={{ marginBottom: 12 }}>
<button <button
@@ -938,6 +939,7 @@ export function DynamicResourceView({
})} })}
</section> </section>
)} )}
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
</section> </section>
{activeSelected && !showDatabaseSnapshots && ( {activeSelected && !showDatabaseSnapshots && (
<div> <div>
@@ -979,6 +981,12 @@ export function DynamicResourceView({
{service === "userpools" && cloud === "aws" && ( {service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && ( {service === "compute" && (
<ComputePanel <ComputePanel
cloud={cloud} cloud={cloud}
@@ -0,0 +1,94 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {Route53RecordsPanel} from './Route53RecordsPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
getCloudResource: vi.fn(),
listFlatItems: vi.fn(),
putFlatItem: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const zone: CloudResource = {
id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone',
region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3},
}
function item(id: string, body: Record<string, unknown>) {
return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}}
}
const records = [
item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}),
item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}),
item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}),
item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}),
]
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><Route53RecordsPanel resource={zone} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}})
api.listFlatItems.mockResolvedValue({items: records, nextCursor: null})
api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type}))
api.deleteFlatItem.mockResolvedValue(undefined)
})
test('shows the name servers and records, keeping the zone defaults read-only', async () => {
renderPanel()
expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument()
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument()
expect(screen.getByText('zone default')).toBeInTheDocument()
expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument()
expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument()
})
test('creates a record from a name inside the zone', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Create record'})
await user.type(within(form).getByLabelText(/Record name/), 'api')
await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME')
await user.clear(within(form).getByLabelText(/TTL/))
await user.type(within(form).getByLabelText(/TTL/), '60')
await user.type(within(form).getByLabelText(/Values/), 'app.example.net')
await user.click(within(form).getByRole('button', {name: 'Create record'}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']})
expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument()
})
test('edits a record with its name and type fixed, showing TXT text without quotes', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'}))
const form = screen.getByRole('form', {name: 'Edit record'})
expect(within(form).getByLabelText(/Record name/)).toHaveValue('@')
expect(within(form).getByLabelText(/Record name/)).toBeDisabled()
expect(within(form).getByLabelText('Type')).toBeDisabled()
expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all')
})
test('deletes a record by its name and type', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A')
expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument()
})
@@ -0,0 +1,205 @@
import {Pencil, RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import type {ChildItem, CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type DnsRecord = {
id: string
name: string
type: string
ttl: number | null
values: string[]
alias: {dnsName: string; hostedZoneId: string} | null
setIdentifier: string | null
managed: boolean
editable: boolean
}
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA']
const VALUE_HINTS: Record<string, string> = {
A: 'IPv4 addresses, one per line: 192.0.2.10',
AAAA: 'IPv6 addresses, one per line: 2001:db8::10',
CNAME: 'One host name: app.example.net',
TXT: 'Text, one string per line. Quotes are added for you.',
MX: 'Priority and mail server, one per line: 10 mail.example.com',
SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com',
CAA: 'Flags, tag and value: 0 issue "amazon.com"',
}
/** Every page of a zone is fetched; zones on a local runtime are small. */
const MAX_PAGES = 20
const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''}
export function Route53RecordsPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [draft, setDraft] = useState(emptyDraft)
const [editing, setEditing] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const recordsKey = ['dns-records', accountId, resource?.id]
const zoneQuery = useQuery({
queryKey: ['dns-zone', accountId, resource?.id],
queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const recordsQuery = useQuery({
queryKey: recordsKey,
queryFn: async ({signal}) => {
const items: ChildItem[] = []
let cursor: string | undefined
for (let page = 0; page < MAX_PAGES; page++) {
const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal)
items.push(...result.items)
if (!result.nextCursor) break
cursor = result.nextCursor
}
return items.map(toRecord)
},
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setDraft(emptyDraft)
setEditing(null)
setNotice('')
}, [resource?.id])
// The zone list shows each zone's record count.
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: recordsKey}),
queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}),
])
const saveMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', {
name: draft.name,
type: draft.type,
ttl: Number(draft.ttl),
values: draft.values.split('\n'),
}),
onSuccess: (saved) => {
setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`)
setDraft(emptyDraft)
setEditing(null)
return refresh()
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id),
onSuccess: (_result, id) => {
setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`)
if (editing === id) { setEditing(null); setDraft(emptyDraft) }
return refresh()
},
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a hosted zone</h3><p>Choose a hosted zone to see and change its DNS records.</p></div></section>
}
const zoneName = resource.name
const records = recordsQuery.data ?? []
const nameServers = stringList(zoneQuery.data?.metadata.nameServers)
const pending = saveMutation.isPending || deleteMutation.isPending
const disabled = !runtimeReachable || pending
const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error
const edit = (record: DnsRecord) => {
setNotice('')
setEditing(record.id)
setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')})
}
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Records in ${zoneName}`}>
{nameServers.length > 0 && <>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Name servers</p><span className="muted">Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.</span></div>
</div>
<ul className="dns-name-servers">{nameServers.map((server) => <li key={server}><span className="copy-value mono">{server}<CopyValueButton value={server} label="Name server"/></span></li>)}</ul>
</>}
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Records</p><span className="muted">{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || recordsQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{recordsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" aria-label={editing ? 'Edit record' : 'Create record'} onSubmit={(event) => { event.preventDefault(); setNotice(''); saveMutation.mutate() }}>
<label className="dynamic-field"><span>Record name</span>
<div className="dns-name-input"><input className="input" value={draft.name} placeholder="www" onChange={(event) => setDraft({...draft, name: event.target.value})} disabled={disabled || Boolean(editing)} autoComplete="off"/><span className="dns-zone-suffix">.{zoneName}</span></div>
<small className="muted">Leave empty or use @ for {zoneName} itself.</small>
</label>
<label className="dynamic-field"><span>Type</span>
<select className="input" value={draft.type} onChange={(event) => setDraft({...draft, type: event.target.value})} disabled={disabled || Boolean(editing)}>
{RECORD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<label className="dynamic-field"><span>TTL (seconds)</span>
<input className="input" type="number" min={0} value={draft.ttl} onChange={(event) => setDraft({...draft, ttl: event.target.value})} disabled={disabled} required/>
</label>
<label className="dynamic-field dynamic-field--span"><span>Values</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.values} onChange={(event) => setDraft({...draft, values: event.target.value})} disabled={disabled} required/>
<small className="muted">{VALUE_HINTS[draft.type] ?? 'One value per line.'}</small>
</label>
<div className="dns-form-actions">
<button className="button primary" type="submit" disabled={disabled || !draft.values.trim()}>{saveMutation.isPending ? 'Saving' : editing ? 'Save record' : 'Create record'}</button>
{editing && <button className="button" type="button" disabled={pending} onClick={() => { setEditing(null); setDraft(emptyDraft) }}>Cancel</button>}
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{recordsQuery.isLoading && <div className="muted padded">Loading records</div>}
{records.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>TTL</th><th>Value</th><th/></tr></thead><tbody>{records.map((record) => (
<tr key={record.id} aria-current={editing === record.id ? 'true' : undefined}>
<td className="mono">{record.name}{record.managed && <span className="badge dns-badge">zone default</span>}{record.setIdentifier && <span className="badge dns-badge">{record.setIdentifier}</span>}</td>
<td><code>{record.type}</code></td>
<td>{record.alias ? '—' : record.ttl ?? '—'}</td>
<td className="dns-values">{record.alias
? <span className="mono">Alias → {record.alias.dnsName}</span>
: record.values.map((value, index) => <div key={index} className="mono">{value}</div>)}</td>
<td><div className="cognito-row-actions">
{record.editable && !record.managed && <button className="icon-btn" type="button" title="Edit record" aria-label={`Edit ${record.type} ${record.name}`} disabled={disabled} onClick={() => edit(record)}><Pencil size={14}/></button>}
{!record.managed && <button className="icon-btn danger" type="button" title="Delete record" aria-label={`Delete ${record.type} ${record.name}`} disabled={disabled} onClick={() => { setNotice(''); deleteMutation.mutate(record.id) }}><Trash2 size={14}/></button>}
</div></td>
</tr>
))}</tbody></table></div>}
</section>
}
function toRecord(item: ChildItem): DnsRecord {
const body = item.body
const alias = body.alias && typeof body.alias === 'object' ? body.alias as {dnsName?: unknown; hostedZoneId?: unknown} : null
return {
id: item.id,
name: String(body.name ?? ''),
type: String(body.type ?? ''),
ttl: typeof body.ttl === 'number' ? body.ttl : null,
values: stringList(body.values),
alias: alias ? {dnsName: String(alias.dnsName ?? ''), hostedZoneId: String(alias.hostedZoneId ?? '')} : null,
setIdentifier: typeof body.setIdentifier === 'string' ? body.setIdentifier : null,
managed: body.managed === true,
editable: body.editable === true,
}
}
function relativeName(name: string, zone: string): string {
if (name === zone) return '@'
return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name
}
function unquote(value: string): string {
return value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : value
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -62,7 +62,7 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
return <div className="ses-console"> return <div className="ses-console">
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section> <section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox below to inspect the captured message.</p>}</section> <section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox above to inspect the captured message.</p>}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section> <section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section> <section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section> <section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section>
@@ -4,6 +4,8 @@ import {
Clock, Clock,
Container, Container,
Database, Database,
Earth,
Globe,
HardDrive, HardDrive,
Hammer, Hammer,
KeyRound, KeyRound,
@@ -31,6 +33,8 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg' import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg' import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg' import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -87,6 +91,8 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
iam: ShieldCheck, iam: ShieldCheck,
kms: Lock, kms: Lock,
userpools: UsersRound, userpools: UsersRound,
dns: Globe,
cdn: Earth,
loadbalancing: Scale, loadbalancing: Scale,
parameters: SlidersHorizontal, parameters: SlidersHorizontal,
configuration: SlidersHorizontal, configuration: SlidersHorizontal,
@@ -131,6 +137,8 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
serverless: lambdaIcon, serverless: lambdaIcon,
storage: s3Icon, storage: s3Icon,
userpools: cognitoIcon, userpools: cognitoIcon,
dns: route53Icon,
cdn: cloudFrontIcon,
workflows: stepFunctionsIcon, workflows: stepFunctionsIcon,
} }
+68
View File
@@ -2033,6 +2033,10 @@ pre,
min-height: 0; min-height: 0;
} }
.resource-main > .ses-console {
margin-top: 16px;
}
.resource-main > .drawer-tabs { .resource-main > .drawer-tabs {
padding: 0 4px; padding: 0 4px;
} }
@@ -4391,6 +4395,21 @@ pre,
margin-bottom: 8px; margin-bottom: 8px;
} }
.cognito-check small { flex-basis: 100%; padding-left: 24px; margin-top: -4px; }
.cognito-check:has(small) { flex-wrap: wrap; row-gap: 0; }
.cognito-flow { display: inline-block; margin: 0 6px 4px 0; }
.cognito-client-urls {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
align-items: start;
}
.cognito-client-urls > div { align-self: end; }
.delete-impact { .delete-impact {
color: var(--status-warn); color: var(--status-warn);
font-size: 13px; font-size: 13px;
@@ -6470,3 +6489,52 @@ button.console-service-card {
.lightsail-port-form { align-items: end; margin: 16px 0; } .lightsail-port-form { align-items: end; margin: 16px 0; }
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; } .lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } } @media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
.dns-name-servers {
display: grid;
gap: 4px;
margin: 0;
padding: 0 16px 12px;
list-style: none;
}
.dns-name-input {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dns-name-input .input { flex: 1; min-width: 0; }
.dynamic-field .dns-zone-suffix {
color: var(--text-3);
font-family: var(--mono);
font-size: 13px;
font-weight: 400;
text-transform: none;
overflow-wrap: anywhere;
}
.dns-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-self: end;
}
.dns-values { overflow-wrap: anywhere; }
.dns-badge { margin-left: 8px; }
tr[aria-current="true"] td { background: var(--hover-md); }
.cdn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.cdn-viewer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 0 16px 12px;
}
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
.cdn-viewer small { flex-basis: 100%; }
+23
View File
@@ -302,6 +302,29 @@ export interface UserPoolSignInTestInput {
password: string password: string
} }
export interface DistributionInvalidation {
id: string
status: string
createdAt: string | null
paths: string[]
}
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
export interface UserPoolClientSettings {
clientId: string
clientName: string
hasSecret: boolean
explicitAuthFlows: string[]
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export type UpdateUserPoolClientOAuthInput = Pick<UserPoolClientSettings, 'callbackUrls' | 'logoutUrls' | 'oauthFlows' | 'oauthScopes' | 'identityProviders'>
export type UserPoolSignInTestResult = export type UserPoolSignInTestResult =
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>} | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
| {outcome: 'challenge'; challengeName: string} | {outcome: 'challenge'; challengeName: string}
+3 -1
View File
@@ -27,7 +27,8 @@ export type KubernetesActionName =
| 'createFargateProfile' | 'createFargateProfile'
| 'deleteFargateProfile' | 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {
@@ -88,6 +89,7 @@ export interface ServiceSchema {
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName> kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName> itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>> userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
} }
filters: FieldSchema[] filters: FieldSchema[]
columns: TableColumnSchema[] columns: TableColumnSchema[]
+101 -54
View File
@@ -19,6 +19,9 @@ importers:
'@aws-sdk/client-cloudformation': '@aws-sdk/client-cloudformation':
specifier: ^3.1111.0 specifier: ^3.1111.0
version: 3.1111.0 version: 3.1111.0
'@aws-sdk/client-cloudfront':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-cloudwatch': '@aws-sdk/client-cloudwatch':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1146.0 version: 3.1146.0
@@ -88,6 +91,9 @@ importers:
'@aws-sdk/client-rds-data': '@aws-sdk/client-rds-data':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1144.0 version: 3.1144.0
'@aws-sdk/client-route-53':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -301,6 +307,10 @@ packages:
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==} resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-cloudfront@3.1156.0':
resolution: {integrity: sha512-z4V/ecv6b5uGwVVMaE8NbczaPVfM2HwbZAGUFp9XDjm+Q8WoFrF0vRlWaRWpW+rox4yPZc4tSo+cv8drud+r8A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-cloudwatch-logs@3.1131.0': '@aws-sdk/client-cloudwatch-logs@3.1131.0':
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==} resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -393,6 +403,10 @@ packages:
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==} resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-route-53@3.1156.0':
resolution: {integrity: sha512-1IwDSboTwUovZhuyDBhjAgPxFeIdy5+XCmZNKYFdg0XJax27qx4Ir7/m5CA60pdAHWBTMpUKm0jtyNEOlZnsuA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1076.0': '@aws-sdk/client-s3@3.1076.0':
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==} resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -708,6 +722,10 @@ packages:
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==} resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-route53@3.972.28':
resolution: {integrity: sha512-93l3lB5ngw0XQDGvuK/R9R3UAN4+xeYgLOW8E6nzldlNhu0E8vfkYMTlh5km643i9F193b5ZO21wOWWT47RxHw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.59': '@aws-sdk/middleware-sdk-s3@3.972.59':
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==} resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2905,6 +2923,17 @@ snapshots:
'@smithy/types': 4.16.1 '@smithy/types': 4.16.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-cloudfront@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-cloudwatch-logs@3.1131.0': '@aws-sdk/client-cloudwatch-logs@3.1131.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3174,6 +3203,18 @@ snapshots:
'@smithy/types': 4.15.1 '@smithy/types': 4.15.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-route-53@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/middleware-sdk-route53': 3.972.28
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-s3@3.1076.0': '@aws-sdk/client-s3@3.1076.0':
dependencies: dependencies:
'@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha1-browser': 5.2.0
@@ -3578,55 +3619,55 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.60': '@aws-sdk/credential-provider-login@3.972.60':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.69': '@aws-sdk/credential-provider-login@3.972.69':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.36 '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.76': '@aws-sdk/credential-provider-login@3.972.76':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.77': '@aws-sdk/credential-provider-login@3.972.77':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.78': '@aws-sdk/credential-provider-login@3.972.78':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.79': '@aws-sdk/credential-provider-login@3.972.79':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.46 '@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
@@ -3990,6 +4031,12 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/middleware-sdk-route53@3.972.28':
dependencies:
'@aws-sdk/types': 3.975.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.59': '@aws-sdk/middleware-sdk-s3@3.972.59':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -4008,10 +4055,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.28': '@aws-sdk/nested-clients@3.997.28':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4019,10 +4066,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.36': '@aws-sdk/nested-clients@3.997.36':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4030,10 +4077,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.43': '@aws-sdk/nested-clients@3.997.43':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4041,10 +4088,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.44': '@aws-sdk/nested-clients@3.997.44':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4052,10 +4099,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.45': '@aws-sdk/nested-clients@3.997.45':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4063,10 +4110,10 @@ snapshots:
'@aws-sdk/nested-clients@3.997.46': '@aws-sdk/nested-clients@3.997.46':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.47 '@aws-sdk/signature-v4-multi-region': 3.996.47
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
@@ -4113,55 +4160,55 @@ snapshots:
'@aws-sdk/token-providers@3.1080.0': '@aws-sdk/token-providers@3.1080.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1096.0': '@aws-sdk/token-providers@3.1096.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.36 '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1111.0': '@aws-sdk/token-providers@3.1111.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1116.0': '@aws-sdk/token-providers@3.1116.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1129.0': '@aws-sdk/token-providers@3.1129.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1138.0': '@aws-sdk/token-providers@3.1138.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.46 '@aws-sdk/nested-clients': 3.997.46
'@aws-sdk/types': 3.974.6 '@aws-sdk/types': 3.975.0
'@smithy/core': 3.35.0 '@smithy/core': 3.36.2
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1