diff --git a/.github/workflows/support-form-tests.yml b/.github/workflows/support-form-tests.yml new file mode 100644 index 000000000000..1f7c18071f2d --- /dev/null +++ b/.github/workflows/support-form-tests.yml @@ -0,0 +1,118 @@ +name: Support form tests + +# Runs the unit suites for the support form -- both halves. The server suite +# covers the /api/support Lambda (payload validation, the handler's status +# paths, the Intercom client); the client suite drives theme/src/ts/ +# support-form.ts against jsdom (draft recovery, query-param prefill, +# validation, submission). They are separate packages with separate +# toolchains, so they run as separate steps. +# +# The suite existed before this workflow did, and nothing ran it: `make test` +# covers only the example programs, and `make ci_pull_request` previews the +# Pulumi stack without compiling or testing infrastructure/ on its own. That +# gap was not theoretical -- when the Intercom client replaced the logging stub, +# three handler tests started reaching api.intercom.io with no credentials and +# failing, and CI stayed green because nothing was running them. +# +# Deliberately standalone rather than folded into the PR gate: ci_pull_request +# needs AWS and Pulumi credentials and degrades to a plain build without them, +# so it skips on fork PRs. A dependency-free unit suite should run for everyone. +# +# The filter is the whole of infrastructure/, not just support-form/, because +# tsconfig.json's `files` array starts at index.ts: `tsc -p tsconfig.json` +# type-checks the entire Pulumi program, so a type error in index.ts or +# supportForm.ts fails this suite. Filtering to support-form/** would let that +# PR go green and land the breakage on the next unrelated support-form PR, which +# would then look like the culprit. +# +# versioned-docs is excluded because it is a separate Pulumi program with its +# own Pulumi.yaml, package.json and tsconfig.json, and the main program only +# reaches it by stack reference — `tsc -p tsconfig.json` never compiles a line +# of it. Without the exclusion this job would run, pass, and report green on a +# PR it had not examined at all, which is worse than not running: a check that +# looks like coverage and isn't. Nothing in CI type-checks that program today — +# see pulumi/docs#21136, which is what would make the directory genuinely +# covered rather than merely matched by a filter. +on: + pull_request: + paths: + - 'infrastructure/**' + - '!infrastructure/versioned-docs/**' + # The shared "did every suite actually run" guard, used by both halves. + - 'scripts/check-test-suites-compiled.js' + - 'theme/src/ts/support-form.ts' + # Every *.test.ts under theme/src/ts, not just this one. The guard walks + # the whole tree at any depth and fails on a suite missing from + # tsconfig.test.json's `files` array, so watching only the named file + # would let the PR that adds theme/src/ts/lightbox.test.ts land green and + # turn this job red on the next unrelated support-form PR -- the same + # failure the server filter above was widened to avoid. Scoped to test + # files rather than all of theme/src/ts because, unlike the Pulumi + # program, tsconfig.test.json compiles a fixed three-file list: an + # unrelated theme module cannot break this suite, only an unregistered + # suite can. + - 'theme/src/ts/**/*.test.ts' + - 'theme/tsconfig.test.json' + - 'theme/package.json' + # The client suite asserts the layout still provides the ids it binds to. + - 'layouts/page/support-new.html' + - '.github/workflows/support-form-tests.yml' + +permissions: + contents: read + +concurrency: + group: support-form-tests-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test: + name: Run support-form test suite + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: actions/setup-node@v7 + with: + node-version: '24.x' + cache: 'yarn' + cache-dependency-path: | + infrastructure/yarn.lock + theme/yarn.lock + - name: Install deps + run: | + yarn --cwd infrastructure install --frozen-lockfile + yarn --cwd theme install --frozen-lockfile + # Tee'd rather than run bare so the count can be asserted afterwards. + # check-test-suites-compiled.js proves every suite will be reached; this proves + # the runner actually executed assertions once it got there. A suite whose + # tests were all commented out would satisfy the first check and not this + # one. `pipefail` keeps a genuine test failure failing the step, which a + # bare pipe into tee would otherwise mask. + - name: Run server suite + shell: bash + run: | + set -o pipefail + yarn --cwd infrastructure test-support-form 2>&1 | tee /tmp/support-form-tests.log + + - name: Run client suite + shell: bash + run: | + set -o pipefail + yarn --cwd theme test-support-form 2>&1 | tee /tmp/support-form-client-tests.log + + - name: Assert both suites actually ran + shell: bash + run: | + for suite in server:/tmp/support-form-tests.log client:/tmp/support-form-client-tests.log; do + name=${suite%%:*} + log=${suite#*:} + count=$(grep -oP '^# tests \K\d+|^ℹ tests \K\d+' "$log" | tail -1) + if [ -z "$count" ] || [ "$count" -eq 0 ]; then + echo "::error::The $name suite reported ${count:-no} tests. Something is not running." + exit 1 + fi + echo "$name suite ran $count tests." + done diff --git a/.gitignore b/.gitignore index 13f12cd7975e..67024403f274 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,9 @@ assets/images/generated/ # Written to the repo root (prettier resolves --ignore-path patterns relative to # the ignore file's own directory) and removed when the run exits. /.prettierignore.union + +# Compiled output for the theme unit tests (see theme/tsconfig.test.json). +/theme/bin-test/ + +# Playwright MCP session artifacts (browser snapshots written into the repo root). +.playwright-mcp/ diff --git a/content/blog/hidden-costs-of-infrastructure-management/index.md b/content/blog/hidden-costs-of-infrastructure-management/index.md index f75f4bb39f9e..c7b6f4ad6580 100644 --- a/content/blog/hidden-costs-of-infrastructure-management/index.md +++ b/content/blog/hidden-costs-of-infrastructure-management/index.md @@ -99,7 +99,7 @@ With Pulumi IaC, your teams can tackle the growing complexity of modern architec When you’re scaling your organization, you’ll need an IaC backend that makes team onboarding efficient. DIY backends often involve ad-hoc onboarding with bespoke identity solutions, requiring custom documentation and training to troubleshoot unique problems. This can slow down onboarding and reduce end-user productivity. Additionally, DIY backends place the internal support burden on your team, requiring them to assist users in navigating the system. -In contrast, Pulumi Cloud offers standardized, well-documented capabilities and integrates seamlessly with identity systems, leading to efficient onboarding and increased productivity for new employees. Pulumi Cloud provides [12x5 or 24x7 support](https://support.pulumi.com/hc/en-us), ensuring your organization receives immediate assistance with any architectural, cloud-related, or Pulumi best-practices issues. Furthermore, Pulumi Cloud includes built-in RBAC, simplifying the onboarding and scaling of new users. +In contrast, Pulumi Cloud offers standardized, well-documented capabilities and integrates seamlessly with identity systems, leading to efficient onboarding and increased productivity for new employees. Pulumi Cloud provides [12x5 or 24x7 support](/pricing/), ensuring your organization receives immediate assistance with any architectural, cloud-related, or Pulumi best-practices issues. Furthermore, Pulumi Cloud includes configurable RBAC (Enterprise edition and above), simplifying the onboarding and scaling of new users. {{% notes type="tip" %}} **BMW Group** used Pulumi to build a scalable and resilient hybrid cloud implementation that could handle more than eleven thousand developers. diff --git a/content/blog/journaling-ga/index.md b/content/blog/journaling-ga/index.md index bb57c5c6d463..65bbb3345db4 100644 --- a/content/blog/journaling-ga/index.md +++ b/content/blog/journaling-ga/index.md @@ -68,4 +68,4 @@ This data already shows the expected significant improvement in update times, es While this was an opt-in process using the `PULUMI_ENABLE_JOURNALING` environment variable, this opt-in is no longer required. Just upgrade your Pulumi CLI to v3.225.0+ and use the Pulumi Cloud backend, and journaling will automatically speed up your updates. -If you encounter any issues, reach out on the [Pulumi Community Slack](https://slack.pulumi.com/) or through [Pulumi Support](https://support.pulumi.com/hc/en-us). You can also set the `PULUMI_DISABLE_JOURNALING=true` env variable to opt out of journaling. +If you encounter any issues, reach out on the [Pulumi Community Slack](https://slack.pulumi.com/) or through [Pulumi Support](/support/new/). You can also set the `PULUMI_DISABLE_JOURNALING=true` env variable to opt out of journaling. diff --git a/content/blog/journaling/index.md b/content/blog/journaling/index.md index 3c1c69af02b6..98d5ba4584a0 100644 --- a/content/blog/journaling/index.md +++ b/content/blog/journaling/index.md @@ -325,7 +325,7 @@ The full documentation of the algorithm can be found in our [developer docs](htt - We implemented the replay interface inside the `pulumi` CLI, and ran it in parallel with the current snapshotting implementation in our tests. The snapshots were then compared automatically, and tests made to fail when the result didn't match. - Since tests can't cover all possible edge cases, the next step was to run the journaler in parallel with the current snapshotting implementation internally. This was still without sending the results to the service. However we would compare the snapshot, and send an error event to the service if the snapshot didn't match. In our data warehouse we could then inspect any mismatches, and fix them. Since this does involve the service in a minor way, we would only do this if the user is using the Cloud backend. - Next up was adding a feature flag for the service, so journaling could be turned on selectively for some orgs. At the same time we implemented an opt-in environment variable in the CLI (`PULUMI_ENABLE_JOURNALING`), so the feature could be selectively turned on by users, if both the feature flag is enabled and the user sets the environment variable. This way we could slowly start enabling this in our repos, e.g. first in the integration tests for `pulumi/pulumi`, then in the tests for `pulumi/examples` and `pulumi/templates`, etc. -- Allow users to start opting in. If you want to opt-in with your org, please reach out to us, either on the [Community Slack](https://slack.pulumi.com/), or through our [Support channels](https://support.pulumi.com/hc/en-us), and we'll opt your org into the feature flag. Then you can begin seeing the performance improvements by setting the `PULUMI_ENABLE_JOURNALING` env variable to true. +- Allow users to start opting in. If you want to opt-in with your org, please reach out to us, either on the [Community Slack](https://slack.pulumi.com/), or through our [Support channels](/support/new/), and we'll opt your org into the feature flag. Then you can begin seeing the performance improvements by setting the `PULUMI_ENABLE_JOURNALING` env variable to true. - Turn on the feature flag for everyone, but still require the `PULUMI_ENABLE_JOURNALING` env variable to be set to true. (We are here right now). - Flip the feature on by default, but still allow users to opt out using a `PULUMI_DISABLE_JOURNALING` env variable. diff --git a/content/blog/short-lived-access-tokens/index.md b/content/blog/short-lived-access-tokens/index.md index a798a5c9173b..55de4bf8382a 100644 --- a/content/blog/short-lived-access-tokens/index.md +++ b/content/blog/short-lived-access-tokens/index.md @@ -62,4 +62,4 @@ curl \ We invite you to try out the new short lived access tokens in Pulumi Cloud. As always, we value your feedback and look forward to hearing how this feature helps streamline your workflows and enhances security. -For more details, check out [our documentation](/docs/administration/access-identity/access-tokens/) and [API reference docs](/docs/reference/cloud-rest-api/). If you have any questions or need assistance, [our support team](https://support.pulumi.com/hc/en-us) is here to help. +For more details, check out [our documentation](/docs/administration/access-identity/access-tokens/) and [API reference docs](/docs/reference/cloud-rest-api/). If you have any questions or need assistance, [our support team](/support/new/) is here to help. diff --git a/content/contact/_index.md b/content/contact/_index.md index 1b9ceae8cd7b..29ee66549f1e 100644 --- a/content/contact/_index.md +++ b/content/contact/_index.md @@ -19,7 +19,7 @@ quick_links: - label: Already a customer? description: File a support ticket for a fast response from our team. cta_label: Get support - url: https://support.pulumi.com/hc/en-us/requests/new + url: /support/new/ form: - key: general @@ -39,6 +39,6 @@ form: hubspot_form_id: cta1 cta: label: Submit a Request - url: https://support.pulumi.com/hc/en-us/requests/new + url: /support/new/ --- diff --git a/content/docs/administration/concepts/organizations.md b/content/docs/administration/concepts/organizations.md index 00a5e550f69e..b0d37647fbd1 100644 --- a/content/docs/administration/concepts/organizations.md +++ b/content/docs/administration/concepts/organizations.md @@ -112,7 +112,7 @@ To update your organization's display name: Updating the display name requires the `organization:rename` permission, which is granted to organization admins. -If you need a legal entity name on invoices that is different from your organization's product-facing display name, [contact support](https://support.pulumi.com/). +If you need a legal entity name on invoices that is different from your organization's product-facing display name, [contact support](/support/new/). ## Transferring stacks diff --git a/content/docs/administration/guides/saml/auth0.md b/content/docs/administration/guides/saml/auth0.md index 8ffa063d9e56..3f43fe90ad0c 100644 --- a/content/docs/administration/guides/saml/auth0.md +++ b/content/docs/administration/guides/saml/auth0.md @@ -62,4 +62,4 @@ To configure Pulumi with the SAML metadata: Auth0 troubleshooting: [SAML app error messages](https://auth0.com/docs/troubleshoot/authentication-issues/troubleshoot-saml-configurations) -For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](/support/new/). diff --git a/content/docs/administration/guides/saml/entra.md b/content/docs/administration/guides/saml/entra.md index 6fa9d0b00458..01ae31becd22 100644 --- a/content/docs/administration/guides/saml/entra.md +++ b/content/docs/administration/guides/saml/entra.md @@ -115,4 +115,4 @@ sign in to your Entra ID instance, and then immediately be redirected back to Pu ## Troubleshooting -For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](/support/new/). diff --git a/content/docs/administration/guides/saml/gsuite.md b/content/docs/administration/guides/saml/gsuite.md index e097f4b140f8..5a5a935d4266 100644 --- a/content/docs/administration/guides/saml/gsuite.md +++ b/content/docs/administration/guides/saml/gsuite.md @@ -102,4 +102,4 @@ name of your Pulumi organization. Google Workspace SAML troubleshooting: [SAML app error messages](https://support.google.com/a/answer/6301076) -For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](/support/new/). diff --git a/content/docs/administration/guides/saml/okta.md b/content/docs/administration/guides/saml/okta.md index f1bee157b67a..e0226e92125e 100644 --- a/content/docs/administration/guides/saml/okta.md +++ b/content/docs/administration/guides/saml/okta.md @@ -138,4 +138,4 @@ name of your Pulumi organization. ## Troubleshooting -For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](/support/new/). diff --git a/content/docs/administration/guides/saml/onelogin.md b/content/docs/administration/guides/saml/onelogin.md index caba41799fae..72e46a2ab3a9 100644 --- a/content/docs/administration/guides/saml/onelogin.md +++ b/content/docs/administration/guides/saml/onelogin.md @@ -110,4 +110,4 @@ name of your Pulumi organization. ## Troubleshooting -For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/guides/saml/troubleshooting/) or [contact support](/support/new/). diff --git a/content/docs/administration/guides/saml/troubleshooting.md b/content/docs/administration/guides/saml/troubleshooting.md index 61ed91764936..e10e14ac6995 100644 --- a/content/docs/administration/guides/saml/troubleshooting.md +++ b/content/docs/administration/guides/saml/troubleshooting.md @@ -16,7 +16,7 @@ aliases: ## Locked out of your organization -If you are locked out of your Pulumi organization due to a SAML configuration error or an expired certificate, a [SAML admin](/docs/administration/guides/saml/saml-admin/) can log in using an alternative login method to resolve the issue. If your organization does not have a SAML admin configured, [contact support](https://support.pulumi.com/). +If you are locked out of your Pulumi organization due to a SAML configuration error or an expired certificate, a [SAML admin](/docs/administration/guides/saml/saml-admin/) can log in using an alternative login method to resolve the issue. If your organization does not have a SAML admin configured, [contact support](/support/new/). ## Validation error while trying to save an IdP-provided metadata XML in Pulumi Cloud diff --git a/content/docs/administration/guides/scim/troubleshooting.md b/content/docs/administration/guides/scim/troubleshooting.md index 4b6907e6ffb8..89b10c941436 100644 --- a/content/docs/administration/guides/scim/troubleshooting.md +++ b/content/docs/administration/guides/scim/troubleshooting.md @@ -22,7 +22,7 @@ This page describes how to resolve issues that may occur when configuring SCIM p ## User provisioning failures -These errors can occur when attempting to create (POST), replace (PUT), or update (PATCH) a user. If you encounter difficulties resolving these issues, please contact our [customer support](https://support.pulumi.com/) for assistance. +These errors can occur when attempting to create (POST), replace (PUT), or update (PATCH) a user. If you encounter difficulties resolving these issues, contact [customer support](/support/new/). ### Email already in use diff --git a/content/docs/administration/self-hosting/components/api.md b/content/docs/administration/self-hosting/components/api.md index d0569a3fdef7..fad89365f3e6 100644 --- a/content/docs/administration/self-hosting/components/api.md +++ b/content/docs/administration/self-hosting/components/api.md @@ -73,8 +73,8 @@ between the API and the database. The API also supports [exporting OpenTelemetry | PULUMI_DATABASE_NAME | The name of the database on the database server. | | PULUMI_API_DOMAIN | The internet or network-local domain using which the API service can be reached, e.g. `pulumiapi.acmecorp.com`. Default is `localhost:8080`. | | PULUMI_CONSOLE_DOMAIN | The internet or network-local domain using which the Console can be reached, e.g. `pulumiconsole.acmecorp.com`. Default is `localhost:3000`. | -| PULUMI_ENGINE_EVENTS_SCHEMA_V2 | Set this environment variable to `true` for fresh installs. **If you have an existing installation and the environment variable is currently not set or set to `false`, contact [Pulumi support](https://support.pulumi.com/) before setting it to `true`.** | -| PULUMI_ENGINE_EVENTS_LEGACY_WRITE | Set this environment variable to `false` for fresh installs. **If you have an existing installation and the environment variable is currently not set or set to `true` in your installation, contact [Pulumi support](https://support.pulumi.com/) before setting it to `false`.** | +| PULUMI_ENGINE_EVENTS_SCHEMA_V2 | Set this environment variable to `true` for fresh installs. **If you have an existing installation and the environment variable is currently not set or set to `false`, contact [Pulumi support](/support/new/) before setting it to `true`.** | +| PULUMI_ENGINE_EVENTS_LEGACY_WRITE | Set this environment variable to `false` for fresh installs. **If you have an existing installation and the environment variable is currently not set or set to `true` in your installation, contact [Pulumi support](/support/new/) before setting it to `false`.** | ## Object storage diff --git a/content/docs/iac/get-started/terraform/next-steps.md b/content/docs/iac/get-started/terraform/next-steps.md index 2fa4c1af8fd8..89a822ed7f85 100644 --- a/content/docs/iac/get-started/terraform/next-steps.md +++ b/content/docs/iac/get-started/terraform/next-steps.md @@ -349,7 +349,7 @@ Reach out to us via these support channels: * **[Pulumi Community Slack](https://slack.pulumi.com/)**: Real-time community support * **[GitHub Issues](https://github.com/pulumi/pulumi/issues)**: Bug reports and feature requests -* **[Pulumi Support](https://support.pulumi.com/)**: Professional support for Pulumi Cloud customers +* **[Pulumi Support](/support/new/)**: Professional support for Pulumi Cloud customers ### Open source contributions diff --git a/content/docs/iac/guides/building-extending/packages/publishing-packages.md b/content/docs/iac/guides/building-extending/packages/publishing-packages.md index 847843a70351..49439e245f80 100644 --- a/content/docs/iac/guides/building-extending/packages/publishing-packages.md +++ b/content/docs/iac/guides/building-extending/packages/publishing-packages.md @@ -80,7 +80,7 @@ If you don't need the full customization of a published package — you just wan Popular Terraform providers also surface in the public Pulumi Registry as **dynamically-bridged** listings (for example, [Honeycomb](/registry/packages/honeycombio/) and [Supabase](/registry/packages/supabase/)); consumers still generate the SDK locally with `pulumi package add`. The rest of this guide covers authoring and publishing a full package; if the Any Terraform Provider path fits your needs, follow that guide instead. {{% notes type="info" %}} -Registry listings for dynamically-bridged Terraform providers are generated automatically and don't include a logo by default. To have a logo added to your provider's Registry page, reach out to [Pulumi support](https://support.pulumi.com/) with a link to a web-accessible SVG (wordmarks preferred, with all surrounding whitespace removed). +Registry listings for dynamically-bridged Terraform providers are generated automatically and don't include a logo by default. To have a logo added to your provider's Registry page, reach out to [Pulumi support](/support/new/) with a link to a web-accessible SVG (wordmarks preferred, with all surrounding whitespace removed). {{% /notes %}} ## Author your resources or components diff --git a/content/docs/iac/operations/stack-management/restoring-deleted-stacks.md b/content/docs/iac/operations/stack-management/restoring-deleted-stacks.md index e3f7450cd1b7..1349b4e580ad 100644 --- a/content/docs/iac/operations/stack-management/restoring-deleted-stacks.md +++ b/content/docs/iac/operations/stack-management/restoring-deleted-stacks.md @@ -27,7 +27,7 @@ If the stack was deleted with `pulumi stack rm --force` while resources still ex - Only the **last 25 deleted stacks** in an organization are available for self-service restore. - Only **organization administrators** can restore stacks. -- If you need to restore an older stack that is no longer in the list, [contact Pulumi support](https://support.pulumi.com/). +- If you need to restore an older stack that is no longer in the list, [contact Pulumi support](/support/new/). ## Restore a stack diff --git a/content/docs/integrations/version-control/github-app.md b/content/docs/integrations/version-control/github-app.md index 0a7ead8ed93e..84bb7c75b008 100644 --- a/content/docs/integrations/version-control/github-app.md +++ b/content/docs/integrations/version-control/github-app.md @@ -54,7 +54,7 @@ An installation can be linked to one Pulumi organization this way. If it's alrea Multiple GitHub organizations can be connected to a single Pulumi organization. You can add each one via **Management** > **Version control** > **Add account**. {{% notes type="info" %}} -Mapping a single GitHub organization to multiple Pulumi organizations requires contacting [Pulumi support](https://www.pulumi.com/support/). This option is only available for Enterprise and Business Critical customers. +Mapping a single GitHub organization to multiple Pulumi organizations requires contacting [Pulumi support](/support/new/). This option is only available for Enterprise and Business Critical customers. {{% /notes %}} ### GitHub Enterprise Server support diff --git a/content/extend-trial/_index.md b/content/extend-trial/_index.md index b9675cd1688e..1c46952c0aa9 100644 --- a/content/extend-trial/_index.md +++ b/content/extend-trial/_index.md @@ -41,7 +41,7 @@ help_links: - label: Talk to sales about pricing url: /contact/ - label: Open a support ticket - url: https://support.pulumi.com/ + url: /support/new/ - label: Ask the community on Slack url: https://slack.pulumi.com/ --- diff --git a/content/support/_index.md b/content/support/_index.md index 4e3d82cc52e6..56c103012762 100644 --- a/content/support/_index.md +++ b/content/support/_index.md @@ -1,4 +1,4 @@ --- -redirect_to: "https://support.pulumi.com/" +redirect_to: "/support/new/" block_external_search_index: true --- diff --git a/content/support/new/_index.md b/content/support/new/_index.md new file mode 100644 index 000000000000..23eeffde0bb2 --- /dev/null +++ b/content/support/new/_index.md @@ -0,0 +1,78 @@ +--- +title: Submit a Support Request +meta_desc: Open a support request with the Pulumi support team. Tell us what you're running into and we'll get back to you by email. +type: page +layout: support-new +# Transactional form page. Keep it out of search until the Intercom cutover +# makes it the canonical support entry point. +block_external_search_index: true + +overview: + eyebrow: Pulumi support + title: Submit a request + description: Tell us what you're running into and the Pulumi support team will get back to you by email. Fields marked with an asterisk (*) are required. + +form: + fields: + email: + label: Your email address + name: + label: Full name + organization: + label: Pulumi organization name + help: https://app.pulumi.com/PULUMI_ORG_NAME + priority: + label: Priority + options: + - label: Normal + value: normal + - label: Urgent + value: urgent + subject: + label: Subject + description: + label: Description + help: Enter the details of your request. It always helps to include code snippets, current behavior, and expected behavior when encountering issues. Markdown is welcome. + submit: Submit + submitting: Submitting… + # Rendered through markdownify, so the Slack escape hatch is a real link. + # This banner is the one moment a visitor needs it, and a bare URL they + # have to copy out by hand is a poor thing to hand someone whose request + # just failed. + error_banner: "We couldn't send your request just now. Your entries are saved in this browser tab — please try again in a moment, or [ask the community in Pulumi Slack](https://slack.pulumi.com/)." + +confirmation: + title: Request received. We're on it. + description: Your request is with the Pulumi support team. Keep an eye on your inbox — replies come from Pulumi support by email. + recap: + - label: Organization + field: organization + - label: Subject + field: subject + steps: + - title: Now. + description: Your request has been logged with the Pulumi support team. + # No response-time number here. data/pulumi_pricing.yaml scopes contracted + # support to the Enterprise edition and above, and puts the normal-ticket + # SLA at "1 or 5 business days" even there — so "usually within one + # business day" overstates it for every reader of a form that any + # anonymous visitor can reach. If support wants a published figure, it + # belongs here with their sign-off and it has to match that file. + - title: Next. + description: A support engineer reviews it and replies by email. How soon depends on your Pulumi Cloud edition. + - title: Then. + description: You work the issue together over email. If we need files or more detail, we'll ask there. + +help_links: + title: Need something else? + description: "If this isn't a support request, these get you there faster:" + links: + - label: Ask the community on Slack + url: https://slack.pulumi.com/ + - label: Browse the documentation + url: /docs/ + - label: Check Pulumi service status + url: https://status.pulumi.com/ + - label: Talk to sales + url: /contact/ +--- diff --git a/data/footer.yml b/data/footer.yml index 174f6c859337..ececa4eaaa5d 100644 --- a/data/footer.yml +++ b/data/footer.yml @@ -120,7 +120,7 @@ columns: href: https://slack.pulumi.com/ track: footer-help-slack - label: Customer support - href: https://support.pulumi.com/ + href: /support/new/ track: footer-support - label: Professional services href: /proserv/ diff --git a/infrastructure/Pulumi.www-production.yaml b/infrastructure/Pulumi.www-production.yaml index 562f1cf9fac8..bcbbec31f65e 100644 --- a/infrastructure/Pulumi.www-production.yaml +++ b/infrastructure/Pulumi.www-production.yaml @@ -18,3 +18,7 @@ config: www.pulumi.com:enableWaf: "true" www.pulumi.com:wafRateLimit: "500" www.pulumi.com:enableDataWarehouseAccess: "true" + www.pulumi.com:enableSupportForm: "true" + www.pulumi.com:intercomTicketTypeId: "3036244" + www.pulumi.com:intercomApiKey: + secure: AAABABqrMKEtK06xtI+MNA7niGyBS1pe7ns9Se96vKEXESNWcRn9RNX7E06uraYBygHF1PUifKZZemQCgnWUtwufpHxBn+wb4j0qKC/lCuB7fhn65DkgVaYaswY= diff --git a/infrastructure/Pulumi.www-testing.yaml b/infrastructure/Pulumi.www-testing.yaml index f2844c83e890..6ba5462c30a5 100644 --- a/infrastructure/Pulumi.www-testing.yaml +++ b/infrastructure/Pulumi.www-testing.yaml @@ -3,6 +3,7 @@ config: www.pulumi.com:addSecurityHeaders: "true" www.pulumi.com:certificateArn: "arn:aws:acm:us-east-1:571684982431:certificate/dacf95ab-d4dd-4370-9c93-6ce0b9dda7c0" www.pulumi.com:doEdgeRedirects: "true" + www.pulumi.com:enableSupportForm: "true" www.pulumi.com:hostedZone: www.pulumi-test.io www.pulumi.com:makeFallbackBucket: "false" www.pulumi.com:pathToOriginBucketMetadata: ../origin-bucket-metadata.json @@ -13,3 +14,6 @@ config: www.pulumi.com:setRootRecord: "true" www.pulumi.com:websiteDomain: www.pulumi-test.io www.pulumi.com:websiteLogsBucketName: pulumi-test-io-website-logs + www.pulumi.com:intercomTicketTypeId: "4573798" + www.pulumi.com:intercomApiKey: + secure: AAABAJ2bHeR1rJ6zKuK1eW5I83ueGZ3bwh8ELILQeauY3Ca+cnmF+YyaS4GNAgPKN03geZ0mBxEli2g1kSBftGApsV3q3tvw4ImqXTtj7+dckCroD8xx97P+YVc= diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 825229fc2c31..9945f647d3b4 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -6,6 +6,7 @@ import * as fs from "fs"; import { getAIRedirectAndGoneAssociation, getEdgeRedirectAssociation } from "./cloudfrontLambdaAssociations"; import { getMarkdownNegotiationFunctionAssociation, getMarketingMarkdownNegotiationFunctionAssociation, getApiCatalogContentTypeFunctionAssociation } from "./cloudfrontFunctions"; +import { SupportFormApi } from "./supportForm"; const stackConfig = new pulumi.Config(); @@ -77,6 +78,12 @@ const config = { // wafRateLimit is the maximum number of requests per 5-minute window per IP before WAF blocks. wafRateLimit: stackConfig.getNumber("wafRateLimit") || 500, + + // enableSupportForm toggles the /api/support endpoint backing the support-request + // form at /support/new/ (see supportForm.ts), which files submissions as Intercom + // tickets. Requires the intercomApiKey (secret) and intercomTicketTypeId stack + // config values — see SupportFormApiArgs in supportForm.ts. + enableSupportForm: stackConfig.getBoolean("enableSupportForm") || false, }; // CloudFront Function to lowercase URIs for .NET SDK docs so that @@ -115,17 +122,60 @@ if (config.enableWaf) { description: `Rate limiting for ${config.websiteDomain}`, defaultAction: { allow: {} }, rules: [{ + // The link checker crawls hard enough to trip the rate limit, so it + // is exempt -- but the exemption is keyed on a User-Agent string, + // which any caller can send. That was harmless while everything + // behind this WAF was a static GET; /api/support is neither. It + // takes unauthenticated POSTs that create Intercom contacts and + // tickets, and the rate-based rule below is the only thing limiting + // them, so a one-line header would have lifted that limit entirely. + // + // The exemption is therefore scoped to what the link checker + // actually does: everything except /api/. The crawler only follows + // links on rendered pages and never posts, so nothing it does is + // affected. name: "allow-link-checker", priority: 0, action: { allow: {} }, statement: { - byteMatchStatement: { - searchString: "pulumi+blc/0.1", - fieldToMatch: { - singleHeader: { name: "user-agent" }, - }, - positionalConstraint: "EXACTLY", - textTransformations: [{ priority: 0, type: "NONE" }], + andStatement: { + statements: [{ + byteMatchStatement: { + searchString: "pulumi+blc/0.1", + fieldToMatch: { + singleHeader: { name: "user-agent" }, + }, + positionalConstraint: "EXACTLY", + textTransformations: [{ priority: 0, type: "NONE" }], + }, + }, { + notStatement: { + statement: { + byteMatchStatement: { + searchString: "/api/", + fieldToMatch: { + uriPath: {}, + }, + positionalConstraint: "STARTS_WITH", + // Decode and lowercase before comparing, per + // AWS's guidance for URI-path matching. The + // rule this negates is a priority-0 + // terminating allow, so a path shape the + // byte-match fails to recognise -- /%61pi/, + // /API/ -- would hand the rate-limit + // exemption back to anyone who sets the + // header. Whether either shape reaches the + // Lambda depends on CloudFront's own + // normalization, so this is defence in + // depth rather than a known bypass. + textTransformations: [ + { priority: 0, type: "URL_DECODE" }, + { priority: 1, type: "LOWERCASE" }, + ], + }, + }, + }, + }], }, }, visibilityConfig: { @@ -787,6 +837,20 @@ const VersionedDocsResponseHeadersPolicy = new aws.cloudfront.ResponseHeadersPol }, }); +// API responses (currently just /api/support*) must never be cached by browsers +// or intermediaries. DefaultCachePolicy would stamp max-age=60 on them, so this +// policy overrides Cache-Control to no-store while keeping the security headers. +const ApiResponseHeadersPolicy = new aws.cloudfront.ResponseHeadersPolicy("api-response-headers", { + securityHeadersConfig: baseSecurityHeadersConfig, + customHeadersConfig: { + items: [permissionsPolicyHeaderItem, { + header: "Cache-Control", + value: "no-store", + override: true, + }], + }, +}); + // baseCacheBehavior holds the fields shared by every behavior. TTLs and // cache-key config are NOT set here: each behavior (default or ordered) must // attach its own cachePolicyId, or set forwardedValues + minTtl/defaultTtl/maxTtl @@ -928,6 +992,75 @@ if (config.versionedDocsStack) { }); } +// The support-request form endpoint (see supportForm.ts). Additive and fully +// optional — dev stacks and PR previews without enableSupportForm get no origin +// or behavior, and the form's frontend degrades gracefully when POSTs to +// /api/support fail. +const supportFormOrigins: aws.types.input.cloudfront.DistributionOrigin[] = []; +const supportFormBehaviors: aws.types.input.cloudfront.DistributionOrderedCacheBehavior[] = []; +let supportForm: SupportFormApi | undefined; + +if (config.enableSupportForm) { + supportForm = new SupportFormApi("support-form", { + intercomApiKey: stackConfig.requireSecret("intercomApiKey"), + intercomTicketTypeId: stackConfig.require("intercomTicketTypeId"), + }); + + supportFormOrigins.push(supportForm.getOrigin()); + + // Origin request policy for /api/support*. + // + // An explicit whitelist rather than "every viewer header except Host", because + // this is an API endpoint and the handler reads exactly one thing from the + // viewer's own headers: content-type. Forwarding nothing else means a caller + // cannot smuggle a header the origin might one day interpret. If the handler + // ever needs another viewer header, it has to be added here — nothing else + // reaches the Lambda. + // + // CloudFront-Viewer-Address is the second item and is not a viewer header at + // all: CloudFront sets it from the TCP connection and overwrites anything the + // client sent, so it cannot be forged. It is how the handler learns the + // submitter's address, which is otherwise unknowable — requestContext's sourceIp + // is the edge node, because CloudFront is what invokes the Function URL. A + // managed header is preferred over a CloudFront Function that stamps the same + // value: there is no edge code to typo, and no way for it to fail closed and + // 502 the endpoint. + // + // Host is dropped by construction, which Lambda Function URL origins require. + // The x-origin-verify shared secret is unaffected — it is an origin + // customHeaders entry (see SupportFormApi.getOrigin), added by CloudFront + // regardless of this policy. + const supportFormOriginRequestPolicy = new aws.cloudfront.OriginRequestPolicy("support-form-origin-request", { + comment: "POST /api/support: forwards the content type and the viewer's address, nothing else.", + cookiesConfig: { cookieBehavior: "none" }, + queryStringsConfig: { queryStringBehavior: "none" }, + headersConfig: { + headerBehavior: "whitelist", + headers: { items: ["content-type", "CloudFront-Viewer-Address"] }, + }, + }); + + supportFormBehaviors.push({ + ...baseCacheBehavior, + targetOriginId: "support-form-api", + pathPattern: "/api/support*", + // CloudFront's only POST-capable allowedMethods set is all seven; the + // handler 405s everything but POST. Only GET/HEAD are cacheable, and + // the no-cache policy keeps even those uncached. + allowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], + cachedMethods: ["GET", "HEAD"], + cachePolicyId: noCacheKeyPolicy.id, + originRequestPolicyId: supportFormOriginRequestPolicy.id, + responseHeadersPolicyId: ApiResponseHeadersPolicy.id, + // API traffic gets no edge redirects, no markdown negotiation, and no + // edge functions at all. The submitter's address arrives as the + // CloudFront-managed CloudFront-Viewer-Address header instead — see the + // origin request policy above. + lambdaFunctionAssociations: [], + functionAssociations: [], + }); +} + // domainAliases is a list of CNAMEs that accompany the CloudFront distribution. Any const domainAliases = []; @@ -994,6 +1127,7 @@ const distributionArgs: aws.cloudfront.DistributionArgs = { ...guidesOrigins, ...answersOrigins, ...versionedDocsOrigins, + ...supportFormOrigins, ], // Default object to serve when no path is given. @@ -1016,6 +1150,10 @@ const distributionArgs: aws.cloudfront.DistributionArgs = { }, orderedCacheBehaviors: [ + // The support-form API endpoint. /api/support* overlaps no other + // pattern; listed first because it's the only non-content behavior. + ...supportFormBehaviors, + ...registryBehaviors, ...guidesBehaviors, ...answersBehaviors, @@ -1369,4 +1507,5 @@ export const cloudFrontDistributionId = cdn.id; export const websiteDomain = config.websiteDomain; export const originS3BucketName = originBucket.bucket; export const wafWebAclArn = webAcl?.arn; +export const supportFormFunctionName = supportForm?.getFunctionName(); export const readme = fs.readFileSync("./README.md").toString(); diff --git a/infrastructure/package.json b/infrastructure/package.json index 30150c636e83..5eb063bdf2e7 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -2,7 +2,8 @@ "name": "www.pulumi.com", "license": "Apache-2.0", "scripts": { - "lint": "tslint --project tsconfig.json" + "lint": "tslint --project tsconfig.json", + "test-support-form": "tsc -p tsconfig.json && node ../scripts/check-test-suites-compiled.js support-form bin/support-form infrastructure/tsconfig.json && node --test bin/support-form/*.test.js" }, "devDependencies": { "@types/aws-lambda": "^8.10.162", @@ -13,6 +14,7 @@ "dependencies": { "@pulumi/aws": "^7.39.0", "@pulumi/pulumi": "^3.255.0", + "@pulumi/random": "^4.16.0", "url-pattern": "^1.0.3" } } diff --git a/infrastructure/support-form/handler.ts b/infrastructure/support-form/handler.ts new file mode 100644 index 000000000000..903e68e8031e --- /dev/null +++ b/infrastructure/support-form/handler.ts @@ -0,0 +1,296 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Lambda handler for POST /api/support — the support-request form endpoint. +// +// The function sits behind a Lambda Function URL that is only reachable (in +// practice) through the www.pulumi.com CloudFront distribution, which injects +// a shared-secret x-origin-verify header at the origin (see supportForm.ts). +// Requests without the secret are rejected, so the public Function URL can't +// be used to bypass the CDN's WAF and rate limiting. +// +// Accepted submissions are filed as Intercom tickets (see ./intercom.ts) and, +// either way, written to CloudWatch Logs as single-line JSON documents (type +// "support_request_accepted" or "support_request_ticket_failed") for +// observability. + +import * as crypto from "crypto"; +import { createSupportTicket } from "./intercom"; +import { MAX_BODY_BYTES, validateSubmission } from "./validation"; + +// Function URLs invoke with the API Gateway v2 payload shape. Only the pieces +// used here are typed, so the closure doesn't drag in @types/aws-lambda at +// runtime. +export interface FunctionUrlEvent { + body?: string; + isBase64Encoded?: boolean; + headers?: Record; + requestContext?: { + http?: { + method?: string; + path?: string; + sourceIp?: string; + }; + }; +} + +export interface FunctionUrlResult { + statusCode: number; + headers: Record; + body: string; +} + +// Longest value accepted from CloudFront-Viewer-Address. An IPv6 address plus a +// port fits comfortably; anything longer is not an address CloudFront set. +const MAX_ADDRESS_LENGTH = 64; + +// Where a logged address came from. Recorded alongside the address itself +// because the two sources mean very different things, and a record that +// silently mixed them would be worse than no record: "edge" is not the +// submitter, and must never be read as though it were. +export type IpSource = "viewer" | "edge" | "unknown"; + +export interface ClientAddress { + ip: string | undefined; + source: IpSource; +} + +// Recovers the address from CloudFront's ":" viewer-address value. +// +// AWS documents the port as always present, so the address is everything before +// the LAST colon -- which is what makes the IPv6 form work ("2001:db8::1:443" -> +// "2001:db8::1") where splitting on the first colon would not. +// +// Two refinements on top of that. The trailing segment has to actually look like +// a port: without that check a value that arrived with no port at all is +// silently truncated to a shorter address that is still labelled as attributed, +// which is worse than not parsing -- a log line that looks like it identifies +// someone and does not. And RFC 3986 bracketing ("[2001:db8::1]:443") is +// stripped, so an address is logged in one queryable form either way. AWS +// documents only the IPv4 example, so the IPv6 rendering is inferred; both +// shapes are handled rather than betting on one. +// +// One case stays ambiguous and is left as-is: a portless IPv6 whose final group +// is all digits ("2001:db8::1") is indistinguishable from an address with a +// port, and loses its last group. AWS documents the port as always present, so +// this should not arise. +function stripPort(value: string): string { + if (value.charAt(0) === "[") { + const closing = value.indexOf("]"); + return closing === -1 ? value : value.slice(1, closing); + } + const lastColon = value.lastIndexOf(":"); + if (lastColon === -1) { + return value; + } + const port = value.slice(lastColon + 1); + if (port.length === 0 || !/^[0-9]+$/.test(port)) { + return value; + } + return value.slice(0, lastColon); +} + +// The submitter's IP address, for the abuse trail in the logs below. +// +// requestContext.http.sourceIp is NOT it: CloudFront invokes the Function URL, +// so that field is the edge node's address (a 3.x CLOUDFRONT_ORIGIN_FACING IP), +// identical in shape for every submission and useless for tracing anyone. +// +// X-Forwarded-For is not the answer either. CloudFront appends the viewer to +// whatever X-Forwarded-For the caller already sent, so the header is partly +// caller-authored by the time it leaves the edge, and reading the wrong end of +// it logs a forged address that looks authentic. (A Function URL may also +// collapse the chain before the handler sees it; we could not find that +// documented either way, which is reason enough not to depend on the shape.) +// +// CloudFront-Viewer-Address avoids all of it. CloudFront sets it from the TCP +// connection and overwrites anything the client sent, so it cannot be forged, +// and it is forwarded by the origin request policy rather than produced by edge +// code that could fail. Trusting it is sound because the caller already proved +// the request came through our distribution: supportFormHandler rejects anything +// without the x-origin-verify shared secret before this is ever called. +// +// The value is ":"; see stripPort for how the address is recovered. +export function clientAddress(event: FunctionUrlEvent): ClientAddress { + const forwarded = event.headers?.["cloudfront-viewer-address"]; + if (typeof forwarded === "string") { + const value = forwarded.trim(); + if (value.length > 0 && value.length <= MAX_ADDRESS_LENGTH) { + const ip = stripPort(value); + if (ip.length > 0) { + return { ip, source: "viewer" }; + } + } + } + // No viewer address: either the origin request policy is not forwarding it + // (a misconfiguration, or mid-deploy propagation) or this is a direct + // Function URL invocation, where sourceIp really is the caller's own peer. + const peer = event.requestContext?.http?.sourceIp; + return { ip: peer, source: peer ? "edge" : "unknown" }; +} + +// A ticket id for the honeypot's fake success. +// +// Intercom's ids are numeric strings, so this has to look like one -- an +// obviously synthetic value (a UUID, a fixed sentinel) would be as good an +// oracle as omitting the field. Nothing consumes it: no ticket exists. +function syntheticTicketId(): string { + // First digit is 1-9. Intercom renders integers, so a real id never has a + // leading zero, and building all 15 digits uniformly gave one in ten of + // these a leading zero -- a free tell for anyone comparing a drop against a + // real acceptance. Width checked against real ids returned by the testing + // workspace: 215475647261127, 215475647300185, 372996254723247. + let digits = String(Math.floor(Math.random() * 9) + 1); + while (digits.length < 15) { + digits += Math.floor(Math.random() * 10).toString(); + } + return digits; +} + +function jsonResponse(statusCode: number, body: object, extraHeaders: Record = {}): FunctionUrlResult { + return { + statusCode, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + ...extraHeaders, + }, + body: JSON.stringify(body), + }; +} + +// The env var holds a comma-separated list so a rotation can accept both the +// old and new secret while the CloudFront origin-header change propagates. +function originSecretOk(header: string | undefined): boolean { + const configured = process.env.SUPPORT_FORM_ORIGIN_SECRET; + if (!configured) { + // Fail closed if the function is somehow deployed without its secret. + return false; + } + if (!header) { + return false; + } + return configured + .split(",") + .map(s => s.trim()) + .filter(s => s.length > 0) + .some(secret => secret === header); +} + +export async function supportFormHandler(event: FunctionUrlEvent): Promise { + const headers = event.headers || {}; + + if (!originSecretOk(headers["x-origin-verify"])) { + return jsonResponse(403, { ok: false, error: "forbidden" }); + } + + // Resolved once, and deliberately only after the secret check: the viewer + // address is trustworthy precisely because CloudFront vouched for this + // request. Computed here rather than at each log site so the success path, + // which runs after the Intercom ticket already exists, cannot fail on it. + const address = clientAddress(event); + + const method = (event.requestContext?.http?.method || "").toUpperCase(); + if (method !== "POST") { + return jsonResponse(405, { ok: false, error: "method_not_allowed" }, { allow: "POST" }); + } + + // Compare the media type alone, not a prefix of the whole header. startsWith + // also accepted application/jsonlines and application/json-patch+json, which + // are different formats that happen to share a prefix; splitting on ";" + // keeps the charset parameter working without that. + const contentType = (headers["content-type"] || "").toLowerCase().split(";")[0].trim(); + if (contentType !== "application/json") { + return jsonResponse(400, { ok: false, error: "unsupported_content_type" }); + } + + if (!event.body) { + return jsonResponse(400, { ok: false, error: "empty_body" }); + } + const rawBody = event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body; + if (Buffer.byteLength(rawBody, "utf8") > MAX_BODY_BYTES) { + return jsonResponse(413, { ok: false, error: "payload_too_large" }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch (err) { + return jsonResponse(400, { ok: false, error: "invalid_json" }); + } + + const result = validateSubmission(parsed); + if (!result.ok) { + return jsonResponse(422, { ok: false, error: "validation_failed", fields: result.fields }); + } + + // Honeypot: the "leave_blank" field is visually hidden on the form, so any + // value in it marks a bot. Pretend success so the bot moves on. + // + // The name matters. As "website" the field was a prime autofill target -- + // password managers store website URLs and match on the field name -- and an + // autofilled trap destroys a real person's request: they are shown the + // confirmation, their draft is deleted, and no ticket exists. A name with no + // autofill semantics costs nothing against the naive bots this catches, + // which fill every field regardless of what it is called. + // + // Deliberately AFTER validation, and returning the same response shape a + // real success does. Checking it first gave a spammer a one-request oracle: + // a knowingly invalid payload plus the honeypot returned 200 where the same + // payload without it returned 422, so the trap announced itself. And a fake + // success that omitted ticketId was distinguishable from a real one by any + // caller that read the documented shape. Both are closed by validating + // first and minting a plausible id. + // + // The response body is indistinguishable; the latency is not. A real + // acceptance awaits up to three sequential round trips to api.intercom.io, + // and this path does no I/O at all, so a determined spammer could tell them + // apart by timing. Left as-is deliberately: closing it means padding this + // path to a plausible duration, which holds a Lambda invocation open to + // serve a bot, and the trap only ever catches the naive ones anyway. + if (typeof parsed === "object" && parsed !== null && (parsed as Record).leave_blank) { + console.log( + JSON.stringify({ + type: "support_request_spam_dropped", + receivedAt: new Date().toISOString(), + sourceIp: address.ip, + ipSource: address.source, + }), + ); + return jsonResponse(200, { ok: true, id: crypto.randomUUID(), ticketId: syntheticTicketId() }); + } + + const id = crypto.randomUUID(); + + let ticketId: string; + try { + ticketId = await createSupportTicket(result.value); + } catch (err) { + console.error( + JSON.stringify({ + type: "support_request_ticket_failed", + id, + error: err instanceof Error ? err.message : String(err), + sourceIp: address.ip, + ipSource: address.source, + request: result.value, + }), + ); + return jsonResponse(502, { ok: false, error: "ticket_creation_failed", id }); + } + + // One JSON document per accepted submission, queryable in CloudWatch Logs + // Insights via { $.type = "support_request_accepted" }. + console.log( + JSON.stringify({ + type: "support_request_accepted", + id, + ticketId, + receivedAt: new Date().toISOString(), + sourceIp: address.ip, + ipSource: address.source, + request: result.value, + }), + ); + + return jsonResponse(200, { ok: true, id, ticketId }); +} diff --git a/infrastructure/support-form/intercom.test.ts b/infrastructure/support-form/intercom.test.ts new file mode 100644 index 000000000000..a7397b62ee04 --- /dev/null +++ b/infrastructure/support-form/intercom.test.ts @@ -0,0 +1,137 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Unit tests for the Intercom ticket-filing client. The Intercom API is +// replaced with an in-process fake bound to globalThis.fetch: intercom.ts calls +// the global directly, so the module needs no injection seam and nothing about +// what handler.ts serializes into the Lambda changes. Run from the +// infrastructure directory with: +// +// yarn test-support-form + +import * as assert from "assert"; +import { test } from "node:test"; + +import { createSupportTicket } from "./intercom"; +import { SupportRequest } from "./validation"; + +process.env.INTERCOM_API_KEY = "test-key"; +process.env.INTERCOM_TICKET_TYPE_ID = "ticket-type-1"; + +interface IntercomCall { + url: string; + method: string; + headers: Record; + body: any; +} + +const request: SupportRequest = { + email: "jane@example.com", + name: "Jane Doe", + organization: "example-corp", + priority: "normal", + subject: "Stack update stuck in progress", + description: "Running `pulumi up` hangs after the preview completes.", +}; + +// Installs a fake Intercom API and returns both the calls it received and a +// restore function, so one test's fake can't leak into the next. failingPath +// makes one leg reject, which is how the throw-on-error paths are exercised. +function fakeIntercom(options: { existingContactId?: string; failingPath?: string } = {}) { + const calls: IntercomCall[] = []; + const realFetch = globalThis.fetch; + + globalThis.fetch = async (input, init) => { + const url = String(input); + calls.push({ + url, + method: (init && init.method) || "GET", + headers: ((init && init.headers) || {}) as Record, + body: init && init.body ? JSON.parse(init.body as string) : undefined, + }); + + if (options.failingPath && url.endsWith(options.failingPath)) { + return new Response("bad request", { status: 400 }); + } + if (url.endsWith("/contacts/search")) { + return new Response( + JSON.stringify({ data: options.existingContactId ? [{ id: options.existingContactId }] : [] }), + { status: 200 }, + ); + } + if (url.endsWith("/contacts")) { + return new Response(JSON.stringify({ id: "contact-created" }), { status: 200 }); + } + if (url.endsWith("/tickets")) { + return new Response(JSON.stringify({ id: "ticket-42" }), { status: 200 }); + } + throw new Error(`unexpected request to ${url}`); + }; + + return { + calls, + restore: () => { + globalThis.fetch = realFetch; + }, + }; +} + +test("files a ticket against an existing contact", async t => { + const fake = fakeIntercom({ existingContactId: "contact-1" }); + t.after(fake.restore); + + assert.strictEqual(await createSupportTicket(request), "ticket-42"); + assert.deepStrictEqual( + fake.calls.map(c => c.url), + ["https://api.intercom.io/contacts/search", "https://api.intercom.io/tickets"], + ); + assert.deepStrictEqual(fake.calls[0].body.query, { field: "email", operator: "=", value: request.email }); + assert.deepStrictEqual(fake.calls[1].body.contacts, [{ id: "contact-1" }]); +}); + +test("creates a contact when the submitter is unknown to Intercom", async t => { + const fake = fakeIntercom(); + t.after(fake.restore); + + await createSupportTicket(request); + + assert.deepStrictEqual( + fake.calls.map(c => c.url), + [ + "https://api.intercom.io/contacts/search", + "https://api.intercom.io/contacts", + "https://api.intercom.io/tickets", + ], + ); + assert.deepStrictEqual(fake.calls[1].body, { role: "lead", email: request.email, name: request.name }); + assert.deepStrictEqual(fake.calls[2].body.contacts, [{ id: "contact-created" }]); +}); + +test("sends the validated submission as ticket attributes", async t => { + const fake = fakeIntercom({ existingContactId: "contact-1" }); + t.after(fake.restore); + + await createSupportTicket(request); + + const ticket = fake.calls[fake.calls.length - 1]; + assert.strictEqual(ticket.method, "POST"); + assert.strictEqual(ticket.headers.Authorization, "Bearer test-key"); + assert.strictEqual(ticket.headers["Intercom-Version"], "2.11"); + assert.strictEqual(ticket.body.ticket_type_id, "ticket-type-1"); + assert.deepStrictEqual(ticket.body.ticket_attributes, { + _default_title_: request.subject, + _default_description_: request.description, + "pulumi-org": request.organization, + priority: request.priority, + }); +}); + +// handler.ts turns any throw from here into a 502, so each leg has to throw +// rather than resolve with a partial result. "/contacts/search" does not end +// with "/contacts", so the middle case really does exercise the create leg. +for (const failingPath of ["/contacts/search", "/contacts", "/tickets"]) { + test(`throws when Intercom rejects ${failingPath}`, async t => { + const fake = fakeIntercom({ failingPath }); + t.after(fake.restore); + await assert.rejects(createSupportTicket(request), /Intercom/); + }); +} diff --git a/infrastructure/support-form/intercom.ts b/infrastructure/support-form/intercom.ts new file mode 100644 index 000000000000..31203bdb37c6 --- /dev/null +++ b/infrastructure/support-form/intercom.ts @@ -0,0 +1,87 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Intercom API client for filing support tickets from accepted submissions. +// Split out from handler.ts so the network-calling code stays separate from +// request/response plumbing; createSupportTicket is the only export handler.ts +// needs. + +import type { SupportRequest } from "./validation"; + +const INTERCOM_API_BASE = "https://api.intercom.io"; +const INTERCOM_VERSION = "2.11"; + +// Per-call ceiling. Three sequential calls run inside the Lambda's own timeout, +// and a bare fetch has none of its own: if Intercom stops responding, the +// function is killed by the runtime instead of returning. That matters because +// the kill happens outside the handler's try/catch, so the caller gets the +// runtime's 502 rather than the documented {ok:false,error:"ticket_creation_failed",id} +// envelope, and support_request_ticket_failed is never logged -- a contact can +// be created with no ticket and no trace of it. Bounding each call keeps the +// failure inside the handler, where it is shaped and recorded. +const INTERCOM_TIMEOUT_MS = 2500; + +function intercomFetch(url: string, body: object): Promise { + return fetch(url, { + method: "POST", + headers: intercomHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(INTERCOM_TIMEOUT_MS), + }); +} + +function intercomHeaders(): Record { + return { + Authorization: `Bearer ${process.env.INTERCOM_API_KEY}`, + "Content-Type": "application/json", + "Intercom-Version": INTERCOM_VERSION, + }; +} + +async function findContactByEmail(email: string): Promise { + const res = await intercomFetch(`${INTERCOM_API_BASE}/contacts/search`, { + query: { field: "email", operator: "=", value: email }, + }); + if (!res.ok) { + throw new Error(`Intercom contact search failed: ${res.status} ${await res.text()}`); + } + const data = (await res.json()) as { data: Array<{ id: string }> }; + return data.data[0]?.id; +} + +async function createContact(email: string, name: string): Promise { + const res = await intercomFetch(`${INTERCOM_API_BASE}/contacts`, { role: "lead", email, name }); + if (!res.ok) { + throw new Error(`Intercom contact create failed: ${res.status} ${await res.text()}`); + } + const contact = (await res.json()) as { id: string }; + return contact.id; +} + +async function createTicket(contactId: string, request: SupportRequest): Promise { + const res = await intercomFetch(`${INTERCOM_API_BASE}/tickets`, { + ticket_type_id: process.env.INTERCOM_TICKET_TYPE_ID, + contacts: [{ id: contactId }], + ticket_attributes: { + _default_title_: request.subject, + _default_description_: request.description, + "pulumi-org": request.organization, + priority: request.priority, + }, + }); + if (!res.ok) { + throw new Error(`Intercom ticket create failed: ${res.status} ${await res.text()}`); + } + const ticket = (await res.json()) as { id: string }; + return ticket.id; +} + +// createSupportTicket finds or creates the submitter's Intercom contact, then +// files a ticket against it. Throws on any Intercom API failure; the caller +// (handler.ts) is responsible for turning that into a response. +export async function createSupportTicket(request: SupportRequest): Promise { + let contactId = await findContactByEmail(request.email); + if (!contactId) { + contactId = await createContact(request.email, request.name); + } + return createTicket(contactId, request); +} diff --git a/infrastructure/support-form/validation.test.ts b/infrastructure/support-form/validation.test.ts new file mode 100644 index 000000000000..4913f7323b17 --- /dev/null +++ b/infrastructure/support-form/validation.test.ts @@ -0,0 +1,678 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Unit tests for the support-form payload validation and Lambda handler. +// Pure Node — no AWS machinery. Run from the infrastructure directory with: +// +// yarn test-support-form +// +// (which compiles this directory with tsc and runs the output under the +// built-in Node test runner). + +import * as assert from "assert"; +import { test } from "node:test"; + +import { clientAddress, FunctionUrlEvent, supportFormHandler } from "./handler"; +import { KNOWN_KEYS, LIMITS, normalizeOrganization, validateSubmission } from "./validation"; + +function validPayload(): Record { + return { + email: "jane@example.com", + name: "Jane Doe", + organization: "example-corp", + priority: "normal", + subject: "Stack update stuck in progress", + description: "Running `pulumi up` hangs after the preview completes. Expected the update to apply.", + }; +} + +test("accepts a fully valid payload", () => { + const result = validateSubmission(validPayload()); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.email, "jane@example.com"); + assert.strictEqual(result.value.priority, "normal"); + } +}); + +test("flags every missing required field", () => { + const result = validateSubmission({}); + assert.ok(!result.ok); + if (!result.ok) { + for (const key of ["email", "name", "organization", "priority", "subject", "description"]) { + assert.ok(result.fields[key], `expected an error for ${key}`); + } + } +}); + +test("rejects malformed email addresses", () => { + for (const email of ["not-an-email", "a@b", "a b@example.com", ""]) { + const result = validateSubmission({ ...validPayload(), email }); + assert.ok(!result.ok, `expected ${JSON.stringify(email)} to be rejected`); + if (!result.ok) { + assert.ok(result.fields.email); + } + } +}); + +test("normalizes a pasted console URL to the organization name", () => { + assert.strictEqual(normalizeOrganization("https://app.pulumi.com/example-corp"), "example-corp"); + assert.strictEqual(normalizeOrganization("https://app.pulumi.com/example-corp/stacks/dev"), "example-corp"); + assert.strictEqual(normalizeOrganization("app.pulumi.com/example-corp"), "example-corp"); + assert.strictEqual(normalizeOrganization(" example-corp "), "example-corp"); + assert.strictEqual(normalizeOrganization("example-corp/"), "example-corp"); +}); + +test("applies normalization before validating the organization", () => { + const result = validateSubmission({ + ...validPayload(), + organization: "https://app.pulumi.com/example-corp", + }); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.organization, "example-corp"); + } +}); + +test("rejects organization names that fail the naming rules", () => { + for (const organization of ["-leading-hyphen", "has spaces", "a".repeat(41)]) { + const result = validateSubmission({ ...validPayload(), organization }); + assert.ok(!result.ok, `expected ${JSON.stringify(organization)} to be rejected`); + } +}); + +test("blames an over-long organization on its length, not its characters", () => { + const result = validateSubmission({ ...validPayload(), organization: "a".repeat(LIMITS.organization + 1) }); + assert.ok(!result.ok); + if (!result.ok) { + assert.match(result.fields.organization || "", /characters/); + assert.doesNotMatch(result.fields.organization || "", /hyphens/); + } + // The bound itself is LIMITS.organization, so a name exactly at it passes. + assert.ok(validateSubmission({ ...validPayload(), organization: "a".repeat(LIMITS.organization) }).ok); +}); + +test("rejects priorities outside the closed set", () => { + const result = validateSubmission({ ...validPayload(), priority: "everything" }); + assert.ok(!result.ok); + if (!result.ok) { + assert.ok(result.fields.priority); + } +}); + +test("rejects unknown top-level keys", () => { + const result = validateSubmission({ ...validPayload(), admin: true }); + assert.ok(!result.ok); + if (!result.ok) { + assert.ok(result.fields._form); + } +}); + +test("rejects non-string values for string fields", () => { + const result = validateSubmission({ ...validPayload(), subject: 42 }); + assert.ok(!result.ok); + if (!result.ok) { + assert.ok(result.fields.subject); + } +}); + +test("rejects too-short descriptions", () => { + const result = validateSubmission({ ...validPayload(), description: "help" }); + assert.ok(!result.ok); + if (!result.ok) { + assert.ok(result.fields.description); + } +}); + +// --- Handler-level tests --- + +const SECRET = "test-secret"; +const TICKET_ID = "ticket-42"; + +// handler.ts files an Intercom ticket on the accept path, so every test below +// that expects a 200 would otherwise reach api.intercom.io with no credentials +// — hanging or 401-ing depending on the network. Replacing the global fetch for +// the whole file (node --test gives each test file its own process, and runs +// tests non-concurrently) makes that impossible by construction rather than +// test by test. intercomCalls records the traffic so the paths that must *not* +// file a ticket can assert on it. The request shape itself is covered in +// intercom.test.ts. +const intercomCalls: string[] = []; +const intercomRequests: Array<{ url: string; body: any }> = []; +let intercomUp = true; + +globalThis.fetch = async (input, init) => { + const url = String(input); + intercomCalls.push(url); + let sent: any; + try { + sent = init && typeof init.body === "string" ? JSON.parse(init.body) : undefined; + } catch (e) { + sent = undefined; + } + intercomRequests.push({ url, body: sent }); + if (!intercomUp) { + return new Response("service unavailable", { status: 503 }); + } + const body = url.endsWith("/contacts/search") ? { data: [{ id: "contact-1" }] } : { id: TICKET_ID }; + return new Response(JSON.stringify(body), { status: 200 }); +}; + +function postEvent(body: unknown, overrides: Partial = {}): FunctionUrlEvent { + return { + body: typeof body === "string" ? body : JSON.stringify(body), + isBase64Encoded: false, + headers: { + "content-type": "application/json", + "x-origin-verify": SECRET, + }, + requestContext: { http: { method: "POST", path: "/api/support", sourceIp: "192.0.2.1" } }, + ...overrides, + }; +} + +test("handler accepts a valid submission", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const response = await supportFormHandler(postEvent(validPayload())); + assert.strictEqual(response.statusCode, 200); + const parsed = JSON.parse(response.body); + assert.strictEqual(parsed.ok, true); + assert.ok(parsed.id); + // id is minted locally; ticketId has to come back from Intercom, so + // asserting it is what catches the result being dropped on the floor. + assert.strictEqual(parsed.ticketId, TICKET_ID); + assert.strictEqual(response.headers["cache-control"], "no-store"); +}); + +test("handler rejects a missing or wrong origin secret", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const noHeader = postEvent(validPayload()); + delete (noHeader.headers as Record)["x-origin-verify"]; + assert.strictEqual((await supportFormHandler(noHeader)).statusCode, 403); + + const wrongHeader = postEvent(validPayload()); + (wrongHeader.headers as Record)["x-origin-verify"] = "nope"; + assert.strictEqual((await supportFormHandler(wrongHeader)).statusCode, 403); +}); + +test("handler accepts any secret in a comma-separated rotation list", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = `old-secret, ${SECRET}`; + const response = await supportFormHandler(postEvent(validPayload())); + assert.strictEqual(response.statusCode, 200); + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; +}); + +test("handler fails closed when no secret is configured", async () => { + delete process.env.SUPPORT_FORM_ORIGIN_SECRET; + const response = await supportFormHandler(postEvent(validPayload())); + assert.strictEqual(response.statusCode, 403); + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; +}); + +test("handler rejects non-POST methods with Allow", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const event = postEvent(validPayload()); + event.requestContext = { http: { method: "GET", path: "/api/support" } }; + const response = await supportFormHandler(event); + assert.strictEqual(response.statusCode, 405); + assert.strictEqual(response.headers.allow, "POST"); +}); + +test("handler rejects non-JSON content types", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const event = postEvent(validPayload()); + (event.headers as Record)["content-type"] = "text/plain"; + assert.strictEqual((await supportFormHandler(event)).statusCode, 400); +}); + +test("handler rejects malformed JSON", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const response = await supportFormHandler(postEvent("{not json")); + assert.strictEqual(response.statusCode, 400); + assert.strictEqual(JSON.parse(response.body).error, "invalid_json"); +}); + +test("handler rejects oversized bodies", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const response = await supportFormHandler(postEvent("x".repeat(256 * 1024 + 1))); + assert.strictEqual(response.statusCode, 413); +}); + +test("handler decodes base64-encoded bodies", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const event = postEvent(validPayload()); + event.body = Buffer.from(event.body as string, "utf8").toString("base64"); + event.isBase64Encoded = true; + assert.strictEqual((await supportFormHandler(event)).statusCode, 200); +}); + +// --- Client-IP attribution --- +// +// requestContext.http.sourceIp is CloudFront's edge node, never the submitter. +// The real address arrives as the CloudFront-managed CloudFront-Viewer-Address +// header; these pin that it is preferred, that nothing a caller can author gets +// logged in its place, and that a fallback is labelled as one. + +const EDGE_IP = "3.172.120.71"; +const VIEWER_IP = "198.51.100.9"; +const VIEWER_HEADER = "cloudfront-viewer-address"; + +function ipEvent(headers: Record = {}): FunctionUrlEvent { + return { + headers, + requestContext: { http: { method: "POST", path: "/api/support", sourceIp: EDGE_IP } }, + }; +} + +test("reads the viewer address CloudFront forwarded, stripping the port", () => { + assert.deepStrictEqual(clientAddress(ipEvent({ [VIEWER_HEADER]: `${VIEWER_IP}:52001` })), { + ip: VIEWER_IP, + source: "viewer", + }); + assert.deepStrictEqual(clientAddress(ipEvent({ [VIEWER_HEADER]: ` ${VIEWER_IP}:443 ` })), { + ip: VIEWER_IP, + source: "viewer", + }); +}); + +test("keeps an IPv6 viewer address intact by splitting on the last colon", () => { + assert.deepStrictEqual(clientAddress(ipEvent({ [VIEWER_HEADER]: "2001:db8::1:443" })), { + ip: "2001:db8::1", + source: "viewer", + }); +}); + +test("never reads X-Forwarded-For, which is partly caller-authored", () => { + // CloudFront appends the viewer to whatever the caller already sent, so the + // header is half attacker-authored. If this ever starts returning 1.2.3.4, + // the abuse trail has been poisoned. + const result = clientAddress(ipEvent({ "x-forwarded-for": "1.2.3.4, 198.51.100.9" })); + assert.deepStrictEqual(result, { ip: EDGE_IP, source: "edge" }); +}); + +test("marks a fallback as edge, so it can't be mistaken for the submitter", () => { + // The whole point of ipSource: a record that fell back must never read as an + // attributed one. Malformed values fall back rather than being trusted. + for (const value of ["", " ", "A".repeat(65), ["1.2.3.4"], 5, null, {}]) { + assert.deepStrictEqual(clientAddress(ipEvent({ [VIEWER_HEADER]: value } as any)), { + ip: EDGE_IP, + source: "edge", + }); + } + assert.deepStrictEqual(clientAddress({}), { ip: undefined, source: "unknown" }); +}); + +test("never throws, whatever the headers hold", () => { + // This feeds the success-path log, which runs after the Intercom ticket + // already exists. A throw there would 502 a request that had filed, and the + // user would resubmit into a duplicate. + for (const headers of [{}, { [VIEWER_HEADER]: null }, { [VIEWER_HEADER]: {} }]) { + assert.doesNotThrow(() => clientAddress(ipEvent(headers as any))); + } +}); + +test("logs the viewer address and its provenance on an accepted submission", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const logged: string[] = []; + const realLog = console.log; + console.log = (msg?: any) => { + logged.push(String(msg)); + }; + try { + const event = postEvent(validPayload()); + (event.headers as Record)[VIEWER_HEADER] = `${VIEWER_IP}:52001`; + (event.headers as Record)["x-forwarded-for"] = "1.2.3.4"; + await supportFormHandler(event); + } finally { + console.log = realLog; + } + const accepted = logged.map(l => JSON.parse(l)).find(l => l.type === "support_request_accepted"); + assert.ok(accepted, "expected a support_request_accepted record"); + assert.strictEqual(accepted.sourceIp, VIEWER_IP); + assert.strictEqual(accepted.ipSource, "viewer"); +}); + +test("handler rejects an empty body", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const event = postEvent(validPayload()); + event.body = ""; + const response = await supportFormHandler(event); + assert.strictEqual(response.statusCode, 400); + assert.strictEqual(JSON.parse(response.body).error, "empty_body"); +}); + +test("handler returns field errors as a 422", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const before = intercomCalls.length; + const response = await supportFormHandler(postEvent({ ...validPayload(), email: "nope" })); + assert.strictEqual(response.statusCode, 422); + const parsed = JSON.parse(response.body); + assert.strictEqual(parsed.error, "validation_failed"); + assert.ok(parsed.fields.email); + // A submission that failed validation must never reach Intercom. + assert.strictEqual(intercomCalls.length, before); +}); + +test("handler reports a ticket-creation failure as a 502", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + intercomUp = false; + try { + const response = await supportFormHandler(postEvent(validPayload())); + assert.strictEqual(response.statusCode, 502); + const parsed = JSON.parse(response.body); + assert.strictEqual(parsed.ok, false); + assert.strictEqual(parsed.error, "ticket_creation_failed"); + // The id still comes back so a failed submission can be traced to its + // support_request_ticket_failed log entry. + assert.ok(parsed.id); + } finally { + intercomUp = true; + } +}); + +test("handler swallows honeypot submissions with a fake success", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const before = intercomCalls.length; + const response = await supportFormHandler(postEvent({ ...validPayload(), leave_blank: "https://spam.example" })); + assert.strictEqual(response.statusCode, 200); + const parsed = JSON.parse(response.body); + assert.strictEqual(parsed.ok, true); + // The whole point of the honeypot: it looks like success to the bot but + // files nothing. + assert.strictEqual(intercomCalls.length, before); + // "Looks like" has to mean it. Omitting ticketId made the fake success + // trivially distinguishable from a real one by anyone reading the + // documented response shape, so the drop announced itself. + assert.ok(parsed.id, "a dropped submission still gets an id"); + // Leading digit 1-9: Intercom renders integers, so a real id never starts + // with a zero and one that did would be a free tell. + assert.ok(/^[1-9][0-9]{14}$/.test(parsed.ticketId), "a dropped submission gets a plausible ticket id"); +}); + +test("the honeypot does not announce itself through the validation order", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const before = intercomCalls.length; + + // Checking the honeypot before validation gave a spammer a one-request + // oracle: a knowingly invalid payload came back 200 with the trap set and + // 422 without it, so a single pair of requests revealed the field. Both + // must now be refused the same way. + const invalid = { ...validPayload(), email: "not-an-email" }; + + const withTrap = await supportFormHandler(postEvent({ ...invalid, leave_blank: "https://spam.example" })); + const withoutTrap = await supportFormHandler(postEvent(invalid)); + + assert.strictEqual(withTrap.statusCode, 422); + assert.strictEqual(withoutTrap.statusCode, 422); + assert.deepStrictEqual(JSON.parse(withTrap.body), JSON.parse(withoutTrap.body)); + assert.strictEqual(intercomCalls.length, before, "neither may file a ticket"); +}); + +// --- Text sanitization --------------------------------------------------- + +test("strips control characters and bidi overrides from every field", () => { + const result = validateSubmission({ + ...validPayload(), + name: "Jane\u0000 Doe\u001b[31m", + subject: "harmless text\rMALICIOUS", + description: "Line one\nLine two\ttabbed \u202Egnirts desrever a\u202C ok", + }); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.name, "Jane Doe[31m"); + assert.strictEqual(result.value.subject, "harmless textMALICIOUS"); + // Tabs and newlines survive -- the description is Markdown. + assert.ok(result.value.description.indexOf("\n") !== -1); + assert.ok(result.value.description.indexOf("\t") !== -1); + assert.strictEqual(result.value.description.indexOf("\u202E"), -1); + assert.strictEqual(result.value.description.indexOf("\u202C"), -1); + } +}); + +test("rejects email addresses carrying separators that mean something downstream", () => { + for (const email of [ + "@evil.example", + "Support ", + "a,b@example.com", + "a;b@example.com", + "a\"b@example.com", + ]) { + const result = validateSubmission({ ...validPayload(), email }); + assert.ok(!result.ok, `expected ${JSON.stringify(email)} to be rejected`); + if (!result.ok) { + assert.ok(result.fields.email); + } + } +}); + +test("still accepts ordinary addresses after the tightening", () => { + for (const email of [ + "jane@example.com", + "jane.doe+support@example.co.uk", + "jane_doe@sub.example.io", + "jane-doe123@example.dev", + "'quoted@example.com", + ]) { + const result = validateSubmission({ ...validPayload(), email }); + assert.ok(result.ok, `expected ${JSON.stringify(email)} to be accepted`); + } +}); + +// --- The validation -> side-effect boundary ------------------------------- +// +// Every other handler test posts an already-normalized validPayload(), so the +// normalization is a no-op in them and nothing notices which object crosses +// into the Intercom client. Posting a value that normalization actually +// changes is what makes the difference observable. + +test("handler files the validated value, not the caller's raw payload", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const before = intercomRequests.length; + + const response = await supportFormHandler(postEvent({ + ...validPayload(), + organization: "https://app.pulumi.com/example-corp/stacks/dev", + })); + assert.strictEqual(response.statusCode, 200); + + const ticket = intercomRequests.slice(before).find(r => r.url.endsWith("/tickets")); + assert.ok(ticket, "expected a ticket to be filed"); + assert.strictEqual(ticket!.body.ticket_attributes["pulumi-org"], "example-corp"); +}); + +test("handler does not forward the honeypot key to Intercom", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const before = intercomRequests.length; + + // An empty honeypot is not spam, so this is accepted and filed -- but the + // key is not part of the ticket. + const response = await supportFormHandler(postEvent({ ...validPayload(), leave_blank: "" })); + assert.strictEqual(response.statusCode, 200); + + const ticket = intercomRequests.slice(before).find(r => r.url.endsWith("/tickets")); + assert.ok(ticket, "expected a ticket to be filed"); + assert.ok(!JSON.stringify(ticket!.body).includes("leave_blank")); +}); + +// --- The 403 gate -------------------------------------------------------- + +test("the origin-secret gate cannot be swayed by anything in the body", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + + // The realistic regression here is not "the check was deleted" -- that is + // already covered -- but "someone added an escape hatch for an internal + // caller". Any such hatch is derived from the payload, so the gate has to + // be proven independent of it rather than tested at one body shape. + const bodies: unknown[] = [ + validPayload(), + { ...validPayload(), leave_blank: "spam" }, + { leave_blank: "x" }, + {}, + [], + "", + "{not json", + "x".repeat(256 * 1024 + 1), + ]; + + for (const body of bodies) { + const event = postEvent(body); + delete event.headers!["x-origin-verify"]; + const response = await supportFormHandler(event); + assert.strictEqual( + response.statusCode, 403, + `a request without the origin secret must be refused whatever the body holds (${JSON.stringify(body).slice(0, 40)})`); + } +}); + +// --- Length caps --------------------------------------------------------- + +test("enforces every length cap at its exact documented boundary", () => { + // Literal boundaries on purpose. Writing these as LIMITS.foo + 1 pins the + // shape of the rule but not its value, so the cap could be raised to + // anything and the test would follow it up. + const domain = "@example.com"; + const atLimit: Record = { + email: "a".repeat(254 - domain.length) + domain, + name: "a".repeat(200), + organization: "a".repeat(40), + subject: "a".repeat(200), + description: "a".repeat(20000), + }; + const overLimit: Record = { + email: "a".repeat(255 - domain.length) + domain, + name: "a".repeat(201), + organization: "a".repeat(41), + subject: "a".repeat(201), + description: "a".repeat(20001), + }; + + for (const field of Object.keys(atLimit)) { + const ok = validateSubmission({ ...validPayload(), [field]: atLimit[field] }); + assert.ok(ok.ok, `${field} at its limit must be accepted`); + + const tooLong = validateSubmission({ ...validPayload(), [field]: overLimit[field] }); + assert.ok(!tooLong.ok, `${field} one character over its limit must be rejected`); + if (!tooLong.ok) { + assert.ok(tooLong.fields[field], `expected the error to be reported against ${field}`); + } + } +}); + +test("pins the published limits, which /llms.txt documents to agents", () => { + assert.deepStrictEqual(LIMITS, { + email: 254, + name: 200, + organization: 40, + subject: 200, + descriptionMin: 10, + description: 20000, + }); +}); + +// --- The accepted-key set ------------------------------------------------ + +test("pins the accepted top-level keys", () => { + // A "rejects an unknown key" test cannot see the way this actually erodes, + // which is a key being added to the allowlist. + assert.deepStrictEqual(KNOWN_KEYS, [ + "email", + "name", + "organization", + "priority", + "subject", + "description", + "leave_blank", + ]); +}); + +test("rejects payloads that are not JSON objects", () => { + for (const input of [[], [validPayload()], null, "a string", 42, true]) { + const result = validateSubmission(input); + assert.ok(!result.ok, `expected ${JSON.stringify(input)} to be rejected`); + if (!result.ok) { + assert.ok(result.fields._form, "a payload-level problem is reported against _form"); + } + } +}); + +// --- Header normalization ------------------------------------------------ + +test("matches the method and content type case-insensitively", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + + const lowercaseMethod = postEvent(validPayload()); + lowercaseMethod.requestContext!.http!.method = "post"; + assert.strictEqual((await supportFormHandler(lowercaseMethod)).statusCode, 200); + + const mixedContentType = postEvent(validPayload()); + mixedContentType.headers!["content-type"] = "Application/JSON; charset=utf-8"; + assert.strictEqual((await supportFormHandler(mixedContentType)).statusCode, 200); +}); + +// --- Normalization details ----------------------------------------------- + +test("normalizes the remaining console-URL shapes", () => { + assert.strictEqual(normalizeOrganization("/example-corp"), "example-corp"); + assert.strictEqual(normalizeOrganization("https://www.app.pulumi.com/example-corp"), "example-corp"); + assert.strictEqual(normalizeOrganization("www.app.pulumi.com/example-corp"), "example-corp"); +}); + +test("trims surrounding whitespace on every string field", () => { + const result = validateSubmission({ + ...validPayload(), + email: " jane@example.com ", + subject: " Stack update stuck in progress ", + }); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.email, "jane@example.com"); + assert.strictEqual(result.value.subject, "Stack update stuck in progress"); + } +}); + +// --- Viewer-address shapes ----------------------------------------------- + +test("reads bracketed and portless viewer addresses", () => { + // RFC 3986 bracketing, so an address is logged in one queryable form. + assert.deepStrictEqual( + clientAddress(ipEvent({ [VIEWER_HEADER]: "[2001:db8::1]:443" })), + { ip: "2001:db8::1", source: "viewer" }); + + // No port: the trailing group is not numeric, so it must not be mistaken + // for one and trimmed away. + assert.deepStrictEqual( + clientAddress(ipEvent({ [VIEWER_HEADER]: "2001:db8::abc" })), + { ip: "2001:db8::abc", source: "viewer" }); + + assert.deepStrictEqual( + clientAddress(ipEvent({ [VIEWER_HEADER]: "198.51.100.9" })), + { ip: "198.51.100.9", source: "viewer" }); +}); + +test("accepts only the application/json media type, not a prefix of it", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + + for (const contentType of ["application/json", "application/json; charset=utf-8", "APPLICATION/JSON"]) { + const event = postEvent(validPayload()); + event.headers!["content-type"] = contentType; + assert.strictEqual((await supportFormHandler(event)).statusCode, 200, `${contentType} must be accepted`); + } + + // Different formats that merely share a prefix. + for (const contentType of ["application/jsonlines", "application/json-patch+json", "application/jsonwhatever"]) { + const event = postEvent(validPayload()); + event.headers!["content-type"] = contentType; + assert.strictEqual((await supportFormHandler(event)).statusCode, 400, `${contentType} must be refused`); + } +}); + +test("does not echo an unbounded unknown key back to the caller", () => { + const key = "z".repeat(100000); + const result = validateSubmission({ ...validPayload(), [key]: 1 }); + assert.ok(!result.ok); + if (!result.ok) { + // The key is attacker-controlled and lands verbatim in every + // consumer's logs, so the reflection has to be bounded. + assert.ok(result.fields._form.length < 200, "the reflected key must be truncated"); + assert.ok(result.fields._form.indexOf("zzzz") !== -1, "and still name the offending key"); + } +}); diff --git a/infrastructure/support-form/validation.ts b/infrastructure/support-form/validation.ts new file mode 100644 index 000000000000..f489ba31b06f --- /dev/null +++ b/infrastructure/support-form/validation.ts @@ -0,0 +1,241 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Validation for support-request submissions POSTed to /api/support. +// +// This module is deliberately pure and dependency-free (types only) so it can +// be unit-tested with the Node test runner without standing up any AWS +// machinery, and so the Lambda closure it ships in stays small. The rules here +// are the single source of truth for the payload contract; the client-side +// validation in theme/src/ts/support-form.ts mirrors them for UX, but only +// this module is authoritative. + +// Priority ids for the "Priority" select. The display labels live in the +// form's front matter (content/support/new/_index.md); ids and labels must +// stay in sync with it. +export const PRIORITIES = ["normal", "urgent"] as const; +export type Priority = (typeof PRIORITIES)[number]; + +// Maximum accepted request body, enforced before JSON.parse. The field limits +// below keep legitimate payloads far under this. +export const MAX_BODY_BYTES = 256 * 1024; + +export const LIMITS = { + email: 254, + name: 200, + organization: 40, + subject: 200, + descriptionMin: 10, + description: 20000, +}; + +// Pragmatic email shape check: something@something.tld. Full RFC 5322 +// validation rejects real addresses and accepts junk; the confirmation email +// is the real verifier. +// Angle brackets, quotes, commas and semicolons are excluded on top of the +// whitespace rule: they are legal in a quoted local part but never appear in an +// address anyone types, and every one of them is a separator in some downstream +// consumer -- a display-name form ("Support "), a header list, a CSV +// export. Control characters and bidi marks are already gone by this point; +// sanitizeText strips them before any field is validated. +const EMAIL_PATTERN = /^[^\s@<>",;]+@[^\s@<>",;]+\.[^\s@<>",;]+$/; + +// Pulumi organization names: alphanumeric start, then alphanumeric, hyphen, or +// underscore (matches the Pulumi Cloud org-name rules). The length bound is +// LIMITS.organization rather than a repeat count here, so the two rules have +// one source of truth apiece — and an over-long name gets told it's too long +// instead of being blamed on its characters. +const ORGANIZATION_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/; + +export interface SupportRequest { + email: string; + name: string; + organization: string; + priority: Priority; + subject: string; + description: string; +} + +export type ValidationResult = + | { ok: true; value: SupportRequest } + | { ok: false; fields: Record }; + +// Keys accepted at the top level of the JSON payload. +// +// "leave_blank" is the honeypot, and its membership here is load-bearing rather +// than a tolerance. The handler checks it *after* validation (see the comment +// at that check), so a trapped submission has to validate cleanly to reach the +// drop. Remove it from this array and a trapped payload takes the unknown-key +// path to a 422 instead -- restoring exactly the status-code oracle that +// ordering was introduced to close, and which validation.test.ts pins. +// Exported so the suite can pin the exact set: the way this rule erodes is a +// new key being added, which no "rejects an unknown key" test can see. +export const KNOWN_KEYS = [ + "email", + "name", + "organization", + "priority", + "subject", + "description", + "leave_blank", +]; + +// Strips a pasted console URL ("https://app.pulumi.com/my-org/...") or +// stray slashes down to the bare organization name. +export function normalizeOrganization(raw: string): string { + let value = raw.trim(); + // One pattern rather than two, so every combination of scheme and www is + // handled. As two, the schemeless branch did not allow www., and a pasted + // "www.app.pulumi.com/my-org" survived as far as the host name and then + // failed validation on its dots -- a confusing character-set error for what + // is a perfectly ordinary paste. + value = value.replace(/^(https?:\/\/)?(www\.)?app\.pulumi\.com\//i, ""); + value = value.replace(/^\/+/, ""); + const slash = value.indexOf("/"); + if (slash !== -1) { + value = value.slice(0, slash); + } + return value.trim(); +} + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input); +} + +// Strips characters that carry no meaning in a support request but change how +// the text is read once it leaves here. +// +// Nothing downstream escapes these. The values land in an Intercom ticket that a +// support engineer reads, and from there commonly in a Slack relay or a +// terminal, so the risk is not code execution -- it is a ticket whose displayed +// text differs from its actual text. +// +// - C0 controls except tab and newline (so CR goes, normalising CRLF to LF). +// NUL truncates a string in anything +// C-backed; CR alone lets "harmless text\rMALICIOUS" overwrite the visible +// line in a terminal; ESC opens ANSI colour and OSC-8 hyperlink sequences. +// - Bidi overrides and isolates (U+202A-202E, U+2066-2069) -- the Trojan +// Source set -- which reorder a rendered line without changing its bytes, +// enough to make a URL or a file name read as something it is not. The +// weaker marks go too (U+061C ALM, U+200E LRM, U+200F RLM): they reorder +// only neutral characters rather than forcing a run, but flipping the +// punctuation in a URL is the same "displays something other than what it +// contains" failure, and none of them is a character anyone types. +// +// Tab and newline are kept: the description is Markdown and legitimately +// multi-line. Everything else printable is left alone; over-filtering user +// prose is its own bug, and callers are told what was rejected rather than +// having their text silently rewritten beyond these two classes. +function sanitizeText(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u0008\u000B-\u001F\u007F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, ""); +} + +// Returns the trimmed string value of a field, or undefined (recording an +// error) when the value is present but not a string. +function stringField( + record: Record, + key: string, + fields: Record, +): string | undefined { + const value = record[key]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + fields[key] = "Expected a string."; + return undefined; + } + return sanitizeText(value).trim(); +} + +export function validateSubmission(input: unknown): ValidationResult { + const fields: Record = {}; + + if (!isRecord(input)) { + return { ok: false, fields: { _form: "Expected a JSON object." } }; + } + + for (const key of Object.keys(input)) { + if (!KNOWN_KEYS.includes(key)) { + // Truncated: the key is attacker-controlled and unbounded, and it + // is echoed verbatim into every consumer's logs. 64 characters is + // more than enough to recognise a mistyped field name. + const shown = key.length > 64 ? `${key.slice(0, 64)}...` : key; + return { ok: false, fields: { _form: `Unexpected field "${shown}".` } }; + } + } + + const email = stringField(input, "email", fields); + if (fields.email === undefined) { + if (!email) { + fields.email = "Enter your email address."; + } else if (email.length > LIMITS.email || !EMAIL_PATTERN.test(email)) { + fields.email = "Enter a valid email address."; + } + } + + const name = stringField(input, "name", fields); + if (fields.name === undefined) { + if (!name) { + fields.name = "Enter your full name."; + } else if (name.length > LIMITS.name) { + fields.name = `Keep your name to ${LIMITS.name} characters or fewer.`; + } + } + + const organizationRaw = stringField(input, "organization", fields); + let organization: string | undefined; + if (fields.organization === undefined) { + organization = organizationRaw ? normalizeOrganization(organizationRaw) : undefined; + if (!organization) { + fields.organization = "Enter your Pulumi organization name."; + } else if (organization.length > LIMITS.organization) { + fields.organization = `Keep the organization name to ${LIMITS.organization} characters or fewer.`; + } else if (!ORGANIZATION_PATTERN.test(organization)) { + fields.organization = + "Enter just the organization name from https://app.pulumi.com/PULUMI_ORG_NAME " + + "(letters, numbers, hyphens, and underscores)."; + } + } + + const priority = stringField(input, "priority", fields); + if (fields.priority === undefined) { + if (!priority) { + fields.priority = "Choose a priority."; + } else if ((PRIORITIES as readonly string[]).indexOf(priority) === -1) { + fields.priority = "Choose one of the listed priorities."; + } + } + + const subject = stringField(input, "subject", fields); + if (fields.subject === undefined) { + if (!subject) { + fields.subject = "Enter a subject."; + } else if (subject.length > LIMITS.subject) { + fields.subject = `Keep the subject to ${LIMITS.subject} characters or fewer.`; + } + } + + const description = stringField(input, "description", fields); + if (fields.description === undefined) { + if (!description || description.length < LIMITS.descriptionMin) { + fields.description = "Describe the issue in at least a few words."; + } else if (description.length > LIMITS.description) { + fields.description = `Keep the description to ${LIMITS.description} characters or fewer.`; + } + } + + if (Object.keys(fields).length > 0) { + return { ok: false, fields }; + } + + const value: SupportRequest = { + email: email as string, + name: name as string, + organization: organization as string, + priority: priority as Priority, + subject: subject as string, + description: description as string, + }; + return { ok: true, value }; +} diff --git a/infrastructure/supportForm.ts b/infrastructure/supportForm.ts new file mode 100644 index 000000000000..985e08fd4537 --- /dev/null +++ b/infrastructure/supportForm.ts @@ -0,0 +1,179 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; +import * as random from "@pulumi/random"; + +import { supportFormHandler } from "./support-form/handler"; + +// SupportFormApi is the server side of the support-request form at +// /support/new/ — a Lambda (fronted by a Function URL) that validates +// submissions and files them as Intercom tickets. See support-form/handler.ts +// for the endpoint's behavior, support-form/intercom.ts for the ticket-filing +// client, and support-form/validation.ts for the payload contract. +// +// The Function URL uses authorizationType NONE, so it is technically publicly +// invokable — but the handler rejects any request that doesn't carry the +// x-origin-verify shared secret, which only the www.pulumi.com CloudFront +// distribution injects (via getOrigin() below). That keeps all real traffic +// behind the CDN's WAF rate limiting. If we ever need to seal the URL +// cryptographically, the upgrade path is authorizationType AWS_IAM plus a +// CloudFront Origin Access Control — which requires the browser to send +// x-amz-content-sha256 on every POST, so it needs frontend changes too. +export interface SupportFormApiArgs { + // intercomApiKey is the Intercom access token used to search/create + // contacts and file tickets. Passed as a secret stack config value + // (pulumi config set --secret intercomApiKey, or an ESC environment + // entry) — never checked into this repo or shipped to the frontend. + intercomApiKey: pulumi.Input; + // intercomTicketTypeId is the Intercom ticket type filed for support + // requests. + intercomTicketTypeId: pulumi.Input; +} + +export class SupportFormApi extends pulumi.ComponentResource { + private readonly originSecret: random.RandomPassword; + private readonly func: aws.lambda.CallbackFunction; + private readonly functionUrl: aws.lambda.FunctionUrl; + + constructor(name: string, args: SupportFormApiArgs, opts?: pulumi.ComponentResourceOptions) { + super("www-pulumi:infrastructure:SupportFormApi", name, undefined, opts); + + // The shared secret CloudFront stamps on origin requests. Rotating it + // (pulumi up with a taint/replace of this resource) briefly races + // CloudFront config propagation; the handler accepts a comma-separated + // list in its env var if a graceful two-secret rotation is ever needed. + this.originSecret = new random.RandomPassword( + `${name}-origin-secret`, + { + length: 32, + special: false, + }, + { parent: this }, + ); + + const role = new aws.iam.Role( + `${name}-role`, + { + assumeRolePolicy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "sts:AssumeRole", + Principal: { + Service: "lambda.amazonaws.com", + }, + }, + ], + }, + }, + { parent: this }, + ); + + const rolePolicy = new aws.iam.RolePolicy( + `${name}-cloudwatch-policy`, + { + role, + policy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + Resource: "*", + }, + ], + }, + }, + { parent: this }, + ); + + // Accepted submissions contain contact details (PII), so the log group + // is created explicitly with a bounded retention rather than letting + // Lambda auto-create one that keeps logs forever. The function gets an + // explicit name (unique per stack) so the group name can be derived. + const functionName = `${name}-${pulumi.getStack()}`; + const logGroup = new aws.cloudwatch.LogGroup( + `${name}-logs`, + { + name: `/aws/lambda/${functionName}`, + retentionInDays: 90, + }, + { parent: this }, + ); + + this.func = new aws.lambda.CallbackFunction( + `${name}-handler`, + { + name: functionName, + callback: supportFormHandler, + description: "Validates support-request form submissions from www.pulumi.com/support/new/.", + memorySize: 256, + timeout: 10, + role, + runtime: aws.lambda.Runtime.NodeJS22dX, + environment: { + variables: { + SUPPORT_FORM_ORIGIN_SECRET: this.originSecret.result, + INTERCOM_API_KEY: args.intercomApiKey, + INTERCOM_TICKET_TYPE_ID: args.intercomTicketTypeId, + }, + }, + }, + { parent: this, dependsOn: [logGroup, rolePolicy] }, + ); + + this.functionUrl = new aws.lambda.FunctionUrl( + `${name}-url`, + { + functionName: this.func.name, + authorizationType: "NONE", + }, + { parent: this }, + ); + + const invokePermission = new aws.lambda.Permission( + `${name}-invoke-url-permission`, + { + action: "lambda:InvokeFunctionUrl", + function: this.func, + principal: "*", + functionUrlAuthType: "NONE", + }, + { parent: this }, + ); + + super.registerOutputs({}); + } + + // getOrigin returns the CloudFront origin for the Function URL, stamping + // the shared secret the handler requires on every origin request. + public getOrigin(): aws.types.input.cloudfront.DistributionOrigin { + return { + originId: "support-form-api", + // Function URLs are "https://.lambda-url..on.aws/"; + // CloudFront wants just the hostname. + domainName: this.functionUrl.functionUrl.apply(url => new URL(url).hostname), + customOriginConfig: { + originProtocolPolicy: "https-only", + httpPort: 80, + httpsPort: 443, + originSslProtocols: ["TLSv1.2"], + }, + customHeaders: [ + { + name: "x-origin-verify", + value: this.originSecret.result, + }, + ], + }; + } + + public getFunctionName(): pulumi.Output { + return this.func.name; + } +} diff --git a/infrastructure/tsconfig.json b/infrastructure/tsconfig.json index 5c8e54651ced..f43defe51bbe 100644 --- a/infrastructure/tsconfig.json +++ b/infrastructure/tsconfig.json @@ -18,6 +18,12 @@ "skipLibCheck": true }, "files": [ - "index.ts" + "index.ts", + "supportForm.ts", + "support-form/validation.ts", + "support-form/handler.ts", + "support-form/intercom.ts", + "support-form/validation.test.ts", + "support-form/intercom.test.ts" ] } diff --git a/infrastructure/yarn.lock b/infrastructure/yarn.lock index 4fa632817e6e..2ebb5fa73c80 100644 --- a/infrastructure/yarn.lock +++ b/infrastructure/yarn.lock @@ -476,6 +476,13 @@ source-map-support "^0.5.6" upath "^1.1.0" +"@pulumi/random@^4.16.0": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@pulumi/random/-/random-4.21.1.tgz#5bfe67166f530a0bff2ba32fc4abb81ce805308d" + integrity sha512-2wD0UJTsoyj+MyBOa60E2FyrqyIaspTCz1qBG+mj34xQUk/u568s4925oWpGEeY3Z3wV7BbRpiEnnztnDb1xOA== + dependencies: + "@pulumi/pulumi" "^3.142.0" + "@sigstore/bundle@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-4.0.0.tgz#854eda43eb6a59352037e49000177c8904572f83" diff --git a/layouts/index.llms.txt b/layouts/index.llms.txt index b48b676d435d..3d8ee76e8d3f 100644 --- a/layouts/index.llms.txt +++ b/layouts/index.llms.txt @@ -69,6 +69,21 @@ If you are an AI agent or programmatic consumer, start with these endpoints. Eac - **Doing infrastructure work as an agent** — the Pulumi CLI is designed for agents: run any command with `npx pulumi`, perform one-shot resource operations with [`pulumi do`](https://www.pulumi.com/docs/iac/cli/direct-resource-operations/), and get an [ephemeral Pulumi Cloud account provisioned automatically](https://www.pulumi.com/docs/administration/concepts/agent-accounts/) on first use — no signup required. [Pulumi Agent Skills](https://www.pulumi.com/docs/ai/skills/) provide proven Pulumi workflows, and the [Pulumi MCP server](https://www.pulumi.com/docs/ai/mcp-server/) exposes stacks, resource search, and the Registry as MCP tools. +- **Filing a support request** — open a ticket with the Pulumi support team without driving the [web form](https://www.pulumi.com/support/new/). No authentication and no cookies. Post exactly the six fields below: any unknown top-level key is rejected, so send this shape rather than scraping the HTML form for its inputs. In particular the rendered form carries a hidden `leave_blank` input, which is a spam trap — sending it discards the request behind a response indistinguishable from success, so never send a `leave_blank` key. + + curl -X POST https://www.pulumi.com/api/support \ + -H "Content-Type: application/json" \ + -d '{ + "email": "you@example.com", + "name": "Your Name", + "organization": "your-pulumi-org", + "priority": "normal", + "subject": "Stack update stuck in progress", + "description": "What you expected, what actually happened, and any relevant code or CLI output." + }' + + `priority` is `normal` or `urgent`. `organization` is the bare org name from `https://app.pulumi.com/` — starting with a letter or number, then letters, numbers, hyphens and underscores, 40 characters or fewer; a pasted console URL is reduced to the org name for you. The other limits, so you can trim before sending rather than discovering them: `email` 254, `name` 200, `subject` 200, `description` 10–20000, and the whole body 256 KB (`413` past that). Control characters and bidirectional overrides are stripped from every field. Success returns `{"ok": true, "id": "...", "ticketId": "..."}`. A validation failure returns `422` with a `fields` object keyed by field name — or by `_form` when the problem is with the payload as a whole rather than one field, as with an unknown key — so each error maps back to its input without parsing prose. Other failures use `400`, `405`, `413` and `502`. A `403` means the request was blocked or rate limited: back off rather than retrying immediately. A body large enough to exceed the invocation limit is rejected by the platform before any of this applies, so a `413` is the one status that may arrive without the `ok`/`error` envelope. Replies come by email to the address you supply, so give one that a person reads. + ## Site overview This llms.txt covers www.pulumi.com, which includes: diff --git a/layouts/page/support-new.html b/layouts/page/support-new.html new file mode 100644 index 000000000000..97dd28843ccc --- /dev/null +++ b/layouts/page/support-new.html @@ -0,0 +1,197 @@ +{{ define "main" }} +{{/* + /support/new — the custom support-request form replacing the Zendesk form at + support.pulumi.com/hc/en-us/requests/new as support moves to Intercom. All + copy lives in front matter; this layout just renders it. + + Unlike the site's HubSpot-embedded forms, this is a hand-built
that + POSTs JSON to the same-origin /api/support endpoint (a Lambda behind + CloudFront; see infrastructure/supportForm.ts). Field chrome comes from the + shared form system (theme/src/scss/shared/_forms.scss) — invalid styling + keys off aria-invalid="true"; do not add CSS here. + + Validation, submit flow, and the confirmation swap: theme/src/ts/support-form.ts. + That module owns the DOM contract via the data-support-form* attributes below, + and mirrors the server's validation rules (infrastructure/support-form/validation.ts). + + The priority option values are the API's closed enum — keep the front matter's + form.fields.priority.options in sync with PRIORITIES in validation.ts. +*/}} + +{{ $overview := .Params.overview }} +{{ $form := .Params.form }} +{{ $fields := $form.fields }} +{{ $confirmation := .Params.confirmation }} +{{ $helpLinks := .Params.help_links }} + +
+
+ +
+ {{ with $overview.eyebrow }} + {{ . }} + {{ end }} +

