Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6586f48539 | |||
| 5c3c73c582 | |||
| 2b4d4b146d | |||
| ac58bfc98b | |||
| 51a0a60a73 | |||
| a5c9de25d0 | |||
| 238bb1a3d5 | |||
| d411f632c9 | |||
| 954e2e8a47 | |||
| ce444f0851 | |||
| 18c1f5067f | |||
| d457c08b12 | |||
| 4bc8da6ee1 | |||
| bafe799d00 | |||
| 43f4cae9b5 | |||
| b4c2cadf5c | |||
| 8803498498 | |||
| f6b9a62811 | |||
| f1574b89df | |||
| fe70e88bcc | |||
| 4156afccfe | |||
| 8b166d86d4 | |||
| f956d977ca | |||
| 9f38313e59 | |||
| ece36e4a34 | |||
| dd16bcf4cd |
@@ -62,26 +62,38 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
|---|---|---|---|---|
|
||||
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
||||
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
||||
| Compute | CodeBuild | Yes (list, create, delete, inspect) | No | No |
|
||||
| Compute | ECS | Yes (list, create, delete, inspect) | No | No |
|
||||
| Compute | Fargate | Yes (list, create, delete, inspect) | No | No |
|
||||
| Compute | Lightsail | Yes (list, create, inspect, update, delete) | No | No |
|
||||
| Developer Tools | CodeBuild | Yes (list, create, delete, inspect) | No | No |
|
||||
| Developer Tools | CodePipeline | Yes (list, create, delete, inspect) | No | No |
|
||||
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
||||
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
||||
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
||||
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
||||
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
||||
| Networking | Networking | Yes (list) | No | No |
|
||||
| Networking | ELB / Load Balancing | Yes (list, create, 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 | 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 | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
||||
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
||||
| Integration | CodePipeline | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No |
|
||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
||||
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
||||
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
||||
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
||||
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Cognito / User Directories | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
"@aws-sdk/client-appconfig": "^3.1076.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-logs": "^3.1096.0",
|
||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||
"@aws-sdk/client-codedeploy": "^3.1144.0",
|
||||
"@aws-sdk/client-codepipeline": "^3.1076.0",
|
||||
"@aws-sdk/client-cognito-identity-provider": "^3.1146.0",
|
||||
"@aws-sdk/client-dynamodb": "^3.1088.0",
|
||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||
"@aws-sdk/client-rds": "^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-secrets-manager": "^3.1076.0",
|
||||
"@aws-sdk/client-sesv2": "^3.1076.0",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
(() => {
|
||||
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
|
||||
const THEME_KEY = 'floci-theme';
|
||||
const root = document.documentElement;
|
||||
const buttons = document.querySelectorAll('.tg button');
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
@@ -6,7 +8,7 @@
|
||||
function setTheme(theme) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
try {
|
||||
localStorage.setItem('thecloud-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
} catch (ignored) {
|
||||
// Theme persistence is optional when browser storage is unavailable.
|
||||
}
|
||||
@@ -15,7 +17,7 @@
|
||||
|
||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
try {
|
||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
||||
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||
initialTheme = savedTheme;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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() {
|
||||
|
||||
@@ -46,6 +46,31 @@ describe('AwsSesAdapter', () => {
|
||||
})])
|
||||
})
|
||||
|
||||
test('picks out the code from verification emails', async () => {
|
||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||
messages: [
|
||||
{Id: 'cognito', Subject: 'Your verification code', Body: {text_part: 'Your verification code is 869472.'}},
|
||||
{Id: 'html', Subject: 'Reset', Body: {html_part: '<p>Your confirmation code is: <b>4410</b></p>'}},
|
||||
{Id: 'plain', Subject: 'Invoice', Body: {text_part: 'Order 123456 shipped.'}},
|
||||
],
|
||||
}))
|
||||
|
||||
const codes = (await adapter.list()).map((resource) => resource.metadata.code)
|
||||
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 () => {
|
||||
const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({
|
||||
messages: [
|
||||
|
||||
@@ -47,7 +47,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const messages = await this.fetchInbox()
|
||||
const resources = messages.map(messageToResource)
|
||||
const resources = messages.map(messageToResource).sort(newestFirst)
|
||||
return filterBySearch(resources, query.search)
|
||||
}
|
||||
|
||||
@@ -125,10 +125,22 @@ function messageToResource(message: FlociSesMessage): CloudResource {
|
||||
htmlBody: message.Body?.html_part ?? null,
|
||||
rawData: message.RawData ?? null,
|
||||
messageType: raw ? 'raw' : 'simple',
|
||||
code: findCode(message.Body?.text_part ?? message.Body?.html_part),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the wording of Cognito's default and most custom messages: "Your verification code is 123456."
|
||||
const CODE_PATTERN = /\bcode\s+is:?\s*(?:<[^>]+>\s*)*(\d{4,8})\b/i
|
||||
|
||||
function findCode(body: string | null | undefined): string | 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[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import {createHmac} from 'node:crypto'
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminConfirmSignUpCommand,
|
||||
AdminDisableUserCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminUserGlobalSignOutCommand,
|
||||
CreateGroupCommand,
|
||||
DeleteGroupCommand,
|
||||
DescribeUserPoolClientCommand,
|
||||
InitiateAuthCommand,
|
||||
ListGroupsCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type CognitoIdentityProviderClient,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as CognitoIdentityProviderClient
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}})
|
||||
}
|
||||
|
||||
const POOL_ID = 'us-east-1_8e65d229a'
|
||||
const CREATED = new Date('2026-10-23T16:01:22.000Z')
|
||||
|
||||
const pool = {
|
||||
Id: POOL_ID,
|
||||
Name: 'orders-users',
|
||||
Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`,
|
||||
Status: 'Enabled',
|
||||
CreationDate: CREATED,
|
||||
UsernameAttributes: ['email'],
|
||||
EstimatedNumberOfUsers: 1,
|
||||
MfaConfiguration: 'OFF',
|
||||
DeletionProtection: 'INACTIVE',
|
||||
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}},
|
||||
}
|
||||
|
||||
const alice = {
|
||||
Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436',
|
||||
Attributes: [
|
||||
{Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'},
|
||||
{Name: 'email', Value: 'alice@example.com'},
|
||||
{Name: 'email_verified', Value: 'true'},
|
||||
],
|
||||
UserCreateDate: CREATED,
|
||||
UserLastModifiedDate: CREATED,
|
||||
Enabled: true,
|
||||
UserStatus: 'CONFIRMED',
|
||||
}
|
||||
|
||||
function runtimeStub() {
|
||||
return stubCognito((command) => {
|
||||
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]}
|
||||
if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}}
|
||||
if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AwsUserPoolsAdapter', () => {
|
||||
test('lists every page of user pools and filters by name or id', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (!(command instanceof ListUserPoolsCommand)) return {}
|
||||
return command.input.NextToken
|
||||
? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]}
|
||||
: {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'}
|
||||
})
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second'])
|
||||
expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2)
|
||||
expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing'])
|
||||
expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()})
|
||||
})
|
||||
|
||||
test('lists pools with the details the inspector shows', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]}
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]}
|
||||
return {}
|
||||
})
|
||||
|
||||
const [listed] = await new AwsUserPoolsAdapter(client).list()
|
||||
expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]})
|
||||
})
|
||||
|
||||
test('describes a pool with its sign-in method, password policy and app clients', async () => {
|
||||
const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID)
|
||||
|
||||
expect(resource?.metadata).toEqual({
|
||||
arn: pool.Arn,
|
||||
signInWith: 'email',
|
||||
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
|
||||
estimatedNumberOfUsers: 1,
|
||||
userCount: 0,
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
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 () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
|
||||
if (command instanceof ListUsersCommand) return command.input.PaginationToken ? {Users: [alice]} : {Users: [alice, alice], PaginationToken: 'page-2'}
|
||||
return {}
|
||||
})
|
||||
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
||||
|
||||
expect(resource?.metadata.userCount).toBe(3)
|
||||
expect(resource?.metadata.estimatedNumberOfUsers).toBe(0)
|
||||
})
|
||||
|
||||
test('summarises a relaxed password policy without character rules', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
|
||||
return {}
|
||||
})
|
||||
|
||||
expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters')
|
||||
})
|
||||
|
||||
test('returns null for a pool the runtime does not know', async () => {
|
||||
const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') })
|
||||
|
||||
expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates an email pool with a public app client by default', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})
|
||||
|
||||
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||
expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']})
|
||||
const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand
|
||||
expect(createClient.input).toEqual({
|
||||
UserPoolId: POOL_ID,
|
||||
ClientName: 'orders-users-client',
|
||||
GenerateSecret: false,
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a username pool with a relaxed password policy and no app client', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}})
|
||||
|
||||
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
|
||||
expect(createPool.input).toEqual({
|
||||
PoolName: 'testers',
|
||||
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}},
|
||||
})
|
||||
expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('removes the new pool when its app client cannot be created', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
|
||||
if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException')
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException')
|
||||
expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||
})
|
||||
|
||||
test('rejects invalid create input before calling the runtime', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('deletes a pool by id', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).delete(POOL_ID)
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand)
|
||||
expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsUserPoolsAdapter users', () => {
|
||||
test('lists users as items keyed by username with their email and status', async () => {
|
||||
const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'}))
|
||||
const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'})
|
||||
|
||||
expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'})
|
||||
expect(page.nextCursor).toBe('next-page')
|
||||
expect(page.items).toEqual([{
|
||||
id: alice.Username,
|
||||
collectionId: null,
|
||||
timestamp: CREATED.toISOString(),
|
||||
body: {
|
||||
username: alice.Username,
|
||||
email: 'alice@example.com',
|
||||
emailVerified: true,
|
||||
status: 'CONFIRMED',
|
||||
enabled: true,
|
||||
lastModifiedAt: CREATED.toISOString(),
|
||||
attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'},
|
||||
},
|
||||
metadata: {},
|
||||
}])
|
||||
})
|
||||
|
||||
test('creates a user whose password is permanent so it can sign in at once', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '})
|
||||
|
||||
expect((sent[0] as AdminCreateUserCommand).input).toEqual({
|
||||
UserPoolId: POOL_ID,
|
||||
Username: 'alice@example.com',
|
||||
UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}],
|
||||
TemporaryPassword: ' Passw0rd! ',
|
||||
MessageAction: 'SUPPRESS',
|
||||
})
|
||||
expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true})
|
||||
expect(user.body.status).toBe('CONFIRMED')
|
||||
})
|
||||
|
||||
test('leaves the password temporary when asked', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||
|
||||
expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob')
|
||||
expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([])
|
||||
expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects a user without a name, password or valid email', async () => {
|
||||
const {client, sent} = runtimeStub()
|
||||
const users = new AwsUserPoolsAdapter(client).items
|
||||
|
||||
await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address')
|
||||
await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required')
|
||||
await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address')
|
||||
await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false')
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns null for a missing user and deletes by username', async () => {
|
||||
const missing = stubCognito(() => { throw awsError('UserNotFoundException') })
|
||||
expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull()
|
||||
|
||||
const {client, sent} = runtimeStub()
|
||||
await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username)
|
||||
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
|
||||
})
|
||||
})
|
||||
|
||||
function jwt(claims: Record<string, unknown>): string {
|
||||
return ['{"alg":"RS256"}', JSON.stringify(claims), 'sig'].map((part) => Buffer.from(part).toString('base64url')).join('.')
|
||||
}
|
||||
|
||||
describe('AwsUserPoolsAdapter user actions and groups', () => {
|
||||
test('reads a user with the groups it belongs to', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof AdminGetUserCommand) return {Username: alice.Username, UserAttributes: alice.Attributes, UserStatus: 'CONFIRMED', Enabled: true, UserCreateDate: CREATED}
|
||||
if (command instanceof AdminListGroupsForUserCommand) return {Groups: [{GroupName: 'admins'}]}
|
||||
return {}
|
||||
})
|
||||
const user = await new AwsUserPoolsAdapter(client).items.getItem!(POOL_ID, alice.Username)
|
||||
|
||||
expect(user?.body.groups).toEqual(['admins'])
|
||||
expect((sent[1] as AdminListGroupsForUserCommand).input).toMatchObject({UserPoolId: POOL_ID, Username: alice.Username})
|
||||
})
|
||||
|
||||
test('runs each user action as its Admin call', async () => {
|
||||
const {client, sent} = stubCognito(() => ({}))
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'disable'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'signOut'})
|
||||
await adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword', password: ' Passw0rd! '})
|
||||
|
||||
expect(sent[0]).toBeInstanceOf(AdminDisableUserCommand)
|
||||
expect(sent[1]).toBeInstanceOf(AdminConfirmSignUpCommand)
|
||||
expect(sent[2]).toBeInstanceOf(AdminUserGlobalSignOutCommand)
|
||||
expect((sent[3] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: 'bob', Password: ' Passw0rd! ', Permanent: true})
|
||||
})
|
||||
|
||||
test('rejects unknown actions and reports a refused one as a conflict, never a 401', async () => {
|
||||
const {client} = stubCognito(() => { throw awsError('NotAuthorizedException') })
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'explode'} as never)).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'setPassword'} as never)).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.updateUserPoolUser(POOL_ID, 'bob', {action: 'confirm'})).rejects.toBeInstanceOf(ConflictError)
|
||||
})
|
||||
|
||||
test('sets a user\'s groups by adding and removing only the difference', async () => {
|
||||
const {client, sent} = stubCognito((command) => command instanceof AdminListGroupsForUserCommand ? {Groups: [{GroupName: 'staff'}, {GroupName: 'beta'}]} : {})
|
||||
await new AwsUserPoolsAdapter(client).setUserPoolUserGroups(POOL_ID, 'bob', ['staff', 'admins'])
|
||||
|
||||
const changes = sent.slice(1)
|
||||
expect(changes).toHaveLength(2)
|
||||
expect(changes[0]).toBeInstanceOf(AdminAddUserToGroupCommand)
|
||||
expect((changes[0] as AdminAddUserToGroupCommand).input.GroupName).toBe('admins')
|
||||
expect(changes[1]).toBeInstanceOf(AdminRemoveUserFromGroupCommand)
|
||||
expect((changes[1] as AdminRemoveUserFromGroupCommand).input.GroupName).toBe('beta')
|
||||
})
|
||||
|
||||
test('lists, creates and deletes groups', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof ListGroupsCommand) return {Groups: [{GroupName: 'admins', Description: 'Full access', Precedence: 0, CreationDate: CREATED}]}
|
||||
if (command instanceof CreateGroupCommand) return {Group: {GroupName: command.input.GroupName, Precedence: command.input.Precedence, CreationDate: CREATED}}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
expect(await adapter.listUserPoolGroups(POOL_ID)).toEqual([{name: 'admins', description: 'Full access', precedence: 0, createdAt: CREATED.toISOString()}])
|
||||
expect(await adapter.createUserPoolGroup(POOL_ID, {name: 'editors', precedence: 5})).toMatchObject({name: 'editors', precedence: 5})
|
||||
await adapter.deleteUserPoolGroup(POOL_ID, 'editors')
|
||||
|
||||
expect((sent[1] as CreateGroupCommand).input).toEqual({UserPoolId: POOL_ID, GroupName: 'editors', Precedence: 5})
|
||||
expect(sent[2]).toBeInstanceOf(DeleteGroupCommand)
|
||||
})
|
||||
|
||||
test('validates a group before creating it', async () => {
|
||||
const {client, sent} = stubCognito(() => ({}))
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
|
||||
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'has space'})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.createUserPoolGroup(POOL_ID, {name: 'ok', precedence: -1})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('test sign-in returns decoded claims, adding SECRET_HASH for a client with a secret', async () => {
|
||||
const {client, sent} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: {ClientId: 'client-1', ClientSecret: 'shh'}}
|
||||
if (command instanceof InitiateAuthCommand) return {AuthenticationResult: {ExpiresIn: 3600, IdToken: jwt({sub: 's-1', email: 'alice@example.com', token_use: 'id'}), AccessToken: jwt({sub: 's-1', token_use: 'access'})}}
|
||||
return {}
|
||||
})
|
||||
const result = await new AwsUserPoolsAdapter(client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'alice@example.com', password: 'Passw0rd!'})
|
||||
|
||||
expect(result).toEqual({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: 's-1', email: 'alice@example.com', token_use: 'id'}, accessToken: {sub: 's-1', token_use: 'access'}})
|
||||
const auth = (sent[1] as InitiateAuthCommand).input
|
||||
expect(auth.AuthFlow).toBe('USER_PASSWORD_AUTH')
|
||||
expect(auth.AuthParameters?.SECRET_HASH).toBe(createHmac('sha256', 'shh').update('alice@example.comclient-1').digest('base64'))
|
||||
})
|
||||
|
||||
test('test sign-in reports a wrong password or a challenge as its outcome', async () => {
|
||||
const refusing = stubCognito((command) => {
|
||||
if (command instanceof InitiateAuthCommand) throw Object.assign(awsError('NotAuthorizedException'), {message: 'Incorrect username or password.'})
|
||||
return {UserPoolClient: {ClientId: 'client-1'}}
|
||||
})
|
||||
expect(await new AwsUserPoolsAdapter(refusing.client).testUserPoolSignIn(POOL_ID, {clientId: 'client-1', username: 'a', password: 'x'}))
|
||||
.toEqual({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
||||
expect((refusing.sent[1] as InitiateAuthCommand).input.AuthParameters?.SECRET_HASH).toBeUndefined()
|
||||
|
||||
const challenging = stubCognito((command) => command instanceof InitiateAuthCommand ? {ChallengeName: 'NEW_PASSWORD_REQUIRED'} : {UserPoolClient: {}})
|
||||
expect(await new AwsUserPoolsAdapter(challenging.client).testUserPoolSignIn(POOL_ID, {clientId: 'c', username: 'a', password: 'x'}))
|
||||
.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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,689 @@
|
||||
import {createHmac} from 'node:crypto'
|
||||
import {
|
||||
AdminAddUserToGroupCommand,
|
||||
AdminConfirmSignUpCommand,
|
||||
AdminCreateUserCommand,
|
||||
AdminDeleteUserCommand,
|
||||
AdminDisableUserCommand,
|
||||
AdminEnableUserCommand,
|
||||
AdminGetUserCommand,
|
||||
AdminListGroupsForUserCommand,
|
||||
AdminRemoveUserFromGroupCommand,
|
||||
AdminResetUserPasswordCommand,
|
||||
AdminSetUserPasswordCommand,
|
||||
AdminUserGlobalSignOutCommand,
|
||||
CreateGroupCommand,
|
||||
CreateUserPoolClientCommand,
|
||||
CreateUserPoolCommand,
|
||||
DeleteGroupCommand,
|
||||
DeleteUserPoolCommand,
|
||||
DescribeUserPoolClientCommand,
|
||||
DescribeUserPoolCommand,
|
||||
InitiateAuthCommand,
|
||||
ListGroupsCommand,
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type GroupType,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolClientType,
|
||||
type UserPoolDescriptionType,
|
||||
type UserPoolType,
|
||||
type UserType,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_GROUP_NAME_PATTERN,
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_OAUTH_FLOWS,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
awsUserPoolsSchema,
|
||||
} from '../cloud-spi/userPoolsSchema'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
CreateUserPoolGroupInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolClientSettings,
|
||||
UserPoolGroup,
|
||||
UserPoolOAuthFlow,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** Cognito caps every list page at 60. */
|
||||
const COGNITO_PAGE_LIMIT = 60
|
||||
/** Counting stops here so a very large pool cannot stall the pool list. */
|
||||
const USER_COUNT_CAP = 1000
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
* Talks to Cognito user pools — verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - A missing pool is `ResourceNotFoundException` and a missing user is
|
||||
* `UserNotFoundException`, both with HTTP **400**; `get` matches on the name.
|
||||
* - With email sign-in the stored `Username` is the generated `sub`, not the
|
||||
* address, so users are keyed by `Username` and the email is read from the
|
||||
* attributes. Admin calls accept either form.
|
||||
* - An omitted password policy defaults to requiring upper, lower, number and
|
||||
* symbol, matching AWS.
|
||||
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
|
||||
* password is permanent, so users created here can sign in immediately.
|
||||
* - `EstimatedNumberOfUsers` stays 0 however many users exist (AWS also only
|
||||
* updates it periodically), so `userCount` is counted with `ListUsers`.
|
||||
* - An admin action the user's state does not allow (confirming a confirmed
|
||||
* user) is `NotAuthorizedException`, surfaced here as a conflict because the
|
||||
* console caller is authorised and a 401 would end the console session.
|
||||
*
|
||||
* Users are the pool's child items. The app client is created with the pool, as
|
||||
* the console's create flow does, and its id is surfaced in the pool metadata.
|
||||
*/
|
||||
export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'userpools' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly cognito: CognitoIdentityProviderClient) {
|
||||
this.items = new UserStore(cognito)
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsUserPoolsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const pools: UserPoolDescriptionType[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
pools.push(...(res.UserPools ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
// ListUserPools omits the sign-in method, password policy and app clients the
|
||||
// inspector shows, so each match is described. Pools are few on a local runtime.
|
||||
const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search)
|
||||
return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const [pool, clients, userCount] = await Promise.all([
|
||||
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
|
||||
this.listClients(id),
|
||||
this.countUsers(id),
|
||||
])
|
||||
return pool.UserPool ? toResource(pool.UserPool, clients, userCount) : null
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = requiredString(input.values.name, 'name')
|
||||
if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) {
|
||||
throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -')
|
||||
}
|
||||
const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email'
|
||||
const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default'
|
||||
const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes'
|
||||
|
||||
const created = await this.cognito.send(new CreateUserPoolCommand({
|
||||
PoolName: name,
|
||||
...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}),
|
||||
...(passwordPolicy === 'relaxed'
|
||||
? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}
|
||||
: {}),
|
||||
}))
|
||||
const id = created.UserPool?.Id
|
||||
if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`)
|
||||
|
||||
if (createAppClient === 'yes') {
|
||||
try {
|
||||
await this.cognito.send(new CreateUserPoolClientCommand({
|
||||
UserPoolId: id,
|
||||
ClientName: `${name}-client`,
|
||||
GenerateSecret: false,
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
}))
|
||||
} catch (error) {
|
||||
// Without its client the pool is not what was asked for; do not leave it half-made.
|
||||
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const pool = await this.get(id)
|
||||
if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`)
|
||||
return pool
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
|
||||
}
|
||||
|
||||
async updateUserPoolUser(poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
|
||||
const target = {UserPoolId: poolId, Username: username}
|
||||
const action = (input as {action?: unknown} | null)?.action
|
||||
const command = (() => {
|
||||
switch (action) {
|
||||
case 'enable': return new AdminEnableUserCommand(target)
|
||||
case 'disable': return new AdminDisableUserCommand(target)
|
||||
case 'confirm': return new AdminConfirmSignUpCommand(target)
|
||||
case 'resetPassword': return new AdminResetUserPasswordCommand(target)
|
||||
case 'signOut': return new AdminUserGlobalSignOutCommand(target)
|
||||
case 'setPassword': {
|
||||
const {password, permanent} = input as {password?: unknown; permanent?: unknown}
|
||||
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
|
||||
if (permanent !== undefined && typeof permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
|
||||
return new AdminSetUserPasswordCommand({...target, Password: password, Permanent: permanent ?? true})
|
||||
}
|
||||
default:
|
||||
throw new ValidationError('action must be one of enable, disable, confirm, resetPassword, signOut, setPassword')
|
||||
}
|
||||
})()
|
||||
try {
|
||||
await this.cognito.send(command as AdminEnableUserCommand)
|
||||
} catch (error) {
|
||||
throw asConflict(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes the user's groups exactly `groups`, adding and removing only the difference. */
|
||||
async setUserPoolUserGroups(poolId: string, username: string, groups: string[]): Promise<void> {
|
||||
if (!Array.isArray(groups) || groups.some((group) => typeof group !== 'string' || !group)) {
|
||||
throw new ValidationError('groups must be a list of group names')
|
||||
}
|
||||
const wanted = new Set(groups)
|
||||
const current = new Set(await this.listGroupsForUser(poolId, username))
|
||||
for (const group of wanted) {
|
||||
if (!current.has(group)) await this.cognito.send(new AdminAddUserToGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
|
||||
}
|
||||
for (const group of current) {
|
||||
if (!wanted.has(group)) await this.cognito.send(new AdminRemoveUserFromGroupCommand({UserPoolId: poolId, Username: username, GroupName: group}))
|
||||
}
|
||||
}
|
||||
|
||||
async listUserPoolGroups(poolId: string): Promise<UserPoolGroup[]> {
|
||||
const groups: GroupType[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cognito.send(new ListGroupsCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
groups.push(...(res.Groups ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
return groups.flatMap(toGroup)
|
||||
}
|
||||
|
||||
async createUserPoolGroup(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
|
||||
const name = requiredString(input?.name, 'name')
|
||||
if (!new RegExp(USER_POOL_GROUP_NAME_PATTERN, 'u').test(name)) throw new ValidationError('name must be up to 128 characters with no spaces')
|
||||
const description = optionalString(input.description, 'description')
|
||||
if (description && description.length > 2048) throw new ValidationError('description must be 2048 characters or fewer')
|
||||
const precedence = input.precedence
|
||||
if (precedence !== undefined && precedence !== null && (!Number.isInteger(precedence) || precedence < 0)) {
|
||||
throw new ValidationError('precedence must be a whole number of 0 or more')
|
||||
}
|
||||
|
||||
const res = await this.cognito.send(new CreateGroupCommand({
|
||||
UserPoolId: poolId,
|
||||
GroupName: name,
|
||||
...(description ? {Description: description} : {}),
|
||||
...(typeof precedence === 'number' ? {Precedence: precedence} : {}),
|
||||
}))
|
||||
const group = res.Group ? toGroup(res.Group)[0] : undefined
|
||||
if (!group) throw new RuntimeError(`Cognito did not return the group ${name} created in ${poolId}`)
|
||||
return group
|
||||
}
|
||||
|
||||
async deleteUserPoolGroup(poolId: string, groupName: string): Promise<void> {
|
||||
await this.cognito.send(new DeleteGroupCommand({UserPoolId: poolId, GroupName: groupName}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in the way an app would (`InitiateAuth`, `USER_PASSWORD_AUTH`) and
|
||||
* returns the decoded claims. A refused sign-in is a result, not an error:
|
||||
* the wrong password is exactly what this is for trying.
|
||||
*/
|
||||
async testUserPoolSignIn(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
|
||||
const clientId = requiredString(input?.clientId, 'clientId')
|
||||
const username = requiredString(input.username, 'username')
|
||||
if (typeof input.password !== 'string' || !input.password) throw new ValidationError('password is required')
|
||||
|
||||
const client = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: clientId}))
|
||||
const secret = client.UserPoolClient?.ClientSecret
|
||||
try {
|
||||
const res = await this.cognito.send(new InitiateAuthCommand({
|
||||
ClientId: clientId,
|
||||
AuthFlow: 'USER_PASSWORD_AUTH',
|
||||
AuthParameters: {
|
||||
USERNAME: username,
|
||||
PASSWORD: input.password,
|
||||
...(secret ? {SECRET_HASH: secretHash(username, clientId, secret)} : {}),
|
||||
},
|
||||
}))
|
||||
if (res.ChallengeName) return {outcome: 'challenge', challengeName: res.ChallengeName}
|
||||
const result = res.AuthenticationResult
|
||||
if (!result?.IdToken || !result.AccessToken) throw new RuntimeError('Cognito returned no tokens')
|
||||
return {
|
||||
outcome: 'signed-in',
|
||||
expiresIn: result.ExpiresIn ?? null,
|
||||
idToken: decodeJwtPayload(result.IdToken),
|
||||
accessToken: decodeJwtPayload(result.AccessToken),
|
||||
}
|
||||
} catch (error) {
|
||||
const name = (error as {name?: string} | null)?.name ?? ''
|
||||
if (SIGN_IN_REFUSALS.has(name)) return {outcome: 'rejected', code: name, message: (error as Error).message}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return listGroupNamesForUser(this.cognito, poolId, username)
|
||||
}
|
||||
|
||||
private async countUsers(poolId: string): Promise<number> {
|
||||
let count = 0
|
||||
let token: string | undefined
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUsersCommand({UserPoolId: poolId, Limit: COGNITO_PAGE_LIMIT, ...(token ? {PaginationToken: token} : {})}))
|
||||
count += res.Users?.length ?? 0
|
||||
token = res.PaginationToken || undefined
|
||||
} while (token && count < USER_COUNT_CAP)
|
||||
return Math.min(count, USER_COUNT_CAP)
|
||||
}
|
||||
|
||||
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
|
||||
const clients: UserPoolClientDescription[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
do {
|
||||
const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
clients.push(...(res.UserPoolClients ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
return clients
|
||||
}
|
||||
}
|
||||
|
||||
class UserStore implements ItemStoreAdapter {
|
||||
constructor(private readonly cognito: CognitoIdentityProviderClient) {}
|
||||
|
||||
async listItems(poolId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const res = await this.cognito.send(new ListUsersCommand({
|
||||
UserPoolId: poolId,
|
||||
Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT),
|
||||
...(page.cursor ? {PaginationToken: page.cursor} : {}),
|
||||
}))
|
||||
return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null}
|
||||
}
|
||||
|
||||
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
|
||||
try {
|
||||
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
|
||||
const item = toUserItem(user.Username, user.UserAttributes, user)[0]
|
||||
if (!item) return null
|
||||
return {...item, body: {...item.body, groups: await listGroupNamesForUser(this.cognito, poolId, item.id)}}
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user that can sign in straight away: the password is set as
|
||||
* permanent unless `permanent` is false, in which case Cognito will demand a
|
||||
* new one at first sign-in (`FORCE_CHANGE_PASSWORD`).
|
||||
*/
|
||||
async putItem(poolId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const email = optionalString(body.email, 'email')
|
||||
if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address')
|
||||
const username = optionalString(body.username, 'username') ?? email
|
||||
if (!username) throw new ValidationError('Provide a username or an email address')
|
||||
// Not trimmed: leading or trailing spaces are part of a password.
|
||||
const password = body.password
|
||||
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
|
||||
if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
|
||||
|
||||
const created = await this.cognito.send(new AdminCreateUserCommand({
|
||||
UserPoolId: poolId,
|
||||
Username: username,
|
||||
UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [],
|
||||
TemporaryPassword: password,
|
||||
MessageAction: 'SUPPRESS',
|
||||
}))
|
||||
const storedUsername = created.User?.Username
|
||||
if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`)
|
||||
|
||||
if (body.permanent !== false) {
|
||||
await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true}))
|
||||
}
|
||||
|
||||
const user = await this.getItem(poolId, storedUsername)
|
||||
if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`)
|
||||
return user
|
||||
}
|
||||
|
||||
async deleteItem(poolId: string, username: string): Promise<void> {
|
||||
await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Refusals a test sign-in reports as its outcome rather than failing. */
|
||||
const SIGN_IN_REFUSALS = new Set([
|
||||
'NotAuthorizedException',
|
||||
'UserNotFoundException',
|
||||
'UserNotConfirmedException',
|
||||
'PasswordResetRequiredException',
|
||||
'InvalidParameterException',
|
||||
])
|
||||
|
||||
async function listGroupNamesForUser(cognito: CognitoIdentityProviderClient, poolId: string, username: string): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await cognito.send(new AdminListGroupsForUserCommand({UserPoolId: poolId, Username: username, Limit: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
|
||||
names.push(...(res.Groups ?? []).flatMap((group) => group.GroupName ? [group.GroupName] : []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
return names
|
||||
}
|
||||
|
||||
function toGroup(group: GroupType): UserPoolGroup[] {
|
||||
if (!group.GroupName) return []
|
||||
return [{
|
||||
name: group.GroupName,
|
||||
description: group.Description ?? null,
|
||||
precedence: group.Precedence ?? null,
|
||||
createdAt: group.CreationDate ? group.CreationDate.toISOString() : null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Required by app clients that have a secret: Base64(HMAC-SHA256(secret, username + clientId)). */
|
||||
function secretHash(username: string, clientId: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(username + clientId).digest('base64')
|
||||
}
|
||||
|
||||
/** The token came straight from Cognito, so only its payload is read; nothing is trusted from it. */
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new RuntimeError('Cognito returned a malformed token')
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function asConflict(error: unknown): unknown {
|
||||
if ((error as {name?: string} | null)?.name !== 'NotAuthorizedException') return error
|
||||
return new ConflictError((error as Error).message || 'The user is not in a state that allows this')
|
||||
}
|
||||
|
||||
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
|
||||
if (!pool.Id) return []
|
||||
return [{
|
||||
id: pool.Id,
|
||||
name: pool.Name ?? pool.Id,
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
type: 'user-pool',
|
||||
region: regionFromPoolId(pool.Id),
|
||||
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||
status: pool.Status ?? null,
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], userCount: number): CloudResource {
|
||||
const id = pool.Id ?? ''
|
||||
const policy = pool.Policies?.PasswordPolicy
|
||||
return {
|
||||
id,
|
||||
name: pool.Name ?? id,
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
type: 'user-pool',
|
||||
region: regionFromPoolId(id),
|
||||
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
|
||||
status: pool.Status ?? null,
|
||||
metadata: {
|
||||
arn: pool.Arn,
|
||||
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
|
||||
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
|
||||
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
|
||||
userCount,
|
||||
mfaConfiguration: pool.MfaConfiguration ?? null,
|
||||
deletionProtection: pool.DeletionProtection ?? null,
|
||||
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(
|
||||
username: string | undefined,
|
||||
attributes: AttributeType[] | undefined,
|
||||
user: Pick<UserType, 'Enabled' | 'UserStatus' | 'UserCreateDate' | 'UserLastModifiedDate'>,
|
||||
): ChildItem[] {
|
||||
if (!username) return []
|
||||
const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : []))
|
||||
return [{
|
||||
id: username,
|
||||
collectionId: null,
|
||||
timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null,
|
||||
body: {
|
||||
username,
|
||||
email: attributeMap.email ?? null,
|
||||
emailVerified: attributeMap.email_verified === 'true',
|
||||
status: user.UserStatus ?? null,
|
||||
enabled: user.Enabled ?? true,
|
||||
lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null,
|
||||
attributes: attributeMap,
|
||||
},
|
||||
metadata: {},
|
||||
}]
|
||||
}
|
||||
|
||||
/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */
|
||||
function describePasswordPolicy(policy: PasswordPolicyType): string {
|
||||
const required = [
|
||||
policy.RequireUppercase && 'uppercase',
|
||||
policy.RequireLowercase && 'lowercase',
|
||||
policy.RequireNumbers && 'number',
|
||||
policy.RequireSymbols && 'symbol',
|
||||
].filter((rule): rule is string => Boolean(rule))
|
||||
const length = `${policy.MinimumLength ?? 8}+ characters`
|
||||
return required.length ? `${length} with ${required.join(', ')}` : length
|
||||
}
|
||||
|
||||
/** Pool ids are `<region>_<suffix>`. */
|
||||
function regionFromPoolId(id: string): string | null {
|
||||
const separator = id.indexOf('_')
|
||||
return separator > 0 ? id.slice(0, separator) : null
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
|
||||
return resources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
|
||||
)
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const name = (error as {name?: string}).name
|
||||
return name === 'ResourceNotFoundException' || name === 'UserNotFoundException'
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
const trimmed = optionalString(value, field)
|
||||
if (!trimmed) throw new ValidationError(`${field} is required`)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
|
||||
return value.trim() || undefined
|
||||
}
|
||||
|
||||
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T | undefined {
|
||||
const text = optionalString(value, field)
|
||||
if (text === undefined) return undefined
|
||||
if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
return text as T
|
||||
}
|
||||
@@ -127,3 +127,23 @@ describe('logs errors carry HTTP 400 and must not fall through to it', () => {
|
||||
expect(mapped?.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Cognito reports every failure with HTTP 400, so these names must be mapped
|
||||
* explicitly or a missing user would read as a validation error.
|
||||
*/
|
||||
describe('cognito errors carry HTTP 400 and keep their meaning', () => {
|
||||
const cases: Array<[string, CloudErrorStatus]> = [
|
||||
['UserNotFoundException', 404],
|
||||
['UsernameExistsException', 409],
|
||||
['AliasExistsException', 409],
|
||||
['GroupExistsException', 409],
|
||||
['InvalidPasswordException', 400],
|
||||
]
|
||||
|
||||
for (const [name, status] of cases) {
|
||||
test(`${name} maps to ${status}`, () => {
|
||||
expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -58,6 +58,9 @@ const CONFLICT_NAMES = new Set([
|
||||
'ConcurrentModificationException',
|
||||
'IncorrectState',
|
||||
'DeleteConflictException',
|
||||
'UsernameExistsException',
|
||||
'AliasExistsException',
|
||||
'GroupExistsException',
|
||||
])
|
||||
|
||||
const ACCESS_DENIED_NAMES = new Set([
|
||||
@@ -90,6 +93,7 @@ const VALIDATION_NAMES = new Set([
|
||||
'InvalidBucketName',
|
||||
'InvalidArgumentException',
|
||||
'InvalidParameterException',
|
||||
'InvalidPasswordException',
|
||||
])
|
||||
|
||||
const NOT_FOUND_NAMES = new Set([
|
||||
@@ -119,6 +123,7 @@ const NOT_FOUND_NAMES = new Set([
|
||||
'InvalidSubnetID.NotFound',
|
||||
'InvalidGroupId.NotFound',
|
||||
'InvalidAMIID.NotFound',
|
||||
'UserNotFoundException',
|
||||
])
|
||||
|
||||
const NOT_IMPLEMENTED_NAMES = new Set([
|
||||
|
||||
@@ -13,7 +13,9 @@ const realFetch = globalThis.fetch
|
||||
let passwordHash: string
|
||||
|
||||
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(() => {
|
||||
@@ -239,7 +241,8 @@ describe('auth state cleanup', () => {
|
||||
test('caps pending OIDC requests and sweeps expired state', async () => {
|
||||
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
||||
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}`)
|
||||
|
||||
expect(authStateSizes().oidcRequests).toBe(1_000)
|
||||
|
||||
@@ -30,6 +30,9 @@ import { SESv2Client } from "@aws-sdk/client-sesv2";
|
||||
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
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 region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -91,6 +94,9 @@ export type AwsClients = {
|
||||
cloudwatch: CloudWatchClient;
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
route53: Route53Client;
|
||||
cloudfront: CloudFrontClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -136,6 +142,9 @@ function buildClients(accountId: string): AwsClients {
|
||||
cloudwatch: new CloudWatchClient(base),
|
||||
elasticache: new ElastiCacheClient(base),
|
||||
lightsail: new LightsailClient(base),
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
route53: new Route53Client(base),
|
||||
cloudfront: new CloudFrontClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const emailColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Subject'},
|
||||
{name: 'source', label: 'From', path: 'metadata.source', format: 'code'},
|
||||
{name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'},
|
||||
{name: 'code', label: 'Code', path: 'metadata.code', format: 'code'},
|
||||
{name: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Captured At', format: 'datetime'},
|
||||
]
|
||||
|
||||
@@ -239,6 +239,27 @@ export const SERVICE_CATALOG = {
|
||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||
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: {
|
||||
displayName: 'User Directories',
|
||||
displayNameByCloud: {aws: 'Cognito'},
|
||||
iconKey: 'userpools',
|
||||
group: 'Security',
|
||||
order: 7,
|
||||
},
|
||||
iac: {
|
||||
displayName: 'Infrastructure as Code',
|
||||
displayNameByCloud: {aws: 'CloudFormation'},
|
||||
|
||||
@@ -171,6 +171,16 @@ export type KubernetesActionName =
|
||||
| 'listFargateProfiles'
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type UserPoolActionName =
|
||||
| 'updateUser'
|
||||
| 'setUserGroups'
|
||||
| 'listGroups'
|
||||
| 'createGroup'
|
||||
| 'deleteGroup'
|
||||
| 'testSignIn'
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -214,6 +224,8 @@ export interface ServiceSchema {
|
||||
objectActions?: CapabilitySchema<ObjectActionName>[]
|
||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -223,6 +235,11 @@ export interface ServiceSchema {
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
updateFields?: FieldSchema[]
|
||||
/**
|
||||
* Counts read from the resource (`path` as in columns) that the delete
|
||||
* confirmation names, so deleting a pool says it also deletes its users.
|
||||
*/
|
||||
deleteImpact?: {path: string; singular: string; plural: string}[]
|
||||
}
|
||||
|
||||
export type KnownResourceType =
|
||||
@@ -230,7 +247,7 @@ export type KnownResourceType =
|
||||
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
|
||||
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
|
||||
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
|
||||
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group'
|
||||
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'user-pool'
|
||||
|
||||
export interface CloudResource {
|
||||
id: string
|
||||
@@ -411,6 +428,70 @@ export interface SqsMoveTaskResult {
|
||||
taskHandle: string
|
||||
}
|
||||
|
||||
/** What an admin can do to one user pool user, each a single Admin* call. */
|
||||
export type UserPoolUserAction =
|
||||
| {action: 'enable'}
|
||||
| {action: 'disable'}
|
||||
| {action: 'confirm'}
|
||||
| {action: 'resetPassword'}
|
||||
| {action: 'signOut'}
|
||||
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||
|
||||
export interface UserPoolGroup {
|
||||
name: string
|
||||
description: string | null
|
||||
precedence: number | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserPoolGroupInput {
|
||||
name: string
|
||||
description?: string
|
||||
precedence?: number
|
||||
}
|
||||
|
||||
export interface UserPoolSignInTestInput {
|
||||
clientId: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 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 =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
| {outcome: 'rejected'; code: string; message: string}
|
||||
|
||||
/** Provider-neutral representation of a managed Kubernetes node pool. */
|
||||
export interface KubernetesNodegroup {
|
||||
id: string
|
||||
@@ -917,6 +998,16 @@ export interface CloudServiceAdapter {
|
||||
listKubernetesFargateProfiles?(clusterId: string): Promise<KubernetesFargateProfile[]>
|
||||
createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise<KubernetesFargateProfile>
|
||||
deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise<void>
|
||||
updateUserPoolUser?(poolId: string, username: string, input: UserPoolUserAction): Promise<void>
|
||||
setUserPoolUserGroups?(poolId: string, username: string, groups: string[]): Promise<void>
|
||||
listUserPoolGroups?(poolId: string): Promise<UserPoolGroup[]>
|
||||
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
|
||||
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
|
||||
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. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** Cognito's own limit and character set for user pool names. */
|
||||
export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
|
||||
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
|
||||
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const
|
||||
/** Cognito's group name rule: 1–128 letters, marks, symbols, digits or punctuation (no whitespace). Needs the `u` flag. */
|
||||
export const USER_POOL_GROUP_NAME_PATTERN = '^[\\p{L}\\p{M}\\p{S}\\p{N}\\p{P}]{1,128}$'
|
||||
|
||||
const userPoolColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'id', label: 'Pool ID', format: 'code'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const userPoolFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsUserPoolsSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
displayName: 'Amazon Cognito user pools',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Pool name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: USER_POOL_NAME_PATTERN, message: 'Use up to 128 letters, digits, spaces or + = , . @ _ -'},
|
||||
},
|
||||
{
|
||||
name: 'signInWith',
|
||||
label: 'Sign in with',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'email',
|
||||
description: 'Email makes the address the sign-in name; username keeps a separate user name.',
|
||||
options: [{label: 'Email address', value: 'email'}, {label: 'Username', value: 'username'}],
|
||||
},
|
||||
{
|
||||
name: 'passwordPolicy',
|
||||
label: 'Password policy',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'default',
|
||||
description: 'Relaxed only requires 8 characters, which keeps local test users quick to create.',
|
||||
options: [
|
||||
{label: 'AWS default (upper, lower, number, symbol)', value: 'default'},
|
||||
{label: 'Relaxed (8+ characters)', value: 'relaxed'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'createAppClient',
|
||||
label: 'App client',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'yes',
|
||||
description: 'A public client (no secret) with username/password and refresh-token sign-in, named <pool>-client.',
|
||||
options: [{label: 'Create an app client', value: 'yes'}, {label: 'No app client', value: 'no'}],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List user pools', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect user pool', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
itemActions: [
|
||||
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'get', label: 'Get user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
userPoolActions: [
|
||||
{name: 'updateUser', label: 'Enable, disable, confirm, sign out or reset a user', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setUserGroups', label: 'Change a user\'s groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'listGroups', label: 'List groups', 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: '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,
|
||||
columns: userPoolColumns,
|
||||
deleteImpact: [
|
||||
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return cloud === 'aws' ? awsUserPoolsSchema() : null
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} 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.
|
||||
@@ -47,6 +47,22 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
||||
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> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
listGroups: 'listUserPoolGroups',
|
||||
createGroup: 'createUserPoolGroup',
|
||||
deleteGroup: 'deleteUserPoolGroup',
|
||||
testSignIn: 'testUserPoolSignIn',
|
||||
describeClient: 'describeUserPoolClient',
|
||||
updateClientOAuth: 'updateUserPoolClientOAuth',
|
||||
}
|
||||
|
||||
const registry = createCloudAdapterRegistry()
|
||||
|
||||
function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> {
|
||||
@@ -121,6 +137,24 @@ describe('registered adapters honour their schema', () => {
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const userPoolClaims = (adapter.schema().capabilities?.userPoolActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of userPoolClaims) {
|
||||
const method = METHOD_FOR_USER_POOL_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).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`, () => {
|
||||
@@ -133,6 +167,8 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.objectActions ?? []),
|
||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
|
||||
@@ -47,6 +47,9 @@ import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
||||
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -96,6 +99,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsKinesisAdapter(clients.kinesis),
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -16,6 +16,8 @@ import {awsEksSchema} from '../cloud-spi/eksSchema'
|
||||
import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||
@@ -694,6 +696,105 @@ describe('cloud schema routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('reads one flat item, answering 404 when it is gone', async () => {
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
items: {
|
||||
listItems: async () => ({items: [], nextCursor: null}),
|
||||
getItem: async (_poolId, username) => username === 'bob@example.com'
|
||||
? {id: 'u-1', collectionId: null, timestamp: null, body: {email: username, groups: ['admins']}, metadata: {}}
|
||||
: null,
|
||||
},
|
||||
})])
|
||||
|
||||
const found = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/bob%40example.com')
|
||||
const missing = await app.request('/api/clouds/aws/services/userpools/resources/us-east-1_x/items/nobody')
|
||||
|
||||
expect(found.status).toBe(200)
|
||||
expect((await found.json()).body.groups).toEqual(['admins'])
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test('routes Cognito user actions, groups and test sign-in to the adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
updateUserPoolUser: async (poolId, username, input) => { calls.push(['action', poolId, username, input]) },
|
||||
setUserPoolUserGroups: async (poolId, username, groups) => { calls.push(['groups', poolId, username, groups]) },
|
||||
listUserPoolGroups: async () => [{name: 'admins', description: null, precedence: 0, createdAt: null}],
|
||||
createUserPoolGroup: async (_poolId, input) => ({name: input.name, description: null, precedence: null, createdAt: null}),
|
||||
deleteUserPoolGroup: async (poolId, groupName) => { calls.push(['deleteGroup', poolId, groupName]) },
|
||||
testUserPoolSignIn: async () => ({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'}),
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x'
|
||||
|
||||
expect((await app.request(`${base}/users/bob%40example.com/actions`, json('POST', {action: 'disable'}))).status).toBe(200)
|
||||
expect((await app.request(`${base}/users/bob%40example.com/groups`, json('PUT', {groups: ['admins']}))).status).toBe(200)
|
||||
expect(await (await app.request(`${base}/groups`)).json()).toEqual([{name: 'admins', description: null, precedence: 0, createdAt: null}])
|
||||
expect((await app.request(`${base}/groups`, json('POST', {name: 'editors'}))).status).toBe(201)
|
||||
expect((await app.request(`${base}/groups/editors`, {method: 'DELETE'})).status).toBe(200)
|
||||
|
||||
const signIn = await app.request(`${base}/sign-in-test`, json('POST', {clientId: 'c', username: 'bob', password: 'x'}))
|
||||
expect(signIn.status).toBe(200)
|
||||
expect((await signIn.json()).outcome).toBe('rejected')
|
||||
|
||||
expect(calls).toEqual([
|
||||
['action', 'us-east-1_x', 'bob@example.com', {action: 'disable'}],
|
||||
['groups', 'us-east-1_x', 'bob@example.com', ['admins']],
|
||||
['deleteGroup', 'us-east-1_x', 'editors'],
|
||||
])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
|
||||
@@ -392,6 +392,95 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/users/:username/actions', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).updateUserPoolUser(cloud, c.req.param('id'), c.req.param('username'), await c.req.json())
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/userpools/resources/:id/users/:username/groups', 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<{groups?: string[]}>()
|
||||
await svc(c).setUserPoolUserGroups(cloud, c.req.param('id'), c.req.param('username'), body.groups as string[])
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/userpools/resources/:id/groups', 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).listUserPoolGroups(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/groups', 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 group = await svc(c).createUserPoolGroup(cloud, c.req.param('id'), await c.req.json())
|
||||
return c.json(group, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/userpools/resources/:id/groups/:groupName', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteUserPoolGroup(cloud, c.req.param('id'), c.req.param('groupName'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/userpools/resources/:id/sign-in-test', 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).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) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
@@ -1157,6 +1246,16 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const item = await svc(c).getFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId'))
|
||||
return item ? c.json(item) : c.json({error: 'Item not found'}, 404)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => {
|
||||
const target = childTarget(c)
|
||||
if (!target) return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
||||
|
||||
// The secretsmanager router imports the `secretsManager` singleton directly from
|
||||
// `../aws`, so we replace that module with a stub whose `send()` is driven per
|
||||
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||
// `../aws`, so we replace that export with a stub whose `send()` is driven per
|
||||
// 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.
|
||||
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
||||
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
||||
|
||||
const realAws = await import('../aws')
|
||||
mock.module('../aws', () => ({
|
||||
...realAws,
|
||||
secretsManager: {
|
||||
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
||||
lastCommand = command
|
||||
|
||||
@@ -77,6 +77,14 @@ import type {
|
||||
StorageObjectDownload,
|
||||
StorageObjectList,
|
||||
UpdateResourceInput,
|
||||
CreateUserPoolGroupInput,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
import type {
|
||||
ChildCollection,
|
||||
@@ -664,6 +672,12 @@ export class CloudProxyService {
|
||||
return this.requireItems(cloud, service).listItems(resourceId, page)
|
||||
}
|
||||
|
||||
async getFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, itemId: string): Promise<ChildItem | null> {
|
||||
const items = this.requireItems(cloud, service)
|
||||
if (!items.getItem) throw new NotSupportedError(`Reading one item is not supported for ${cloud}/${service}`)
|
||||
return items.getItem(resourceId, itemId)
|
||||
}
|
||||
|
||||
async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const items = this.requireItems(cloud, service)
|
||||
if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`)
|
||||
@@ -784,6 +798,66 @@ export class CloudProxyService {
|
||||
return adapter.filterLogEvents(logGroupName, input, page)
|
||||
}
|
||||
|
||||
async updateUserPoolUser(cloud: CloudProvider, poolId: string, username: string, input: UserPoolUserAction): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.updateUserPoolUser) throw new NotSupportedError(`User actions are not supported for ${cloud}/userpools`)
|
||||
await adapter.updateUserPoolUser(poolId, username, input)
|
||||
}
|
||||
|
||||
async setUserPoolUserGroups(cloud: CloudProvider, poolId: string, username: string, groups: string[]): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.setUserPoolUserGroups) throw new NotSupportedError(`User groups are not supported for ${cloud}/userpools`)
|
||||
await adapter.setUserPoolUserGroups(poolId, username, groups)
|
||||
}
|
||||
|
||||
async listUserPoolGroups(cloud: CloudProvider, poolId: string): Promise<UserPoolGroup[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.listUserPoolGroups) throw new NotSupportedError(`Groups are not supported for ${cloud}/userpools`)
|
||||
return adapter.listUserPoolGroups(poolId)
|
||||
}
|
||||
|
||||
async createUserPoolGroup(cloud: CloudProvider, poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.createUserPoolGroup) throw new NotSupportedError(`Group creation is not supported for ${cloud}/userpools`)
|
||||
return adapter.createUserPoolGroup(poolId, input)
|
||||
}
|
||||
|
||||
async deleteUserPoolGroup(cloud: CloudProvider, poolId: string, groupName: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.deleteUserPoolGroup) throw new NotSupportedError(`Group deletion is not supported for ${cloud}/userpools`)
|
||||
await adapter.deleteUserPoolGroup(poolId, groupName)
|
||||
}
|
||||
|
||||
async testUserPoolSignIn(cloud: CloudProvider, poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.testUserPoolSignIn) throw new NotSupportedError(`Test sign-in is not supported for ${cloud}/userpools`)
|
||||
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[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||
|
||||
@@ -25,6 +25,7 @@ const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
|
||||
codebuild: ['codebuild'],
|
||||
codedeploy: ['codedeploy'],
|
||||
codepipeline: ['codepipeline'],
|
||||
'cognito-idp': ['userpools'],
|
||||
dynamodb: ['nosql'],
|
||||
ec2: ['compute', 'networking'],
|
||||
ecr: ['ecr'],
|
||||
|
||||
@@ -5,7 +5,9 @@ const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
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
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
@@ -130,6 +130,24 @@ 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"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
},
|
||||
userpools: {
|
||||
userAction: "clouds.services.userpools.users.action",
|
||||
userGroups: "clouds.services.userpools.users.groups",
|
||||
groups: {
|
||||
list: "clouds.services.userpools.groups.list",
|
||||
create: "clouds.services.userpools.groups.create",
|
||||
delete: "clouds.services.userpools.groups.delete",
|
||||
},
|
||||
signInTest: "clouds.services.userpools.sign-in-test",
|
||||
client: {
|
||||
get: "clouds.services.userpools.clients.get",
|
||||
updateOAuth: "clouds.services.userpools.clients.update-oauth",
|
||||
},
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
list: "clouds.services.k8s.nodegroups.list",
|
||||
@@ -154,6 +172,7 @@ export const apiEndpointKeys = {
|
||||
},
|
||||
},
|
||||
flatItems: {
|
||||
get: "clouds.services.flatItems.get",
|
||||
list: "clouds.services.flatItems.list",
|
||||
query: "clouds.services.flatItems.query",
|
||||
create: "clouds.services.flatItems.create",
|
||||
@@ -829,6 +848,76 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.userAction,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/actions",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.userGroups,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/users/:username/groups",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/groups/:groupName",
|
||||
method: "DELETE",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.signInTest,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/sign-in-test",
|
||||
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,
|
||||
{
|
||||
@@ -998,6 +1087,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.flatItems.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/items/:itemId",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.flatItems.list,
|
||||
{
|
||||
|
||||
@@ -27,6 +27,14 @@ import type {
|
||||
DatabaseSnapshot,
|
||||
KubernetesFargateProfile,
|
||||
KubernetesNodegroup,
|
||||
CreateUserPoolGroupInput,
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
LogsFilterInput,
|
||||
@@ -1377,6 +1385,152 @@ export async function deleteFlatItem(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
resourceId: string,
|
||||
itemId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChildItem> {
|
||||
const res = await apiClient.call<ChildItem>(
|
||||
apiEndpointKeys.clouds.flatItems.get,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service, id: resourceId, itemId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateUserPoolUser(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
username: string,
|
||||
input: UserPoolUserAction,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, UserPoolUserAction>(
|
||||
apiEndpointKeys.clouds.userpools.userAction,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId, username },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setUserPoolUserGroups(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
username: string,
|
||||
groups: string[],
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { groups: string[] }>(
|
||||
apiEndpointKeys.clouds.userpools.userGroups,
|
||||
requestOptions(cloud, "userpools", { body: { groups } }),
|
||||
{ cloud, id: poolId, username },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserPoolGroups(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UserPoolGroup[]> {
|
||||
const res = await apiClient.call<UserPoolGroup[]>(
|
||||
apiEndpointKeys.clouds.userpools.groups.list,
|
||||
requestOptions(cloud, "userpools", { signal }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createUserPoolGroup(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
input: CreateUserPoolGroupInput,
|
||||
): Promise<UserPoolGroup> {
|
||||
const res = await apiClient.call<UserPoolGroup, CreateUserPoolGroupInput>(
|
||||
apiEndpointKeys.clouds.userpools.groups.create,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteUserPoolGroup(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
groupName: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.userpools.groups.delete,
|
||||
requestOptions(cloud, "userpools"),
|
||||
{ cloud, id: poolId, groupName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function testUserPoolSignIn(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
input: UserPoolSignInTestInput,
|
||||
): Promise<UserPoolSignInTestResult> {
|
||||
const res = await apiClient.call<UserPoolSignInTestResult, UserPoolSignInTestInput>(
|
||||
apiEndpointKeys.clouds.userpools.signInTest,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId },
|
||||
);
|
||||
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(
|
||||
cloud: CloudProvider,
|
||||
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,64 @@
|
||||
import {Trash2} from 'lucide-react'
|
||||
import {useMutation, useQueryClient, type UseQueryResult} from '@tanstack/react-query'
|
||||
import {useState} from 'react'
|
||||
import {createUserPoolGroup, deleteUserPoolGroup} from '@/api/cloudProxyClient'
|
||||
import type {UserPoolGroup} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
groupsQuery: UseQueryResult<UserPoolGroup[]>
|
||||
groupsKey: unknown[]
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function CognitoGroupsSection({poolId, groupsQuery, groupsKey, runtimeReachable}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [precedence, setPrecedence] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey: groupsKey})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createUserPoolGroup('aws', poolId, {
|
||||
name: name.trim(),
|
||||
...(description.trim() ? {description: description.trim()} : {}),
|
||||
...(precedence.trim() ? {precedence: Number(precedence)} : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setPrecedence('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupName: string) => deleteUserPoolGroup('aws', poolId, groupName),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data ?? []
|
||||
const pending = createMutation.isPending || deleteMutation.isPending
|
||||
const error = groupsQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Groups</p><span className="muted">Group names appear in the <code>cognito:groups</code> token claim, so apps can check roles</span></div>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Create group" onSubmit={(event) => { event.preventDefault(); if (name.trim()) createMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Group name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="admins" disabled={pending} required/></label>
|
||||
<label className="dynamic-field"><span>Description</span><input className="input" value={description} onChange={(event) => setDescription(event.target.value)} disabled={pending}/></label>
|
||||
<label className="dynamic-field"><span>Precedence</span><input className="input" type="number" min={0} step={1} value={precedence} onChange={(event) => setPrecedence(event.target.value)} disabled={pending}/><small className="muted">Lower wins when a user is in several groups.</small></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !name.trim() || pending}>{createMutation.isPending ? 'Creating' : 'Create group'}</button></div>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{groupsQuery.isSuccess && groups.length === 0 && <p className="muted padded">No groups yet.</p>}
|
||||
{groups.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Description</th><th>Precedence</th><th/></tr></thead><tbody>{groups.map((group) => (
|
||||
<tr key={group.name}>
|
||||
<td><code>{group.name}</code></td>
|
||||
<td>{group.description || '—'}</td>
|
||||
<td>{group.precedence ?? '—'}</td>
|
||||
<td><button className="icon-btn danger" type="button" title="Delete group" aria-label={`Delete group ${group.name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(group.name)}><Trash2 size={14}/></button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {testUserPoolSignIn} from '@/api/cloudProxyClient'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
appClients: Array<{clientId: string; clientName: string}>
|
||||
loginLabel: string
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const KEY_CLAIMS = ['sub', 'email', 'cognito:username', 'cognito:groups', 'client_id', 'aud', 'token_use', 'exp']
|
||||
|
||||
export function CognitoSignInTest({poolId, appClients, loginLabel, runtimeReachable}: Props) {
|
||||
const [clientId, setClientId] = useState(appClients[0]?.clientId ?? '')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!appClients.some((client) => client.clientId === clientId)) setClientId(appClients[0]?.clientId ?? '')
|
||||
}, [appClients, clientId])
|
||||
|
||||
const signIn = useMutation({
|
||||
mutationFn: () => testUserPoolSignIn('aws', poolId, {clientId, username: username.trim(), password}),
|
||||
})
|
||||
const result = signIn.data
|
||||
|
||||
if (appClients.length === 0) return null
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Test sign-in</p><span className="muted">Signs in the way an app does and shows what is inside the tokens</span></div>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Test sign-in" onSubmit={(event) => { event.preventDefault(); if (clientId && username.trim() && password) signIn.mutate() }}>
|
||||
<label className="dynamic-field"><span>App client</span><select className="input" value={clientId} onChange={(event) => setClientId(event.target.value)} disabled={signIn.isPending}>{appClients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}</select></label>
|
||||
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="off" disabled={signIn.isPending} required/></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button" type="submit" disabled={!runtimeReachable || !clientId || !username.trim() || !password || signIn.isPending}>{signIn.isPending ? 'Signing in' : 'Try sign-in'}</button></div>
|
||||
</form>
|
||||
{signIn.error instanceof Error && <p className="error-text compact-text" role="alert">{signIn.error.message}</p>}
|
||||
{result?.outcome === 'rejected' && <p className="cognito-outcome cognito-outcome--rejected" role="status"><strong>Refused</strong> <code>{result.code}</code> {result.message}</p>}
|
||||
{result?.outcome === 'challenge' && <p className="cognito-outcome cognito-outcome--challenge" role="status"><strong>Cognito wants another step</strong> <code>{result.challengeName}</code> {challengeHint(result.challengeName)}</p>}
|
||||
{result?.outcome === 'signed-in' && <div className="cognito-outcome cognito-outcome--ok" role="status">
|
||||
<p><strong>Signed in.</strong> {result.expiresIn ? `The ID and access tokens are valid for ${Math.round(result.expiresIn / 60)} minutes.` : ''}</p>
|
||||
<div className="cognito-claims">
|
||||
<ClaimsTable title="ID token: who the user is" claims={result.idToken}/>
|
||||
<ClaimsTable title="Access token: what this app may do" claims={result.accessToken}/>
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
||||
function ClaimsTable({title, claims}: {title: string; claims: Record<string, unknown>}) {
|
||||
const keys = [...KEY_CLAIMS.filter((key) => key in claims), ...Object.keys(claims).filter((key) => !KEY_CLAIMS.includes(key)).sort()]
|
||||
return <div>
|
||||
<p className="eyebrow">{title}</p>
|
||||
<table className="table"><tbody>{keys.map((key) => (
|
||||
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{formatClaim(key, claims[key])}</td></tr>
|
||||
))}</tbody></table>
|
||||
</div>
|
||||
}
|
||||
|
||||
function formatClaim(key: string, value: unknown): string {
|
||||
if ((key === 'exp' || key === 'iat' || key === 'auth_time') && typeof value === 'number') return `${value} (${new Date(value * 1000).toLocaleString()})`
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
function challengeHint(challenge: string): string {
|
||||
if (challenge === 'NEW_PASSWORD_REQUIRED') return 'The password is temporary: the user must choose a new one before getting tokens.'
|
||||
if (challenge.includes('MFA') || challenge === 'SOFTWARE_TOKEN_MFA') return 'The user must enter a second-factor code.'
|
||||
return 'The app has to answer this challenge before Cognito issues tokens.'
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {getFlatItem, setUserPoolUserGroups, updateUserPoolUser} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {UserPoolGroup, UserPoolUserAction} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
username: string
|
||||
groups: UserPoolGroup[]
|
||||
usersKey: unknown[]
|
||||
runtimeReachable: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ACTION_DONE: Record<UserPoolUserAction['action'], string> = {
|
||||
enable: 'User enabled.',
|
||||
disable: 'User disabled. New sign-ins are refused; tokens already issued last until they expire.',
|
||||
confirm: 'User confirmed.',
|
||||
resetPassword: 'Password reset. The user must use a reset code to sign in again.',
|
||||
signOut: 'Signed out everywhere. Refresh tokens no longer work.',
|
||||
setPassword: 'Password set.',
|
||||
}
|
||||
|
||||
export function CognitoUserDetail({poolId, username, groups, usersKey, runtimeReachable, onClose}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const userKey = ['cognito-user', accountId, poolId, username]
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const userQuery = useQuery({
|
||||
queryKey: userKey,
|
||||
queryFn: ({signal}) => getFlatItem('aws', 'userpools', poolId, username, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const user = userQuery.data?.body
|
||||
const currentGroups = stringList(user?.groups)
|
||||
|
||||
// Reset the checkboxes only when the stored membership changes, not on every render.
|
||||
const storedGroups = currentGroups.join('\n')
|
||||
useEffect(() => {
|
||||
setSelectedGroups(storedGroups ? storedGroups.split('\n') : [])
|
||||
}, [storedGroups])
|
||||
|
||||
const refresh = () => Promise.all([queryClient.invalidateQueries({queryKey: userKey}), queryClient.invalidateQueries({queryKey: usersKey})])
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: (input: UserPoolUserAction) => updateUserPoolUser('aws', poolId, username, input),
|
||||
onSuccess: (_result, input) => {
|
||||
setNotice(ACTION_DONE[input.action])
|
||||
if (input.action === 'setPassword') setPassword('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const groupsMutation = useMutation({
|
||||
mutationFn: () => setUserPoolUserGroups('aws', poolId, username, selectedGroups),
|
||||
onSuccess: () => {
|
||||
setNotice('Groups saved. They appear in tokens issued from the next sign-in.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const pending = actionMutation.isPending || groupsMutation.isPending
|
||||
const error = userQuery.error ?? actionMutation.error ?? groupsMutation.error
|
||||
const run = (input: UserPoolUserAction) => { setNotice(''); actionMutation.mutate(input) }
|
||||
const groupsChanged = selectedGroups.slice().sort().join('\n') !== currentGroups.slice().sort().join('\n')
|
||||
const name = stringValue(user?.email) || username
|
||||
const attributes = Object.entries((user?.attributes ?? {}) as Record<string, string>)
|
||||
const disabled = !runtimeReachable || pending
|
||||
|
||||
return <section className="cognito-user" aria-label={`User ${name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">User</p><strong>{name}</strong></div>
|
||||
<button className="icon-btn" type="button" title="Close" aria-label="Close user details" onClick={onClose}><X size={14}/></button>
|
||||
</div>
|
||||
{userQuery.isLoading && <p className="muted padded">Loading user</p>}
|
||||
{user && <div className="cognito-user-grid">
|
||||
<dl className="cognito-facts">
|
||||
<dt>Status</dt><dd><code>{stringValue(user.status) || '—'}</code></dd>
|
||||
<dt>Enabled</dt><dd>{user.enabled === false ? 'No' : 'Yes'}</dd>
|
||||
<dt>Email verified</dt><dd>{user.emailVerified ? 'Yes' : 'No'}</dd>
|
||||
<dt>Created</dt><dd>{formatDateTime(userQuery.data?.timestamp ?? null) ?? '—'}</dd>
|
||||
<dt>Username</dt><dd className="mono">{username}</dd>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Actions</p>
|
||||
<div className="cognito-actions">
|
||||
{user.enabled === false
|
||||
? <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'enable'})}>Enable</button>
|
||||
: <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'disable'})}>Disable</button>}
|
||||
{user.status === 'UNCONFIRMED' && <button className="button" type="button" disabled={disabled} onClick={() => run({action: 'confirm'})}>Confirm sign-up</button>}
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => run({action: 'signOut'})}>Sign out everywhere</button>
|
||||
<button className="button" type="button" disabled={disabled} title="Cognito sends the user a reset code" onClick={() => run({action: 'resetPassword'})}>Reset password</button>
|
||||
</div>
|
||||
<form className="cognito-set-password" onSubmit={(event) => { event.preventDefault(); if (password) run({action: 'setPassword', password, permanent}) }}>
|
||||
<label className="dynamic-field"><span>New password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={disabled}/></label>
|
||||
<label className="cognito-check"><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={disabled}/> User can sign in with it now</label>
|
||||
<button className="button" type="submit" disabled={disabled || !password}>Set password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Groups</p>
|
||||
{groups.length === 0 && <p className="muted">This pool has no groups. Create one in Groups above.</p>}
|
||||
{groups.map((group) => (
|
||||
<label key={group.name} className="cognito-check">
|
||||
<input type="checkbox" checked={selectedGroups.includes(group.name)} disabled={disabled} onChange={(event) => setSelectedGroups((current) => event.target.checked ? [...current, group.name] : current.filter((value) => value !== group.name))}/>
|
||||
<code>{group.name}</code>
|
||||
</label>
|
||||
))}
|
||||
{groups.length > 0 && <button className="button" type="button" disabled={disabled || !groupsChanged} onClick={() => { setNotice(''); groupsMutation.mutate() }}>{groupsMutation.isPending ? 'Saving' : 'Save groups'}</button>}
|
||||
</div>
|
||||
|
||||
{attributes.length > 0 && <div className="cognito-attributes">
|
||||
<p className="eyebrow">Attributes</p>
|
||||
<table className="table"><tbody>{attributes.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => (
|
||||
<tr key={key}><th scope="row"><code>{key}</code></th><td className="mono">{value}</td></tr>
|
||||
))}</tbody></table>
|
||||
</div>}
|
||||
</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 stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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 {CognitoUsersPanel} from './CognitoUsersPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
getCloudResource: vi.fn(),
|
||||
listFlatItems: vi.fn(),
|
||||
listUserPoolGroups: vi.fn(),
|
||||
putFlatItem: vi.fn(),
|
||||
getFlatItem: vi.fn(),
|
||||
updateUserPoolUser: vi.fn(),
|
||||
setUserPoolUserGroups: vi.fn(),
|
||||
createUserPoolGroup: vi.fn(),
|
||||
deleteUserPoolGroup: vi.fn(),
|
||||
testUserPoolSignIn: vi.fn(),
|
||||
describeUserPoolClient: vi.fn(),
|
||||
updateUserPoolClientOAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const summary: CloudResource = {
|
||||
id: 'us-east-1_8e65d229a', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||
region: 'us-east-1', createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
function pool(signInWith: 'email' | 'username'): CloudResource {
|
||||
return {...summary, metadata: {signInWith, appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}}
|
||||
}
|
||||
|
||||
const alice = {
|
||||
id: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', collectionId: null, timestamp: '2026-10-23T16:01:22.000Z', metadata: {},
|
||||
body: {username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', email: 'alice@example.com', status: 'CONFIRMED', enabled: true},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CognitoUsersPanel resource={summary} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
|
||||
api.putFlatItem.mockResolvedValue(alice)
|
||||
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||
api.listUserPoolGroups.mockResolvedValue([{name: 'admins', description: 'Full access', precedence: 0, createdAt: null}])
|
||||
api.getFlatItem.mockResolvedValue({...alice, body: {...alice.body, emailVerified: true, groups: [], attributes: {sub: alice.id, email: 'alice@example.com'}}})
|
||||
api.updateUserPoolUser.mockResolvedValue(undefined)
|
||||
api.setUserPoolUserGroups.mockResolvedValue(undefined)
|
||||
api.createUserPoolGroup.mockResolvedValue({name: 'editors', description: null, precedence: null, createdAt: null})
|
||||
})
|
||||
|
||||
test('shows the app client id and the pool users', async () => {
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: 'Copy Client ID'})).toBeInTheDocument()
|
||||
expect(await screen.findByText('alice@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('CONFIRMED')).toBeInTheDocument()
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
const form = screen.getByRole('form', {name: 'Create user'})
|
||||
await user.type(within(form).getByLabelText('Email'), 'bob@example.com')
|
||||
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
|
||||
})
|
||||
|
||||
test('creates a username user with a temporary password when asked', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('username'))
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
const form = screen.getByRole('form', {name: 'Create user'})
|
||||
await user.type(within(form).getByLabelText('Username'), 'bob')
|
||||
await user.type(within(form).getByLabelText('Password'), 'Passw0rd!')
|
||||
await user.click(within(form).getByLabelText(/User can sign in now/))
|
||||
await user.click(within(form).getByRole('button', {name: 'Create user'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
|
||||
})
|
||||
|
||||
test('deletes a user by its stored username', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Delete user alice@example.com'}))
|
||||
|
||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
|
||||
})
|
||||
|
||||
test('manages a user: disables it and puts it in a group', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Manage user alice@example.com'}))
|
||||
await user.click(await screen.findByRole('button', {name: 'Disable'}))
|
||||
expect(api.updateUserPoolUser).toHaveBeenCalledWith('aws', summary.id, alice.id, {action: 'disable'})
|
||||
expect(await screen.findByText(/User disabled/)).toBeInTheDocument()
|
||||
|
||||
const groupBox = screen.getByRole('checkbox', {name: 'admins'})
|
||||
await user.click(groupBox)
|
||||
await user.click(screen.getByRole('button', {name: 'Save groups'}))
|
||||
expect(api.setUserPoolUserGroups).toHaveBeenCalledWith('aws', summary.id, alice.id, ['admins'])
|
||||
})
|
||||
|
||||
test('creates a group with its precedence', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
await user.type(await screen.findByLabelText('Group name'), 'editors')
|
||||
await user.type(screen.getByLabelText(/^Precedence/), '5')
|
||||
await user.click(screen.getByRole('button', {name: 'Create group'}))
|
||||
|
||||
expect(api.createUserPoolGroup).toHaveBeenCalledWith('aws', summary.id, {name: 'editors', precedence: 5})
|
||||
})
|
||||
|
||||
test('test sign-in shows the decoded claims, or why Cognito refused', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
api.testUserPoolSignIn
|
||||
.mockResolvedValueOnce({outcome: 'rejected', code: 'NotAuthorizedException', message: 'Incorrect username or password.'})
|
||||
.mockResolvedValueOnce({outcome: 'signed-in', expiresIn: 3600, idToken: {sub: alice.id, email: 'alice@example.com', 'cognito:groups': ['admins']}, accessToken: {sub: alice.id, client_id: 'a8e5c7d38d3b4a1a97c9d822de'}})
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
|
||||
const form = screen.getByRole('form', {name: 'Test sign-in'})
|
||||
await user.type(within(form).getByLabelText('Email'), 'alice@example.com')
|
||||
await user.type(within(form).getByLabelText('Password'), 'wrong')
|
||||
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||
expect(await screen.findByText('Incorrect username or password.')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(form).getByRole('button', {name: 'Try sign-in'}))
|
||||
expect(await screen.findByText(/valid for 60 minutes/)).toBeInTheDocument()
|
||||
expect(screen.getByText('cognito:groups', {selector: 'th code'})).toBeInTheDocument()
|
||||
expect(api.testUserPoolSignIn).toHaveBeenLastCalledWith('aws', summary.id, {clientId: 'a8e5c7d38d3b4a1a97c9d822de', username: 'alice@example.com', password: 'wrong'})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import {RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
|
||||
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
||||
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
||||
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
|
||||
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
||||
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 AppClient = {clientId: string; clientName: string}
|
||||
|
||||
export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [login, setLogin] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||
const [openClient, setOpenClient] = useState<string | null>(null)
|
||||
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||
const groupsKey = ['cognito-groups', accountId, resource?.id]
|
||||
|
||||
// The table row is a list summary; sign-in method and app clients come from the full pool.
|
||||
const poolQuery = useQuery({
|
||||
queryKey: ['cognito-pool', accountId, resource?.id],
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const usersQuery = useQuery({
|
||||
queryKey: usersKey,
|
||||
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: groupsKey,
|
||||
queryFn: ({signal}) => listUserPoolGroups('aws', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLogin('')
|
||||
setPassword('')
|
||||
setPermanent(true)
|
||||
setOpenUser(null)
|
||||
}, [resource?.id])
|
||||
|
||||
// The pool list carries each pool's user count, which the delete confirmation reads.
|
||||
const refreshUsers = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: usersKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'userpools']}),
|
||||
])
|
||||
|
||||
const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
|
||||
...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}),
|
||||
password,
|
||||
permanent,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setLogin('')
|
||||
setPassword('')
|
||||
return refreshUsers()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
|
||||
onSuccess: (_result, username) => {
|
||||
if (openUser === username) setOpenUser(null)
|
||||
return refreshUsers()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a user pool</h3><p>Choose a user pool to manage its app clients and users.</p></div></section>
|
||||
}
|
||||
|
||||
const users = usersQuery.data?.items ?? []
|
||||
const appClients = appClientsOf(poolQuery.data)
|
||||
const pending = createMutation.isPending || deleteMutation.isPending
|
||||
const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error
|
||||
const loginLabel = signInWithEmail ? 'Email' : 'Username'
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Users in ${resource.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<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>
|
||||
{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><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>
|
||||
<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>}
|
||||
{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}/>
|
||||
|
||||
<CognitoGroupsSection poolId={resource.id} groupsQuery={groupsQuery} groupsKey={groupsKey} runtimeReachable={runtimeReachable}/>
|
||||
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Users</p><span className="muted">{users.length} {users.length === 1 ? 'user' : 'users'} in {resource.name}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch(), groupsQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<form className="dynamic-form" aria-label="Create user" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" type={signInWithEmail ? 'email' : 'text'} value={login} onChange={(event) => setLogin(event.target.value)} autoComplete="off" disabled={pending} required/></label>
|
||||
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={pending} required/></label>
|
||||
<label className="dynamic-field dynamic-field--span"><span><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={pending}/> User can sign in now</span><small className="muted">When unchecked the password is temporary and must be changed at first sign-in.</small></label>
|
||||
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !login.trim() || !password || pending}>{createMutation.isPending ? 'Creating' : 'Create user'}</button></div>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{usersQuery.isLoading && <div className="muted padded">Loading users</div>}
|
||||
{usersQuery.isSuccess && users.length === 0 && <div className="empty compact"><h3>No users</h3><p>Create a user to sign in to apps that use this pool.</p></div>}
|
||||
{users.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>{loginLabel}</th><th>Status</th><th>Enabled</th><th>Created</th><th/></tr></thead><tbody>{users.map((user) => {
|
||||
const name = stringValue(user.body.email) || stringValue(user.body.username)
|
||||
return <tr key={user.id}>
|
||||
<td><span className="copy-value">{name}<CopyValueButton value={name} label={loginLabel}/></span></td>
|
||||
<td><code>{stringValue(user.body.status) || '—'}</code></td>
|
||||
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
|
||||
<td>{formatDateTime(user.timestamp) ?? '—'}</td>
|
||||
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openUser === user.id} aria-label={`Manage user ${name}`} onClick={() => setOpenUser(openUser === user.id ? null : user.id)}>Manage</button><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
|
||||
</tr>
|
||||
})}</tbody></table></div>}
|
||||
{openUser && <CognitoUserDetail key={openUser} poolId={resource.id} username={openUser} groups={groupsQuery.data ?? []} usersKey={usersKey} runtimeReachable={runtimeReachable} onClose={() => setOpenUser(null)}/>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function appClientsOf(pool?: CloudResource): AppClient[] {
|
||||
const clients = pool?.metadata.appClients
|
||||
if (!Array.isArray(clients)) return []
|
||||
return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '')
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
|
||||
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
||||
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
||||
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
||||
import { describeDeleteImpact } from "@/lib/deleteImpact";
|
||||
import { getPath } from "@/lib/resourcePath";
|
||||
import {
|
||||
capabilityEnabled,
|
||||
@@ -63,6 +64,9 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
|
||||
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
@@ -573,12 +577,14 @@ export function DynamicResourceView({
|
||||
const resources = resourcesQuery.data ?? [];
|
||||
const canCreate = schema.actions.includes("create");
|
||||
const canDelete = schema.actions.includes("delete");
|
||||
const deleteImpactText = describeDeleteImpact(schema.deleteImpact, selectedItems);
|
||||
const isAllSelected =
|
||||
resources.length > 0 &&
|
||||
resources.every((r) => selectedItems.some((s) => s.id === r.id));
|
||||
// Prefer the latest listed copy, so the inspector follows refreshes instead of the click-time snapshot.
|
||||
const activeSelected =
|
||||
selected?.cloud === cloud && selected.service === service
|
||||
? selected
|
||||
? resources.find((resource) => resource.id === selected.id) ?? selected
|
||||
: undefined;
|
||||
const runtimeReachable = cloudStatus?.runtime === "reachable";
|
||||
const resourceCapabilityInputs =
|
||||
@@ -628,7 +634,6 @@ export function DynamicResourceView({
|
||||
className={`resource-workbench${activeSelected && !showDatabaseSnapshots ? " with-inspector" : ""}`}
|
||||
>
|
||||
<section className="resource-main">
|
||||
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
|
||||
{isAwsDatabase && (
|
||||
<div className="drawer-tabs" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
@@ -743,6 +748,9 @@ export function DynamicResourceView({
|
||||
{canDelete && (
|
||||
deleteConfirm ? (
|
||||
<>
|
||||
{deleteImpactText && (
|
||||
<span className="delete-impact" role="status">{deleteImpactText}</span>
|
||||
)}
|
||||
<button
|
||||
className="button danger"
|
||||
type="button"
|
||||
@@ -931,6 +939,7 @@ export function DynamicResourceView({
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
|
||||
</section>
|
||||
{activeSelected && !showDatabaseSnapshots && (
|
||||
<div>
|
||||
@@ -969,6 +978,15 @@ export function DynamicResourceView({
|
||||
{service === "loadbalancing" && cloud === "aws" && (
|
||||
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "userpools" && cloud === "aws" && (
|
||||
<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" && (
|
||||
<ComputePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {ResourceTable} from './ResourceTable'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
const schema: ServiceSchema = {
|
||||
cloud: 'aws',
|
||||
service: 'userpools',
|
||||
displayName: 'Cognito',
|
||||
fields: [],
|
||||
actions: ['list', 'delete'],
|
||||
filters: [],
|
||||
columns: [{name: 'name', label: 'Name'}],
|
||||
deleteImpact: [
|
||||
{path: 'metadata.userCount', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
],
|
||||
}
|
||||
|
||||
const pool: CloudResource = {
|
||||
id: 'us-east-1_abc', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
|
||||
region: 'us-east-1', createdAt: null, metadata: {userCount: 2, appClients: [{clientId: 'c1'}]},
|
||||
}
|
||||
|
||||
test('the row delete confirmation says what else is deleted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn()
|
||||
render(<ResourceTable schema={schema} resources={[pool]} onSelect={vi.fn()} onDelete={onDelete}/>)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Delete orders-users'}))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('This also deletes 2 users and 1 app client.')
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm'}))
|
||||
expect(onDelete).toHaveBeenCalledWith(pool)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Pencil, Trash2} from 'lucide-react';
|
||||
import {describeDeleteImpact} from '@/lib/deleteImpact';
|
||||
import type {CloudResource} from '@/types/resource';
|
||||
import type {ServiceSchema} from '@/types/schema';
|
||||
import {getPath} from '@/lib/resourcePath';
|
||||
@@ -140,6 +141,7 @@ export function ResourceTable({
|
||||
className="table-confirm-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DeleteImpact schema={schema} resource={resource} />
|
||||
<button
|
||||
className="button danger compact"
|
||||
type="button"
|
||||
@@ -207,3 +209,8 @@ function CopyableColumnValue({resource, column}: {resource: CloudResource; colum
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteImpact({schema, resource}: {schema: ServiceSchema; resource: CloudResource}) {
|
||||
const text = describeDeleteImpact(schema.deleteImpact, [resource]);
|
||||
return text ? <span className="delete-impact" role="status">{text}</span> : null;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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">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>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
Earth,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
UsersRound,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
@@ -29,6 +32,9 @@ import appConfigIcon from '@/assets/aws-services/appconfig.svg'
|
||||
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
|
||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.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 cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
@@ -84,6 +90,9 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
@@ -127,6 +136,9 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
secrets: secretsManagerIcon,
|
||||
serverless: lambdaIcon,
|
||||
storage: s3Icon,
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -779,6 +779,47 @@ pre,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.topbar .search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar .search input {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.topbar .kbd,
|
||||
.topbar .account-meta,
|
||||
.topbar .connection-target {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar .connection-state {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar .connection {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topbar .account-trigger {
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-status:empty + .account-switcher {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1992,6 +2033,10 @@ pre,
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.resource-main > .ses-console {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.resource-main > .drawer-tabs {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -4261,6 +4306,130 @@ pre,
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cognito-table {
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cognito-row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cognito-outcome {
|
||||
margin: 0 16px 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent-tint-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cognito-outcome p { margin: 0 0 12px; }
|
||||
.cognito-outcome--ok { border-left-color: var(--status-ok); }
|
||||
.cognito-outcome--challenge { border-left-color: var(--status-warn); }
|
||||
.cognito-outcome--rejected { border-left-color: var(--status-error); }
|
||||
|
||||
.cognito-claims {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cognito-claims .table th,
|
||||
.cognito-attributes .table th {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cognito-user {
|
||||
font-size: 13px;
|
||||
margin: 0 16px 16px;
|
||||
border: 1px solid var(--accent-tint-border);
|
||||
border-radius: 8px;
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.cognito-user-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.cognito-attributes { grid-column: 1 / -1; overflow-x: auto; }
|
||||
|
||||
.cognito-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cognito-facts dt { color: var(--text-muted); }
|
||||
.cognito-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.cognito-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cognito-set-password {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.cognito-set-password .dynamic-field { width: 100%; }
|
||||
|
||||
.cognito-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 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 {
|
||||
color: var(--status-warn);
|
||||
font-size: 13px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.table-confirm-actions:has(.delete-impact) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-confirm-actions .delete-impact {
|
||||
flex-basis: 100%;
|
||||
max-width: 220px;
|
||||
margin-left: auto;
|
||||
white-space: normal;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -6320,3 +6489,52 @@ button.console-service-card {
|
||||
.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; }
|
||||
@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%; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {describeDeleteImpact} from './deleteImpact'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const impact = [
|
||||
{path: 'metadata.estimatedNumberOfUsers', singular: 'user', plural: 'users'},
|
||||
{path: 'metadata.appClients', singular: 'app client', plural: 'app clients'},
|
||||
]
|
||||
|
||||
function pool(users: unknown, clients: unknown[]): CloudResource {
|
||||
return {id: 'p', name: 'p', cloud: 'aws', service: 'userpools', type: 'user-pool', region: null, createdAt: null, metadata: {estimatedNumberOfUsers: users, appClients: clients}}
|
||||
}
|
||||
|
||||
test('sums numbers and list lengths across the selection', () => {
|
||||
expect(describeDeleteImpact(impact, [pool(2, [{}]), pool(1, [])])).toBe('This also deletes 3 users and 1 app client.')
|
||||
})
|
||||
|
||||
test('leaves out zero and unreadable counts', () => {
|
||||
expect(describeDeleteImpact(impact, [pool(0, [{}, {}])])).toBe('This also deletes 2 app clients.')
|
||||
expect(describeDeleteImpact(impact, [pool(null, [])])).toBeNull()
|
||||
expect(describeDeleteImpact(undefined, [pool(5, [])])).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import {getPath} from '@/lib/resourcePath'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
/**
|
||||
* "This also deletes 2 users and 1 app client." for the delete confirmation,
|
||||
* summed over the selected resources. A count is a number at the path, or the
|
||||
* length of a list there; anything else, and zero counts, are left out.
|
||||
*/
|
||||
export function describeDeleteImpact(impact: ServiceSchema['deleteImpact'], resources: CloudResource[]): string | null {
|
||||
if (!impact?.length) return null
|
||||
|
||||
const parts = impact.flatMap(({path, singular, plural}) => {
|
||||
const total = resources.reduce((sum, resource) => {
|
||||
const value = getPath(resource, path)
|
||||
if (Array.isArray(value)) return sum + value.length
|
||||
return typeof value === 'number' && Number.isFinite(value) ? sum + value : sum
|
||||
}, 0)
|
||||
return total > 0 ? [`${total} ${total === 1 ? singular : plural}`] : []
|
||||
})
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
||||
return `This also deletes ${list}.`
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'cyber'
|
||||
|
||||
/** Shared with the login page (public/login.js) and index.html. */
|
||||
const THEME_KEY = 'floci-theme'
|
||||
|
||||
function getStored(): Theme {
|
||||
const v = localStorage.getItem('floci-theme')
|
||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — fall back to the default theme.
|
||||
return 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('floci-theme', theme)
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
} catch {
|
||||
// Persisting the choice is optional when storage is unavailable.
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return {theme, setTheme}
|
||||
|
||||
@@ -49,7 +49,9 @@ test('summarises the runtime and lists failing console services first', async ()
|
||||
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
|
||||
const consoleServices = screen.getByRole('region', {name: 'Console services'})
|
||||
const rows = within(consoleServices).getAllByRole('row')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[1]).toHaveTextContent('messaging')
|
||||
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
||||
|
||||
@@ -59,6 +61,25 @@ test('summarises the runtime and lists failing console services first', async ()
|
||||
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('keeps responding console services behind a toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue({
|
||||
...healthy,
|
||||
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
|
||||
} satisfies RuntimeDiagnostics)
|
||||
renderPage()
|
||||
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
|
||||
|
||||
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
|
||||
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
|
||||
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
|
||||
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('filters emulated services by console coverage and state', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||
|
||||
@@ -88,17 +88,25 @@ function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
||||
}
|
||||
|
||||
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||
const [showResponding, setShowResponding] = useState(false)
|
||||
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
|
||||
const responding = services.filter((service) => service.runtime === 'reachable')
|
||||
const ordered = showResponding ? [...failing, ...responding] : failing
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console-services">Console services</h2>
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
|
||||
<div className="diagnostics-filters">
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
|
||||
{responding.length > 0 && (
|
||||
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
|
||||
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{services.length === 0
|
||||
? <p className="muted padded">No console services are registered for this cloud.</p>
|
||||
: <table className="table">
|
||||
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
|
||||
{ordered.length > 0 && <table className="table">
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||
<tbody>{ordered.map((service) => (
|
||||
<tr key={service.service}>
|
||||
|
||||
@@ -274,3 +274,58 @@ export interface AppConfigDeployment {
|
||||
completedAt: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export type UserPoolUserAction =
|
||||
| {action: 'enable'}
|
||||
| {action: 'disable'}
|
||||
| {action: 'confirm'}
|
||||
| {action: 'resetPassword'}
|
||||
| {action: 'signOut'}
|
||||
| {action: 'setPassword'; password: string; permanent?: boolean}
|
||||
|
||||
export interface UserPoolGroup {
|
||||
name: string
|
||||
description: string | null
|
||||
precedence: number | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserPoolGroupInput {
|
||||
name: string
|
||||
description?: string
|
||||
precedence?: number
|
||||
}
|
||||
|
||||
export interface UserPoolSignInTestInput {
|
||||
clientId: string
|
||||
username: 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 =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
| {outcome: 'rejected'; code: string; message: string}
|
||||
|
||||
@@ -27,6 +27,8 @@ export type KubernetesActionName =
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
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 interface CapabilitySchema<TAction extends string> {
|
||||
@@ -86,8 +88,12 @@ export interface ServiceSchema {
|
||||
databaseActions?: Array<CapabilitySchema<DatabaseActionName> | DatabaseActionName>
|
||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
updateFields?: FieldSchema[]
|
||||
/** Counts read from the resource that the delete confirmation names. */
|
||||
deleteImpact?: Array<{path: string; singular: string; plural: string}>
|
||||
}
|
||||
|
||||
Generated
+321
-58
@@ -19,6 +19,9 @@ importers:
|
||||
'@aws-sdk/client-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
'@aws-sdk/client-cloudfront':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-cloudwatch':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
@@ -34,6 +37,9 @@ importers:
|
||||
'@aws-sdk/client-codepipeline':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1144.0
|
||||
'@aws-sdk/client-cognito-identity-provider':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-dynamodb':
|
||||
specifier: ^3.1088.0
|
||||
version: 3.1112.0
|
||||
@@ -85,6 +91,9 @@ importers:
|
||||
'@aws-sdk/client-rds-data':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1144.0
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -298,6 +307,10 @@ packages:
|
||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -318,6 +331,10 @@ packages:
|
||||
resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cognito-identity-provider@3.1156.0':
|
||||
resolution: {integrity: sha512-R+fVyawDRELzLTMKYIrzGtqku6OULvRnk1SQDR1QQhyVnvZXmJDNuKlcvChl0pEFllpO69Rce9TiZwzMAe/2oQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-dynamodb@3.1112.0':
|
||||
resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -386,6 +403,10 @@ packages:
|
||||
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -441,6 +462,10 @@ packages:
|
||||
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.978.5':
|
||||
resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.54':
|
||||
resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -465,6 +490,10 @@ packages:
|
||||
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.76':
|
||||
resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.56':
|
||||
resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -489,6 +518,10 @@ packages:
|
||||
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.78':
|
||||
resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.61':
|
||||
resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -509,6 +542,10 @@ packages:
|
||||
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||
resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||
resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -537,6 +574,10 @@ packages:
|
||||
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.83':
|
||||
resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.63':
|
||||
resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -565,6 +606,10 @@ packages:
|
||||
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.88':
|
||||
resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.54':
|
||||
resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -589,6 +634,10 @@ packages:
|
||||
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.76':
|
||||
resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||
resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -609,6 +658,10 @@ packages:
|
||||
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||
resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||
resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -637,6 +690,10 @@ packages:
|
||||
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||
resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/dynamodb-codec@3.973.43':
|
||||
resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -665,6 +722,10 @@ packages:
|
||||
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -697,6 +758,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.50':
|
||||
resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.38':
|
||||
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -709,6 +774,10 @@ packages:
|
||||
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||
resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1080.0':
|
||||
resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -733,6 +802,10 @@ packages:
|
||||
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1155.0':
|
||||
resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.973.15':
|
||||
resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -753,6 +826,10 @@ packages:
|
||||
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.975.0':
|
||||
resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-dynamodb@3.996.9':
|
||||
resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1220,6 +1297,10 @@ packages:
|
||||
resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.36.2':
|
||||
resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.14':
|
||||
resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1300,6 +1381,10 @@ packages:
|
||||
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.7.4':
|
||||
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.15.1':
|
||||
resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -2798,10 +2883,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/checksums@3.1000.13':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-api-gateway@3.1096.0':
|
||||
@@ -2838,6 +2923,17 @@ snapshots:
|
||||
'@smithy/types': 4.16.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':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -2894,6 +2990,17 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cognito-identity-provider@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-dynamodb@3.1112.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.8
|
||||
@@ -3096,6 +3203,18 @@ snapshots:
|
||||
'@smithy/types': 4.15.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':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
@@ -3249,6 +3368,17 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.978.5':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@aws-sdk/xml-builder': 3.972.41
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/signature-v4': 5.7.4
|
||||
'@smithy/types': 4.19.0
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.54':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3297,6 +3427,14 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.56':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3357,6 +3495,16 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.78':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@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/credential-provider-ini@3.972.61':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3437,6 +3585,22 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-env': 3.972.76
|
||||
'@aws-sdk/credential-provider-http': 3.972.78
|
||||
'@aws-sdk/credential-provider-login': 3.972.83
|
||||
'@aws-sdk/credential-provider-process': 3.972.76
|
||||
'@aws-sdk/credential-provider-sso': 3.973.20
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.82
|
||||
'@aws-sdk/nested-clients': 3.997.50
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.7':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3455,55 +3619,64 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.78':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.83':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.50
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3605,6 +3778,20 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.88':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.76
|
||||
'@aws-sdk/credential-provider-http': 3.972.78
|
||||
'@aws-sdk/credential-provider-ini': 3.973.21
|
||||
'@aws-sdk/credential-provider-process': 3.972.76
|
||||
'@aws-sdk/credential-provider-sso': 3.973.20
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.82
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.54':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3653,6 +3840,14 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3703,6 +3898,16 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.50
|
||||
'@aws-sdk/token-providers': 3.1155.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.6':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3767,6 +3972,15 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.50
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/dynamodb-codec@3.973.43':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3817,6 +4031,12 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
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':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3835,10 +4055,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.28':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -3846,10 +4066,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.36':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -3857,10 +4077,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.43':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -3868,10 +4088,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -3879,10 +4099,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.45':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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
|
||||
@@ -3890,10 +4110,21 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.46':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@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/nested-clients@3.997.50':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.49
|
||||
'@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
|
||||
@@ -3920,57 +4151,73 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/signature-v4': 5.7.4
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1080.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1096.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1111.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1116.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1129.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1138.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1155.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.50
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3999,6 +4246,11 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.975.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)':
|
||||
dependencies:
|
||||
'@aws-sdk/client-dynamodb': 3.1112.0
|
||||
@@ -4496,6 +4748,11 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.36.2':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.14':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
@@ -4615,6 +4872,12 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.4':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.15.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
Reference in New Issue
Block a user