Compare commits

..

49 Commits

Author SHA1 Message Date
Zakaria eeadeef664 fix(frontend): stop MSK breaking the Console Home resource count
CI / checks (pull_request) Successful in 26m59s
Console Home counted every available service through the generic resource
list, but MSK and MSK Connect are available through their own pre-SPI pages
with no adapter behind /resources, so each answered 501 and the Resources tile
read "Resource load failed". Service descriptors now say whether the generic
list serves them (listable, true when an adapter is registered), and Console
Home counts only those; the MSK cards still link to their pages.
2026-10-25 10:09:35 -04:00
Zakaria 9f1ea32183 Merge pull request 'docs: rebrand the README as TheCloud' (#21) from chore/readme-thecloud into main
CI / checks (push) Has been cancelled
Reviewed-on: #21
2026-10-25 14:00:14 +00:00
Zakaria 811791603c docs: rebrand the README as TheCloud
CI / checks (pull_request) Successful in 27m5s
Replace the Floci logo, screenshots and architecture diagram with TheCloud's
logo, a current screenshot and a redrawn diagram; say TheCloud in prose while
keeping literal identifiers (env vars, image and compose service names); point
the runtime setup at FlociClone; drop the upstream release and Docker badges
and the upstream community section; keep the credit to Floci under License.
2026-10-25 09:57:22 -04:00
Zakaria 67660858eb Merge pull request 'feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page' (#20) from feat/apigateway-v2 into main
CI / checks (push) Has been cancelled
Reviewed-on: #20
2026-10-25 13:49:09 +00:00
Zakaria bb5ca85a88 feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs
made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page
now lists all three kinds with a Type column, creates each from the same form,
and opens HTTP and WebSocket APIs in their own panel:

- Quick create: an HTTP API with a Lambda gets a catch-all route on an
  auto-deployed $default stage, plus the Lambda permission.
- Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes
  the integration and Lambda permission only it used.
- Stages with or without automatic deployment, and deploy on demand.
- JWT authorizers filled in from a Cognito pool's OpenID Connect discovery
  document and an app client, or from any issuer; protect routes with them.
- CORS configuration.
- A tester that sends a real request to a stage, and for WebSocket APIs one
  that connects, sends a message and collects the replies.

Capabilities are advertised under a new httpApiActions group, covered by the
cloudProxy guard test. Verified end to end against TheCloud's runtime.
2026-10-25 09:18:50 -04:00
Zakaria 6acd3656a1 Merge pull request 'feat(api): add CloudWatch alarms and metric charts to the cloud explorer' (#19) from feat/cloudwatch into main
CI / checks (push) Successful in 26m58s
Reviewed-on: #19
2026-10-25 03:08:02 +00:00
Zakaria 6dd33b8f65 feat(api): add CloudWatch alarms and metric charts to the cloud explorer
CI / checks (pull_request) Successful in 27m29s
2026-10-24 22:48:41 -04:00
Zakaria f4b3e45f3d Merge pull request 'chore: show TheCloud instead of Floci in console text and messages' (#18) from chore/rebrand-thecloud into main
CI / checks (push) Successful in 25m42s
Reviewed-on: #18
2026-10-24 18:32:47 +00:00
Zakaria 91ce11274d chore: show TheCloud instead of Floci in console text and messages
CI / checks (pull_request) Successful in 25m47s
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00
Zakaria 7669b5387f Merge pull request 'fix(api): show CloudFront distributions at <id>.cloudfront.localhost' (#17) from feat/cloudfront-thecloud-url into main
CI / checks (push) Has been cancelled
Reviewed-on: #17
2026-10-24 18:26:25 +00:00
Zakaria a86bee443b fix(api): show CloudFront distributions at <id>.cloudfront.localhost
CI / checks (pull_request) Successful in 26m17s
Distributions were linked under localhost.floci.io, another project's domain.
Every .localhost name resolves to the machine itself, so the brand-neutral
<id>.cloudfront.localhost reaches the runtime with no DNS or hosts entry.
2026-10-24 14:25:35 -04:00
Zakaria 9cb164f211 Merge pull request 'feat(api): add CloudFront distributions to the cloud explorer' (#16) from feat/cloudfront into main
CI / checks (push) Has been cancelled
Reviewed-on: #16
2026-10-24 18:13:39 +00:00
Zakaria 6586f48539 feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s
2026-10-24 14:10:16 -04:00
Zakaria 5c3c73c582 Merge pull request 'feat(api): add Route 53 hosted zones and DNS records to the cloud explorer' (#15) from feat/route53 into main
CI / checks (push) Successful in 25m44s
Reviewed-on: #15
2026-10-24 17:10:52 +00:00
Zakaria 2b4d4b146d feat(api): add Route 53 hosted zones and DNS records to the cloud explorer
CI / checks (pull_request) Successful in 25m40s
2026-10-24 13:07:57 -04:00
Zakaria ac58bfc98b Merge pull request 'test(api): stop tests leaking module mocks and env into later files' (#14) from fix/ci-test-isolation into main
CI / checks (push) Successful in 25m29s
Reviewed-on: #14
2026-10-24 15:36:52 +00:00
Zakaria 51a0a60a73 test(api): stop tests leaking module mocks and env into later files
CI / checks (pull_request) Successful in 25m28s
CI failed on a different set of tests each run because bun runs every test
file in one process and the file order differs between machines:

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

Checked with 20 runs under bun test --randomize.
2026-10-24 11:30:46 -04:00
Zakaria a5c9de25d0 Merge pull request 'feat(api): show a user pool's Lambda triggers' (#13) from feat/cognito-app-client-settings into main
CI / checks (push) Has been cancelled
Reviewed-on: #13
2026-10-24 15:22:35 +00:00
Zakaria 238bb1a3d5 feat(frontend): hosted sign-in and OAuth settings for Cognito app clients
CI / checks (pull_request) Successful in 26m7s
2026-10-24 11:17:42 -04:00
Zakaria d411f632c9 feat(api): show a user pool's Lambda triggers 2026-10-24 02:07:18 -04:00
Zakaria 954e2e8a47 Merge pull request 'feat(frontend): newest emails first, mailbox above the SES tools' (#12) from feat/mailbox-newest-first into main
CI / checks (push) Failing after 12m26s
Reviewed-on: #12
2026-10-24 06:02:06 +00:00
Zakaria ce444f0851 feat(frontend): newest emails first, mailbox above the SES tools
CI / checks (pull_request) Failing after 12m24s
2026-10-24 02:01:14 -04:00
Zakaria 18c1f5067f Merge pull request 'feat(api): show the verification code in the SES mailbox' (#10) from feat/emails-page into main
CI / checks (push) Has been cancelled
Reviewed-on: #10
2026-10-24 05:29:58 +00:00
Zakaria d457c08b12 feat(api): show the verification code in the SES mailbox
CI / checks (pull_request) Failing after 12m24s
2026-10-24 01:23:56 -04:00
Zakaria 4bc8da6ee1 Merge pull request 'feat(api): Cognito user actions, groups and test sign-in' (#9) from feat/cognito-round-2 into main
CI / checks (push) Failing after 12m34s
Reviewed-on: #9
2026-10-23 22:56:19 +00:00
Zakaria bafe799d00 feat(frontend): manage Cognito users and groups, and test sign-in
CI / checks (pull_request) Failing after 12m24s
The Cognito panel gains a test sign-in that shows the decoded ID and
access token claims, a groups section, and a per-user panel to enable or
disable, confirm, sign out everywhere, reset or set a password and change
group membership.

Delete confirmations (row and bulk) now say what else goes, driven by
the schema's deleteImpact, and the inspector follows list refreshes
instead of the click-time snapshot.
2026-10-23 17:54:15 -04:00
Zakaria 43f4cae9b5 fix(api): count Cognito pool users directly
The runtime leaves EstimatedNumberOfUsers at 0, so the pool metadata now
carries a userCount from ListUsers (capped at 1000), which the delete
confirmation reads.
2026-10-23 17:54:15 -04:00
Zakaria b4c2cadf5c feat(api): Cognito user actions, groups and test sign-in
Adds enable, disable, confirm, sign-out, password reset and set-password
for one user; user group membership; group list, create and delete; and a
test sign-in that returns decoded token claims. A refused test sign-in is
an outcome rather than a 401, which would end the console session.

Also adds a generic GET for one flat item and a schema deleteImpact so
delete confirmations can say what else goes with a resource.
2026-10-23 17:38:26 -04:00
Zakaria 8803498498 Merge pull request 'feat(api): add Cognito user pools to the cloud explorer' (#8) from feat/cognito-user-pools into main
CI / checks (push) Failing after 12m33s
Reviewed-on: #8
2026-10-23 18:41:30 +00:00
Zakaria f6b9a62811 Merge pull request 'fix(frontend): share one theme setting between the login page and console' (#7) from chore/ui-quick-wins into main
CI / checks (push) Has been cancelled
Reviewed-on: #7
2026-10-23 18:41:01 +00:00
Zakaria f1574b89df docs: regenerate the service matrix
CI / checks (pull_request) Successful in 26m4s
Adds Cognito and the services the table had drifted behind on (ECS, Fargate, Lightsail, ElastiCache, MSK, Kinesis and others).
2026-10-23 12:12:40 -04:00
Zakaria fe70e88bcc feat(frontend): manage Cognito app clients and users
Adds a users panel under the selected user pool: copyable app client ids, a create-user form that follows the pool's sign-in method, and the user list with delete. Maps the userpools icon key to the Cognito artwork.
2026-10-23 12:12:40 -04:00
Zakaria 4156afccfe feat(api): add Cognito user pools to the cloud explorer
Adds a userpools catalog entry, schema and AwsUserPoolsAdapter, verified
against Floci core:

- list/get/create/delete user pools; create can set email or username
  sign-in, a relaxed password policy for local test users, and a public
  app client (on by default) whose id is surfaced in the pool metadata
- users as the pool's child items: list (paged), get, create and delete;
  passwords are set permanent by default so users can sign in at once
- map Cognito's HTTP-400 error names (UserNotFoundException,
  UsernameExistsException, AliasExistsException, InvalidPasswordException)
- link Floci's cognito-idp service to the new page on the diagnostics page
2026-10-23 12:12:40 -04:00
Zakaria 8b166d86d4 feat(frontend): collapse responding services on the diagnostics page
CI / checks (pull_request) Failing after 12m12s
Failing console services stay visible; responding ones are behind a toggle.
2026-10-23 12:00:10 -04:00
Zakaria f956d977ca fix(frontend): fit the top bar on phone-width screens
Below 640px the search shrinks, the account switcher shows only its icon, and the connection indicator collapses to its status dot with the label kept for screen readers.
2026-10-23 12:00:10 -04:00
Zakaria 9f38313e59 fix(frontend): share one theme setting between the login page and console
The login page stored its theme under thecloud-theme while the console read floci-theme, so a choice made at sign-in was lost. Both now use floci-theme (falling back to the old key once), and useTheme tolerates unavailable storage.
2026-10-23 12:00:10 -04:00
Zakaria ece36e4a34 Merge pull request 'feat: add a runtime diagnostics page' (#6) from feat/diagnostics-page into main
CI / checks (push) Has been cancelled
Reviewed-on: #6
2026-10-23 15:42:29 +00:00
Zakaria 953ba951db feat: add a runtime diagnostics page
CI / checks (pull_request) Failing after 13m18s
Adds GET /api/clouds/:cloud/diagnostics and a Diagnostics page in the
General nav. The report combines, in one call:

- runtime reachability, latency, version and edition from /_floci/health
- every service the runtime emulates, with the console pages backed by it
- per-console-service probes (the existing status check)
- EC2 instance containers from /_floci/diagnose
- console uptime, sign-in mode and active session count

Each source is optional, so a down runtime still yields a partial report.
2026-10-23 11:29:48 -04:00
Zakaria dd16bcf4cd Merge pull request 'test: bring stale tests and lint in line with the current UI' (#5) from chore/ci-green-checks into main
CI / checks (push) Has been cancelled
Reviewed-on: #5
2026-10-23 15:16:18 +00:00
Zakaria 432d9d4ce1 ci: run lint, type-check, tests and build on Gitea Actions
CI / checks (pull_request) Successful in 28m29s
Runs on pushes to main and on every pull request.
2026-10-23 11:12:51 -04:00
Zakaria b171f745db test: bring stale tests and lint in line with the current UI
- CodeDeploy: query the redesigned form's labels.
- CloudFormation: expect the parameters and capabilities fields the form
  now submits.
- ALB listeners: advance the five-second minimum create state before
  deleting, since row actions are disabled while it is pending.
- Lightsail: cast the fetch mock as a whole so it type-checks.
- TargetGroupPanel: depend on the stable refetch function to satisfy
  react-hooks/exhaustive-deps.
2026-10-23 11:12:51 -04:00
Zakaria f5e70a78d8 Merge pull request 'fix(api): block open redirects, add health check and body limits, run as non-root' (#4) from fix/api-hardening into main
Reviewed-on: #4
2026-10-23 14:59:32 +00:00
Zakaria c5a2fc9105 fix(api): block open redirects, add health check and body limits, run as non-root
- safeReturnTo resolves the target against a placeholder origin and rejects
  backslashes and control characters, so `/\evil.example` and similar
  payloads can no longer redirect off-site after sign-in.
- Add a real unauthenticated /api/health and answer unknown /api/* paths
  with a JSON 404 instead of the SPA shell.
- Limit request bodies: 16 KB for /api/auth/* (read before a session
  exists), 128 MB for resource create/update and uploads, 10 MB elsewhere.
- Run the production image as an unprivileged user and add a Docker
  HEALTHCHECK against /api/health.
2026-10-23 10:43:41 -04:00
Zakaria c9b0677fed Merge pull request 'feat(frontend): add a terminal theme and three-way theme switcher' (#3) from feat/console-themes-and-aws-focus into main
Reviewed-on: #3
2026-10-23 14:41:07 +00:00
Zakaria 0371a8884f chore: remove unused index.sample.html 2026-10-23 10:12:41 -04:00
Zakaria 75432a4bc7 feat(s3): add a copy button for the bucket name 2026-10-23 10:12:41 -04:00
Zakaria c519d41799 feat(frontend): focus the console on AWS
Redirects the Azure and GCP console and explorer routes to AWS and removes the cloud selector from Console Home.
2026-10-23 10:12:41 -04:00
Zakaria ba90eff5db feat(frontend): add a terminal theme and three-way theme switcher
Adds the cyber theme to the console and login page, replaces the dark/light toggle with a theme switcher, and moves the login page script to /login.js so it is served under the existing CSP.
2026-10-23 10:12:41 -04:00
Zakaria 502d0b3986 Merge pull request 'fix(api): harden sign-in throttling, OIDC authorization and auth state' (#2) from fix/auth-hardening into main
Reviewed-on: #2
2026-10-23 14:01:46 +00:00
130 changed files with 10657 additions and 900 deletions
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type-check
run: pnpm type-check
- name: API tests
run: pnpm test
- name: Frontend tests
run: pnpm --filter @floci/frontend test
- name: Build
run: pnpm build
+59 -72
View File
@@ -1,26 +1,23 @@
<p align="center">
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
</p>
<h1 align="center">TheCloud</h1>
<p align="center">
<strong>Any Cloud. Locally.</strong><br />
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
A self-hosted cloud console for local AWS, Azure and GCP runtimes.
</p>
<p align="center">
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
</p>
<p align="center">
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
</p>
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
TheCloud is an AWS-Console-style web console for locally running cloud runtimes. It is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`, and it renders only real data returned by the runtimes, with explicit placeholders for work that is not wired yet.
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
## Exposing To The Internet
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
TheCloud requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
Put only TheCloud's console behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind TheCloud's AWS runtime (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
@@ -62,48 +59,57 @@ 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 |
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
Console Home is available for all three clouds.
Runtime gaps — an adapter exists but the local runtime does not implement it:
- Azure Serverless: the Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.
- Azure Serverless: TheCloud's Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.
Services marked `No` render as a disabled sidebar row whose tooltip carries the
server-supplied reason. Adding one is a catalog row in
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
<p align="center">
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
</p>
Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled
`AKS` for this provider, and every unavailable service carries a reason — Serverless
reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions,
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
`AKS` for that provider, and every unavailable service carries a reason: Serverless
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
even though an adapter is registered.
## Current Capability Snapshot
@@ -146,7 +152,7 @@ All three clouds, through the unified shell.
Current gaps:
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
the shipped Azure runtime (`floci-az`) config runs AKS unmocked with no Docker socket to start k3s
with, so a created cluster never leaves `provisioningState: Failed`.
</details>
@@ -156,7 +162,7 @@ Current gaps:
Relational and document database workflows across providers:
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them.
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because TheCloud's RDS runtime does not support modifying them yet.
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
- Azure Cosmos DB NoSQL: database, container, and document workflows.
- AWS DynamoDB: table management, item browsing, and Add record.
@@ -183,8 +189,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
Current gaps:
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but TheCloud's runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
- AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
</details>
@@ -246,14 +252,18 @@ Current gaps:
<details>
<summary><strong>API Gateway</strong></summary>
AWS only, through the generic apigateway service category.
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
- List and inspect REST APIs.
- Create and delete REST APIs.
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
Current gaps:
- Resources, methods, deployments, and stages are not yet exposed.
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
- No Azure or GCP API Gateway adapter yet.
</details>
@@ -263,7 +273,7 @@ Current gaps:
AWS SES email capture through the unified Cloud Explorer.
- Lists emails actually captured by Floci SES.
- Lists emails actually captured by TheCloud's SES.
- Filters by subject, sender, and recipient.
- Inspects sender, recipients, timestamp, and message type.
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
@@ -280,10 +290,10 @@ aws ses send-email \
--endpoint-url http://localhost:4566 \
--from sender@example.test \
--destination 'ToAddresses=recipient@example.test' \
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
--message 'Subject={Data="TheCloud SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from TheCloud</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
```
The email is captured by the local Floci runtime; it is not delivered externally. Open
The email is captured by TheCloud's runtime, and is delivered externally only when an SMTP relay is configured. Open
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
Raw views. You can also inspect the captured messages directly with:
@@ -311,7 +321,7 @@ AWS and GCP, both through the unified shell.
Current gaps:
- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented,
- Azure Functions is registered but the Azure runtime answers 501 NotImplemented,
so it reports `coming_soon` with that reason rather than appearing available.
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
`coming_soon` instead of being silently missing.
@@ -364,7 +374,7 @@ Current gaps:
## Product Direction
Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell.
TheCloud is a metadata-driven, cloud-aware console: one web app renders multiple local runtimes through the same shell.
The guiding rules are:
@@ -376,7 +386,7 @@ The guiding rules are:
## Architecture
![Floci Unified UI Multi-Cloud Architecture](docs/images/floci-unified-ui-architecture.png)
![TheCloud architecture: the console calls TheCloud's API, which dispatches through one proxy and an adapter registry to AWS, Azure and GCP adapters and their local runtimes](docs/images/thecloud-architecture.svg)
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
@@ -418,9 +428,9 @@ Browser
Default compose stack:
- `floci-ui` on `http://localhost:4500`
- `floci-api` on `http://localhost:4501`
- `floci` on `http://localhost:4566`
- `floci-ui` (the console) on `http://localhost:4500`
- `floci-api` (TheCloud's API) on `http://localhost:4501`
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
Start AWS-only:
@@ -450,7 +460,7 @@ Prerequisites:
- Node.js 20+
- pnpm 9+
- Bun
- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP
- A running local runtime: TheCloud's AWS runtime, and optionally the Azure and GCP runtimes
Install dependencies:
@@ -466,29 +476,18 @@ cp .env.example packages/api/.env
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
Start Floci AWS Core with Docker:
Start TheCloud's AWS runtime from its repository:
```bash
docker run -d --name floci \
-p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
-e FLOCI_DEFAULT_REGION=us-east-1 \
-u root \
floci/floci:latest
```
Or from a local clone:
```bash
git clone https://github.com/floci-io/floci.git ../floci
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
cd ../floci
./mvnw clean quarkus:dev
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
```
Optional local runtimes:
- Floci-AZ on `http://localhost:4577`
- Floci-GCP on `http://localhost:4588`
- Azure runtime (`floci-az`) on `http://localhost:4577`
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
Start the UI stack:
@@ -597,18 +596,6 @@ When adding new UI surface:
- Keep placeholders explicit instead of inventing fake data.
- Update this README when the visible UI surface changes.
## Community Projects
Floci UI is the first-party console, but it is not the only one. The community builds
consoles for Floci too:
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
covers Azure and GCP, so pick whichever fits your stack.
Building something for Floci? Open a PR to add it here.
## License
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
[MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
+7 -1
View File
@@ -23,13 +23,19 @@ RUN bun build --compile --minify src/index.ts --outfile server
# Stage 3 — minimal runtime (no bun, no node_modules)
FROM alpine:3
RUN apk add --no-cache ca-certificates libstdc++
RUN apk add --no-cache ca-certificates libstdc++ \
&& addgroup -S -g 10001 thecloud \
&& adduser -S -D -H -u 10001 -G thecloud thecloud
WORKDIR /app
COPY --from=api-build /app/server ./server
COPY --from=frontend-build /app/packages/frontend/dist ./public
COPY packages/api/public ./public
COPY TheCloud.png ./public/TheCloud.png
COPY svgAsset/authentik.svg ./public/authentik.svg
COPY aws-icons-svg ./public/aws-icons
ENV PORT=4500
EXPOSE 4500
USER thecloud
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
CMD ["./server"]
-200
View File
@@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #151515;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #151515;
}
.st4 {
fill: #262425;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #151515;
stroke-width: 2.6;
}
.guide-word {
stroke: #262425;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

-200
View File
@@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #FFFFFF;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #FFFFFF;
}
.st4 {
fill: #FFFFFF;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #FFFFFF;
stroke-width: 2.6;
}
.guide-word {
stroke: #FFFFFF;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 620" width="960" height="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">TheCloud architecture</title>
<desc id="desc">The browser talks only to TheCloud's API, which dispatches each request through one proxy service and an adapter registry to a provider adapter, which calls the local AWS, Azure or GCP runtime with that cloud's real API.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#5b6473"/>
</marker>
</defs>
<rect width="960" height="620" rx="16" fill="#f7f4ee"/>
<text x="480" y="44" text-anchor="middle" font-size="24" font-weight="700" fill="#1d2330">TheCloud architecture</text>
<text x="480" y="70" text-anchor="middle" font-size="14" fill="#5b6473">One console for local AWS, Azure and GCP runtimes, using each cloud's real API</text>
<!-- Browser -->
<rect x="250" y="96" width="460" height="74" rx="10" fill="#ffffff" stroke="#c9b48a" stroke-width="2"/>
<text x="270" y="124" font-size="16" font-weight="700" fill="#1d2330">Console (React, port 4500)</text>
<text x="270" y="148" font-size="13" fill="#5b6473">Nav and pages rendered from each service's schema; calls only /api/*</text>
<line x1="480" y1="170" x2="480" y2="202" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- API -->
<rect x="160" y="206" width="640" height="186" rx="10" fill="#ffffff" stroke="#1d2330" stroke-width="2"/>
<text x="180" y="234" font-size="16" font-weight="700" fill="#1d2330">TheCloud API (Bun and Hono, port 4501)</text>
<text x="180" y="256" font-size="13" fill="#5b6473">Sign-in, then the generic /api/clouds/:cloud/services/:service routes</text>
<rect x="180" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
<text x="196" y="294" font-size="14" font-weight="600" fill="#1d2330">CloudProxyService</text>
<text x="196" y="312" font-size="12" fill="#5b6473">One dispatcher for every request</text>
<rect x="500" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
<text x="516" y="294" font-size="14" font-weight="600" fill="#1d2330">Adapter registry and service catalog</text>
<text x="516" y="312" font-size="12" fill="#5b6473">Registered adapters decide what is shown</text>
<line x1="460" y1="297" x2="496" y2="297" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<text x="180" y="350" font-size="13" fill="#1d2330"><tspan font-weight="600">Schemas</tspan> describe fields, columns and capabilities; a capability</text>
<text x="180" y="370" font-size="13" fill="#1d2330">is only advertised when its adapter can perform it (checked by tests).</text>
<line x1="240" y1="392" x2="200" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="480" y1="392" x2="480" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="720" y1="392" x2="760" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Adapters -->
<rect x="60" y="442" width="270" height="60" rx="10" fill="#fff3e3" stroke="#e08a2c" stroke-width="2"/>
<text x="76" y="468" font-size="15" font-weight="700" fill="#a85a0c">AWS adapters</text>
<text x="76" y="488" font-size="12" fill="#5b6473">AWS SDK v3, one per service</text>
<rect x="345" y="442" width="270" height="60" rx="10" fill="#e8f0fd" stroke="#2f6fd6" stroke-width="2"/>
<text x="361" y="468" font-size="15" font-weight="700" fill="#1f4fa3">Azure adapters</text>
<text x="361" y="488" font-size="12" fill="#5b6473">Azure runtime client</text>
<rect x="630" y="442" width="270" height="60" rx="10" fill="#e8f6ec" stroke="#2f9a55" stroke-width="2"/>
<text x="646" y="468" font-size="15" font-weight="700" fill="#1f7140">GCP adapters</text>
<text x="646" y="488" font-size="12" fill="#5b6473">GCP runtime client</text>
<line x1="195" y1="502" x2="195" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="480" y1="502" x2="480" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="765" y1="502" x2="765" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Runtimes -->
<rect x="60" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="76" y="558" font-size="14" font-weight="700" fill="#f7f4ee">TheCloud AWS runtime</text>
<text x="76" y="578" font-size="12" fill="#c9cfd8">port 4566</text>
<rect x="345" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="361" y="558" font-size="14" font-weight="700" fill="#f7f4ee">Azure runtime</text>
<text x="361" y="578" font-size="12" fill="#c9cfd8">port 4577, optional</text>
<rect x="630" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="646" y="558" font-size="14" font-weight="700" fill="#f7f4ee">GCP runtime</text>
<text x="646" y="578" font-size="12" fill="#c9cfd8">port 4588, optional</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

-176
View File
@@ -1,176 +0,0 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · TheCloud</title>
<style>
:root[data-theme=dark]{--frame:#07111d;--rail:#0a1626;--rfg:#e8f1fa;--rmut:#8aa3bc;--rbd:rgba(232,241,250,.14);--ac:#6cc4ff;--on:#06223a;
--pbg:#08131f;--grid:rgba(232,241,250,.08);--o1:#6cc4ff;--o2:#ffb547;--pfg:#f2f8ff;--pmut:#a9c3dc;--tile:rgba(12,26,43,.72);--tbd:rgba(232,241,250,.14);--ic:#8fd3ff;--hi:#ffb547;--glow:rgba(108,196,255,.16);--err:#ff9a9a;--errbg:#3a1822}
:root[data-theme=light]{--frame:#eef4fa;--rail:#0e2238;--rfg:#f2f8ff;--rmut:#9db6cf;--rbd:rgba(242,248,255,.18);--ac:#7fd0ff;--on:#06223a;
--pbg:#eaf2fa;--grid:rgba(14,34,56,.09);--o1:#0a6fc2;--o2:#c26a00;--pfg:#0e2238;--pmut:#4a627a;--tile:rgba(255,255,255,.8);--tbd:rgba(14,34,56,.14);--ic:#0a6fc2;--hi:#c26a00;--glow:rgba(10,111,194,.14);--err:#ffb0b0;--errbg:#3a1822}
:root[data-theme=cyber]{--frame:#010305;--rail:#02080a;--rfg:#4eff96;--rmut:#2f9a68;--rbd:rgba(78,255,150,.3);--ac:#ffea00;--on:#02080a;
--pbg:#02060a;--grid:rgba(78,255,150,.16);--o1:#4eff96;--o2:#ff10f0;--pfg:#4eff96;--pmut:#2fb877;--tile:rgba(5,13,16,.8);--tbd:rgba(78,255,150,.34);--ic:#4eff96;--hi:#ff10f0;--glow:rgba(78,255,150,.18);--err:#ff8da1;--errbg:#2a0a14}
*{box-sizing:border-box;margin:0}
html,body{min-height:100%}
body{background:var(--frame);color:var(--rfg);font-family:system-ui,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
body.fade,body.fade *{transition:background-color .5s,color .5s,border-color .5s!important}
.mono{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace}
.defs{position:absolute;width:0;height:0;overflow:hidden}
.defs .hi{stroke:var(--hi)}.defs .hf{fill:var(--hi);stroke:none}
.frame{min-height:100vh;display:grid;grid-template-columns:400px minmax(0,1fr)}
/* rail */
.rail{background:var(--rail);padding:44px 48px 32px;display:flex;flex-direction:column;justify-content:space-between;gap:40px}
.brand{font-size:34px;font-weight:800;letter-spacing:-.045em;color:var(--ac);line-height:1;text-decoration:none;display:inline-block}
.signin{margin-top:clamp(40px,14vh,150px)}
.signin h2{font-size:15px;font-weight:600;margin-bottom:6px}
.lead{font-size:14px;line-height:1.55;color:var(--rmut)}
.dest{display:none;margin-top:16px;padding:9px 12px;border:1px dashed var(--rbd);border-radius:10px;font-size:12.5px;color:var(--rmut);overflow-wrap:anywhere}
.dest.on{display:block}.dest span{color:var(--rfg)}
.alert{display:none;margin-top:16px;padding:12px 14px;border:1px solid currentColor;border-radius:12px;background:var(--errbg);color:var(--err);font-size:14px;line-height:1.45}
.alert.on{display:block}
.btn{margin-top:22px;width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:15px 22px;border:0;border-radius:999px;background:var(--ac);color:var(--on);font:700 15px system-ui,sans-serif;text-decoration:none;cursor:pointer;transition:transform .15s,box-shadow .2s,filter .2s}
.btn:hover{transform:translateY(-2px);box-shadow:0 12px 28px -10px var(--ac);filter:brightness(1.06)}
.btn:active{transform:none}
.btn:focus-visible,.tg button:focus-visible{outline:3px solid var(--rfg);outline-offset:3px}
.btn[aria-busy=true]{pointer-events:none;filter:saturate(.6)}
.sp{display:none;width:15px;height:15px;border:2.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
.btn[aria-busy=true] .sp{display:block}.btn[aria-busy=true] .sh{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
.sso{margin-top:14px;font-size:12.5px;color:var(--rmut);text-align:center}
.foot{display:grid;gap:16px}
.tg{display:inline-flex;width:max-content;gap:2px;padding:3px;border:1px solid var(--rbd);border-radius:999px}
.tg button{width:30px;height:30px;border:0;border-radius:999px;background:none;color:var(--rmut);cursor:pointer;display:grid;place-items:center;transition:.25s}
.tg button:hover{color:var(--rfg)}
.tg button.on{background:var(--ac);color:var(--on)}
.legal{font-size:11.5px;line-height:1.55;color:var(--rmut)}
/* panel */
.dot{position:absolute;border-radius:50%;filter:blur(70px);opacity:.24;animation:dr 16s ease-in-out infinite;pointer-events:none}
.d1{width:460px;height:460px;top:-8%;left:-120px;background:var(--o1)}
.d2{width:540px;height:540px;bottom:-22%;right:-160px;background:var(--o2);animation-delay:-5s}
@keyframes dr{33%{transform:translate(60px,-40px) scale(1.1)}66%{transform:translate(-30px,50px) scale(.95)}}
.panel{position:relative;margin:20px;border-radius:28px;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:56px 40px;
background:linear-gradient(var(--grid) 1px,transparent 1px) center/56px 56px,linear-gradient(90deg,var(--grid) 1px,transparent 1px) center/56px 56px,var(--pbg);color:var(--pfg);border:1px solid var(--tbd)}
.pglow{position:absolute;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,var(--glow),transparent 62%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}
.inner{position:relative;width:100%;max-width:960px;text-align:center}
h1{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;font-weight:500;font-size:clamp(2.2rem,4.6vw,4rem);line-height:1.08;letter-spacing:-.02em;max-width:15em;margin:0 auto 48px;text-wrap:balance}
h1::after{content:"";display:inline-block;width:.08em;height:.85em;margin-left:.12em;background:var(--hi);vertical-align:-.05em;animation:bl 1.1s steps(1) infinite}
@keyframes bl{50%{opacity:0}}
.tiles{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px;list-style:none;padding:0}
.tile{padding:22px 8px 18px;border-radius:18px;background:var(--tile);border:1px solid var(--tbd);display:grid;align-content:start;justify-items:center;gap:12px;opacity:0;animation:up .6s cubic-bezier(.2,.7,.2,1) forwards;transition:transform .25s,border-color .25s,box-shadow .25s}
.tile:hover{transform:translateY(-5px);border-color:var(--hi);box-shadow:0 18px 36px -18px rgba(0,0,0,.5)}
.tile:nth-child(2){animation-delay:.06s}.tile:nth-child(3){animation-delay:.12s}.tile:nth-child(4){animation-delay:.18s}.tile:nth-child(5){animation-delay:.24s}.tile:nth-child(6){animation-delay:.3s}
@keyframes up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
.ic{width:52px;height:52px;color:var(--ic)}
.tile b{display:block;font-size:14px;font-weight:650;line-height:1.25}
.tile small{display:block;margin-top:3px;font-size:11.5px;color:var(--pmut)}
.tag{margin-top:40px;font-size:16px;color:var(--pmut)}
@media (max-width:980px){.tiles{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (max-width:900px){
.frame{grid-template-columns:1fr}
.rail{padding:28px 24px 24px;gap:26px}.signin{margin-top:22px}
.foot{order:3}
.panel{margin:0 14px 14px;padding:40px 18px;border-radius:22px}
h1{margin-bottom:30px}.tag{display:none}
}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.tile{opacity:1}.pglow{display:none}}
</style>
</head>
<body>
<svg class="defs" aria-hidden="true" focusable="false">
<symbol id="i-lambda" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 9h7l15 30"/><path class="hi" d="M24 25l-10 14"/></g></symbol>
<symbol id="i-s3" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="12" rx="15" ry="5.5"/><path d="M9 12l3.4 25c.4 2.8 5.700 5 11.600 5s11.200-2.200 11.600-5L39 12"/><path class="hi" d="M11 23c3 2.600 8.200 3.800 13 3.800S34 25.600 37 23"/></g></symbol>
<symbol id="i-sqs" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="9" width="34" height="8" rx="4"/><rect x="7" y="20" width="34" height="8" rx="4"/><rect x="7" y="31" width="34" height="8" rx="4"/><path class="hi" d="M13 24h8m-3-3l3 3-3 3"/></g></symbol>
<symbol id="i-sns" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M13 13a16 16 0 0 0 0 22M35 13a16 16 0 0 1 0 22M19 18a8 8 0 0 0 0 12M29 18a8 8 0 0 1 0 12"/><circle class="hi hf" cx="24" cy="24" r="3.6"/></g></symbol>
<symbol id="i-ddb" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="11" rx="14" ry="5.500"/><path d="M10 11v26c0 3 6.300 5.500 14 5.500S38 40 38 37V11"/><path class="hi" d="M10 24c0 3 6.300 5.500 14 5.500S38 27 38 24"/></g></symbol>
<symbol id="i-sfn" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 24h6c4 0 5-9 9-9h2M13 24h6c4 0 5 9 9 9h2"/><rect x="30" y="9" width="11" height="11" rx="3"/><rect x="30" y="28" width="11" height="11" rx="3"/><circle class="hi" cx="9" cy="24" r="4"/></g></symbol>
</svg>
<div class="frame">
<aside class="rail">
<div>
<a class="brand" href="/">TheCloud</a>
<div class="signin">
<h2>Welcome back</h2>
<p class="lead">Continue with your Authentik account to open the console.</p>
<div class="dest mono" id="dest">Landing on <span id="destPath"></span></div>
<div class="alert" id="alert" role="alert"></div>
<a class="btn" id="go" href="/api/auth/oidc/login">
<svg class="sh" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.500-3.200 8-8 9-4.800-1-8-4.500-8-9V6l8-3z"/><path d="M9 12l2 2 4-4"/></svg>
<span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span>
</a>
<p class="sso">Single sign-on. MFA happens there.</p>
</div>
</div>
<div class="foot">
<div class="tg" role="group" aria-label="Theme">
<button data-t="dark" aria-label="Dark theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21 13.500A9 9 0 1 1 10.500 3a7 7 0 0 0 10.500 10.500z"/></svg></button>
<button data-t="light" aria-label="Light theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.900 4.900l1.400 1.400M17.700 17.700l1.400 1.400M4.900 19.100l1.400-1.400M17.700 6.300l1.400-1.400"/></svg></button>
<button data-t="cyber" aria-label="Cyber theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.400" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l6 6-6 6M12 19h8"/></svg></button>
</div>
<p class="legal">TheCloud is powered by Floci and is not affiliated with Amazon Web Services.</p>
</div>
</aside>
<main class="panel" id="panel">
<div class="dot d1"></div><div class="dot d2"></div>
<div class="pglow" id="pglow"></div>
<div class="inner">
<h1>Your own cloud, on your own hardware.</h1>
<ul class="tiles">
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-lambda"/></svg><span><b>Invoke functions</b><small>Lambda</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-s3"/></svg><span><b>Store objects</b><small>S3</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sqs"/></svg><span><b>Queue messages</b><small>SQS</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sns"/></svg><span><b>Publish events</b><small>SNS</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-ddb"/></svg><span><b>Query tables</b><small>DynamoDB</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sfn"/></svg><span><b>Orchestrate workflows</b><small>Step Functions</small></span></li>
</ul>
<p class="tag">Build and test against AWS services locally, with SSO in front.</p>
</div>
</main>
</div>
<script>
(function(){
var root=document.documentElement,btns=document.querySelectorAll('.tg button');
function setTheme(t,anim){
if(anim){document.body.classList.add('fade');setTimeout(function(){document.body.classList.remove('fade')},600)}
root.setAttribute('data-theme',t);
try{localStorage.setItem('thecloud-theme',t)}catch(e){}
btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)});
}
var t0=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
try{var sv=localStorage.getItem('thecloud-theme');if(sv==='dark'||sv==='light'||sv==='cyber')t0=sv}catch(e){}
setTheme(t0,false);
btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t,true)})});
document.addEventListener('keydown',function(e){
if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
var o=['dark','light','cyber'];setTheme(o[(o.indexOf(root.getAttribute('data-theme'))+1)%3],true)}
});
var panel=document.getElementById('panel'),gl=document.getElementById('pglow');
panel.addEventListener('mousemove',function(e){var r=panel.getBoundingClientRect();gl.style.left=(e.clientX-r.left)+'px';gl.style.top=(e.clientY-r.top)+'px';gl.style.opacity='1'});
panel.addEventListener('mouseleave',function(){gl.style.opacity='0'});
var p=new URLSearchParams(location.search),go=document.getElementById('go'),label=document.getElementById('label');
var rt=p.get('returnTo')||'/';
if(!(rt.length<512&&rt.charAt(0)==='/'&&rt.charAt(1)!=='/'&&rt.charAt(1)!=='\\'&&!/[\u0000-\u001f]/.test(rt)))rt='/';
go.href='/api/auth/oidc/login?returnTo='+encodeURIComponent(rt);
if(rt!=='/'){document.getElementById('destPath').textContent=rt;document.getElementById('dest').className='dest mono on'}
var msgs={access_denied:'Your account is not allowed to use this console. Ask the admin to grant access in Authentik.',session_expired:'Your session ended. Sign in again to continue.',state_mismatch:'The sign-in request expired. Start again.',invalid_state:'The sign-in request expired. Start again.',oidc_failed:'Authentik could not complete the sign-in. Try again in a moment.'};
var er=p.get('error');
if(er){var a=document.getElementById('alert');a.textContent=Object.prototype.hasOwnProperty.call(msgs,er)?msgs[er]:'Sign-in did not complete. Try again.';a.className='alert on'}
go.addEventListener('click',function(ev){
if(go.getAttribute('aria-busy')==='true'){ev.preventDefault();return}
go.setAttribute('aria-busy','true');label.textContent='Redirecting to Authentik';
});
addEventListener('pageshow',function(){go.removeAttribute('aria-busy');label.textContent='Sign in with Authentik'});
})();
</script>
</body>
</html>
+4
View File
@@ -11,13 +11,16 @@
},
"dependencies": {
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^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 +38,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",
+114
View File
@@ -0,0 +1,114 @@
(() => {
// 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;
function setTheme(theme) {
root.setAttribute('data-theme', theme);
try {
localStorage.setItem(THEME_KEY, theme);
} catch (ignored) {
// Theme persistence is optional when browser storage is unavailable.
}
buttons.forEach((button) => button.classList.toggle('on', button.dataset.t === theme));
}
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
try {
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
initialTheme = savedTheme;
}
} catch (ignored) {
// The OS preference remains the fallback when browser storage is unavailable.
}
setTheme(initialTheme);
buttons.forEach((button) => button.addEventListener('click', () => setTheme(button.dataset.t)));
document.addEventListener('keydown', (event) => {
if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey && !event.altKey && !/INPUT|TEXTAREA/.test(event.target.tagName)) {
const themes = ['dark', 'light', 'cyber'];
setTheme(themes[(themes.indexOf(root.getAttribute('data-theme')) + 1) % themes.length]);
}
});
const lines = ['hello, engineer.', 'aws s3 mb s3://my-bucket', 'your own AWS, on your own hardware.', 'sfn start-execution --local', 'welcome to TheCloud.'];
const typewriter = document.getElementById('tw');
let line = 0;
let count = 0;
let deleting = false;
if (reducedMotion) {
typewriter.textContent = lines[2];
} else {
function type() {
const word = lines[line];
if (deleting) {
count--;
typewriter.textContent = word.slice(0, count);
if (count === 0) {
deleting = false;
line = (line + 1) % lines.length;
setTimeout(type, 400);
return;
}
setTimeout(type, 35);
return;
}
count++;
typewriter.textContent = word.slice(0, count);
if (count === word.length) {
deleting = true;
setTimeout(type, 2200);
return;
}
setTimeout(type, 75 + Math.random() * 50);
}
type();
}
const hero = document.getElementById('top');
const glow = document.getElementById('grid-glow');
if (!reducedMotion) {
hero.addEventListener('mousemove', (event) => {
const rect = hero.getBoundingClientRect();
glow.style.left = `${event.clientX - rect.left}px`;
glow.style.top = `${event.clientY - rect.top}px`;
glow.style.opacity = '1';
});
hero.addEventListener('mouseleave', () => {
glow.style.opacity = '0';
});
}
const progress = document.getElementById('bar');
addEventListener('scroll', () => {
const height = document.documentElement.scrollHeight - innerHeight;
progress.style.width = `${height > 0 ? scrollY / height * 100 : 0}%`;
}, {passive: true});
const form = document.querySelector('[data-sso-form]');
if (form) {
form.addEventListener('submit', () => {
const button = form.querySelector('.btn');
button.classList.add('is-loading');
button.disabled = true;
document.getElementById('label').textContent = 'Redirecting...';
});
}
const copy = document.getElementById('cp');
if (copy) {
copy.addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById('code').innerText).then(() => {
copy.textContent = 'copied';
copy.classList.add('ok');
setTimeout(() => {
copy.textContent = 'copy';
copy.classList.remove('ok');
}, 1800);
});
});
}
})();
@@ -18,6 +18,7 @@ import {
type APIGatewayClient,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
@@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
} as unknown as LambdaClient
}
/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */
const noHttpApis = {
send: async (command: unknown) => {
if (command instanceof GetApisCommand) return {Items: []}
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
},
} as unknown as ApiGatewayV2Client
function restOnlyAdapter(client: APIGatewayClient) {
return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis)
}
const orders = {
id: 'a1b2c3',
name: 'orders-api',
@@ -44,7 +57,7 @@ const orders = {
describe('AwsApiGatewayAdapter', () => {
test('lists every page and maps REST APIs to normalized resources', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
const adapter = restOnlyAdapter(fakeClient((command) => {
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
if (!command.input.position) return {items: [orders], position: 'next'}
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
@@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('filters APIs by search term', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
const adapter = restOnlyAdapter(fakeClient(() => ({
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
})))
@@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('returns null when API Gateway reports a missing REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
const adapter = restOnlyAdapter(fakeClient(() => {
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
}))
@@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('deletes the requested REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
const adapter = restOnlyAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteRestApiCommand)
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
return {}
@@ -22,10 +22,13 @@ import {
type RestApi,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
import {ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
@@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
private readonly lambda: LambdaClient = defaultLambda,
private readonly accountId = defaultAccountId(),
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
) {}
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
) {
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
}
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
private readonly httpApis: AwsHttpApiOperations
schema(): ServiceSchema {
return awsApiGatewaySchema()
@@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
position = res.position
} while (position)
return filterBySearch(apis.map(toResource), query.search)
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
}
/** A runtime without API Gateway v2 still lists its REST APIs. */
private async listHttpApis(): Promise<CloudResource[]> {
try {
return await this.httpApis.list()
} catch (error) {
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
throw error
}
}
async get(id: string): Promise<CloudResource | null> {
@@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
return toResource(res)
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
if (!hasHttpStatus(error, 404)) throw error
}
return this.httpApis.get(id)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const protocol = stringValue(input.values.protocol) || 'REST'
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
const name = stringValue(input.values.name)
const description = stringValue(input.values.description)
if (!name) throw new ValidationError('name is required')
@@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
async delete(id: string): Promise<void> {
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
}
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
return this.httpApis.describe(apiId)
}
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
return this.httpApis.createRoute(apiId, input)
}
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
return this.httpApis.deleteRoute(apiId, routeId)
}
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
}
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
return this.httpApis.createStage(apiId, input)
}
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
return this.httpApis.deleteStage(apiId, stageName)
}
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
return this.httpApis.deploy(apiId, stageName)
}
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
return this.httpApis.createAuthorizer(apiId, input)
}
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
}
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
return this.httpApis.updateCors(apiId, cors)
}
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
return this.httpApis.testWebSocket(apiId, input)
}
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
@@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string {
function stageName(stage: string): string {
const value = stringValue(stage)
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
}
return value
@@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource {
metadata: {
provider: 'aws',
apigatewayService: 'rest',
protocol: 'REST',
description: api.description,
version: api.version,
endpointTypes: api.endpointConfiguration?.types,
@@ -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: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,301 @@
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` on the runtime's
* port. Browsers and curl resolve every `.localhost` name to the machine itself (RFC 6761),
* so no DNS or hosts-file entry is needed.
*/
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:${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 ?? ''
}
@@ -0,0 +1,326 @@
import {afterEach, describe, expect, test} from 'bun:test'
import {
CreateApiCommand,
CreateAuthorizerCommand,
CreateDeploymentCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateRouteResponseCommand,
CreateStageCommand,
DeleteApiCommand,
DeleteCorsConfigurationCommand,
DeleteIntegrationCommand,
DeleteRouteCommand,
GetApiCommand,
GetApisCommand,
GetAuthorizersCommand,
GetIntegrationsCommand,
GetRoutesCommand,
GetStageCommand,
GetStagesCommand,
UpdateApiCommand,
UpdateRouteCommand,
type ApiGatewayV2Client,
} from '@aws-sdk/client-apigatewayv2'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
import {ValidationError} from '../cloud-spi/errors'
const CREATED = new Date('2026-10-25T10:00:00Z')
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
interface Runtime {
apis?: Record<string, unknown>
routes?: unknown[]
integrations?: unknown[]
stages?: unknown[]
authorizers?: unknown[]
reply?: (command: object) => unknown
}
function stubRuntime(runtime: Runtime = {}) {
const sent: object[] = []
const lambdaSent: object[] = []
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
const client = {
async send(command: object) {
sent.push(command)
const custom = runtime.reply?.(command)
if (custom !== undefined) return custom
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
if (command instanceof GetApiCommand) {
const api = apis[command.input.ApiId!]
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
return api
}
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
if (command instanceof CreateStageCommand) return {...command.input}
return {}
},
} as unknown as ApiGatewayV2Client
const lambda = {
async send(command: object) {
lambdaSent.push(command)
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
return {}
},
} as unknown as LambdaClient
const discoveries: string[] = []
const fetchJson = async (url: string) => {
discoveries.push(url)
return {issuer: 'http://floci:4566/us-east-1_pool1'}
}
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
return {operations, sent, of, lambdaOf, discoveries}
}
describe('AwsHttpApiOperations APIs', () => {
test('lists HTTP and WebSocket APIs with their protocol', async () => {
const {operations} = stubRuntime()
const apis = await operations.list()
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
['h1', 'http-api', 'HTTP', 'http'],
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
])
expect(await operations.get('rest-id')).toBeNull()
})
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
const {operations, of, lambdaOf} = stubRuntime()
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
expect(of(CreateStageCommand)).toHaveLength(0)
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
})
})
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
const {operations, of} = stubRuntime()
await operations.create({name: 'empty'}, 'HTTP')
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
})
test('creates a WebSocket API with its route selection expression and a stage', async () => {
const {operations, of} = stubRuntime()
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
})
test('deletes an API and the Lambda permissions it granted', async () => {
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
await operations.delete('h1')
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
})
})
describe('AwsHttpApiOperations routes', () => {
test('describes routes with their integration, stages, authorizers and CORS', async () => {
const {operations} = stubRuntime({
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
routes: [
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
],
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
stages: [{StageName: '$default', AutoDeploy: true}],
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
})
const overview = await operations.describe('h1')
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
['GET /notes', 'notes', null],
['POST /notes', 'notes', 'a1'],
])
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
})
test('adds a Lambda route, reusing the integration the function already has', async () => {
const {operations, of, lambdaOf} = stubRuntime({
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
})
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
expect(of(CreateIntegrationCommand)).toHaveLength(0)
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
expect(route.integration?.functionName).toBe('notes')
})
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
const {operations, of} = stubRuntime()
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
})
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
const {operations, of} = stubRuntime()
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
})
test('checks a route before creating anything', async () => {
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
const before = sent.length
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
})
test('deleting a route removes the integration and permission only it used', async () => {
const integrations = [
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
]
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
await shared.operations.deleteRoute('h1', 'r1')
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
await last.operations.deleteRoute('h1', 'r1')
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
})
test('protects a route with an authorizer and removes it again', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
])
})
})
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
test('a stage without auto-deploy is deployed once when created', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
})
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
})
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
})
test('will not delete an authorizer that still protects a route', async () => {
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
})
test('sets and removes the CORS configuration', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
expect(await operations.updateCors('h1', null)).toBeNull()
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
})
})
describe('AwsHttpApiOperations WebSocket test', () => {
const endpoint = process.env.FLOCI_ENDPOINT
afterEach(() => {
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
else process.env.FLOCI_ENDPOINT = endpoint
})
test('connects to the stage, sends the message and collects the replies', async () => {
const paths: string[] = []
const server = Bun.serve({
port: 0,
fetch(request, upgrade) {
paths.push(new URL(request.url).pathname + new URL(request.url).search)
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
},
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
})
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
try {
const {operations} = stubRuntime()
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
} finally {
server.stop(true)
}
})
test('reports a refused connection', async () => {
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
const {operations} = stubRuntime()
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
expect(result.connected).toBe(false)
expect(result.error).toBeTruthy()
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
})
})
describe('route keys', () => {
test('HTTP route keys are $default or a method and a path', () => {
expect(httpRouteKey('$default')).toBe('$default')
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
expect(httpRouteKey('GET /')).toBe('GET /')
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
}
})
test('WebSocket route keys are the system routes or an action name', () => {
expect(webSocketRouteKey('$connect')).toBe('$connect')
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
})
})
@@ -0,0 +1,592 @@
import {
CreateApiCommand,
CreateAuthorizerCommand,
CreateDeploymentCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateRouteResponseCommand,
CreateStageCommand,
DeleteApiCommand,
DeleteAuthorizerCommand,
DeleteCorsConfigurationCommand,
DeleteIntegrationCommand,
DeleteRouteCommand,
DeleteStageCommand,
GetApiCommand,
GetApisCommand,
GetAuthorizersCommand,
GetIntegrationsCommand,
GetRoutesCommand,
GetStageCommand,
GetStagesCommand,
UpdateApiCommand,
UpdateRouteCommand,
type Api,
type ApiGatewayV2Client,
type Authorizer,
type Cors,
type Integration,
type Route,
type Stage,
} from '@aws-sdk/client-apigatewayv2'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import type {
CloudResource,
HttpApiAuthorizerInput,
HttpApiAuthorizerOverview,
HttpApiCors,
HttpApiIntegrationOverview,
HttpApiOverview,
HttpApiProtocol,
HttpApiRouteInput,
HttpApiRouteOverview,
HttpApiStageInput,
HttpApiStageOverview,
HttpApiWebSocketTestInput,
HttpApiWebSocketTestResult,
} from '../cloud-spi/types'
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
/**
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
*/
export class AwsHttpApiOperations {
constructor(
private readonly client: ApiGatewayV2Client,
private readonly lambda: LambdaClient,
private readonly accountId: string,
private readonly region: string,
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
) {}
async list(): Promise<CloudResource[]> {
const apis: Api[] = []
let next: string | undefined
do {
const page = await this.client.send(new GetApisCommand({NextToken: next}))
apis.push(...(page.Items ?? []))
next = page.NextToken
} while (next)
return apis.map(toResource)
}
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
async get(apiId: string): Promise<CloudResource | null> {
const api = await this.findApi(apiId)
return api ? toResource(api) : null
}
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
const name = stringValue(values.name)
if (!name) throw new ValidationError('name is required')
const description = stringValue(values.description) || undefined
if (protocol === 'WEBSOCKET') {
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
}
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
return toResource(api)
}
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
const functionName = stringValue(values.functionName)
if (functionName) {
const functionArn = await this.functionArn(functionName)
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
await this.allowInvoke(api.ApiId!, functionName)
return toResource(api)
}
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
return toResource(api)
}
async delete(apiId: string): Promise<void> {
const {integrations} = await this.describeParts(apiId)
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
await this.removeInvokePermission(apiId, functionName)
}
}
async describe(apiId: string): Promise<HttpApiOverview> {
const api = await this.requireApi(apiId)
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
const protocol = protocolOf(api)
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
return {
apiId,
protocol,
apiEndpoint: api.ApiEndpoint ?? null,
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
}
}
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
const api = await this.requireApi(apiId)
const protocol = protocolOf(api)
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
const functionName = stringValue(input.functionName)
const httpUrl = stringValue(input.httpUrl)
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
const {routes, integrations} = await this.describeParts(apiId)
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
const authorizerId = stringValue(input.authorizerId)
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
let integration: Partial<Integration> | undefined
if (functionName) {
const functionArn = await this.functionArn(functionName)
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
?? await this.client.send(new CreateIntegrationCommand({
ApiId: apiId,
IntegrationType: 'AWS_PROXY',
IntegrationUri: uri,
IntegrationMethod: 'POST',
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
}))
await this.allowInvoke(apiId, functionName)
} else {
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
?? await this.client.send(new CreateIntegrationCommand({
ApiId: apiId,
IntegrationType: 'HTTP_PROXY',
IntegrationUri: httpUrl,
IntegrationMethod: method,
PayloadFormatVersion: '1.0',
}))
}
const route = await this.client.send(new CreateRouteCommand({
ApiId: apiId,
RouteKey: routeKey,
Target: `integrations/${integration.IntegrationId}`,
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
AuthorizerId: authorizerId || undefined,
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
}))
// A WebSocket route sends its integration's response back only once it has a route response.
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
}
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
}
async deleteRoute(apiId: string, routeId: string): Promise<void> {
const {routes, integrations} = await this.describeParts(apiId)
const route = routes.find((candidate) => candidate.RouteId === routeId)
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
const integrationId = targetIntegrationId(route.Target)
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
if (!integration || stillUsed) return
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
const functionName = integrationFunctionName(integration)
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
}
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
const api = await this.requireApi(apiId)
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
const route = await this.client.send(new UpdateRouteCommand({
ApiId: apiId,
RouteId: routeId,
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
AuthorizerId: authorizerId ?? undefined,
}))
const {integrations} = await this.describeParts(apiId)
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
}
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
const api = await this.requireApi(apiId)
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
// A stage without auto-deploy serves nothing until its first deployment.
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
return toStageOverview(apiId, protocolOf(api), stage)
}
async deleteStage(apiId: string, stageName: string): Promise<void> {
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
}
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
const api = await this.requireApi(apiId)
const name = stageNameValue(stageName)
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
return toStageOverview(apiId, protocolOf(api), stage)
}
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
const api = await this.requireApi(apiId)
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
const name = stringValue(input.name)
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
let issuer = stringValue(input.issuer)
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
const userPoolId = stringValue(input.userPoolId)
if (userPoolId) {
const clientId = stringValue(input.userPoolClientId)
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
issuer = await this.userPoolIssuer(userPoolId)
audience = [clientId]
}
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
const authorizer = await this.client.send(new CreateAuthorizerCommand({
ApiId: apiId,
Name: name,
AuthorizerType: 'JWT',
IdentitySource: ['$request.header.Authorization'],
JwtConfiguration: {Issuer: issuer, Audience: audience},
}))
return toAuthorizerOverview(authorizer)
}
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
const {routes} = await this.describeParts(apiId)
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
}
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
const api = await this.requireApi(apiId)
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
if (!cors) {
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
return null
}
const configuration = toCors(cors)
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
return fromCors(updated.CorsConfiguration)
}
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
const api = await this.requireApi(apiId)
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
const stage = stageNameValue(input.stage)
const query = stringValue(input.query).replace(/^\?/, '')
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
return exchangeWebSocketMessage(url, input.message, timeoutMs)
}
private async describeParts(apiId: string) {
const [routes, integrations, stages, authorizers] = await Promise.all([
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
])
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
}
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
const items: T[] = []
let next: string | undefined
do {
const page = await fetchPage(next)
items.push(...(page.Items ?? []))
next = page.NextToken
} while (next)
return items
}
private async findApi(apiId: string): Promise<Partial<Api> | null> {
try {
return await this.client.send(new GetApiCommand({ApiId: apiId}))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
private async requireApi(apiId: string): Promise<Partial<Api>> {
const api = await this.findApi(apiId)
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
return api
}
private async functionArn(functionName: string): Promise<string> {
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
const arn = result.Configuration?.FunctionArn
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
return arn
}
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
private async allowInvoke(apiId: string, functionName: string) {
try {
await this.lambda.send(new AddPermissionCommand({
FunctionName: functionName,
StatementId: invokeStatementId(apiId),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
}))
} catch (error) {
if (!hasHttpStatus(error, 409)) throw error
}
}
private async removeInvokePermission(apiId: string, functionName: string) {
try {
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
} catch (error) {
if (!hasHttpStatus(error, 404)) throw error
}
}
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
private async userPoolIssuer(userPoolId: string): Promise<string> {
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
return issuer
}
}
async function defaultFetchJson(url: string): Promise<unknown> {
const response = await globalThis.fetch(url)
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
return response.json()
}
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
const startedAt = performance.now()
return new Promise((resolve) => {
const messages: string[] = []
let connected = false
let settled = false
let socket: WebSocket
const finish = (closeCode: number | null, error: string | null) => {
if (settled) return
settled = true
clearTimeout(timer)
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
}
// After the first reply, wait briefly for any more before hanging up.
let quiet: ReturnType<typeof setTimeout> | undefined
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
try {
socket = new WebSocket(url)
} catch (error) {
finish(null, error instanceof Error ? error.message : 'Could not connect')
return
}
socket.onopen = () => {
connected = true
if (message) socket.send(message)
else finish(null, null)
}
socket.onmessage = (event) => {
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
clearTimeout(quiet)
quiet = setTimeout(() => finish(null, null), 500)
}
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
})
}
function toResource(api: Partial<Api>): CloudResource {
const protocol = protocolOf(api)
return {
id: api.ApiId ?? '',
name: api.Name ?? '',
cloud: 'aws',
service: 'apigateway',
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
region: null,
createdAt: api.CreatedDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
protocol,
description: api.Description,
apiEndpoint: api.ApiEndpoint,
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
},
}
}
function protocolOf(api: Partial<Api>): HttpApiProtocol {
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
}
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
const integration = integrations.get(targetIntegrationId(route.Target))
return {
routeId: route.RouteId ?? '',
routeKey: route.RouteKey ?? '',
integration: integration ? toIntegrationOverview(integration) : null,
authorizationType: route.AuthorizationType ?? 'NONE',
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
authorizationScopes: route.AuthorizationScopes ?? [],
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
}
}
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
return {
integrationId: integration.IntegrationId ?? '',
type: integration.IntegrationType ?? '',
uri: integration.IntegrationUri ?? null,
functionName: integrationFunctionName(integration),
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
}
}
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
const stageName = stage.StageName ?? ''
return {
stageName,
autoDeploy: Boolean(stage.AutoDeploy),
deploymentId: stage.DeploymentId ?? null,
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
}
}
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
return {
authorizerId: authorizer.AuthorizerId ?? '',
name: authorizer.Name ?? '',
type: authorizer.AuthorizerType ?? '',
identitySource: authorizer.IdentitySource ?? [],
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
audience: authorizer.JwtConfiguration?.Audience ?? [],
}
}
function fromCors(cors: Cors | undefined): HttpApiCors | null {
if (!cors) return null
return {
allowOrigins: cors.AllowOrigins ?? [],
allowMethods: cors.AllowMethods ?? [],
allowHeaders: cors.AllowHeaders ?? [],
exposeHeaders: cors.ExposeHeaders ?? [],
maxAgeSeconds: cors.MaxAge ?? null,
allowCredentials: Boolean(cors.AllowCredentials),
}
}
function toCors(cors: HttpApiCors): Cors {
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
const allowOrigins = clean(cors.allowOrigins)
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
}
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
const maxAge = cors.maxAgeSeconds
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
}
return {
AllowOrigins: allowOrigins,
AllowMethods: allowMethods,
AllowHeaders: clean(cors.allowHeaders),
ExposeHeaders: clean(cors.exposeHeaders),
MaxAge: maxAge ?? undefined,
AllowCredentials: Boolean(cors.allowCredentials),
}
}
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
export function httpRouteKey(value: string): string {
const key = stringValue(value).replace(/\s+/g, ' ')
if (key === '$default') return key
const [method, path, ...rest] = key.split(' ')
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
}
const segments = path === '/' ? [] : path.split('/').slice(1)
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
}
return `${method.toUpperCase()} ${path}`
}
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
export function webSocketRouteKey(value: string): string {
const key = stringValue(value)
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
}
return key
}
function stageNameValue(value: string): string {
const name = stringValue(value)
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
}
return name
}
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
}
function lambdaInvocationUri(region: string, functionArn: string): string {
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
}
function integrationFunctionName(integration: Partial<Integration>): string | null {
if (integration.IntegrationType !== 'AWS_PROXY') return null
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
}
function targetIntegrationId(target: string | undefined): string | undefined {
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
}
function invokeStatementId(apiId: string): string {
return `apigatewayv2-${apiId}`
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
}
@@ -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() {
@@ -60,9 +62,9 @@ describe('AwsLightsailAdapter', () => {
test('merges diagnostic host-port mappings into UI metadata', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetchMock = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
globalThis.fetch = (async () => new Response(JSON.stringify({mappings: [
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
]}))
]}))) as unknown as typeof fetch
try {
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
expect(resource?.metadata.hostPortMappings).toEqual([
@@ -135,7 +135,7 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
limit: input.limit,
}),
)
if (!queryId) throw new NotFoundError(`Floci did not return a query id for ${logGroupName}.`)
if (!queryId) throw new NotFoundError(`The runtime did not return a query id for ${logGroupName}.`)
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
@@ -0,0 +1,147 @@
import {describe, expect, test} from 'bun:test'
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
} from '@aws-sdk/client-cloudwatch'
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as CloudWatchClient
return {adapter: new AwsMonitoringAdapter(client), sent}
}
const UPDATED = new Date('2026-10-25T10:00:00Z')
const metricAlarm = {
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
}
const compositeAlarm = {
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
}
describe('AwsMonitoringAdapter alarms', () => {
test('lists metric and composite alarms with a readable condition', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
: {})
const alarms = await adapter.list()
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
})
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
await adapter.create({values: {
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
}})
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
})
})
test('creates a composite alarm from its rule', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
expect(created.metadata.alarmType).toBe('composite')
})
test('checks the alarm before creating it', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
})
test('deletes, sets the state and toggles the actions of an alarm', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
await adapter.delete('high-errors')
await adapter.setAlarmState('high-errors', 'ALARM', '')
await adapter.setAlarmActionsEnabled('high-errors', false)
await adapter.setAlarmActionsEnabled('high-errors', true)
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
})
test('reads the alarm history newest first', async () => {
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
})
})
describe('AwsMonitoringAdapter metrics', () => {
test('lists metrics sorted by namespace and filters them', async () => {
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
? {Metrics: [
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
]}
: {})
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
})
test('charts a metric with a period that suits the range', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
: {})
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
})
})
@@ -0,0 +1,326 @@
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
type ComparisonOperator,
type CompositeAlarm,
type MetricAlarm,
type Statistic,
} from '@aws-sdk/client-cloudwatch'
import {
ALARM_COMPARISONS,
ALARM_NAME_PATTERN,
ALARM_PERIODS,
ALARM_STATES,
ALARM_STATISTICS,
ALARM_TYPES,
awsMonitoringSchema,
} from '../cloud-spi/monitoringSchema'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {
AlarmHistoryItem,
AlarmStateValue,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
MetricDimension,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** CloudWatch's page and request limits. */
const ALARM_PAGE_LIMIT = 100
const METRIC_LIST_CAP = 500
const HISTORY_LIMIT = 25
const MAX_DIMENSIONS = 30
const MAX_RANGE_MINUTES = 14 * 24 * 60
const SYMBOL: Record<string, string> = {
GreaterThanThreshold: '>',
GreaterThanOrEqualToThreshold: '>=',
LessThanThreshold: '<',
LessThanOrEqualToThreshold: '<=',
}
/**
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
* metrics are browsed and charted through the monitoring actions.
*
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
* are returned by `GetMetricData` straight away.
*/
export class AwsMonitoringAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'monitoring' as const
constructor(private readonly cloudwatch: CloudWatchClient) {}
schema(): ServiceSchema {
return awsMonitoringSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const metricAlarms: MetricAlarm[] = []
const compositeAlarms: CompositeAlarm[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
MaxRecords: ALARM_PAGE_LIMIT,
...(nextToken ? {NextToken: nextToken} : {}),
}))
metricAlarms.push(...(res.MetricAlarms ?? []))
compositeAlarms.push(...(res.CompositeAlarms ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
const search = query.search?.trim().toLowerCase()
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.name.localeCompare(b.name))
}
async get(name: string): Promise<CloudResource | null> {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
const metric = res.MetricAlarms?.[0]
if (metric) return metricAlarmResource(metric)
const composite = res.CompositeAlarms?.[0]
return composite ? compositeAlarmResource(composite) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
const name = text(values.name)
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
const description = text(values.description) || undefined
const topicArn = text(values.topicArn)
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
}
const actions = topicArn ? [topicArn] : []
if (type === 'composite') {
const rule = text(values.alarmRule)
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
await this.cloudwatch.send(new PutCompositeAlarmCommand({
AlarmName: name,
AlarmDescription: description,
AlarmRule: rule,
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
} else {
const namespace = text(values.namespace)
const metricName = text(values.metricName)
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
const threshold = Number(text(values.threshold))
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
await this.cloudwatch.send(new PutMetricAlarmCommand({
AlarmName: name,
AlarmDescription: description,
Namespace: namespace,
MetricName: metricName,
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
EvaluationPeriods: evaluationPeriods,
Threshold: threshold,
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
TreatMissingData: 'missing',
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
}
const created = await this.get(name)
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
return created
}
async delete(name: string): Promise<void> {
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
}
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
const metrics: MetricSummary[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new ListMetricsCommand({
...(query.namespace ? {Namespace: query.namespace} : {}),
...(nextToken ? {NextToken: nextToken} : {}),
}))
for (const metric of res.Metrics ?? []) {
metrics.push({
namespace: metric.Namespace ?? '',
metricName: metric.MetricName ?? '',
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
})
}
nextToken = res.NextToken || undefined
} while (nextToken && metrics.length < METRIC_LIST_CAP)
const search = query.search?.trim().toLowerCase()
return metrics.slice(0, METRIC_LIST_CAP)
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
}
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
const namespace = text(query?.namespace)
const metricName = text(query.metricName)
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
const rangeMinutes = Number(query.rangeMinutes ?? 60)
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
const end = new Date()
const start = new Date(end.getTime() - rangeMinutes * 60_000)
const res = await this.cloudwatch.send(new GetMetricDataCommand({
StartTime: start,
EndTime: end,
ScanBy: 'TimestampAscending',
MetricDataQueries: [{
Id: 'm1',
ReturnData: true,
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
}],
}))
const result = res.MetricDataResults?.[0]
const timestamps = result?.Timestamps ?? []
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return {label: result?.Label || metricName, statistic, periodSeconds, points}
}
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
return (res.AlarmHistoryItems ?? []).map((item) => ({
timestamp: item.Timestamp?.toISOString() ?? null,
type: item.HistoryItemType ?? '',
summary: item.HistorySummary ?? '',
}))
}
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
const value = oneOf(state, ALARM_STATES, 'state')
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
}
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
}
}
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
const period = alarm.Period ?? 0
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'metric',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
namespace: alarm.Namespace ?? null,
metricName: alarm.MetricName ?? null,
dimensions,
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
periodSeconds: period || null,
threshold: alarm.Threshold ?? null,
comparison: alarm.ComparisonOperator ?? null,
evaluationPeriods: alarm.EvaluationPeriods ?? null,
condition: alarm.MetricName
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
: 'Metric math',
},
}
}
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'composite',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
alarmRule: alarm.AlarmRule ?? null,
condition: alarm.AlarmRule ?? null,
},
}
}
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
return {
id: name,
name,
cloud: 'aws' as const,
service: 'monitoring' as const,
type: 'alarm',
region: arn?.split(':')[3] || null,
createdAt: updated?.toISOString() ?? null,
status: state ?? null,
}
}
/** One Name=Value per line, as the create form takes it. */
function parseDimensions(value: unknown): MetricDimension[] {
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
return lines.map((line) => {
const index = line.indexOf('=')
const name = index > 0 ? line.slice(0, index).trim() : ''
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
return {name, value: dimensionValue}
})
}
function periodLabel(seconds: number): string {
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
return `${seconds} s`
}
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() : typeof value === 'number' ? String(value) : ''
}
@@ -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: [
+18 -6
View File
@@ -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)
}
@@ -62,7 +62,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES')
throw new NotSupportedError('Deleting individual captured emails is not supported by TheCloud SES')
}
async clearEmailInbox(): Promise<void> {
@@ -76,10 +76,10 @@ export class AwsSesAdapter implements CloudServiceAdapter {
try {
body = await response.json() as FlociSesInbox
} catch (error) {
throw new RuntimeError('Floci SES inspection returned invalid JSON', {cause: error})
throw new RuntimeError('TheCloud SES inspection returned invalid JSON', {cause: error})
}
if (!Array.isArray(body.messages)) {
throw new RuntimeError('Floci SES inspection response did not contain messages')
throw new RuntimeError('TheCloud SES inspection response did not contain messages')
}
return body.messages
}
@@ -89,14 +89,14 @@ export class AwsSesAdapter implements CloudServiceAdapter {
try {
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
} catch (error) {
throw new RuntimeUnavailableError(`Cannot reach Floci SES at ${this.endpoint}`, {cause: error})
throw new RuntimeUnavailableError(`Cannot reach TheCloud SES at ${this.endpoint}`, {cause: error})
}
if (response.ok) return response
const detail = await response.text().catch(() => '')
throw httpStatusToCloudError(
response.status,
detail || `Floci SES inspection returned HTTP ${response.status}`,
detail || `TheCloud SES inspection returned HTTP ${response.status}`,
)
}
}
@@ -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([
@@ -106,7 +106,7 @@ export class AzureAksAdapter implements CloudServiceAdapter {
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
const id = body?.value?.[0]?.subscriptionId
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope cluster requests to')
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope cluster requests to')
this.subscriptionId = id
return id
@@ -182,7 +182,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter {
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
const id = body?.value?.[0]?.subscriptionId
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to')
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope compute requests to')
this.subscriptionId = id
return id
@@ -49,7 +49,7 @@ export class AzureServerlessAdapter implements CloudServiceAdapter {
descriptorOverride(): CloudServiceDescriptorOverride {
return {
availability: 'coming_soon',
reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.',
reason: 'TheCloud\'s Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.',
}
}
@@ -8,5 +8,5 @@ import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors'
export class CosmosNoSqlUnavailableError extends CloudError {
readonly status: CloudErrorStatus = 502
readonly code = 'cosmos_nosql_unavailable' as const
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime'
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Azure runtime'
}
@@ -57,7 +57,7 @@ function connectionConfig(connection: SqlDataConnection): MssqlConfig {
options: {
encrypt: connection.tlsMode !== 'disable',
trustServerCertificate: connection.tlsMode !== 'verify',
appName: 'Floci UI',
appName: 'TheCloud',
},
}
}
@@ -26,7 +26,7 @@ export class PostgresDataClient implements SqlDataClient {
connectionTimeoutMillis: 10_000,
query_timeout: 30_000,
statement_timeout: 30_000,
application_name: 'Floci UI',
application_name: 'TheCloud',
})
const startedAt = performance.now()
let phase: SqlDataPhase = 'connect'
+26 -2
View File
@@ -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(() => {
@@ -106,6 +108,27 @@ describe('login throttling', () => {
})
})
describe('post-login redirect', () => {
async function redirectAfterLogin(returnTo: string) {
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
method: 'POST',
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
})
return (await response.json() as {redirectTo: string}).redirectTo
}
test('keeps same-origin paths with their query and fragment', async () => {
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
})
test('refuses destinations that a browser would resolve to another origin', async () => {
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
}
})
})
describe('OIDC authorization', () => {
let privateKey: CryptoKey
let jwk: JWK
@@ -218,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)
+16 -4
View File
@@ -270,7 +270,7 @@ function loginPage(c: Context, config: AuthConfig): Response {
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
const error = loginErrorMessage(c.req.query('error'))
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>Floci DevTools</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>TheCloud</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
}
function loginFailure(c: Context, status: 401 | 403 | 429): Response {
@@ -288,9 +288,21 @@ function loginErrorMessage(code: string | undefined): string | null {
return 'We could not complete your sign-in. Please try again.'
}
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
// otherwise pass a plain prefix check and land on another origin.
function safeReturnTo(value?: string): string {
const fallback = '/console/aws'
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
const base = 'http://return-to.invalid'
try {
const url = new URL(value, base)
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
} catch {
return fallback
}
}
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) }
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
@@ -336,4 +348,4 @@ function parseTrustedProxies(value: string | undefined): BlockList | null {
}
return proxies
}
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
+13
View File
@@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
import { IAMClient } from "@aws-sdk/client-iam";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns";
@@ -30,6 +31,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";
@@ -76,6 +80,7 @@ export type AwsClients = {
iam: IAMClient;
dynamodb: DynamoDBClient;
apiGateway: APIGatewayClient;
apiGatewayV2: ApiGatewayV2Client;
cloudformation: CloudFormationClient;
appConfig: AppConfigClient;
sns: SNSClient;
@@ -91,6 +96,9 @@ export type AwsClients = {
cloudwatch: CloudWatchClient;
elasticache: ElastiCacheClient;
lightsail: LightsailClient;
cognito: CognitoIdentityProviderClient;
route53: Route53Client;
cloudfront: CloudFrontClient;
};
export type AwsClientName = keyof AwsClients;
@@ -121,6 +129,7 @@ function buildClients(accountId: string): AwsClients {
iam: new IAMClient(base),
dynamodb: new DynamoDBClient(base),
apiGateway: new APIGatewayClient(base),
apiGatewayV2: new ApiGatewayV2Client(base),
cloudformation: new CloudFormationClient(base),
appConfig: new AppConfigClient(base),
sns: new SNSClient(base),
@@ -136,6 +145,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),
};
}
@@ -172,6 +184,7 @@ export const sqs = awsClients.sqs;
export const iam = awsClients.iam;
export const dynamodb = awsClients.dynamodb;
export const apiGateway = awsClients.apiGateway;
export const apiGatewayV2 = awsClients.apiGatewayV2;
export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr;
export const kinesis = awsClients.kinesis;
+1 -1
View File
@@ -32,7 +32,7 @@ describe('AzureRestRuntimeClient', () => {
const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow(
'Cannot reach Floci-AZ at http://localhost:4577: connection refused',
"Cannot reach TheCloud's Azure runtime at http://localhost:4577: connection refused",
)
})
})
+1 -1
View File
@@ -29,7 +29,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient {
})
} catch (error) {
throw new RuntimeUnavailableError(
`Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`,
`Cannot reach TheCloud's Azure runtime at ${this.endpoint}: ${errorMessage(error)}`,
{cause: error},
)
}
+62 -5
View File
@@ -1,4 +1,4 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
@@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
]
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
]
const httpOnly = {field: 'protocol', values: ['HTTP']}
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
export function awsApiGatewaySchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'apigateway',
displayName: 'AWS API Gateway',
fields: [
{
name: 'protocol',
label: 'API type',
type: 'select',
required: true,
defaultValue: 'REST',
options: [
{label: 'REST API (resources, methods and stages)', value: 'REST'},
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
],
},
{
name: 'name',
label: 'API Name',
@@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema {
label: 'Description',
type: 'text',
required: false,
description: 'Optional description for the REST API.',
description: 'Optional description for the API.',
validation: {
maxLength: 1024,
message: 'Description must be 1024 characters or fewer.',
},
},
{
name: 'functionName',
label: 'Lambda function',
type: 'text',
required: false,
visibleWhen: httpOnly,
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
},
{
name: 'routeSelectionExpression',
label: 'Route selection expression',
type: 'text',
required: false,
defaultValue: '$request.body.action',
visibleWhen: webSocketOnly,
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
},
{
name: 'stageName',
label: 'Stage',
type: 'text',
required: false,
defaultValue: 'dev',
visibleWhen: webSocketOnly,
description: 'Created with automatic deployment, so route changes go live right away.',
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
capabilities: {resourceActions, httpApiActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'API Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
{name: 'createdAt', label: 'Created At', format: 'relative'},
],
}
}
+131
View File
@@ -0,0 +1,131 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
export const CDN_ORIGIN_TYPES = ['s3', 'custom'] as const
export const CDN_VIEWER_PROTOCOLS = ['redirect-to-https', 'allow-all', 'https-only'] as const
export const CDN_ORIGIN_PROTOCOLS = ['https-only', 'http-only', 'match-viewer'] as const
export const CDN_ALLOWED_METHODS = ['read', 'all'] as const
/** An S3 bucket name as S3 allows it for new buckets. */
export const CDN_BUCKET_PATTERN = '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$'
/** A host name, optionally with a port, as a custom origin's DomainName. */
export const CDN_ORIGIN_DOMAIN_PATTERN = '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$'
const distributionColumns: TableColumnSchema[] = [
{name: 'id', label: 'Distribution ID', format: 'code'},
{name: 'viewerUrl', label: 'Local URL', path: 'metadata.viewerUrl', format: 'code'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'enabled', label: 'Enabled', path: 'metadata.enabled', format: 'boolean'},
{name: 'origins', label: 'Origins', path: 'metadata.originDomains', format: 'list'},
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
]
const distributionFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCdnSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'cdn',
displayName: 'Amazon CloudFront distributions',
fields: [
{
name: 'originType',
label: 'Origin',
type: 'select',
required: true,
defaultValue: 's3',
options: [{label: 'S3 bucket', value: 's3'}, {label: 'Custom origin (a web server or API)', value: 'custom'}],
},
{
name: 'bucket',
label: 'S3 bucket',
type: 'text',
required: false,
requiredWhen: {field: 'originType', equals: 's3'},
visibleWhen: {field: 'originType', values: ['s3']},
validation: {pattern: CDN_BUCKET_PATTERN, message: 'Use the name of an existing bucket'},
},
{
name: 'originDomain',
label: 'Origin domain',
type: 'text',
required: false,
requiredWhen: {field: 'originType', equals: 'custom'},
visibleWhen: {field: 'originType', values: ['custom']},
description: 'For example api.example.com. A local server needs FLOCI_SERVICES_CLOUDFRONT_ALLOWED_PRIVATE_ORIGIN_HOSTS on the runtime.',
validation: {pattern: CDN_ORIGIN_DOMAIN_PATTERN, message: 'Use a host name such as api.example.com'},
},
{
name: 'originProtocol',
label: 'Origin protocol',
type: 'select',
required: false,
defaultValue: 'https-only',
visibleWhen: {field: 'originType', values: ['custom']},
options: [
{label: 'HTTPS only', value: 'https-only'},
{label: 'HTTP only', value: 'http-only'},
{label: 'Match the viewer', value: 'match-viewer'},
],
},
{
name: 'originPort',
label: 'Origin port',
type: 'text',
required: false,
visibleWhen: {field: 'originType', values: ['custom']},
description: 'Leave empty for 443 (HTTPS) and 80 (HTTP).',
validation: {pattern: '^\\d{1,5}$', message: 'Use a port number'},
},
{
name: 'defaultRootObject',
label: 'Default root object',
type: 'text',
required: false,
defaultValue: 'index.html',
description: 'What the distribution serves for a request to /.',
},
{
name: 'viewerProtocol',
label: 'Viewer protocol',
type: 'select',
required: false,
defaultValue: 'allow-all',
options: [
{label: 'HTTP and HTTPS', value: 'allow-all'},
{label: 'Redirect HTTP to HTTPS', value: 'redirect-to-https'},
{label: 'HTTPS only', value: 'https-only'},
],
},
{
name: 'allowedMethods',
label: 'Allowed methods',
type: 'select',
required: false,
defaultValue: 'read',
options: [
{label: 'GET and HEAD (a website or files)', value: 'read'},
{label: 'All methods (an API)', value: 'all'},
],
},
{name: 'comment', label: 'Description', type: 'text', required: false},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List distributions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Disable and delete distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect distribution', enabled: true, status: 'available', runtimeRequired: true},
],
distributionActions: [
{name: 'setEnabled', label: 'Enable or disable a distribution', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createInvalidation', label: 'Invalidate cached paths', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: distributionFilters,
columns: distributionColumns,
}
}
export function cdnSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsCdnSchema() : null
}
@@ -36,7 +36,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
{
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}},
description: 'For no source, this is a Floci Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
description: 'For no source, this is TheCloud\'s Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
},
{
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
+1 -1
View File
@@ -131,7 +131,7 @@ export function awsDatabaseSchema(): ServiceSchema {
label: 'Create DB snapshot',
enabled: true,
status: 'partial',
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
runtimeRequired: true,
},
],
+64
View File
@@ -0,0 +1,64 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** A domain name of dot-separated labels, with or without the trailing dot. */
export const DNS_ZONE_NAME_PATTERN = '^(?=.{1,253}\\.?$)([a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.)*[a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.?$'
/** The record types Route 53 accepts in ChangeResourceRecordSets. */
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const
const zoneColumns: TableColumnSchema[] = [
{name: 'name', label: 'Domain'},
{name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'},
{name: 'recordCount', label: 'Records', path: 'metadata.recordCount'},
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
{name: 'id', label: 'Hosted zone ID', format: 'code'},
]
const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsDnsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'dns',
displayName: 'Amazon Route 53 hosted zones',
fields: [
{
name: 'name',
label: 'Domain name',
type: 'text',
required: true,
description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.',
validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'},
},
{name: 'comment', label: 'Description', type: 'text', required: false},
{
name: 'vpcId',
label: 'VPC ID (private zone)',
type: 'text',
required: false,
description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.',
validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true},
],
itemActions: [
{name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true},
{name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: zoneFilters,
columns: zoneColumns,
}
}
export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsDnsSchema() : null
}
@@ -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'},
]
@@ -0,0 +1,132 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
export const ALARM_TYPES = ['metric', 'composite'] as const
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
export const ALARM_COMPARISONS = [
'GreaterThanThreshold',
'GreaterThanOrEqualToThreshold',
'LessThanThreshold',
'LessThanOrEqualToThreshold',
] as const
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
const alarmColumns: TableColumnSchema[] = [
{name: 'name', label: 'Alarm'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
]
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const metricOnly = {field: 'alarmType', values: ['metric']}
export function awsMonitoringSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'monitoring',
displayName: 'Amazon CloudWatch alarms',
fields: [
{
name: 'alarmType',
label: 'Alarm type',
type: 'select',
required: true,
defaultValue: 'metric',
options: [
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
],
},
{
name: 'name',
label: 'Alarm name',
type: 'text',
required: true,
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
},
{name: 'description', label: 'Description', type: 'text', required: false},
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
{
name: 'dimensions',
label: 'Dimensions',
type: 'textarea',
required: false,
visibleWhen: metricOnly,
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
},
{
name: 'statistic',
label: 'Statistic',
type: 'select',
required: false,
defaultValue: 'Average',
visibleWhen: metricOnly,
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
},
{
name: 'period',
label: 'Period',
type: 'select',
required: false,
defaultValue: '300',
visibleWhen: metricOnly,
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
},
{
name: 'comparison',
label: 'Condition',
type: 'select',
required: false,
defaultValue: 'GreaterThanThreshold',
visibleWhen: metricOnly,
options: [
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
{label: 'Less than the threshold', value: 'LessThanThreshold'},
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
],
},
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
{
name: 'alarmRule',
label: 'Rule',
type: 'textarea',
required: false,
requiredWhen: {field: 'alarmType', equals: 'composite'},
visibleWhen: {field: 'alarmType', values: ['composite']},
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
},
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
],
monitoringActions: [
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: alarmFilters,
columns: alarmColumns,
}
}
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsMonitoringSchema() : null
}
@@ -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'},
@@ -260,6 +281,13 @@ export const SERVICE_CATALOG = {
group: 'Integration',
order: 20,
},
monitoring: {
displayName: 'Monitoring',
displayNameByCloud: {aws: 'CloudWatch'},
iconKey: 'monitoring',
group: 'Observability',
order: 5,
},
logs: {
displayName: 'Logs',
displayNameByCloud: {aws: 'CloudWatch Logs'},
+309 -1
View File
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
iconKey: string
group: ServiceGroup
order: number
/**
* Whether the generic resource list serves it. False for a service still on its
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
*/
listable: boolean
}
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
@@ -77,6 +82,48 @@ export interface CloudServiceStatus {
errorCode: string | null
}
/** One service as the runtime itself reports it, which can be far more than the console covers. */
export interface EmulatedServiceStatus {
/** The runtime's own service id, e.g. `states` or `elasticloadbalancing`. */
name: string
state: string
/** Console pages backed by this service; empty when the console has none yet. */
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
}
/** A container the runtime started to back a resource, as reported by its diagnostics. */
export interface RuntimeContainer {
kind: 'ec2-instance'
resourceId: string
status: string | null
sshHost: string | null
sshPort: number | null
}
export interface RuntimeDiagnostics {
cloud: CloudProvider
checkedAt: string
runtime: {
status: RuntimeReachability
endpoint: string | null
latencyMs: number | null
error: string | null
version: string | null
edition: string | null
}
/** Null when the runtime does not publish a per-service list. */
emulatedServices: EmulatedServiceStatus[] | null
consoleServices: CloudServiceStatus[]
/** Null when the runtime does not publish container diagnostics. */
containers: RuntimeContainer[] | null
console: {
startedAt: string
uptimeSeconds: number
authMode: 'local' | 'oidc'
activeSessions: number
}
}
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
export interface FieldSchema {
@@ -129,6 +176,30 @@ export type KubernetesActionName =
| 'listFargateProfiles'
| 'createFargateProfile'
| 'deleteFargateProfile'
export type UserPoolActionName =
| 'updateUser'
| 'setUserGroups'
| 'listGroups'
| 'createGroup'
| 'deleteGroup'
| 'testSignIn'
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
| 'describe'
| 'createRoute'
| 'deleteRoute'
| 'setRouteAuthorizer'
| 'createStage'
| 'deleteStage'
| 'deploy'
| 'createAuthorizer'
| 'deleteAuthorizer'
| 'updateCors'
| 'sendRequest'
| 'testWebSocket'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -172,6 +243,11 @@ export interface ServiceSchema {
objectActions?: CapabilitySchema<ObjectActionName>[]
databaseActions?: CapabilitySchema<DatabaseActionName>[]
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
// 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.
@@ -181,6 +257,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 =
@@ -188,7 +269,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
@@ -369,6 +450,106 @@ 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 MetricDimension {
name: string
value: string
}
export interface MetricSummary {
namespace: string
metricName: string
dimensions: MetricDimension[]
}
export interface MetricSeriesQuery {
namespace: string
metricName: string
dimensions?: MetricDimension[]
statistic?: string
periodSeconds?: number
/** How far back from now, in minutes. */
rangeMinutes?: number
}
export interface MetricSeries {
label: string
statistic: string
periodSeconds: number
points: {timestamp: string; value: number}[]
}
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
export interface AlarmHistoryItem {
timestamp: string | null
type: string
summary: string
}
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
@@ -608,6 +789,107 @@ export interface ApiGatewayCorsResult {
proxyMethods: string[]
}
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
export interface HttpApiIntegrationOverview {
integrationId: string
type: string
uri: string | null
/** The Lambda function a proxy integration invokes, when it is one. */
functionName: string | null
payloadFormatVersion: string | null
}
export interface HttpApiRouteOverview {
routeId: string
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
routeKey: string
integration: HttpApiIntegrationOverview | null
authorizationType: string
authorizerId: string | null
authorizationScopes: string[]
/** WebSocket only: whether the integration's response is sent back to the client. */
returnsResponse: boolean
}
export interface HttpApiStageOverview {
stageName: string
autoDeploy: boolean
deploymentId: string | null
lastUpdatedAt: string | null
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
invokePath: string
}
export interface HttpApiAuthorizerOverview {
authorizerId: string
name: string
type: string
identitySource: string[]
issuer: string | null
audience: string[]
}
export interface HttpApiCors {
allowOrigins: string[]
allowMethods: string[]
allowHeaders: string[]
exposeHeaders: string[]
maxAgeSeconds: number | null
allowCredentials: boolean
}
export interface HttpApiOverview {
apiId: string
protocol: HttpApiProtocol
apiEndpoint: string | null
routeSelectionExpression: string | null
routes: HttpApiRouteOverview[]
stages: HttpApiStageOverview[]
authorizers: HttpApiAuthorizerOverview[]
cors: HttpApiCors | null
}
export interface HttpApiRouteInput {
routeKey: string
/** A Lambda function name; exactly one of functionName and httpUrl. */
functionName?: string
/** An HTTP backend the route proxies to; HTTP APIs only. */
httpUrl?: string
authorizerId?: string
/** WebSocket only: send the integration's response back to the client. */
returnsResponse?: boolean
}
export interface HttpApiStageInput {
stageName: string
autoDeploy: boolean
}
export interface HttpApiAuthorizerInput {
name: string
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
userPoolId?: string
userPoolClientId?: string
issuer?: string
audience?: string[]
}
export interface HttpApiWebSocketTestInput {
stage: string
message: string
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
query?: string
}
export interface HttpApiWebSocketTestResult {
connected: boolean
messages: string[]
closeCode: number | null
error: string | null
durationMs: number
}
export interface S3LambdaTriggerInput {
functionName: string
events: string[]
@@ -802,6 +1084,17 @@ export interface CloudServiceAdapter {
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
@@ -875,6 +1168,21 @@ 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>
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
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
}
+81 -2
View File
@@ -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, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,45 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile',
}
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
listMetrics: 'listMetrics',
getMetricData: 'getMetricSeries',
alarmHistory: 'describeAlarmHistory',
setAlarmState: 'setAlarmState',
setAlarmActions: 'setAlarmActionsEnabled',
}
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
describe: 'describeHttpApi',
createRoute: 'createHttpApiRoute',
deleteRoute: 'deleteHttpApiRoute',
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
createStage: 'createHttpApiStage',
deleteStage: 'deleteHttpApiStage',
deploy: 'deployHttpApi',
createAuthorizer: 'createHttpApiAuthorizer',
deleteAuthorizer: 'deleteHttpApiAuthorizer',
updateCors: 'updateHttpApiCors',
sendRequest: 'testApiGatewayRoute',
testWebSocket: 'testWebSocketApi',
}
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 +160,42 @@ 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 monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of monitoringClaims) {
const method = METHOD_FOR_MONITORING_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of httpApiClaims) {
const method = METHOD_FOR_HTTP_API_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 +208,10 @@ 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?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []),
]
@@ -197,7 +276,7 @@ describe('adapter capability advertisements match the runtime reality', () => {
label: 'Create DB snapshot',
enabled: true,
status: 'partial',
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
runtimeRequired: true,
},
])
+9 -1
View File
@@ -47,6 +47,10 @@ 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 {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -81,7 +85,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
new AwsIamAdapter(clients.iam),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
new AwsCloudFormationAdapter(clients.cloudformation),
new AwsSecretsAdapter(clients.secretsManager),
new AwsSesAdapter(),
@@ -96,6 +100,10 @@ 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 AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+1 -1
View File
@@ -122,6 +122,6 @@ describe('GcpRestRuntimeClient.health', () => {
stubFetch(() => {
throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
})
await expect(client().health()).rejects.toThrow(`Cannot reach Floci-GCP at ${ENDPOINT}`)
await expect(client().health()).rejects.toThrow(`Cannot reach TheCloud's GCP runtime at ${ENDPOINT}`)
})
})
+3 -3
View File
@@ -40,7 +40,7 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
res = await globalThis.fetch(`${this.endpoint}${path}`, init)
} catch (error) {
throw new RuntimeUnavailableError(
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
{cause: error},
)
}
@@ -76,12 +76,12 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
res = await globalThis.fetch(url, {method: 'GET'})
} catch (error) {
throw new RuntimeUnavailableError(
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
{cause: error},
)
}
if (res.status >= 500) {
throw new RuntimeUnavailableError(`Floci-GCP at ${this.endpoint} returned HTTP ${res.status}`)
throw new RuntimeUnavailableError(`TheCloud's GCP runtime at ${this.endpoint} returned HTTP ${res.status}`)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import {describe, expect, test} from 'bun:test'
import server from './index'
describe('API surface hardening', () => {
test('serves an unauthenticated JSON health check', async () => {
const res = await server.fetch(new Request('http://localhost/api/health'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({status: 'ok'})
})
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
expect(res.headers.get('content-type')).toContain('application/json')
})
test('rejects an oversized sign-in body before parsing it', async () => {
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
}))
expect(res.status).toBe(413)
expect(await res.json()).toEqual({error: 'Request body is too large'})
})
test('keeps the upload ceiling for object uploads', async () => {
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
method: 'PUT',
headers: {'content-type': 'application/octet-stream'},
body: new Uint8Array(11 * 1024 * 1024),
}))
expect(res.status).toBe(401)
})
test('applies the default limit to other API routes', async () => {
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: new Uint8Array(11 * 1024 * 1024),
}))
expect(res.status).toBe(413)
})
})
+22
View File
@@ -1,6 +1,7 @@
import "dotenv/config";
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import {authConfig, authMiddleware, authRoutes} from './auth';
@@ -44,9 +45,19 @@ app.use(
}),
);
app.use("*", logger());
// Sign-in endpoints read the body before a session exists, so they get a small
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
// Bun's default 128 MB ceiling.
app.use("/api/*", (c, next) =>
bodyLimit({
maxSize: requestBodyLimit(c.req.path),
onError: (c) => c.json({ error: "Request body is too large" }, 413),
})(c, next),
);
app.use("*", authMiddleware(authentication));
app.use("*", authRoutes(authentication));
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/eks", eks);
app.route("/api/ec2", ec2);
app.route("/api/secretsmanager", secretsmanager);
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses);
app.route("/api/elasticache", elasticache);
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
// Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" }));
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
const port = Number(process.env.PORT ?? 4501);
export default { port, fetch: app.fetch };
function requestBodyLimit(path: string): number {
if (path.startsWith("/api/auth/")) return 16 * 1024;
if (
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
) {
return 128 * 1024 * 1024;
}
return 10 * 1024 * 1024;
}
function isSensitiveCredentialRoute(path: string): boolean {
return (
path.startsWith("/api/secretsmanager") ||
+2 -1
View File
@@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="/TheCloud.png" />
<title>Sign in · TheCloud</title>
<style nonce="${options.styleNonce}">
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
@@ -42,7 +43,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
<script nonce="${options.styleNonce}">(function(){var root=document.documentElement,btns=document.querySelectorAll('.tg button'),still=matchMedia('(prefers-reduced-motion: reduce)').matches;function setTheme(t){root.setAttribute('data-theme',t);try{localStorage.setItem('thecloud-theme',t)}catch(e){}btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)})}var initial=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';try{var saved=localStorage.getItem('thecloud-theme');if(saved==='dark'||saved==='light'||saved==='cyber')initial=saved}catch(e){}setTheme(initial);btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t)})});document.addEventListener('keydown',function(e){if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/INPUT|TEXTAREA/.test(e.target.tagName)){var themes=['dark','light','cyber'];setTheme(themes[(themes.indexOf(root.getAttribute('data-theme'))+1)%themes.length])}});var lines=['hello, engineer.','aws s3 mb s3://my-bucket','your own AWS, on your own hardware.','sfn start-execution --local','welcome to TheCloud.'],el=document.getElementById('tw'),line=0,count=0,deleting=false;if(still){el.textContent=lines[2]}else{(function type(){var word=lines[line];if(deleting){count--;el.textContent=word.slice(0,count);if(count===0){deleting=false;line=(line+1)%lines.length;setTimeout(type,400);return}setTimeout(type,35);return}count++;el.textContent=word.slice(0,count);if(count===word.length){deleting=true;setTimeout(type,2200);return}setTimeout(type,75+Math.random()*50)})()}var hero=document.getElementById('top'),glow=document.getElementById('grid-glow');if(!still){hero.addEventListener('mousemove',function(e){var rect=hero.getBoundingClientRect();glow.style.left=(e.clientX-rect.left)+'px';glow.style.top=(e.clientY-rect.top)+'px';glow.style.opacity='1'});hero.addEventListener('mouseleave',function(){glow.style.opacity='0'})}var bar=document.getElementById('bar');addEventListener('scroll',function(){var height=document.documentElement.scrollHeight-innerHeight;bar.style.width=(height>0?scrollY/height*100:0)+'%'},{passive:true});var form=document.querySelector('[data-sso-form]');if(form)form.addEventListener('submit',function(){var button=form.querySelector('.btn');button.classList.add('is-loading');button.disabled=true;document.getElementById('label').textContent='Redirecting...' });var copy=document.getElementById('cp');if(copy)copy.addEventListener('click',function(){navigator.clipboard.writeText(document.getElementById('code').innerText).then(function(){copy.textContent='copied';copy.classList.add('ok');setTimeout(function(){copy.textContent='copy';copy.classList.remove('ok')},1800)})})})()</script>
<script defer src="/login.js"></script>
</body>
</html>`
}
+141
View File
@@ -16,6 +16,9 @@ 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 {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -694,6 +697,132 @@ 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('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'monitoring',
schema: awsMonitoringSchema,
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/monitoring'
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
expect(calls).toEqual([
['metrics', {namespace: 'App', search: undefined}],
['series', 'Errors', 60],
['state', 'high-errors', 'ALARM', 'test'],
['actions', 'high-errors', false],
])
})
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', {
@@ -1274,6 +1403,18 @@ describe('service descriptors', () => {
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
})
test('marks a service listable only when an adapter serves the generic list', async () => {
// MSK is available through its own pre-SPI page; Console Home must not count it
// through /resources, which has no adapter behind it and answers 501.
const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}>
const of = (service: string) => body.find((descriptor) => descriptor.service === service)
expect(of('storage')).toMatchObject({availability: 'available', listable: true})
expect(of('msk')).toMatchObject({availability: 'available', listable: false})
expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false})
expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false})
})
})
describe('AppConfig nested routes', () => {
+279
View File
@@ -1,6 +1,7 @@
import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
AlarmStateValue,
CloudProvider,
CloudServiceType,
CreateDatabaseSnapshotInput,
@@ -14,6 +15,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
import {serviceForAccount} from '../cloudProxy'
import {CloudProxyService} from '../service/CloudProxyService'
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
// Header (and query-param fallback for direct links such as object downloads)
@@ -64,6 +66,12 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return c.json(await svc(c).status(cloud, {includeServices}))
})
app.get('/:cloud/diagnostics', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return c.json(await runtimeDiagnostics(cloud, svc(c)))
})
app.get('/:cloud/services/:service/status', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
@@ -385,6 +393,138 @@ 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.get('/:cloud/services/monitoring/metrics', 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).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
})
app.post('/:cloud/services/monitoring/metric-data', 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).getMetricSeries(cloud, await c.req.json())))
})
app.get('/:cloud/services/monitoring/resources/:id/history', 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).describeAlarmHistory(cloud, c.req.param('id'))))
})
app.put('/:cloud/services/monitoring/resources/:id/state', 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<{state?: AlarmStateValue; reason?: string}>()
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
return c.json({ok: true})
})
})
app.put('/:cloud/services/monitoring/resources/:id/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 () => {
const body = await c.req.json<{enabled?: boolean}>()
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
return c.json({ok: true})
})
})
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)
@@ -663,6 +803,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
returnsResponse: input.returnsResponse === true,
})
return c.json(route, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<{authorizerId?: unknown}>()
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
stageName: typeof input.stageName === 'string' ? input.stageName : '',
autoDeploy: input.autoDeploy === true,
})
return c.json(stage, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
name: typeof input.name === 'string' ? input.name : '',
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
})
return c.json(authorizer, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<{cors?: Record<string, unknown> | null}>()
const cors = input.cors ? {
allowOrigins: stringList(input.cors.allowOrigins),
allowMethods: stringList(input.cors.allowMethods),
allowHeaders: stringList(input.cors.allowHeaders),
exposeHeaders: stringList(input.cors.exposeHeaders),
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
allowCredentials: input.cors.allowCredentials === true,
} : null
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
stage: typeof input.stage === 'string' ? input.stage : '',
message: typeof input.message === 'string' ? input.message : '',
query: typeof input.query === 'string' ? input.query : undefined,
}))
})
})
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -1150,6 +1415,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)
@@ -1512,6 +1787,10 @@ function isStringRecord(value: unknown): value is Record<string, string> {
&& Object.values(value).every((item) => typeof item === 'string')
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -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
@@ -30,6 +30,16 @@ import type {
ApiGatewayDeleteRouteInput,
ApiGatewayCorsInput,
ApiGatewayCorsResult,
HttpApiAuthorizerInput,
HttpApiAuthorizerOverview,
HttpApiCors,
HttpApiOverview,
HttpApiRouteInput,
HttpApiRouteOverview,
HttpApiStageInput,
HttpApiStageOverview,
HttpApiWebSocketTestInput,
HttpApiWebSocketTestResult,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
S3BucketWebsiteConfiguration,
@@ -77,6 +87,19 @@ import type {
StorageObjectDownload,
StorageObjectList,
UpdateResourceInput,
CreateUserPoolGroupInput,
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
} from '../cloud-spi/types'
import type {
ChildCollection,
@@ -154,6 +177,7 @@ export class CloudProxyService {
iconKey: entry.iconKey,
group: entry.group,
order: entry.order,
listable: Boolean(adapter),
}
})
}
@@ -317,6 +341,56 @@ export class CloudProxyService {
return adapter.enableApiGatewayCors(apiId, input)
}
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
}
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
}
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
}
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
}
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
}
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
}
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
}
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
}
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
}
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
}
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
}
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
return adapter
}
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
@@ -664,6 +738,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 +864,96 @@ 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 listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
return adapter.listMetrics(query)
}
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
return adapter.getMetricSeries(query)
}
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
return adapter.describeAlarmHistory(alarmName)
}
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
await adapter.setAlarmState(alarmName, state, reason)
}
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
await adapter.setAlarmActionsEnabled(alarmName, enabled)
}
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`)
@@ -0,0 +1,102 @@
import {describe, expect, test} from 'bun:test'
import type {CloudProvider, CloudServiceDescriptor, CloudStatus} from '../cloud-spi/types'
import {runtimeDiagnostics} from './runtimeDiagnostics'
const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
const NOW = new Date('2026-10-23T10:30:15.000Z')
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'}
}
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
return {
status: async (cloud: CloudProvider) => ({
cloud, adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), error: null,
services: [{cloud, service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), latencyMs: 4, error: null, errorCode: null}],
...status,
}) as CloudStatus,
services: () => descriptors,
}
}
function runtime(responses: Record<string, unknown>): typeof fetch {
return (async (input: string | URL | Request) => {
const path = new URL(String(input instanceof Request ? input.url : input)).pathname
return path in responses ? Response.json(responses[path]) : new Response('not found', {status: 404})
}) as unknown as typeof fetch
}
const fixed = {now: () => NOW, startedAt: STARTED_AT, authMode: 'oidc' as const, activeSessions: () => 2}
describe('runtimeDiagnostics', () => {
test('reports the runtime version, every emulated service and its console pages', async () => {
const report = await runtimeDiagnostics('aws', proxy({}, [
descriptor('storage', 'S3'),
descriptor('loadbalancing', 'Load Balancers'),
descriptor('targetgroups', 'Target Groups'),
descriptor('scheduler', 'Scheduler', 'coming_soon'),
]), {
...fixed,
fetch: runtime({'/_floci/health': {version: '1.9.0', edition: 'compat', services: {s3: 'running', elasticloadbalancing: 'running', route53: 'running', scheduler: 'stopped'}}}),
})
expect(report.runtime).toMatchObject({status: 'reachable', endpoint: 'http://floci:4566', version: '1.9.0', edition: 'compat', error: null})
expect(report.runtime.latencyMs).toBeNumber()
expect(report.emulatedServices).toEqual([
{name: 'elasticloadbalancing', state: 'running', consolePages: [
{service: 'loadbalancing', displayName: 'Load Balancers', route: '/cloud-explorer/aws/loadbalancing'},
{service: 'targetgroups', displayName: 'Target Groups', route: '/cloud-explorer/aws/targetgroups'},
]},
{name: 'route53', state: 'running', consolePages: []},
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
{name: 'scheduler', state: 'stopped', consolePages: []},
])
expect(report.consoleServices).toHaveLength(1)
})
test('lists the containers the runtime started for EC2 instances', async () => {
const report = await runtimeDiagnostics('aws', proxy(), {
...fixed,
fetch: runtime({'/_floci/diagnose': {ec2: {instances: [
{instanceId: 'i-0abc', containerStatus: 'running', sshHost: 'localhost', sshPort: 2201},
{instanceId: 'i-0def', containerStatus: 'stopped'},
{containerStatus: 'running'},
]}}}),
})
expect(report.containers).toEqual([
{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201},
{kind: 'ec2-instance', resourceId: 'i-0def', status: 'stopped', sshHost: null, sshPort: null},
])
})
test('still returns a partial report when the runtime is down', async () => {
const report = await runtimeDiagnostics('aws', proxy({runtime: 'unavailable', error: 'Cannot reach Floci core at http://floci:4566', services: []}), {
...fixed,
fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch,
})
expect(report.runtime).toEqual({status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null})
expect(report.emulatedServices).toBeNull()
expect(report.containers).toBeNull()
})
test('does not query Floci core endpoints for other clouds', async () => {
const requested: string[] = []
const report = await runtimeDiagnostics('azure', proxy(), {
...fixed,
fetch: (async (input: string | URL | Request) => { requested.push(String(input)); return Response.json({}) }) as unknown as typeof fetch,
})
expect(requested).toEqual([])
expect(report.emulatedServices).toBeNull()
})
test('describes the console process itself', async () => {
const report = await runtimeDiagnostics('aws', proxy(), {...fixed, fetch: runtime({})})
expect(report.checkedAt).toBe('2026-10-23T10:30:15.000Z')
expect(report.console).toEqual({startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9015, authMode: 'oidc', activeSessions: 2})
})
})
@@ -0,0 +1,153 @@
import {authStateSizes} from '../auth'
import type {
CloudProvider,
CloudServiceDescriptor,
CloudServiceType,
EmulatedServiceStatus,
RuntimeContainer,
RuntimeDiagnostics,
} from '../cloud-spi/types'
import type {CloudProxyService} from './CloudProxyService'
import {awsEndpoint} from './runtimeProbe'
const DIAGNOSTICS_TIMEOUT_MS = 3_000
const processStartedAt = new Date()
/**
* Floci core service ids mapped to the console services they back. Only the
* runtime's own ids are listed; whether a console page is actually available is
* still decided by the adapter registry through `services()`.
*/
const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
apigateway: ['apigateway'],
appconfig: ['configuration'],
cloudformation: ['iac'],
codebuild: ['codebuild'],
codedeploy: ['codedeploy'],
codepipeline: ['codepipeline'],
'cognito-idp': ['userpools'],
dynamodb: ['nosql'],
ec2: ['compute', 'networking'],
ecr: ['ecr'],
ecs: ['ecs', 'fargate'],
eks: ['k8s'],
elasticache: ['elasticache'],
elasticloadbalancing: ['loadbalancing', 'targetgroups'],
email: ['email'],
events: ['events'],
iam: ['identity'],
kafka: ['msk'],
kafkaconnect: ['msk-connect'],
kinesis: ['kinesis'],
kms: ['kms'],
lambda: ['serverless'],
lightsail: ['lightsail'],
logs: ['logs'],
rds: ['database'],
'rds-data': ['database'],
s3: ['storage'],
secretsmanager: ['secrets'],
sns: ['notifications'],
sqs: ['messaging'],
ssm: ['parameters'],
states: ['workflows'],
}
type FlociHealth = {services?: Record<string, unknown>; version?: unknown; edition?: unknown}
type FlociDiagnose = {ec2?: {instances?: Array<{instanceId?: unknown; containerStatus?: unknown; sshHost?: unknown; sshPort?: unknown}>}}
export type DiagnosticsDependencies = {
fetch?: typeof fetch
now?: () => Date
startedAt?: Date
authMode?: 'local' | 'oidc'
activeSessions?: () => number
}
/**
* Everything the console can learn about a runtime in one call. Each source is
* optional: a runtime that is down, or one that does not publish a service list
* or container diagnostics, still yields a partial report rather than an error.
*/
export async function runtimeDiagnostics(
cloud: CloudProvider,
proxy: Pick<CloudProxyService, 'status' | 'services'>,
dependencies: DiagnosticsDependencies = {},
): Promise<RuntimeDiagnostics> {
const fetcher = dependencies.fetch ?? globalThis.fetch
const now = dependencies.now?.() ?? new Date()
const startedAt = dependencies.startedAt ?? processStartedAt
const flociCore = cloud === 'aws'
const [status, health, diagnose] = await Promise.all([
proxy.status(cloud, {includeServices: true}),
flociCore ? fetchJson<FlociHealth>(fetcher, `${awsEndpoint()}/_floci/health`) : null,
flociCore ? fetchJson<FlociDiagnose>(fetcher, `${awsEndpoint()}/_floci/diagnose`) : null,
])
return {
cloud,
checkedAt: now.toISOString(),
runtime: {
status: status.runtime,
endpoint: status.endpoint,
latencyMs: health?.latencyMs ?? null,
error: status.error,
version: stringOrNull(health?.body.version),
edition: stringOrNull(health?.body.edition),
},
emulatedServices: emulatedServices(health?.body.services, proxy.services(cloud)),
consoleServices: status.services ?? [],
containers: containers(diagnose?.body),
console: {
startedAt: startedAt.toISOString(),
uptimeSeconds: Math.max(0, Math.floor((now.getTime() - startedAt.getTime()) / 1000)),
authMode: dependencies.authMode ?? (process.env.FLOCI_UI_AUTH_MODE === 'oidc' ? 'oidc' : 'local'),
activeSessions: (dependencies.activeSessions ?? (() => authStateSizes().sessions))(),
},
}
}
function emulatedServices(reported: Record<string, unknown> | undefined, descriptors: CloudServiceDescriptor[]): EmulatedServiceStatus[] | null {
if (!reported || typeof reported !== 'object') return null
const available = new Map(descriptors.filter((descriptor) => descriptor.availability === 'available').map((descriptor) => [descriptor.service, descriptor]))
return Object.entries(reported)
.map(([name, state]) => ({
name,
state: typeof state === 'string' ? state : 'unknown',
consolePages: (AWS_CONSOLE_SERVICES[name] ?? [])
.map((service) => available.get(service))
.filter((descriptor): descriptor is CloudServiceDescriptor => Boolean(descriptor))
.map(({service, displayName, route}) => ({service, displayName, route})),
}))
.sort((left, right) => left.name.localeCompare(right.name))
}
function containers(diagnose: FlociDiagnose | undefined): RuntimeContainer[] | null {
const instances = diagnose?.ec2?.instances
if (!Array.isArray(instances)) return null
return instances
.filter((instance) => typeof instance.instanceId === 'string')
.map((instance) => ({
kind: 'ec2-instance' as const,
resourceId: instance.instanceId as string,
status: stringOrNull(instance.containerStatus),
sshHost: stringOrNull(instance.sshHost),
sshPort: typeof instance.sshPort === 'number' ? instance.sshPort : null,
}))
}
async function fetchJson<T>(fetcher: typeof fetch, url: string): Promise<{body: T; latencyMs: number} | null> {
const startedAt = performance.now()
try {
const response = await fetcher(url, {signal: AbortSignal.timeout(DIAGNOSTICS_TIMEOUT_MS)})
if (!response.ok) return null
return {body: await response.json() as T, latencyMs: Math.round(performance.now() - startedAt)}
} catch {
return null
}
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}
+1 -1
View File
@@ -16,7 +16,7 @@ export type RuntimeProbe = () => Promise<void>
export const runtimeProbes: Record<CloudProvider, RuntimeProbe> = {
// Every runtime exposes a health endpoint, but under its own path: core and
// Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health.
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'),
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'TheCloud AWS runtime'),
azure: async () => {
await azure.fetch('/_floci/health', {method: 'GET'})
},
@@ -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()
})
@@ -5,7 +5,7 @@ export type LightsailNodeDeployment = {status: 'deployed'; files: number; port:
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
let response: Response
try {
@@ -13,19 +13,19 @@ export async function deployLightsailStaticFiles(instanceName: string, form: For
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
})
} catch (error) {
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
}
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
if (body.status !== 'deployed' || typeof body.files !== 'number') {
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid deployment response')
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid deployment response')
}
return {status: 'deployed', files: body.files}
}
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
let response: Response
try {
@@ -33,12 +33,12 @@ export async function deployLightsailNodeApplication(instanceName: string, form:
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
})
} catch (error) {
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
}
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid Node.js deployment response')
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid Node.js deployment response')
}
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
}
+6 -6
View File
@@ -59,23 +59,23 @@ type SshCheckDependencies = {
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
let response: Response
try {
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
if (!response.ok) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
try {
return await response.json() as DiagnosticResponse
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
}
}
@@ -149,12 +149,12 @@ export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
diagnostics = await diagnosticFetcher()
} catch (error) {
if (error instanceof SshCheckError) throw error
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
}
if (!diagnostics || typeof diagnostics !== 'object'
|| !Array.isArray(diagnostics.ec2?.instances)) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
}
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
if (!instance) {
+1 -1
View File
@@ -10,7 +10,7 @@
var t = localStorage.getItem("floci-theme");
document.documentElement.setAttribute(
"data-theme",
t === "light" ? "light" : "dark",
t === "light" || t === "cyber" ? t : "dark",
);
})();
</script>
+9
View File
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
@@ -15,8 +16,16 @@ export default function App() {
<Route index element={<Navigate to="/console/aws" replace/>}/>
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
+140
View File
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
list: "clouds.list",
services: "clouds.services.list",
status: "clouds.status.get",
diagnostics: "clouds.diagnostics.get",
serviceStatus: "clouds.services.status.get",
schema: "clouds.services.schema.get",
dependencies: "clouds.services.dependencies.get",
@@ -129,6 +130,44 @@ 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",
},
httpApi: {
describe: "clouds.services.apigateway.http-api.describe",
createRoute: "clouds.services.apigateway.http-api.routes.create",
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
createStage: "clouds.services.apigateway.http-api.stages.create",
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
deploy: "clouds.services.apigateway.http-api.stages.deploy",
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
cors: "clouds.services.apigateway.http-api.cors",
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
},
monitoring: {
metrics: "clouds.services.monitoring.metrics",
metricData: "clouds.services.monitoring.metric-data",
history: "clouds.services.monitoring.history",
state: "clouds.services.monitoring.state",
actions: "clouds.services.monitoring.actions",
},
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",
@@ -153,6 +192,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",
@@ -323,6 +363,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "GET",
},
],
[
apiEndpointKeys.clouds.diagnostics,
{
path: "/clouds/:cloud/diagnostics",
method: "GET",
},
],
[
apiEndpointKeys.clouds.serviceStatus,
{
@@ -821,6 +868,92 @@ 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.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
[
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,
{
@@ -990,6 +1123,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,
{
@@ -6,6 +6,7 @@ import type {
CloudServiceStatus,
CloudServiceType,
CloudStatus,
RuntimeDiagnostics,
} from "@/types/cloud";
import type {
AppConfigConfigurationProfile,
@@ -26,6 +27,19 @@ import type {
DatabaseSnapshot,
KubernetesFargateProfile,
KubernetesNodegroup,
CreateUserPoolGroupInput,
UserPoolGroup,
UserPoolSignInTestInput,
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
LogsFilterInput,
@@ -86,6 +100,18 @@ export async function getCloudStatus(
return res.data;
}
export async function getCloudDiagnostics(
cloud: CloudProvider,
signal?: AbortSignal,
): Promise<RuntimeDiagnostics> {
const res = await apiClient.call<RuntimeDiagnostics>(
apiEndpointKeys.clouds.diagnostics,
requestOptions(cloud, "cloud-proxy", { signal }),
{ cloud },
);
return res.data;
}
export async function getCloudServiceStatus(
cloud: CloudProvider,
service: CloudServiceType,
@@ -1364,6 +1390,349 @@ 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 listMetrics(
cloud: CloudProvider,
query: { namespace?: string; search?: string } = {},
signal?: AbortSignal,
): Promise<MetricSummary[]> {
const res = await apiClient.call<MetricSummary[]>(
apiEndpointKeys.clouds.monitoring.metrics,
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
{ cloud },
);
return res.data;
}
export async function getMetricSeries(
cloud: CloudProvider,
query: MetricSeriesQuery,
signal?: AbortSignal,
): Promise<MetricSeries> {
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
apiEndpointKeys.clouds.monitoring.metricData,
requestOptions(cloud, "monitoring", { signal, body: query }),
{ cloud },
);
return res.data;
}
export async function describeAlarmHistory(
cloud: CloudProvider,
alarmName: string,
signal?: AbortSignal,
): Promise<AlarmHistoryItem[]> {
const res = await apiClient.call<AlarmHistoryItem[]>(
apiEndpointKeys.clouds.monitoring.history,
requestOptions(cloud, "monitoring", { signal }),
{ cloud, id: alarmName },
);
return res.data;
}
export async function setAlarmState(
cloud: CloudProvider,
alarmName: string,
state: AlarmStateValue,
reason: string,
): Promise<void> {
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
apiEndpointKeys.clouds.monitoring.state,
requestOptions(cloud, "monitoring", { body: { state, reason } }),
{ cloud, id: alarmName },
);
}
export async function setAlarmActionsEnabled(
cloud: CloudProvider,
alarmName: string,
enabled: boolean,
): Promise<void> {
await apiClient.call<void, { enabled: boolean }>(
apiEndpointKeys.clouds.monitoring.actions,
requestOptions(cloud, "monitoring", { body: { enabled } }),
{ cloud, id: alarmName },
);
}
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
export interface HttpApiIntegrationOverview {
integrationId: string;
type: string;
uri: string | null;
functionName: string | null;
payloadFormatVersion: string | null;
}
export interface HttpApiRouteOverview {
routeId: string;
routeKey: string;
integration: HttpApiIntegrationOverview | null;
authorizationType: string;
authorizerId: string | null;
authorizationScopes: string[];
returnsResponse: boolean;
}
export interface HttpApiStageOverview {
stageName: string;
autoDeploy: boolean;
deploymentId: string | null;
lastUpdatedAt: string | null;
invokePath: string;
}
export interface HttpApiAuthorizerOverview {
authorizerId: string;
name: string;
type: string;
identitySource: string[];
issuer: string | null;
audience: string[];
}
export interface HttpApiCors {
allowOrigins: string[];
allowMethods: string[];
allowHeaders: string[];
exposeHeaders: string[];
maxAgeSeconds: number | null;
allowCredentials: boolean;
}
export interface HttpApiOverview {
apiId: string;
protocol: HttpApiProtocol;
apiEndpoint: string | null;
routeSelectionExpression: string | null;
routes: HttpApiRouteOverview[];
stages: HttpApiStageOverview[];
authorizers: HttpApiAuthorizerOverview[];
cors: HttpApiCors | null;
}
export interface HttpApiRouteInput {
routeKey: string;
functionName?: string;
httpUrl?: string;
authorizerId?: string;
returnsResponse?: boolean;
}
export interface HttpApiAuthorizerInput {
name: string;
userPoolId?: string;
userPoolClientId?: string;
issuer?: string;
audience?: string[];
}
export interface HttpApiWebSocketTestResult {
connected: boolean;
messages: string[];
closeCode: number | null;
error: string | null;
durationMs: number;
}
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
return res.data;
}
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
}
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
return res.data;
}
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
}
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
return res.data;
}
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
}
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
return res.data.cors;
}
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
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,
@@ -1,5 +1,6 @@
import {useQuery} from '@tanstack/react-query'
import {
getCloudDiagnostics,
getCloudResource,
getCloudServiceStatus,
getCloudStatus,
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
clouds: ['clouds'] as const,
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
['cloud-service-status', cloud, service] as const,
resources: (cloud: CloudProvider, service: CloudServiceType) =>
@@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter
})
}
/** Full runtime report; polled slowly because it probes every console service. */
export function useCloudDiagnosticsQuery(cloud: CloudProvider) {
return useQuery({
queryKey: cloudQueryKeys.diagnostics(cloud),
queryFn: ({signal}) => getCloudDiagnostics(cloud, signal),
refetchInterval: 30_000,
})
}
/** Health of the single service being viewed, rather than of the whole cloud. */
export function useCloudServiceStatusQuery(
cloud: CloudProvider,
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
import {ServiceIcon} from '@/components/serviceIcons'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a REST API</h3>
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
<h3>Select an API</h3>
<p>Select an API to see its routes, or create one.</p>
</div>
</section>
)
@@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
</div>
}
function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
parsed = JSON.parse(value || '{}')
} catch {
throw new Error('Request headers must be valid JSON.')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
throw new Error('Request headers must be a JSON object with string values.')
}
return parsed as Record<string, string>
}
function formatResponseBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body || '(empty response body)'
}
}
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
let headers: Record<string, string> = {}
try {
@@ -234,7 +234,7 @@ export function AzureSqlPanel({
)}
<small className="sql-credential-note">
{isAwsRds
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
? "Queries use TheCloud's local RDS Data API credentials. Database passwords are not sent by the console."
: "Credentials remain in this page only and are sent to the local API per request."}
</small>
{connectionError && <div className="form-error">{connectionError}</div>}
@@ -243,7 +243,7 @@ export function AzureSqlPanel({
{!connected ? (
<div className="empty compact sql-connect-empty">
<h3>Connect to browse SQL data</h3>
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
<p>{isAwsRds ? "Connect through TheCloud's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
</div>
) : (
<div className="sql-explorer">
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml', parameters: '{}', capabilities: ''})
})
})
@@ -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: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,74 @@
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 {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
import {HttpError} from '@/api/HttpClient'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
describeAlarmHistory: vi.fn(),
getMetricSeries: vi.fn(),
setAlarmState: vi.fn(),
setAlarmActionsEnabled: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const alarm: CloudResource = {
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
metadata: {
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
},
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue(alarm)
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
api.setAlarmState.mockResolvedValue(undefined)
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
})
test('shows the state, its metric with the threshold, the targets and the history', async () => {
renderPanel()
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
})
test('sets a state to test the actions, and turns the actions off', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Set alarm state'})
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
await user.type(within(form).getByLabelText('Reason'), 'Testing')
await user.click(within(form).getByRole('button', {name: 'Set state'}))
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
})
test('says when the runtime does not record alarm history, without an error', async () => {
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
renderPanel()
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -0,0 +1,154 @@
import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
import {HttpError} from '@/api/HttpClient'
import {MetricChart} from '@/components/MetricChart'
import {RANGES} from '@/lib/metricChart'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [rangeMinutes, setRangeMinutes] = useState(180)
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
const [reason, setReason] = useState('')
const [notice, setNotice] = useState('')
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
useEffect(() => {
setNotice('')
setReason('')
}, [resource?.id])
const alarmQuery = useQuery({
queryKey: alarmKey,
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const alarm = alarmQuery.data ?? resource
const metadata = alarm?.metadata ?? {}
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
const seriesQuery = useQuery({
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
queryFn: ({signal}) => getMetricSeries('aws', {
namespace: String(metadata.namespace),
metricName: String(metadata.metricName),
dimensions,
statistic: String(metadata.statistic ?? 'Average'),
rangeMinutes,
}, signal),
enabled: Boolean(resource) && runtimeReachable && isMetric,
})
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: alarmKey}),
queryClient.invalidateQueries({queryKey: historyKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
])
const stateMutation = useMutation({
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
onSuccess: () => {
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
setReason('')
return refresh()
},
})
const actionsMutation = useMutation({
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
onSuccess: (_result, enabled) => {
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
return refresh()
},
})
if (!resource || !alarm) {
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
}
const state = alarm.status ?? 'INSUFFICIENT_DATA'
const actionsEnabled = metadata.actionsEnabled !== false
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
const historyUnsupported = isNotImplemented(historyQuery.error)
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
const history = historyQuery.data ?? []
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Alarm</p>
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<dl className="cognito-facts cloudwatch-facts">
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
</dl>
{isMetric && <>
<div className="cloudwatch-range" role="group" aria-label="Time range">
{RANGES.map((range) => (
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
))}
</div>
<div className="cloudwatch-chart">
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
</div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
<label className="dynamic-field"><span>State</span>
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</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>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
))}</tbody></table></div>}
</section>
}
function isNotImplemented(error: unknown): boolean {
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
}
function list(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -0,0 +1,38 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
beforeEach(() => {
vi.clearAllMocks()
api.listMetrics.mockResolvedValue([
{namespace: 'App', metricName: 'Errors', dimensions: []},
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
])
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
})
function renderExplorer() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
}
test('filters metrics by namespace and charts the chosen one', async () => {
const user = userEvent.setup()
renderExplorer()
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
})
@@ -0,0 +1,86 @@
import {RefreshCw} from 'lucide-react'
import {useQuery} from '@tanstack/react-query'
import {useMemo, useState} from 'react'
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
import {MetricChart} from '@/components/MetricChart'
import {RANGES} from '@/lib/metricChart'
import {useAccountId} from '@/lib/accountStore'
import type {MetricSummary} from '@/types/resource'
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
const accountId = useAccountId()
const [namespace, setNamespace] = useState('')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<MetricSummary | null>(null)
const [statistic, setStatistic] = useState('Average')
const [rangeMinutes, setRangeMinutes] = useState(180)
const metricsQuery = useQuery({
queryKey: ['cloudwatch-metrics', accountId],
queryFn: ({signal}) => listMetrics('aws', {}, signal),
enabled: runtimeReachable,
})
const seriesQuery = useQuery({
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
enabled: runtimeReachable && selected !== null,
})
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
const term = search.trim().toLowerCase()
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
const error = metricsQuery.error ?? seriesQuery.error
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<div className="cloudwatch-filters">
<label className="dynamic-field"><span>Namespace</span>
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
<option value="">All namespaces</option>
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
</div>
{selected && <div className="cloudwatch-chart">
<div className="cloudwatch-chart-head">
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
{RANGES.map((range) => (
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
))}
</div>
</div>
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
</div>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
<td className="mono">{metric.metricName}</td>
<td className="mono">{metric.namespace}</td>
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
</tr>
})}</tbody></table></div>}
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
</section>
}
function metricKey(metric: MetricSummary): string {
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
}
@@ -9,12 +9,12 @@ describe('CodeDeployCreatePanel', () => {
const onSubmit = vi.fn()
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.type(screen.getByLabelText('Application name'), 'orders')
await user.type(screen.getByLabelText('Deployment group name'), 'production')
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
await user.type(screen.getByLabelText(/^Application name/), 'orders')
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument()
expect(screen.queryByLabelText(/^Cluster name/)).not.toBeInTheDocument()
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
})
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
await user.type(screen.getByLabelText('Application name'), 'orders')
await user.type(screen.getByLabelText('Deployment group name'), 'production')
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
await user.type(screen.getByLabelText(/^Application name/), 'orders')
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
await user.click(screen.getByLabelText('Roll back automatically on deployment failure'))
await user.click(screen.getByLabelText(/Automatic rollback/))
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
@@ -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 : ''
}
@@ -77,7 +77,7 @@ export function DatabaseSnapshotsPanel({
"code" in error.body &&
(error.body as { code?: unknown }).code === "operation_not_implemented"
) {
return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot.";
return "Snapshot creation is available in TheCloud, but the selected runtime does not implement CreateDBSnapshot.";
}
if (error instanceof Error) {
return error.message;
@@ -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,
@@ -47,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { HttpApiPanel } from "@/components/HttpApiPanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
@@ -63,6 +65,11 @@ 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 { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -573,12 +580,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 +637,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 +751,9 @@ export function DynamicResourceView({
{canDelete && (
deleteConfirm ? (
<>
{deleteImpactText && (
<span className="delete-impact" role="status">{deleteImpactText}</span>
)}
<button
className="button danger"
type="button"
@@ -931,6 +942,7 @@ export function DynamicResourceView({
})}
</section>
)}
{cloud === 'aws' && service === 'email' && <SesConsolePanel runtimeReachable={canUseRuntime}/>}
</section>
{activeSelected && !showDatabaseSnapshots && (
<div>
@@ -969,6 +981,21 @@ 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 === "monitoring" && cloud === "aws" && (
<>
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
</>
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -1011,14 +1038,21 @@ export function DynamicResourceView({
)}
</>
)}
{service === "apigateway" && (
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
<HttpApiPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
) : (
<ApiGatewayRoutePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
)}
))}
{service === "events" && (
<EventBridgeRulePanel
cloud={cloud}
@@ -0,0 +1,126 @@
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 {HttpApiPanel} from './HttpApiPanel'
import type {HttpApiOverview} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
describeHttpApi: vi.fn(),
listCloudResources: vi.fn(),
createHttpApiRoute: vi.fn(),
deleteHttpApiRoute: vi.fn(),
setHttpApiRouteAuthorizer: vi.fn(),
createHttpApiStage: vi.fn(),
deleteHttpApiStage: vi.fn(),
deployHttpApi: vi.fn(),
createHttpApiAuthorizer: vi.fn(),
deleteHttpApiAuthorizer: vi.fn(),
updateHttpApiCors: vi.fn(),
testApiGatewayRoute: vi.fn(),
testWebSocketApi: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
const httpResource: CloudResource = {
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
}
const httpOverview: HttpApiOverview = {
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
routes: [
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
],
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
cors: null,
}
function renderPanel(resource: CloudResource = httpResource) {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.describeHttpApi.mockResolvedValue(httpOverview)
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
})
test('shows the stage URL, routes with their targets and who may call them', async () => {
renderPanel()
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
const routes = within(screen.getByRole('region', {name: 'Routes'}))
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
})
test('adds a Lambda route and protects an existing one', async () => {
renderPanel()
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
await routes.findByRole('option', {name: 'notes'})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
})
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
renderPanel()
const security = within(await screen.findByRole('region', {name: 'Security'}))
await security.findByRole('option', {name: 'notes-users'})
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
})
test('sends a request to the stage and shows the response', async () => {
renderPanel()
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
expect(await tester.findByText('200')).toBeInTheDocument()
})
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
api.describeHttpApi.mockResolvedValue({
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
authorizers: [], cors: null,
} satisfies HttpApiOverview)
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
expect(await tester.findByText('Connected')).toBeInTheDocument()
})
@@ -0,0 +1,610 @@
import {useEffect, useState} from 'react'
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
createHttpApiAuthorizer,
createHttpApiRoute,
createHttpApiStage,
deleteHttpApiAuthorizer,
deleteHttpApiRoute,
deleteHttpApiStage,
deployHttpApi,
describeHttpApi,
listCloudResources,
setHttpApiRouteAuthorizer,
testApiGatewayRoute,
testWebSocketApi,
updateHttpApiCors,
} from '@/api/cloudProxyClient'
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {formatDateTime} from '@/lib/format'
import {CopyValueButton} from './CopyValueButton'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface HttpApiPanelProps {
cloud: CloudProvider
resource: CloudResource
runtimeReachable: boolean
runtimeEndpoint?: string
}
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
const queryKey = ['http-api', cloud, resource.id]
const query = useQuery({
queryKey,
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
enabled: runtimeReachable,
})
const api = query.data
const endpoint = invokeEndpoint(runtimeEndpoint)
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
return <div className="http-api-workspace">
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
<span className="muted">{isWebSocket
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
{query.isLoading && <p className="muted padded">Loading the API</p>}
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
{api && <StageUrls api={api} endpoint={endpoint}/>}
</section>
{api && <>
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
{api.protocol === 'HTTP'
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
</>}
</div>
}
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
return <div className="http-api-urls">
{api.stages.map((stage) => {
const url = stageUrl(api, stage, endpoint)
return <div className="dynamic-field" key={stage.stageName}>
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
</div>
})}
</div>
}
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const isWebSocket = api.protocol === 'WEBSOCKET'
const [method, setMethod] = useState('GET')
const [path, setPath] = useState('/hello')
const [catchAll, setCatchAll] = useState(false)
const [webSocketKey, setWebSocketKey] = useState('$default')
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
const [functionName, setFunctionName] = useState('')
const [httpUrl, setHttpUrl] = useState('')
const [authorizerId, setAuthorizerId] = useState('')
const [returnsResponse, setReturnsResponse] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
useEffect(() => {
setConfirmDelete(null)
setAuthorizerId('')
}, [api.apiId])
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
enabled: runtimeReachable,
})
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const refresh = () => queryClient.invalidateQueries({queryKey})
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
const createMutation = useMutation({
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
routeKey,
functionName: targetKind === 'lambda' ? functionName : undefined,
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
authorizerId: authorizerId || undefined,
returnsResponse: isWebSocket ? returnsResponse : undefined,
}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const authorizerMutation = useMutation({
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
onSuccess: refresh,
})
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
return <section className="table-panel" aria-label="Routes">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Routes</p>
<span className="muted">{isWebSocket
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
</div>
</div>
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
<td>{describeTarget(route)}</td>
{!isWebSocket && <td>
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
</td>}
<td className="cognito-row-actions-cell">
{confirmDelete === route.routeId
? <span className="apigateway-route-confirm">
<span>Delete {route.routeKey}?</span>
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
{isWebSocket ? <label className="dynamic-field">
<span>Route key</span>
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
</label> : <>
<label className="dynamic-field">
<span>Method</span>
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field">
<span>Path</span>
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
</label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
<span>Catch-all ($default)</span>
</label>
</>}
{!isWebSocket && <label className="dynamic-field">
<span>Target</span>
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
<option value="lambda">Lambda function</option>
<option value="http">HTTP backend</option>
</select>
</label>}
{targetKind === 'lambda' || isWebSocket
? <label className="dynamic-field">
<span>Lambda function</span>
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
<small>{functionsQuery.isLoading ? 'Loading functions' : functions.length === 0 ? 'Create a Lambda function first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
</label>
: <label className="dynamic-field">
<span>Backend URL</span>
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
<small>Requests are proxied here as they are.</small>
</label>}
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
<span>Access</span>
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
</label>}
{isWebSocket && <label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
<span>Send the function's response back to the client</span>
</label>}
<button className="button primary" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [stageName, setStageName] = useState('prod')
const [autoDeploy, setAutoDeploy] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
useEffect(() => {
setConfirmDelete(null)
setNotice('')
}, [api.apiId])
const createMutation = useMutation({
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
onSuccess: (stage) => {
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
return refresh()
},
})
const deployMutation = useMutation({
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
onSuccess: (stage) => {
setNotice(`Deployed the current routes to ${stage.stageName}.`)
return refresh()
},
})
const deleteMutation = useMutation({
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
return <section className="table-panel" aria-label="Stages">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Stages</p>
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
</div>
</div>
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
<td><code>{stage.stageName}</code></td>
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
<td className="cognito-row-actions-cell">
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
{confirmDelete === stage.stageName
? <span className="apigateway-route-confirm">
<span>Delete {stage.stageName}?</span>
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
event.preventDefault()
setNotice('')
if (stageName.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
<span>Deploy changes automatically</span>
</label>
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
return <section className="table-panel" aria-label="Security">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
</div>
</div>
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
</section>
}
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
const [name, setName] = useState('cognito')
const [userPoolId, setUserPoolId] = useState('')
const [clientId, setClientId] = useState('')
const [issuer, setIssuer] = useState('')
const [audience, setAudience] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
const poolsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'userpools'],
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
enabled: runtimeReachable,
})
const pools = poolsQuery.data ?? []
const pool = pools.find((candidate) => candidate.id === userPoolId)
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
const createMutation = useMutation({
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
onSuccess: refresh,
})
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
const error = createMutation.error ?? deleteMutation.error
return <>
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
event.preventDefault()
if (ready && name.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
<label className="dynamic-field">
<span>Tokens from</span>
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
<option value="cognito">A Cognito user pool</option>
<option value="issuer">Another OpenID Connect issuer</option>
</select>
</label>
{source === 'cognito' ? <>
<label className="dynamic-field">
<span>User pool</span>
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
<option value="">Select a user pool</option>
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
</select>
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
</label>
<label className="dynamic-field">
<span>App client</span>
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
<option value="">Select an app client</option>
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
</select>
<small>Only this client's tokens are accepted.</small>
</label>
</> : <>
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
</>}
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</>
}
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [origins, setOrigins] = useState('')
const [methods, setMethods] = useState<string[]>([])
const [headers, setHeaders] = useState('')
const [exposeHeaders, setExposeHeaders] = useState('')
const [maxAge, setMaxAge] = useState('')
const [credentials, setCredentials] = useState(false)
const [notice, setNotice] = useState('')
useEffect(() => {
const cors = api.cors
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
setCredentials(cors?.allowCredentials ?? false)
setNotice('')
}, [api.apiId, api.cors])
const mutation = useMutation({
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
onSuccess: (cors) => {
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
return queryClient.invalidateQueries({queryKey})
},
})
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
return <>
<div className="input-row resource-table-bar"><div>
<p className="eyebrow">CORS</p>
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
</div></div>
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
event.preventDefault()
setNotice('')
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
}}>
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
</>
}
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
const [method, setMethod] = useState('GET')
const [path, setPath] = useState(suggestedPath(api.routes))
const [query, setQuery] = useState('')
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
const [body, setBody] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '$default')
setPath(suggestedPath(api.routes))
// Only when another API is opened; keep what the user typed while this one refreshes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a request</p>
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Method</span>
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
</label>
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
<small>{result.durationMs} ms</small>
</div>
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
<pre>{formatResponseBody(result.body)}</pre>
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
</div>
</div>
</section>
}
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
const [query, setQuery] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '')
// Only when another API is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a message</p>
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending && stage) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
<small>{result.durationMs} ms</small>
</div>
{result.error && <p className="error-text compact-text">{result.error}</p>}
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
</div>
</div>
</section>
}
function describeTarget(route: HttpApiRouteOverview): string {
const integration = route.integration
if (!integration) return 'No target'
if (integration.functionName) return `Lambda: ${integration.functionName}`
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
return integration.type
}
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
if (!endpoint) return null
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
}
/** A path to try first: the first route's path with its parameters filled in. */
function suggestedPath(routes: HttpApiRouteOverview[]): string {
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
if (!path || path === '/') return '/hello'
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
}
+10 -14
View File
@@ -1,5 +1,5 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react'
import theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme'
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
export function Layout() {
const location = useLocation()
const activeCloud = activeCloudFromPath(location.pathname)
const {theme, toggle} = useTheme()
const {theme, setTheme} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
@@ -185,12 +184,13 @@ export function Layout() {
<div className="nav-section">
<span className="nav-label">General</span>
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
</div>
<CloudServiceNav collapsed={collapsed}/>
</nav>
<div className="sidebar-footer">
<span className="sidebar-footer-text">Floci DevTools · Local</span>
<span className="sidebar-footer-text">TheCloud · Local</span>
<button
ref={toggleRef}
className="icon-btn"
@@ -209,15 +209,11 @@ export function Layout() {
<div className="shell">
<header className="topbar">
<TopbarSearch/>
<button
className="icon-btn"
type="button"
onClick={toggle}
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
>
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
</button>
<div className="theme-switcher" role="group" aria-label="Console theme">
<button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
<button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
<button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
</div>
<div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/>
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
@@ -361,6 +357,6 @@ function TopbarSearch() {
}
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
}
@@ -58,7 +58,7 @@ export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSub
<div>
<p className="eyebrow">Amazon Lightsail</p>
<h2>Create an instance</h2>
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally through Floci.</p>
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally on TheCloud.</p>
</div>
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
</div>
@@ -1,7 +1,7 @@
import {render, screen} from '@testing-library/react'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest'
import {afterEach, expect, test, vi} from 'vitest'
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
const api = vi.hoisted(() => ({
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
afterEach(() => {
vi.useRealTimers()
})
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
const user = userEvent.setup()
vi.useFakeTimers({shouldAdvanceTime: true})
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
api.putFlatItem.mockResolvedValue({})
@@ -32,6 +37,10 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
await user.click(screen.getByRole('button', {name: /create listener/i}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
// Creation holds its pending state for a minimum of five seconds, which disables the row actions.
await vi.advanceTimersByTimeAsync(5_000)
await waitFor(() => expect(screen.getByRole('button', {name: /delete listener 80/i})).toBeEnabled())
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
})
@@ -190,7 +190,7 @@ function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult;
</div>
{stillRunning && (
<div className="muted padded">
Floci hadn't finished this query when the console stopped waiting — these rows are a partial,
The runtime hadn't finished this query when the console stopped waiting — these rows are a partial,
in-progress result, not the full answer. Run the query again to see if it has finished
(query id: {result.queryId}).
</div>

Some files were not shown because too many files have changed in this diff Show More