{{ $overview.title }}

+

{{ $overview.description }}

+
+ +
+ {{/* Without JavaScript the submit button posts urlencoded data to + /api/support, which only speaks JSON — the visitor would land on + a raw error response with everything they typed gone. There is + no way to intercept that without script, so the form is hidden + instead and the alternative is offered directly. */}} + + + + +
+ + + +
+ +
+ + + +
+ +
+ + {{ with $fields.organization.help }} +

{{ . }}

+ {{ end }} + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + {{ with $fields.description.help }} +

{{ . }}

+ {{ end }} + + + +
+ + {{/* Honeypot: invisible to people, filled in by naive bots. The + handler drops any submission that sets it. + + Both the name and the label are chosen, and for different + reasons. The trap is indiscriminate -- anything reading the + raw HTML sees it, including a legitimate agent filing a + ticket on someone's behalf, which would fill in a plausible + value, get a 200 back, and report success for a ticket that + was never created -- so the label reads as an instruction + rather than as a field to fill in. The name avoids anything + with autofill semantics: as "website" this was a prime + target for password managers, which store website URLs and + match on the field name, and an autofilled trap destroys a + real request silently. Naive bots fill every field whatever + it is called, so neither choice costs anything against them. + + Agents should use the documented JSON API, which names this + field in the "For agents" section of /llms.txt precisely so + that a legitimate one knows never to send it. */}} + + + + + + +
+ + + + {{ with $helpLinks }} +
+

