diff --git a/content/blog/hidden-costs-of-infrastructure-management/index.md b/content/blog/hidden-costs-of-infrastructure-management/index.md index f75f4bb39f9e..717bf42315ce 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](https://pulumi.com/support/new), 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. {{% 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..4f6bdc759639 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](https://pulumi.com/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..90439367cb36 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](https://pulumi.com/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..39f37d4df2f4 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](https://pulumi.com/support/new) is here to help. diff --git a/content/contact/_index.md b/content/contact/_index.md index 1b9ceae8cd7b..a4148556811d 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: https://pulumi.com/support/new/requests/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: https://pulumi.com/support/new/requests/new --- diff --git a/content/docs/administration/access-identity/saml/auth0.md b/content/docs/administration/access-identity/saml/auth0.md index 87d502ea8932..1a9f5351215f 100644 --- a/content/docs/administration/access-identity/saml/auth0.md +++ b/content/docs/administration/access-identity/saml/auth0.md @@ -61,4 +61,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/access-identity/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://pulumi.com/support/new/). diff --git a/content/docs/administration/access-identity/saml/entra.md b/content/docs/administration/access-identity/saml/entra.md index 583c9f04f199..b1a2e61828b9 100644 --- a/content/docs/administration/access-identity/saml/entra.md +++ b/content/docs/administration/access-identity/saml/entra.md @@ -114,4 +114,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/access-identity/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://pulumi.com/support/new/). diff --git a/content/docs/administration/access-identity/saml/gsuite.md b/content/docs/administration/access-identity/saml/gsuite.md index 24a979200fc4..3b2e79095de7 100644 --- a/content/docs/administration/access-identity/saml/gsuite.md +++ b/content/docs/administration/access-identity/saml/gsuite.md @@ -101,4 +101,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/access-identity/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For additional help, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://pulumi.com/support/new/). diff --git a/content/docs/administration/access-identity/saml/okta.md b/content/docs/administration/access-identity/saml/okta.md index 474cd51b59fd..c0e965538b51 100644 --- a/content/docs/administration/access-identity/saml/okta.md +++ b/content/docs/administration/access-identity/saml/okta.md @@ -137,4 +137,4 @@ name of your Pulumi organization. ## Troubleshooting -For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://pulumi.com/support/new/). diff --git a/content/docs/administration/access-identity/saml/onelogin.md b/content/docs/administration/access-identity/saml/onelogin.md index 1a7fd4fe4bc1..5f76b5374788 100644 --- a/content/docs/administration/access-identity/saml/onelogin.md +++ b/content/docs/administration/access-identity/saml/onelogin.md @@ -109,4 +109,4 @@ name of your Pulumi organization. ## Troubleshooting -For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://support.pulumi.com/). +For help resolving SAML SSO configuration issues, see the [SAML SSO troubleshooting guide](/docs/administration/access-identity/saml/troubleshooting/) or [contact support](https://pulumi.com/support/new/). diff --git a/content/docs/administration/access-identity/saml/troubleshooting.md b/content/docs/administration/access-identity/saml/troubleshooting.md index 82fe8972fe4d..1e9f74a5b6f0 100644 --- a/content/docs/administration/access-identity/saml/troubleshooting.md +++ b/content/docs/administration/access-identity/saml/troubleshooting.md @@ -14,7 +14,7 @@ pulumi_cloud_feature: saml-sso ## 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/access-identity/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/access-identity/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://pulumi.com/support/new/). ## Validation error while trying to save an IdP-provided metadata XML in Pulumi Cloud diff --git a/content/docs/administration/access-identity/scim/troubleshooting.md b/content/docs/administration/access-identity/scim/troubleshooting.md index 04ee55f7cbd4..18ed90db2aff 100644 --- a/content/docs/administration/access-identity/scim/troubleshooting.md +++ b/content/docs/administration/access-identity/scim/troubleshooting.md @@ -21,7 +21,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, please contact our [customer support](https://pulumi.com/support/new/) for assistance. ### Email already in use diff --git a/content/docs/administration/organizations-teams/organizations.md b/content/docs/administration/organizations-teams/organizations.md index d95fc3c52cd9..4d9093a6e666 100644 --- a/content/docs/administration/organizations-teams/organizations.md +++ b/content/docs/administration/organizations-teams/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](https://pulumi.com/support/new/). ## Transferring stacks diff --git a/content/docs/administration/self-hosting/components/api.md b/content/docs/administration/self-hosting/components/api.md index d0569a3fdef7..dd0732848ffc 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](https://pulumi.com/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](https://pulumi.com/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..e71245d0d4c9 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](https://pulumi.com/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..bfaf15b27e9c 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](https://pulumi.com/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 74217c5b6c0e..d1ca17d5a6f3 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](https://pulumi.com/support/new/). ## Restore a stack diff --git a/content/extend-trial/_index.md b/content/extend-trial/_index.md index b9675cd1688e..7544414cb8f1 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: https://pulumi.com/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..457f73d53370 100644 --- a/content/support/_index.md +++ b/content/support/_index.md @@ -1,4 +1,4 @@ --- -redirect_to: "https://support.pulumi.com/" +redirect_to: "https://pulumi.com/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..2aab149a782d --- /dev/null +++ b/content/support/new/_index.md @@ -0,0 +1,68 @@ +--- +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: Please 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… + 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 open a ticket at https://pulumi.com/support/new/. + +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. + - title: Next. + description: A support engineer reviews it and replies by email, usually within one business day. + - 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..f4e67e64905e 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: https://pulumi.com/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..afaa248bfd79 100644 --- a/infrastructure/Pulumi.www-production.yaml +++ b/infrastructure/Pulumi.www-production.yaml @@ -18,3 +18,4 @@ config: www.pulumi.com:enableWaf: "true" www.pulumi.com:wafRateLimit: "500" www.pulumi.com:enableDataWarehouseAccess: "true" + www.pulumi.com:enableSupportForm: "true" diff --git a/infrastructure/Pulumi.www-testing.yaml b/infrastructure/Pulumi.www-testing.yaml index f2844c83e890..b1ffc3e5a925 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 diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 825229fc2c31..21ebb075267e 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,13 @@ 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). The Intercom integration is stubbed + // for now; when it lands, its API key becomes a stack secret (pulumi config set + // --secret intercomApiKey, or an ESC environment entry) surfaced to the Lambda as + // an environment variable — never checked into this repo or shipped to the frontend. + enableSupportForm: stackConfig.getBoolean("enableSupportForm") || false, }; // CloudFront Function to lowercase URIs for .NET SDK docs so that @@ -787,6 +795,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 +950,39 @@ 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"); + + supportFormOrigins.push(supportForm.getOrigin()); + + 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, + // Forwards Content-Type (and the rest of the viewer request) while + // stripping Host, which Function URL origins require. + originRequestPolicyId: allViewerExceptHostHeaderId, + responseHeadersPolicyId: ApiResponseHeadersPolicy.id, + // API traffic gets no edge redirects and no markdown negotiation. + lambdaFunctionAssociations: [], + functionAssociations: [], + }); +} + // domainAliases is a list of CNAMEs that accompany the CloudFront distribution. Any const domainAliases = []; @@ -994,6 +1049,7 @@ const distributionArgs: aws.cloudfront.DistributionArgs = { ...guidesOrigins, ...answersOrigins, ...versionedDocsOrigins, + ...supportFormOrigins, ], // Default object to serve when no path is given. @@ -1016,6 +1072,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 +1429,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..820b4df85525 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 --test bin/support-form/validation.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..fbe948918816 --- /dev/null +++ b/infrastructure/support-form/handler.ts @@ -0,0 +1,141 @@ +// 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. +// +// The Intercom integration is stubbed: accepted submissions are written to +// CloudWatch Logs as single-line JSON documents (type +// "support_request_accepted") where they can be observed and, later, replayed +// against the real ticket API. + +import * as crypto from "crypto"; +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; +} + +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" }); + } + + const method = (event.requestContext?.http?.method || "").toUpperCase(); + if (method !== "POST") { + return jsonResponse(405, { ok: false, error: "method_not_allowed" }, { allow: "POST" }); + } + + const contentType = (headers["content-type"] || "").toLowerCase(); + if (!contentType.startsWith("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" }); + } + + // Honeypot: the "website" field is visually hidden on the form, so any + // value in it marks a bot. Pretend success so the bot moves on. + if (typeof parsed === "object" && parsed !== null && (parsed as Record).website) { + console.log( + JSON.stringify({ + type: "support_request_spam_dropped", + receivedAt: new Date().toISOString(), + sourceIp: event.requestContext?.http?.sourceIp, + }), + ); + return jsonResponse(200, { ok: true, id: crypto.randomUUID() }); + } + + const result = validateSubmission(parsed); + if (!result.ok) { + return jsonResponse(422, { ok: false, error: "validation_failed", fields: result.fields }); + } + + const id = crypto.randomUUID(); + + // Observability stub: 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, + receivedAt: new Date().toISOString(), + sourceIp: event.requestContext?.http?.sourceIp, + request: result.value, + }), + ); + + // TODO(intercom): replace the log line above with a ticket-create call to + // the Intercom API once its spec is available. The API key arrives as a + // stack secret surfaced through another environment variable — never in + // this repo or the frontend. + + return jsonResponse(200, { ok: true, id }); +} diff --git a/infrastructure/support-form/validation.test.ts b/infrastructure/support-form/validation.test.ts new file mode 100644 index 000000000000..1ce560bfb07f --- /dev/null +++ b/infrastructure/support-form/validation.test.ts @@ -0,0 +1,218 @@ +// 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 { FunctionUrlEvent, supportFormHandler } from "./handler"; +import { 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("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"; + +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); + 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); +}); + +test("handler returns field errors as a 422", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + 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); +}); + +test("handler swallows honeypot submissions with a fake success", async () => { + process.env.SUPPORT_FORM_ORIGIN_SECRET = SECRET; + const response = await supportFormHandler(postEvent({ ...validPayload(), website: "https://spam.example" })); + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(JSON.parse(response.body).ok, true); +}); diff --git a/infrastructure/support-form/validation.ts b/infrastructure/support-form/validation.ts new file mode 100644 index 000000000000..6bbf02f44cbb --- /dev/null +++ b/infrastructure/support-form/validation.ts @@ -0,0 +1,187 @@ +// 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. +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Pulumi organization names: alphanumeric start, then alphanumeric, hyphen, or +// underscore, 40 chars max (matches the Pulumi Cloud org-name rules). +const ORGANIZATION_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]{0,39}$/; + +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. "website" is the +// honeypot field: the handler checks it before validation runs, but it is +// tolerated here so a spam submission that slips through still validates +// rather than erroring on an unknown key. +const KNOWN_KEYS = new Set([ + "email", + "name", + "organization", + "priority", + "subject", + "description", + "website", +]); + +// 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(); + value = value.replace(/^https?:\/\/(www\.)?app\.pulumi\.com\//i, ""); + value = value.replace(/^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); +} + +// 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 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.has(key)) { + return { ok: false, fields: { _form: `Unexpected field "${key}".` } }; + } + } + + 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 under ${LIMITS.name} characters.`; + } + } + + 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_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 under ${LIMITS.subject} characters.`; + } + } + + 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 under ${LIMITS.description} characters.`; + } + } + + 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..8a6743d3758f --- /dev/null +++ b/infrastructure/supportForm.ts @@ -0,0 +1,166 @@ +// 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, for now, stubs the Intercom integration by writing accepted +// entries to CloudWatch Logs. See support-form/handler.ts for the endpoint's +// behavior 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 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, 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, + }, + }, + }, + { 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..239d25a8cfb0 100644 --- a/infrastructure/tsconfig.json +++ b/infrastructure/tsconfig.json @@ -18,6 +18,10 @@ "skipLibCheck": true }, "files": [ - "index.ts" + "index.ts", + "supportForm.ts", + "support-form/validation.ts", + "support-form/handler.ts", + "support-form/validation.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/page/support-new.html b/layouts/page/support-new.html new file mode 100644 index 000000000000..8a6d25090abc --- /dev/null +++ b/layouts/page/support-new.html @@ -0,0 +1,169 @@ +{{ 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 }}

+
+ +
+ + + + +
+ + + +
+ +
+ + + +
+ +
+ + {{ 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. */}} + + + + + + +
+ + + + {{ with $helpLinks }} +
+

{{ .title }}

+

{{ .description }}

+ +
+ {{ end }} + +
+
+{{ end }} diff --git a/layouts/partials/hand-raise-section.html b/layouts/partials/hand-raise-section.html index 887515d001b2..8f1c35a453d0 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..e6dffa4e9101 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/link-checker/check-links.js b/scripts/link-checker/check-links.js index 4a7684cc9b12..912af52966a1 100644 --- a/scripts/link-checker/check-links.js +++ b/scripts/link-checker/check-links.js @@ -284,8 +284,8 @@ function getDefaultExcludedKeywords() { "http://localhost:16686/search", // Local Jaeger endpoint presented in troubleshooting guide. "https://ceph.io", "https://www.pagerduty.com", - "https://support.pulumi.com", - "https://support.pulumi.com/", + "https://pulumi.com/support/new", + "https://pulumi.com/support/new/", "https://www.pulumi.com/support/", "https://pbs.twimg.com/profile_images/", "https://linen.dev/", 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/support-form.ts b/theme/src/ts/support-form.ts new file mode 100644 index 000000000000..fe4e12962867 --- /dev/null +++ b/theme/src/ts/support-form.ts @@ -0,0 +1,380 @@ +// /support/new/ — the support-request form. Client-side validation, data-entry +// conveniences, and the JSON submit to the same-origin /api/support endpoint +// (a Lambda behind CloudFront; see infrastructure/supportForm.ts). +// +// The validation rules here MIRROR the server's (infrastructure/support-form/ +// validation.ts) for immediate feedback, but the server is authoritative: a +// 422 response carries per-field messages that are mapped back onto the form +// exactly like local errors. +// +// DOM contract (rendered by layouts/page/support-new.html): +// [data-support-form-root] page root; module no-ops if absent +// [data-support-form-card] form card, hidden after success +// [data-support-form] the
+// [data-support-form-banner] generic (non-field) error banner +// [data-support-form-submit] submit button (data-label/data-busy-label) +// [data-support-form-counter=""] character counter for the control # +// [data-support-form-confirmation] confirmation card, hidden until success +// [data-support-form-value=""] confirmation recap value slots +// Each control has id support-, with a sibling #-error paragraph. +// +// Graceful degradation: on PR-preview buckets and `make serve` there is no +// /api/support origin, so the POST fails or returns S3's XML error — either +// way the banner shows and the sessionStorage draft keeps the user's entries. + +const ENDPOINT = "/api/support"; +const DRAFT_KEY = "pulumi-support-form-draft"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ORGANIZATION_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]{0,39}$/; + +// Field keys are the API payload keys; ids are the DOM ids in the layout. +const FIELD_IDS: Record = { + email: "support-email", + name: "support-name", + organization: "support-organization", + priority: "support-priority", + subject: "support-subject", + description: "support-description", +}; + +type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +// Strips a pasted console URL down to the bare organization name. Mirrors +// normalizeOrganization in infrastructure/support-form/validation.ts. +function normalizeOrganization(raw: string): string { + let value = raw.trim(); + value = value.replace(/^https?:\/\/(www\.)?app\.pulumi\.com\//i, ""); + value = value.replace(/^app\.pulumi\.com\//i, ""); + value = value.replace(/^\/+/, ""); + const slash = value.indexOf("/"); + if (slash !== -1) { + value = value.slice(0, slash); + } + return value.trim(); +} + +function init() { + const root = document.querySelector("[data-support-form-root]"); + if (!root) { + return; + } + const formNode = root.querySelector("[data-support-form]"); + const formCardNode = root.querySelector("[data-support-form-card]"); + const confirmationNode = root.querySelector("[data-support-form-confirmation]"); + const bannerNode = root.querySelector("[data-support-form-banner]"); + const submitButtonNode = root.querySelector("[data-support-form-submit]"); + if (!formNode || !formCardNode || !confirmationNode || !bannerNode || !submitButtonNode) { + return; + } + // Re-bind after the guard so the narrowed (non-null) types flow into the + // nested function declarations below. + const form = formNode; + const formCard = formCardNode; + const confirmation = confirmationNode; + const banner = bannerNode; + const submitButton = submitButtonNode; + + const control = (field: string): FormControl | null => + form.querySelector(`#${FIELD_IDS[field]}`); + + // --- Per-field error plumbing ----------------------------------------- + + const errorElement = (field: string): HTMLElement | null => + form.querySelector(`#${FIELD_IDS[field]}-error`); + + function setError(field: string, message: string): void { + const input = control(field); + const error = errorElement(field); + if (input) { + input.setAttribute("aria-invalid", "true"); + } + if (error) { + error.textContent = message; + error.hidden = false; + } + } + + function clearError(field: string): void { + const input = control(field); + const error = errorElement(field); + if (input) { + input.removeAttribute("aria-invalid"); + } + if (error) { + error.textContent = ""; + error.hidden = true; + } + } + + function firstInvalidControl(): FormControl | null { + return form.querySelector('[aria-invalid="true"]'); + } + + // --- Validation (mirrors the server's rules) -------------------------- + + // Each validator returns an error message or null. Values arrive trimmed. + const validators: Record string | null> = { + email: value => { + if (!value) { + return "Enter your email address."; + } + if (value.length > 254 || !EMAIL_PATTERN.test(value)) { + return "Enter a valid email address."; + } + return null; + }, + name: value => (value ? null : "Enter your full name."), + organization: value => { + if (!value) { + return "Enter your Pulumi organization name."; + } + if (!ORGANIZATION_PATTERN.test(normalizeOrganization(value))) { + return "Enter just the organization name from https://app.pulumi.com/PULUMI_ORG_NAME (letters, numbers, hyphens, and underscores)."; + } + return null; + }, + priority: () => null, + subject: value => (value ? null : "Enter a subject."), + description: value => + value.length >= 10 ? null : "Describe the issue in at least a few words.", + }; + + function validateField(field: string): boolean { + const input = control(field); + if (!input) { + return true; + } + const message = validators[field](input.value.trim()); + if (message) { + setError(field, message); + return false; + } + clearError(field); + return true; + } + + function validateAll(): boolean { + let ok = true; + for (const field of Object.keys(validators)) { + // Validate every field so all errors show at once, not just the first. + ok = validateField(field) && ok; + } + return ok; + } + + // --- Data-entry conveniences ------------------------------------------ + + // Organization: strip a pasted console URL down to the org name. + const organizationInput = control("organization"); + if (organizationInput) { + organizationInput.addEventListener("blur", () => { + organizationInput.value = normalizeOrganization(organizationInput.value); + }); + } + + // Character counters for long fields, shown once the user nears the limit. + root.querySelectorAll("[data-support-form-counter]").forEach(counter => { + const target = document.getElementById(counter.dataset.supportFormCounter || "") as FormControl | null; + if (!target) { + return; + } + const max = Number(target.getAttribute("maxlength")); + if (!max) { + return; + } + const update = () => { + const remaining = max - target.value.length; + counter.hidden = remaining > max * 0.1; + counter.textContent = `${remaining.toLocaleString()} characters left`; + }; + target.addEventListener("input", update); + update(); + }); + + // Draft persistence: a failed submit (or an accidental navigation) never + // loses the user's entries. sessionStorage access can throw (private + // windows, blocked storage) — degrade to no persistence. + const draftFields = ["email", "name", "organization", "priority", "subject", "description"]; + + function saveDraft(): void { + try { + const draft: Record = {}; + for (const field of draftFields) { + const input = control(field); + if (input && input.value) { + draft[field] = input.value; + } + } + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } catch (e) { + // Storage unavailable; drafts just don't persist. + } + } + + function restoreDraft(): void { + try { + const raw = sessionStorage.getItem(DRAFT_KEY); + if (!raw) { + return; + } + const draft = JSON.parse(raw) as Record; + for (const field of draftFields) { + const input = control(field); + if (input && !input.value && typeof draft[field] === "string") { + input.value = draft[field]; + } + } + } catch (e) { + // Ignore unreadable drafts. + } + } + + function clearDraft(): void { + try { + sessionStorage.removeItem(DRAFT_KEY); + } catch (e) { + // Ignore. + } + } + + let draftTimer: number | undefined; + form.addEventListener("input", () => { + window.clearTimeout(draftTimer); + draftTimer = window.setTimeout(saveDraft, 500); + }); + + restoreDraft(); + + // Query-param prefill, e.g. /support/new/?priority=urgent&subject=CLI+crash. + const params = new URLSearchParams(window.location.search); + for (const field of ["priority", "subject", "email", "organization"]) { + const value = params.get(field); + const input = control(field); + if (value && input && !input.value) { + input.value = value; + } + } + + // Errors clear as the user fixes the field. + for (const field of Object.keys(validators)) { + const input = control(field); + if (!input) { + continue; + } + input.addEventListener("input", () => clearError(field)); + input.addEventListener("blur", () => { + if (input.value.trim()) { + validateField(field); + } + }); + } + + // --- Submit ----------------------------------------------------------- + + function setBusy(busy: boolean): void { + submitButton.disabled = busy; + submitButton.setAttribute("aria-busy", String(busy)); + const label = busy ? submitButton.dataset.busyLabel : submitButton.dataset.label; + if (label) { + submitButton.textContent = label; + } + } + + function buildPayload(): Record { + const value = (field: string) => (control(field)?.value || "").trim(); + const payload: Record = { + email: value("email"), + name: value("name"), + organization: normalizeOrganization(value("organization")), + priority: value("priority"), + subject: value("subject"), + description: value("description"), + }; + // Honeypot travels with the payload so the server can drop bot fills. + const honeypot = form.querySelector("#support-website"); + if (honeypot && honeypot.value) { + payload.website = honeypot.value; + } + return payload; + } + + function showConfirmation(payload: Record): void { + confirmation.querySelectorAll("[data-support-form-value]").forEach(node => { + const value = payload[node.dataset.supportFormValue || ""]; + if (typeof value === "string" && value) { + node.textContent = value; + } + }); + formCard.hidden = true; + confirmation.hidden = false; + confirmation.focus(); + confirmation.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + form.addEventListener("submit", async event => { + event.preventDefault(); + banner.hidden = true; + + if (!validateAll()) { + firstInvalidControl()?.focus(); + return; + } + + const payload = buildPayload(); + setBusy(true); + try { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + let body: any = null; + try { + body = await response.json(); + } catch (e) { + // Non-JSON response (e.g. an S3 error page on preview + // environments without the API) — treated as a failure below. + } + + if (response.ok && body && body.ok) { + clearDraft(); + showConfirmation(payload); + (window as any).analytics?.track?.("form-submission", { + form_id: "support-request", + priority: payload.priority, + }); + return; + } + + if (response.status === 422 && body && body.fields) { + for (const field of Object.keys(body.fields)) { + if (FIELD_IDS[field]) { + setError(field, String(body.fields[field])); + } else { + banner.hidden = false; + } + } + firstInvalidControl()?.focus(); + if (firstInvalidControl() === null) { + banner.hidden = false; + } + return; + } + + banner.hidden = false; + } catch (e) { + // Network failure — the draft is saved; the user can retry. + banner.hidden = false; + } finally { + setBusy(false); + } + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, { once: true }); +} else { + init(); +}