feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s

This commit is contained in:
Zakaria
2026-10-24 14:10:16 -04:00
parent 5c3c73c582
commit 6586f48539
23 changed files with 1055 additions and 55 deletions
@@ -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 ?? ''
}