{{ .title }}

+

{{ .description }}

+ +
+ {{ end }} + +
+
+{{ end }} diff --git a/layouts/partials/hand-raise-section.html b/layouts/partials/hand-raise-section.html index 887515d001b2..1c580fd53f23 100644 --- a/layouts/partials/hand-raise-section.html +++ b/layouts/partials/hand-raise-section.html @@ -14,7 +14,7 @@
Need technical help?

Use our Support Portal to get in touch.

diff --git a/layouts/partials/help-links.html b/layouts/partials/help-links.html index aded7c78484f..1c8e035f5acc 100644 --- a/layouts/partials/help-links.html +++ b/layouts/partials/help-links.html @@ -13,7 +13,7 @@
Need technical help?

Use our support portal to get in touch.

diff --git a/scripts/check-test-suites-compiled.js b/scripts/check-test-suites-compiled.js new file mode 100644 index 000000000000..29869942f988 --- /dev/null +++ b/scripts/check-test-suites-compiled.js @@ -0,0 +1,96 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Guards a compile-then-run test script against silently running nothing. +// +// Three ways that happens, all of which otherwise report success: +// +// 1. `node --test ` exits 0 when the glob matches no files. Rename or +// move a suite and CI goes green having run zero tests. +// 2. Both tsconfigs here use an explicit `files` array, so a new *.test.ts +// that nobody added to it is never compiled. `tsc` exits 0 and emits +// nothing, even if the file has type errors, and the glob then can't see +// it. +// 3. The glob is not recursive, so a suite one directory down +// (support-form/sub/foo.test.ts) is never run even when it compiles. +// +// Any of these recreates the exact gap support-form-tests.yml exists to close -- +// an unrun suite is indistinguishable from a passing one. So: every .test.ts on +// disk, at any depth, must have a compiled .test.js counterpart that the runner +// will actually pick up, and there must be at least one. +// +// This checks that the suites will RUN. It cannot check that they contain +// assertions; the workflow asserts a non-zero test count from the runner itself +// for that half. +// +// Both halves of the support form need this, and they are separate packages +// with separate toolchains, so it takes its directories as arguments and lives +// at the repo root rather than inside either one: +// +// node ../scripts/check-test-suites-compiled.js +// +// Paths are relative to the calling package. is named only so the +// failure message can tell you which `files` array to edit. +// +// Plain JS on purpose. It has to run before the test runner and outside the +// TypeScript build it is checking. + +const fs = require("fs"); +const path = require("path"); + +const [srcArg, outArg, tsconfigArg] = process.argv.slice(2); + +if (!srcArg || !outArg || !tsconfigArg) { + console.error("usage: check-test-suites-compiled.js "); + process.exit(2); +} + +const srcDir = path.resolve(srcArg); +const outDir = path.resolve(outArg); + +if (!fs.existsSync(srcDir)) { + console.error(`check-test-suites-compiled: source directory ${srcArg} does not exist.`); + process.exit(1); +} + +// Relative paths of every *.test.ts under srcDir, at any depth. +function findSuites(dir, prefix) { + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? path.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) { + found.push(...findSuites(path.join(dir, entry.name), rel)); + } else if (entry.name.endsWith(".test.ts")) { + found.push(rel); + } + } + return found; +} + +const suites = findSuites(srcDir, ""); + +if (suites.length === 0) { + console.error(`check-test-suites-compiled: no *.test.ts found under ${srcArg} — the suite has vanished.`); + process.exit(1); +} + +const problems = []; + +for (const rel of suites) { + const compiled = rel.replace(/\.ts$/, ".js"); + if (!fs.existsSync(path.join(outDir, compiled))) { + problems.push(` - ${srcArg}/${rel} was not compiled — add it to "files" in ${tsconfigArg}`); + continue; + } + // The runners use a single-level glob, so a compiled suite in a + // subdirectory exists but never runs. + if (rel.includes(path.sep)) { + problems.push(` - ${srcArg}/${rel} is in a subdirectory, which the test glob does not reach`); + } +} + +if (problems.length > 0) { + console.error("check-test-suites-compiled: these suites would not run:\n" + problems.join("\n")); + process.exit(1); +} + +console.log(`check-test-suites-compiled: ${suites.length} suite(s) under ${srcArg} compiled and reachable.`); diff --git a/theme/package.json b/theme/package.json index 613710c37565..c5551b0764b0 100644 --- a/theme/package.json +++ b/theme/package.json @@ -24,6 +24,7 @@ "concurrently": "^6.2.1", "css-loader": "^7.1.4", "cssnano": "^5.0.8", + "jsdom": "^25.0.1", "mini-css-extract-plugin": "^2.10.2", "postcss": "^8.5.18", "postcss-loader": "^8.2.1", @@ -43,6 +44,7 @@ "build": "yarn build:color-theme && yarn run webpack --mode production", "start": "yarn run concurrently 'yarn --cwd stencil run start' 'yarn run webpack --watch' --raw --kill-others", "lint": "prettier --check .", - "lint-fix": "prettier --write ." + "lint-fix": "prettier --write .", + "test-support-form": "tsc -p tsconfig.test.json && node ../scripts/check-test-suites-compiled.js src/ts bin-test theme/tsconfig.test.json && node --test bin-test/*.test.js" } } diff --git a/theme/src/ts/main.ts b/theme/src/ts/main.ts index 3e696e9140f5..b7c2d583d5f1 100644 --- a/theme/src/ts/main.ts +++ b/theme/src/ts/main.ts @@ -25,6 +25,7 @@ import "./releases"; import "./packages"; import "./pricing-calculator"; import "./extend-trial"; +import "./support-form"; import "./developer-advocates"; import "./toc"; import "./docs-main"; diff --git a/theme/src/ts/node-test-shims.d.ts b/theme/src/ts/node-test-shims.d.ts new file mode 100644 index 000000000000..44f461aedbdc --- /dev/null +++ b/theme/src/ts/node-test-shims.d.ts @@ -0,0 +1,34 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Minimal ambient declarations for the Node APIs the theme's unit tests use. +// +// The theme is pinned to TypeScript 3.9, which cannot *parse* the .d.ts syntax +// current @types/node ships (`override` members, and so on) — and skipLibCheck +// does not help, because those are syntax errors rather than type errors. So +// tsconfig.test.json sets "types": [] to turn off automatic @types inclusion, +// and this file supplies only what the tests actually touch. +// +// Bumping the theme's TypeScript would remove the need for this, but that +// compiler also builds the production bundle, so it is not a change to make in +// passing. Delete this file when it is upgraded. + +declare const __dirname: string; + +declare const require: { + (id: string): any; + resolve(id: string): string; + cache: { [id: string]: any }; +}; + +declare module "node:test" { + export function test(name: string, fn: () => void | Promise): void; +} + +declare module "assert" { + function ok(value: any, message?: string): void; + function strictEqual(actual: any, expected: any, message?: string): void; + function notStrictEqual(actual: any, expected: any, message?: string): void; + function deepStrictEqual(actual: any, expected: any, message?: string): void; + function match(value: string, regExp: RegExp, message?: string): void; + function doesNotMatch(value: string, regExp: RegExp, message?: string): void; +} diff --git a/theme/src/ts/support-form.test.ts b/theme/src/ts/support-form.test.ts new file mode 100644 index 000000000000..bc3775b42680 --- /dev/null +++ b/theme/src/ts/support-form.test.ts @@ -0,0 +1,803 @@ +// Copyright 2016-2026, Pulumi Corporation. All rights reserved. + +// Unit tests for the /support/new/ client module, run against jsdom. From the +// theme directory: +// +// yarn test-support-form +// +// Why these exist at this level rather than as narrow unit tests: every defect +// this file was written in response to was an *interaction* bug, not a bad +// function. The draft restore, the query-param prefill, and the rendered +// `) + .join(""); + return ` +
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
`; +} + +interface Harness { + doc: Document; + win: any; + control: (id: string) => any; + errorText: (field: string) => string; + submit: () => Promise; + fetchCalls: Array<{ url: string; body: any }>; + setFetch: (impl: (url: string, init: any) => Promise) => void; +} + +// Builds a page, installs the globals the module reaches for, requires it fresh, +// and fires DOMContentLoaded so it wires itself up. +// The window from the previous mount, so it can be torn down before the next +// one. This matters more than it looks: the module reads a BARE global +// sessionStorage, and mount() re-points that global at each new window. A +// pending 500ms draft-save timer left behind by an earlier test therefore fires +// during a later one and writes the OLD form's values into the NEW test's +// storage -- every draft assertion downstream is then reading another test's +// work. jsdom's window.close() drops the timers with the window. +let previousWindow: any; + +function mount(options: { url?: string; draft?: any; extraPriorities?: string[]; breakStorage?: boolean } = {}): Harness { + if (previousWindow) { + previousWindow.close(); + } + const dom = new JSDOM(`${formHtml(options.extraPriorities)}`, { + url: options.url || PAGE_URL, + }); + const win = dom.window; + previousWindow = win; + + if (options.draft !== undefined) { + win.sessionStorage.setItem("pulumi-support-form-draft", JSON.stringify(options.draft)); + } + + const fetchCalls: Array<{ url: string; body: any }> = []; + let fetchImpl = async (_url: string, _init: any) => ({ + ok: true, + status: 200, + json: async () => ({ ok: true, id: "req-1", ticketId: "ticket-1" }), + }); + + const g: any = globalThis; + for (const key of [ + "document", + "Event", + "HTMLElement", + "HTMLInputElement", + "HTMLSelectElement", + "HTMLTextAreaElement", + "HTMLFormElement", + "HTMLButtonElement", + ]) { + g[key] = (win as any)[key]; + } + g.window = win; + + // The module reads a BARE global `sessionStorage`, not window.sessionStorage, + // so the global is what has to be installed — and what has to throw when the + // test is exercising the blocked-storage path (private windows, storage + // disabled), which the module documents as degrading to no persistence. + Object.defineProperty(g, "sessionStorage", { + configurable: true, + get() { + if (options.breakStorage) { + throw new Error("blocked"); + } + return win.sessionStorage; + }, + }); + + g.fetch = (url: string, init: any) => { + fetchCalls.push({ url, body: init && init.body ? JSON.parse(init.body) : undefined }); + return fetchImpl(url, init); + }; + + delete require.cache[require.resolve(MODULE_PATH)]; + require(MODULE_PATH); + win.document.dispatchEvent(new win.Event("DOMContentLoaded")); + + const doc: Document = win.document; + const control = (id: string) => doc.getElementById(`support-${id}`) as any; + + return { + doc, + win, + control, + fetchCalls, + setFetch: impl => { + fetchImpl = impl as any; + }, + errorText: field => { + const el = doc.getElementById(`support-${field}-error`); + return el && !(el as any).hidden ? el.textContent || "" : ""; + }, + submit: async () => { + const form = doc.querySelector("[data-support-form]") as any; + form.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); + // Let the handler's promise chain settle. + await new Promise(resolve => setTimeout(resolve, 0)); + }, + }; +} + +// Types a value the way a person would, so the module's own listeners fire. +function type(harness: Harness, field: string, value: string): void { + const input = harness.control(field); + input.value = value; + input.dispatchEvent(new harness.win.Event("input", { bubbles: true })); +} + +function choose(harness: Harness, field: string, value: string): void { + const input = harness.control(field); + input.value = value; + input.dispatchEvent(new harness.win.Event("change", { bubbles: true })); +} + +function readDraft(harness: Harness): any { + const raw = harness.win.sessionStorage.getItem("pulumi-support-form-draft"); + return raw ? JSON.parse(raw) : undefined; +} + +// --- Query-param prefill ------------------------------------------------- + +test("prefills a whose first option is selected by default — so ?priority= was + // silently dead for every visitor. + const h = mount({ url: `${PAGE_URL}?priority=urgent` }); + assert.strictEqual(h.control("priority").value, "urgent"); +}); + +test("prefills text inputs from the query string", () => { + const h = mount({ url: `${PAGE_URL}?subject=CLI+crash&email=a%40b.co` }); + assert.strictEqual(h.control("subject").value, "CLI crash"); + assert.strictEqual(h.control("email").value, "a@b.co"); +}); + +test("ignores a query-string priority that is not a rendered option", () => { + const h = mount({ url: `${PAGE_URL}?priority=bogus&subject=FromUrl` }); + // Left on the default rather than blanked — assigning an unmatched value to + // a always reports a value, so saving every field would put + // priority into the draft after a single keystroke elsewhere — which is what + // made a stale default outrank the URL in the first place. + const h = mount(); + type(h, "email", "a@b.co"); + h.win.document.querySelector("[data-support-form]").dispatchEvent(new h.win.Event("input", { bubbles: true })); + return new Promise(resolve => { + setTimeout(() => { + const draft = readDraft(h); + assert.deepStrictEqual(Object.keys(draft || {}), ["email"]); + resolve(); + }, 600); + }); +}); + +test("a touched select is kept in the draft", () => { + const h = mount(); + choose(h, "priority", "urgent"); + return new Promise(resolve => { + setTimeout(() => { + assert.strictEqual((readDraft(h) || {}).priority, "urgent"); + resolve(); + }, 600); + }); +}); + +test("a restored draft survives the next save", () => { + // restoreDraft marks what it restored as touched; without that, the next + // save would drop the very entries just recovered. + const h = mount({ draft: { email: "a@b.co", subject: "Recovered" } }); + type(h, "name", "Jane"); + return new Promise(resolve => { + setTimeout(() => { + const draft = readDraft(h) || {}; + assert.strictEqual(draft.email, "a@b.co"); + assert.strictEqual(draft.subject, "Recovered"); + assert.strictEqual(draft.name, "Jane"); + resolve(); + }, 600); + }); +}); + +test("degrades quietly when sessionStorage is unavailable", () => { + // Private windows and blocked storage throw on access. The form must still + // work, and the query-param prefill must still run. + const h = mount({ url: `${PAGE_URL}?priority=urgent`, breakStorage: true }); + assert.strictEqual(h.control("priority").value, "urgent"); + type(h, "email", "a@b.co"); + assert.strictEqual(h.control("email").value, "a@b.co"); +}); + +// --- Validation ---------------------------------------------------------- + +test("accepts a priority the layout renders but the module never hardcoded", async () => { + // Guards the third sync point: front matter, the server enum, and the client. + // If the client kept its own copy of the list, adding an option would make it + // permanently unsubmittable with no server round trip to explain why. + const h = mount({ extraPriorities: ["low"] }); + choose(h, "priority", "low"); + type(h, "email", "a@b.co"); + type(h, "name", "Jane"); + type(h, "organization", "example-corp"); + type(h, "subject", "Subject"); + type(h, "description", "A description well past ten characters."); + await h.submit(); + assert.strictEqual(h.errorText("priority"), ""); + assert.strictEqual(h.fetchCalls.length, 1, "expected the submission to reach the API"); + assert.strictEqual(h.fetchCalls[0].body.priority, "low"); +}); + +test("blocks submission and reports per-field errors", async () => { + const h = mount(); + type(h, "email", "not-an-email"); + await h.submit(); + assert.match(h.errorText("email"), /valid email/i); + assert.strictEqual(h.fetchCalls.length, 0, "an invalid form must not reach the API"); +}); + +test("normalizes a pasted console URL to the bare organization name", async () => { + const h = mount(); + type(h, "email", "a@b.co"); + type(h, "name", "Jane"); + type(h, "organization", "https://app.pulumi.com/example-corp/stacks/dev"); + type(h, "subject", "Subject"); + type(h, "description", "A description well past ten characters."); + await h.submit(); + assert.strictEqual(h.errorText("organization"), ""); + assert.strictEqual(h.fetchCalls[0].body.organization, "example-corp"); +}); + +test("rejects an over-long organization by length, not by character rules", () => { + const h = mount(); + type(h, "organization", "a".repeat(41)); + h.control("organization").dispatchEvent(new h.win.Event("blur", { bubbles: true })); + const form = h.doc.querySelector("[data-support-form]") as any; + form.dispatchEvent(new h.win.Event("submit", { bubbles: true, cancelable: true })); + const message = h.errorText("organization"); + assert.match(message, /characters/); + assert.doesNotMatch(message, /hyphens/); +}); + +test("clears a field error once the user fixes it", () => { + const h = mount(); + const form = h.doc.querySelector("[data-support-form]") as any; + form.dispatchEvent(new h.win.Event("submit", { bubbles: true, cancelable: true })); + assert.notStrictEqual(h.errorText("email"), ""); + type(h, "email", "a@b.co"); + assert.strictEqual(h.errorText("email"), ""); + assert.strictEqual(h.control("email").getAttribute("aria-invalid"), null); +}); + +// --- Submission ---------------------------------------------------------- + +function fillValid(h: Harness): void { + type(h, "email", "a@b.co"); + type(h, "name", "Jane"); + type(h, "organization", "example-corp"); + type(h, "subject", "Subject"); + type(h, "description", "A description well past ten characters."); +} + +test("posts JSON to the same-origin endpoint and shows the confirmation", async () => { + const h = mount(); + fillValid(h); + await h.submit(); + assert.strictEqual(h.fetchCalls[0].url, "/api/support"); + assert.strictEqual((h.doc.querySelector("[data-support-form-card]") as any).hidden, true); + assert.strictEqual((h.doc.querySelector("[data-support-form-confirmation]") as any).hidden, false); +}); + +test("renders recap values as text, never as markup", async () => { + // The recap echoes the user's own input back into the page. textContent is + // what keeps that from being a self-XSS foothold via ?subject=. + const h = mount(); + fillValid(h); + type(h, "subject", ""); + await h.submit(); + const slot = h.doc.querySelector('[data-support-form-value="subject"]') as any; + assert.strictEqual(slot.textContent, ""); + assert.strictEqual(slot.querySelector("img"), null, "the value must not have been parsed as HTML"); +}); + +test("maps a 422 from the server back onto its fields", async () => { + const h = mount(); + h.setFetch(async () => ({ + ok: false, + status: 422, + json: async () => ({ ok: false, error: "validation_failed", fields: { organization: "Server says no." } }), + })); + fillValid(h); + await h.submit(); + assert.strictEqual(h.errorText("organization"), "Server says no."); +}); + +test("keeps the draft and shows the banner when the endpoint is unreachable", async () => { + // PR previews and `make serve` have no /api/support origin. The entries must + // survive so a retry doesn't cost the user their description. + const h = mount(); + h.setFetch(async () => { + throw new Error("network down"); + }); + fillValid(h); + await h.submit(); + assert.strictEqual((h.doc.querySelector("[data-support-form-banner]") as any).hidden, false); + assert.strictEqual((h.doc.querySelector("[data-support-form-confirmation]") as any).hidden, true); + assert.strictEqual(h.control("description").value, "A description well past ten characters."); +}); + +test("omits the honeypot key entirely from a real submission", async () => { + // An untouched honeypot is left out of the payload rather than sent empty, + // so a genuine submission is byte-identical to one from a client that has + // never seen the form — which is what the documented API shape describes. + const h = mount(); + fillValid(h); + await h.submit(); + assert.ok(!("leave_blank" in h.fetchCalls[0].body), "an empty honeypot must not appear in the payload"); +}); + +// --- The layout half of the DOM contract --------------------------------- + +test("the rendered layout still provides every id the module depends on", () => { + // The fixture above is hand-written, so it could drift from the template that + // actually renders the page. These ids are literal strings in the layout, so + // checking them here catches a rename without needing a Hugo build. + const fs = require("fs"); + const path = require("path"); + const layout = fs.readFileSync( + path.join(__dirname, "..", "..", "layouts", "page", "support-new.html"), + "utf8", + ); + for (const id of [ + "support-email", + "support-name", + "support-organization", + "support-priority", + "support-subject", + "support-description", + "support-leave-blank", + ]) { + assert.ok(layout.includes(`id="${id}"`), `layout is missing id="${id}"`); + assert.ok(layout.includes(`id="${id}-error"`) || id === "support-leave-blank", `layout is missing #${id}-error`); + } + for (const hook of [ + "data-support-form-root", + "data-support-form-card", + "data-support-form-banner", + "data-support-form-submit", + "data-support-form-confirmation", + "data-support-form-counter", + "data-support-form-value", + ]) { + assert.ok(layout.includes(hook), `layout is missing ${hook}`); + } + + // The bare hook needs its own assertion, anchored to the
tag. + // includes("data-support-form") can never fail -- it is a prefix of all + // seven hooks above -- and even a delimiter check passes on the layout's + // own comment, which mentions the "data-support-form*" attributes in prose. + // Meanwhile this is the most consequential attribute on the page: without + // it the module returns immediately and the entire form is inert. + assert.ok( + /]*\sdata-support-form(?![\w-])/.test(layout), + "the is missing the bare data-support-form hook — the module would not bind at all"); + + // Tag identity, not just the id. A keeps + // every id intact while silently killing the option-matching that both the + // prefill and the priority validator depend on. + assert.ok( + /]*id="support-priority"/.test(layout), + "support-priority must be a