From 05f210d4017ad8e949502af4f85d661bbcd6bfb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 01:06:33 +0000 Subject: [PATCH 1/9] Add custom support-request form at /support/new with stubbed /api/support backend As support moves from Zendesk to Intercom, Intercom provides no prebuilt request form, so this adds our own: - /support/new/: a hand-built form (fields mirroring the Zendesk form) rendered by layouts/page/support-new.html from front matter, using the shared .form-* design system. Client-side validation, org-name normalization, sessionStorage draft persistence, char counter, query-param prefill, and a honeypot live in theme/src/ts/support-form.ts. - /api/support: a Lambda (Function URL) mounted same-origin via a new CloudFront origin + /api/support* behavior in infrastructure/index.ts, gated by the enableSupportForm stack config (on for www-testing and www-production). The Function URL is sealed by an x-origin-verify shared secret injected by CloudFront. - The handler validates submissions server-side (infrastructure/support-form/validation.ts, unit-tested via yarn test-support-form) and stubs the Intercom integration by logging accepted entries as structured JSON to CloudWatch (90-day retention). The Intercom ticket call and attachment upload land once the API spec is available; attachments currently submit metadata only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DtyVE5R4oYsgzBU7x1AC45 --- content/support/new/_index.md | 82 ++++ infrastructure/Pulumi.www-production.yaml | 1 + infrastructure/Pulumi.www-testing.yaml | 1 + infrastructure/index.ts | 61 +++ infrastructure/package.json | 4 +- infrastructure/support-form/handler.ts | 141 ++++++ .../support-form/validation.test.ts | 262 +++++++++++ infrastructure/support-form/validation.ts | 276 +++++++++++ infrastructure/supportForm.ts | 166 +++++++ infrastructure/tsconfig.json | 6 +- infrastructure/yarn.lock | 7 + layouts/page/support-new.html | 202 ++++++++ theme/src/ts/main.ts | 1 + theme/src/ts/support-form.ts | 436 ++++++++++++++++++ 14 files changed, 1644 insertions(+), 2 deletions(-) create mode 100644 content/support/new/_index.md create mode 100644 infrastructure/support-form/handler.ts create mode 100644 infrastructure/support-form/validation.test.ts create mode 100644 infrastructure/support-form/validation.ts create mode 100644 infrastructure/supportForm.ts create mode 100644 layouts/page/support-new.html create mode 100644 theme/src/ts/support-form.ts diff --git a/content/support/new/_index.md b/content/support/new/_index.md new file mode 100644 index 000000000000..b5d3506b743f --- /dev/null +++ b/content/support/new/_index.md @@ -0,0 +1,82 @@ +--- +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 + company: + label: Company name + organization: + label: Pulumi organization name + help: https://app.pulumi.com/PULUMI_ORG_NAME + category: + label: "I need help with:" + help: In what area of Pulumi are you encountering issues? + placeholder: Choose an area + options: + - label: My Pulumi Account/Sales + value: account-sales + - label: My Pulumi Program + value: program + - label: My Pulumi Cloud + value: cloud + - label: Pulumi Documentations/Blog + value: docs + 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. + pulumi_about: + label: Please run pulumi about in the directory containing the Pulumi project and share the output. + help: This will print information about the Pulumi environment and is helpful for debugging. + attachments: + label: Attachments + help: "Up to 5 files, 20 MB each. File contents aren't uploaded yet: we'll note what you selected and ask for the files by email if we need them." + 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://support.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. + - 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/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..5f6133242fc6 --- /dev/null +++ b/infrastructure/support-form/validation.test.ts @@ -0,0 +1,262 @@ +// 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", + company: "Example Corp", + organization: "example-corp", + category: "cloud", + subject: "Stack update stuck in progress", + description: "Running `pulumi up` hangs after the preview completes. Expected the update to apply.", + pulumiAbout: "CLI Version 3.100.0", + }; +} + +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.category, "cloud"); + assert.strictEqual(result.value.company, "Example Corp"); + } +}); + +test("accepts a payload without optional fields", () => { + const payload = validPayload(); + delete payload.company; + delete payload.pulumiAbout; + const result = validateSubmission(payload); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.company, undefined); + assert.strictEqual(result.value.pulumiAbout, undefined); + } +}); + +test("flags every missing required field", () => { + const result = validateSubmission({}); + assert.ok(!result.ok); + if (!result.ok) { + for (const key of ["email", "name", "organization", "category", "subject", "description"]) { + assert.ok(result.fields[key], `expected an error for ${key}`); + } + assert.strictEqual(result.fields.company, undefined); + } +}); + +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 categories outside the closed set", () => { + const result = validateSubmission({ ...validPayload(), category: "everything" }); + assert.ok(!result.ok); + if (!result.ok) { + assert.ok(result.fields.category); + } +}); + +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); + } +}); + +test("accepts attachment metadata within limits", () => { + const result = validateSubmission({ + ...validPayload(), + attachments: [{ filename: "stack-trace.txt", sizeBytes: 1024, contentType: "text/plain" }], + }); + assert.ok(result.ok); + if (result.ok) { + assert.strictEqual(result.value.attachments?.length, 1); + } +}); + +test("rejects attachment metadata outside limits", () => { + const tooMany = Array.from({ length: 6 }, (_, i) => ({ + filename: `file-${i}.txt`, + sizeBytes: 1, + contentType: "text/plain", + })); + for (const attachments of [ + tooMany, + [{ filename: "big.bin", sizeBytes: 21 * 1024 * 1024, contentType: "application/octet-stream" }], + [{ filename: "", sizeBytes: 1, contentType: "text/plain" }], + "not-a-list", + ]) { + const result = validateSubmission({ ...validPayload(), attachments }); + assert.ok(!result.ok, `expected ${JSON.stringify(attachments).slice(0, 40)} to be rejected`); + } +}); + +// --- 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..d92aea4ba63f --- /dev/null +++ b/infrastructure/support-form/validation.ts @@ -0,0 +1,276 @@ +// 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. + +// Category ids for the "I need help with:" 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 CATEGORIES = ["account-sales", "program", "cloud", "docs"] as const; +export type Category = (typeof CATEGORIES)[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, + company: 200, + organization: 40, + subject: 200, + descriptionMin: 10, + description: 20000, + pulumiAbout: 20000, + attachmentsMax: 5, + attachmentFilename: 255, + attachmentSizeBytes: 20 * 1024 * 1024, + attachmentContentType: 100, +}; + +// 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 AttachmentMetadata { + filename: string; + sizeBytes: number; + contentType: string; +} + +export interface SupportRequest { + email: string; + name: string; + company?: string; + organization: string; + category: Category; + subject: string; + description: string; + pulumiAbout?: string; + // Metadata only: attachment bytes are not uploaded until the Intercom + // integration defines where they go. + attachments?: AttachmentMetadata[]; +} + +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", + "company", + "organization", + "category", + "subject", + "description", + "pulumiAbout", + "attachments", + "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(); +} + +function validateAttachments(input: unknown, fields: Record): AttachmentMetadata[] | undefined { + if (input === undefined || input === null) { + return undefined; + } + if (!Array.isArray(input)) { + fields.attachments = "Expected a list of attachment metadata."; + return undefined; + } + if (input.length === 0) { + return undefined; + } + if (input.length > LIMITS.attachmentsMax) { + fields.attachments = `Attach up to ${LIMITS.attachmentsMax} files.`; + return undefined; + } + const result: AttachmentMetadata[] = []; + for (const item of input) { + if (!isRecord(item)) { + fields.attachments = "Expected a list of attachment metadata."; + return undefined; + } + const { filename, sizeBytes, contentType } = item; + if ( + typeof filename !== "string" || + filename.trim().length === 0 || + filename.length > LIMITS.attachmentFilename + ) { + fields.attachments = "Each attachment needs a filename of at most 255 characters."; + return undefined; + } + if ( + typeof sizeBytes !== "number" || + !Number.isFinite(sizeBytes) || + sizeBytes < 0 || + sizeBytes > LIMITS.attachmentSizeBytes + ) { + fields.attachments = "Each attachment must be 20 MB or smaller."; + return undefined; + } + if (typeof contentType !== "string" || contentType.length > LIMITS.attachmentContentType) { + fields.attachments = "Each attachment needs a content type."; + return undefined; + } + result.push({ filename: filename.trim(), sizeBytes, contentType }); + } + return result; +} + +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 company = stringField(input, "company", fields); + if (fields.company === undefined && company && company.length > LIMITS.company) { + fields.company = `Keep your company name under ${LIMITS.company} 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 category = stringField(input, "category", fields); + if (fields.category === undefined) { + if (!category) { + fields.category = "Choose the area you need help with."; + } else if ((CATEGORIES as readonly string[]).indexOf(category) === -1) { + fields.category = "Choose one of the listed areas."; + } + } + + 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.`; + } + } + + const pulumiAbout = stringField(input, "pulumiAbout", fields); + if (fields.pulumiAbout === undefined && pulumiAbout && pulumiAbout.length > LIMITS.pulumiAbout) { + fields.pulumiAbout = `Keep the output under ${LIMITS.pulumiAbout} characters.`; + } + + const attachments = validateAttachments(input.attachments, fields); + + 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, + category: category as Category, + subject: subject as string, + description: description as string, + }; + if (company) { + value.company = company; + } + if (pulumiAbout) { + value.pulumiAbout = pulumiAbout; + } + if (attachments && attachments.length > 0) { + value.attachments = attachments; + } + 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..0e478a9ede0e --- /dev/null +++ b/layouts/page/support-new.html @@ -0,0 +1,202 @@ +{{ 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 category option values are the API's closed enum — keep the front matter's + form.fields.category.options in sync with CATEGORIES 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.category.help }} +

{{ . }}

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

{{ . }}

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

{{ . }}

+ {{ end }} + + +
+ +
+ + {{ with $fields.attachments.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/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..e625190af1ce --- /dev/null +++ b/theme/src/ts/support-form.ts @@ -0,0 +1,436 @@ +// /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 MAX_ATTACHMENTS = 5; +const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; + +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", + company: "support-company", + organization: "support-organization", + category: "support-category", + subject: "support-subject", + description: "support-description", + pulumiAbout: "support-pulumi-about", + attachments: "support-attachments", +}; + +type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +interface AttachmentMetadata { + filename: string; + sizeBytes: number; + contentType: string; +} + +// 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."), + company: () => null, + 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; + }, + category: value => (value ? null : "Choose the area you need help with."), + subject: value => (value ? null : "Enter a subject."), + description: value => + value.length >= 10 ? null : "Describe the issue in at least a few words.", + pulumiAbout: () => null, + }; + + 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 validateAttachments(): boolean { + const input = control("attachments") as HTMLInputElement | null; + if (!input || !input.files || input.files.length === 0) { + clearError("attachments"); + return true; + } + if (input.files.length > MAX_ATTACHMENTS) { + setError("attachments", `Attach up to ${MAX_ATTACHMENTS} files.`); + return false; + } + for (const file of Array.from(input.files)) { + if (file.size > MAX_ATTACHMENT_BYTES) { + setError("attachments", `"${file.name}" is larger than 20 MB.`); + return false; + } + } + clearError("attachments"); + 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; + } + ok = validateAttachments() && 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", "company", "organization", "category", "subject", "description", "pulumiAbout"]; + + 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/?category=cloud&subject=CLI+crash. + const params = new URLSearchParams(window.location.search); + for (const field of ["category", "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); + } + }); + } + const attachmentsInput = control("attachments"); + if (attachmentsInput) { + attachmentsInput.addEventListener("change", validateAttachments); + } + + // --- 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")), + category: value("category"), + subject: value("subject"), + description: value("description"), + }; + if (value("company")) { + payload.company = value("company"); + } + if (value("pulumiAbout")) { + payload.pulumiAbout = value("pulumiAbout"); + } + const files = (control("attachments") as HTMLInputElement | null)?.files; + if (files && files.length > 0) { + // Metadata only — bytes aren't uploaded until the Intercom + // integration defines where they go. + const attachments: AttachmentMetadata[] = Array.from(files).map(file => ({ + filename: file.name, + sizeBytes: file.size, + contentType: file.type || "application/octet-stream", + })); + payload.attachments = attachments; + } + // 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", + category: payload.category, + }); + 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(); +} From e31402d4fd35d3698101f349b12ba9c5b75af6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 15:31:32 +0000 Subject: [PATCH 2/9] Fix category label typo: Documentations -> Documentation The fourth "I need help with:" option was carried over verbatim from the Zendesk form, typo included. The option value (docs) is unchanged, so the closed enum in infrastructure/support-form/validation.ts, the client-side validation, and the unit tests are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DtyVE5R4oYsgzBU7x1AC45 --- content/support/new/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/support/new/_index.md b/content/support/new/_index.md index b5d3506b743f..c06ccb3dee94 100644 --- a/content/support/new/_index.md +++ b/content/support/new/_index.md @@ -34,7 +34,7 @@ form: value: program - label: My Pulumi Cloud value: cloud - - label: Pulumi Documentations/Blog + - label: Pulumi Documentation/Blog value: docs subject: label: Subject From 342711e4f280b0ab04d4dfa9530ea248c98d27e7 Mon Sep 17 00:00:00 2001 From: mtan Date: Sun, 23 Aug 2026 20:02:54 -0500 Subject: [PATCH 3/9] adjust form fields --- content/support/new/_index.md | 26 +--- .../support-form/validation.test.ts | 56 +-------- infrastructure/support-form/validation.ts | 115 ++---------------- layouts/page/support-new.html | 46 ++----- theme/src/ts/support-form.ts | 70 ++--------- 5 files changed, 39 insertions(+), 274 deletions(-) diff --git a/content/support/new/_index.md b/content/support/new/_index.md index c06ccb3dee94..46e18f0d9d81 100644 --- a/content/support/new/_index.md +++ b/content/support/new/_index.md @@ -18,35 +18,21 @@ form: label: Your email address name: label: Full name - company: - label: Company name organization: label: Pulumi organization name help: https://app.pulumi.com/PULUMI_ORG_NAME - category: - label: "I need help with:" - help: In what area of Pulumi are you encountering issues? - placeholder: Choose an area + priority: + label: Priority options: - - label: My Pulumi Account/Sales - value: account-sales - - label: My Pulumi Program - value: program - - label: My Pulumi Cloud - value: cloud - - label: Pulumi Documentation/Blog - value: docs + - 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. - pulumi_about: - label: Please run pulumi about in the directory containing the Pulumi project and share the output. - help: This will print information about the Pulumi environment and is helpful for debugging. - attachments: - label: Attachments - help: "Up to 5 files, 20 MB each. File contents aren't uploaded yet: we'll note what you selected and ask for the files by email if we need them." 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://support.pulumi.com/. diff --git a/infrastructure/support-form/validation.test.ts b/infrastructure/support-form/validation.test.ts index 5f6133242fc6..1ce560bfb07f 100644 --- a/infrastructure/support-form/validation.test.ts +++ b/infrastructure/support-form/validation.test.ts @@ -18,12 +18,10 @@ function validPayload(): Record { return { email: "jane@example.com", name: "Jane Doe", - company: "Example Corp", organization: "example-corp", - category: "cloud", + priority: "normal", subject: "Stack update stuck in progress", description: "Running `pulumi up` hangs after the preview completes. Expected the update to apply.", - pulumiAbout: "CLI Version 3.100.0", }; } @@ -32,20 +30,7 @@ test("accepts a fully valid payload", () => { assert.ok(result.ok); if (result.ok) { assert.strictEqual(result.value.email, "jane@example.com"); - assert.strictEqual(result.value.category, "cloud"); - assert.strictEqual(result.value.company, "Example Corp"); - } -}); - -test("accepts a payload without optional fields", () => { - const payload = validPayload(); - delete payload.company; - delete payload.pulumiAbout; - const result = validateSubmission(payload); - assert.ok(result.ok); - if (result.ok) { - assert.strictEqual(result.value.company, undefined); - assert.strictEqual(result.value.pulumiAbout, undefined); + assert.strictEqual(result.value.priority, "normal"); } }); @@ -53,10 +38,9 @@ test("flags every missing required field", () => { const result = validateSubmission({}); assert.ok(!result.ok); if (!result.ok) { - for (const key of ["email", "name", "organization", "category", "subject", "description"]) { + for (const key of ["email", "name", "organization", "priority", "subject", "description"]) { assert.ok(result.fields[key], `expected an error for ${key}`); } - assert.strictEqual(result.fields.company, undefined); } }); @@ -96,11 +80,11 @@ test("rejects organization names that fail the naming rules", () => { } }); -test("rejects categories outside the closed set", () => { - const result = validateSubmission({ ...validPayload(), category: "everything" }); +test("rejects priorities outside the closed set", () => { + const result = validateSubmission({ ...validPayload(), priority: "everything" }); assert.ok(!result.ok); if (!result.ok) { - assert.ok(result.fields.category); + assert.ok(result.fields.priority); } }); @@ -128,34 +112,6 @@ test("rejects too-short descriptions", () => { } }); -test("accepts attachment metadata within limits", () => { - const result = validateSubmission({ - ...validPayload(), - attachments: [{ filename: "stack-trace.txt", sizeBytes: 1024, contentType: "text/plain" }], - }); - assert.ok(result.ok); - if (result.ok) { - assert.strictEqual(result.value.attachments?.length, 1); - } -}); - -test("rejects attachment metadata outside limits", () => { - const tooMany = Array.from({ length: 6 }, (_, i) => ({ - filename: `file-${i}.txt`, - sizeBytes: 1, - contentType: "text/plain", - })); - for (const attachments of [ - tooMany, - [{ filename: "big.bin", sizeBytes: 21 * 1024 * 1024, contentType: "application/octet-stream" }], - [{ filename: "", sizeBytes: 1, contentType: "text/plain" }], - "not-a-list", - ]) { - const result = validateSubmission({ ...validPayload(), attachments }); - assert.ok(!result.ok, `expected ${JSON.stringify(attachments).slice(0, 40)} to be rejected`); - } -}); - // --- Handler-level tests --- const SECRET = "test-secret"; diff --git a/infrastructure/support-form/validation.ts b/infrastructure/support-form/validation.ts index d92aea4ba63f..6bbf02f44cbb 100644 --- a/infrastructure/support-form/validation.ts +++ b/infrastructure/support-form/validation.ts @@ -9,11 +9,11 @@ // validation in theme/src/ts/support-form.ts mirrors them for UX, but only // this module is authoritative. -// Category ids for the "I need help with:" select. The display labels live in -// the form's front matter (content/support/new/_index.md); ids and labels must +// 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 CATEGORIES = ["account-sales", "program", "cloud", "docs"] as const; -export type Category = (typeof CATEGORIES)[number]; +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. @@ -22,16 +22,10 @@ export const MAX_BODY_BYTES = 256 * 1024; export const LIMITS = { email: 254, name: 200, - company: 200, organization: 40, subject: 200, descriptionMin: 10, description: 20000, - pulumiAbout: 20000, - attachmentsMax: 5, - attachmentFilename: 255, - attachmentSizeBytes: 20 * 1024 * 1024, - attachmentContentType: 100, }; // Pragmatic email shape check: something@something.tld. Full RFC 5322 @@ -43,24 +37,13 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // 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 AttachmentMetadata { - filename: string; - sizeBytes: number; - contentType: string; -} - export interface SupportRequest { email: string; name: string; - company?: string; organization: string; - category: Category; + priority: Priority; subject: string; description: string; - pulumiAbout?: string; - // Metadata only: attachment bytes are not uploaded until the Intercom - // integration defines where they go. - attachments?: AttachmentMetadata[]; } export type ValidationResult = @@ -74,13 +57,10 @@ export type ValidationResult = const KNOWN_KEYS = new Set([ "email", "name", - "company", "organization", - "category", + "priority", "subject", "description", - "pulumiAbout", - "attachments", "website", ]); @@ -120,54 +100,6 @@ function stringField( return value.trim(); } -function validateAttachments(input: unknown, fields: Record): AttachmentMetadata[] | undefined { - if (input === undefined || input === null) { - return undefined; - } - if (!Array.isArray(input)) { - fields.attachments = "Expected a list of attachment metadata."; - return undefined; - } - if (input.length === 0) { - return undefined; - } - if (input.length > LIMITS.attachmentsMax) { - fields.attachments = `Attach up to ${LIMITS.attachmentsMax} files.`; - return undefined; - } - const result: AttachmentMetadata[] = []; - for (const item of input) { - if (!isRecord(item)) { - fields.attachments = "Expected a list of attachment metadata."; - return undefined; - } - const { filename, sizeBytes, contentType } = item; - if ( - typeof filename !== "string" || - filename.trim().length === 0 || - filename.length > LIMITS.attachmentFilename - ) { - fields.attachments = "Each attachment needs a filename of at most 255 characters."; - return undefined; - } - if ( - typeof sizeBytes !== "number" || - !Number.isFinite(sizeBytes) || - sizeBytes < 0 || - sizeBytes > LIMITS.attachmentSizeBytes - ) { - fields.attachments = "Each attachment must be 20 MB or smaller."; - return undefined; - } - if (typeof contentType !== "string" || contentType.length > LIMITS.attachmentContentType) { - fields.attachments = "Each attachment needs a content type."; - return undefined; - } - result.push({ filename: filename.trim(), sizeBytes, contentType }); - } - return result; -} - export function validateSubmission(input: unknown): ValidationResult { const fields: Record = {}; @@ -199,11 +131,6 @@ export function validateSubmission(input: unknown): ValidationResult { } } - const company = stringField(input, "company", fields); - if (fields.company === undefined && company && company.length > LIMITS.company) { - fields.company = `Keep your company name under ${LIMITS.company} characters.`; - } - const organizationRaw = stringField(input, "organization", fields); let organization: string | undefined; if (fields.organization === undefined) { @@ -217,12 +144,12 @@ export function validateSubmission(input: unknown): ValidationResult { } } - const category = stringField(input, "category", fields); - if (fields.category === undefined) { - if (!category) { - fields.category = "Choose the area you need help with."; - } else if ((CATEGORIES as readonly string[]).indexOf(category) === -1) { - fields.category = "Choose one of the listed areas."; + 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."; } } @@ -244,13 +171,6 @@ export function validateSubmission(input: unknown): ValidationResult { } } - const pulumiAbout = stringField(input, "pulumiAbout", fields); - if (fields.pulumiAbout === undefined && pulumiAbout && pulumiAbout.length > LIMITS.pulumiAbout) { - fields.pulumiAbout = `Keep the output under ${LIMITS.pulumiAbout} characters.`; - } - - const attachments = validateAttachments(input.attachments, fields); - if (Object.keys(fields).length > 0) { return { ok: false, fields }; } @@ -259,18 +179,9 @@ export function validateSubmission(input: unknown): ValidationResult { email: email as string, name: name as string, organization: organization as string, - category: category as Category, + priority: priority as Priority, subject: subject as string, description: description as string, }; - if (company) { - value.company = company; - } - if (pulumiAbout) { - value.pulumiAbout = pulumiAbout; - } - if (attachments && attachments.length > 0) { - value.attachments = attachments; - } return { ok: true, value }; } diff --git a/layouts/page/support-new.html b/layouts/page/support-new.html index 0e478a9ede0e..717eb1ddcbbd 100644 --- a/layouts/page/support-new.html +++ b/layouts/page/support-new.html @@ -14,8 +14,8 @@ 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 category option values are the API's closed enum — keep the front matter's - form.fields.category.options in sync with CATEGORIES in 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 }} @@ -59,13 +59,6 @@

{{ $overview.title }}

-
- - - -
-
{{ with $fields.organization.help }} @@ -78,18 +71,14 @@

{{ $overview.title }}

- - {{ with $fields.category.help }} -

{{ . }}

- {{ end }} - + {{ range $fields.priority.options }} {{ end }} - +
@@ -111,27 +100,6 @@

{{ $overview.title }}

-
- - {{ with $fields.pulumi_about.help }} -

{{ . }}

- {{ end }} - - -
- -
- - {{ with $fields.attachments.help }} -

{{ . }}

- {{ end }} - - -
- {{/* Honeypot: invisible to people, filled in by naive bots. The handler drops any submission that sets it. */}} 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/", From 7146ce554e622c441da879c74d204c7490d3c9b5 Mon Sep 17 00:00:00 2001 From: mtan Date: Mon, 24 Aug 2026 10:11:44 -0500 Subject: [PATCH 5/9] add lambda handler --- infrastructure/Pulumi.meggy.yaml | 5 ++ infrastructure/index.ts | 12 ++-- infrastructure/support-form/handler.ts | 36 +++++++---- infrastructure/support-form/intercom.ts | 80 +++++++++++++++++++++++++ infrastructure/supportForm.ts | 21 +++++-- 5 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 infrastructure/Pulumi.meggy.yaml create mode 100644 infrastructure/support-form/intercom.ts diff --git a/infrastructure/Pulumi.meggy.yaml b/infrastructure/Pulumi.meggy.yaml new file mode 100644 index 000000000000..5d52ffe50167 --- /dev/null +++ b/infrastructure/Pulumi.meggy.yaml @@ -0,0 +1,5 @@ +config: + www.pulumi.com:intercomTicketTypeId: "4573798" + www.pulumi.com:intercomApiKey: + secure: AAABAA7KQr1JJ9TWlatqOiROnh8sF6s+AouoBkZeUfqg22GlQ9wut4oQZr2BSC5HyY/LS1npuORxPBaKW85gzRePhX6N4M7xYQ/eqgOWRy/hQk/NDZbdC0qRytc= + www.pulumi.com:enableSupportForm: "true" diff --git a/infrastructure/index.ts b/infrastructure/index.ts index 21ebb075267e..862d168c9737 100644 --- a/infrastructure/index.ts +++ b/infrastructure/index.ts @@ -80,10 +80,9 @@ const config = { 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. + // 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, }; @@ -959,7 +958,10 @@ const supportFormBehaviors: aws.types.input.cloudfront.DistributionOrderedCacheB let supportForm: SupportFormApi | undefined; if (config.enableSupportForm) { - supportForm = new SupportFormApi("support-form"); + supportForm = new SupportFormApi("support-form", { + intercomApiKey: stackConfig.requireSecret("intercomApiKey"), + intercomTicketTypeId: stackConfig.require("intercomTicketTypeId"), + }); supportFormOrigins.push(supportForm.getOrigin()); diff --git a/infrastructure/support-form/handler.ts b/infrastructure/support-form/handler.ts index fbe948918816..5b62a4dd9336 100644 --- a/infrastructure/support-form/handler.ts +++ b/infrastructure/support-form/handler.ts @@ -8,12 +8,13 @@ // 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. +// 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 @@ -120,22 +121,33 @@ export async function supportFormHandler(event: FunctionUrlEvent): Promise { + 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 fetch(`${INTERCOM_API_BASE}/contacts/search`, { + method: "POST", + headers: intercomHeaders(), + body: JSON.stringify({ + 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 fetch(`${INTERCOM_API_BASE}/contacts`, { + method: "POST", + headers: intercomHeaders(), + body: JSON.stringify({ 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 fetch(`${INTERCOM_API_BASE}/tickets`, { + method: "POST", + headers: intercomHeaders(), + body: JSON.stringify({ + 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/supportForm.ts b/infrastructure/supportForm.ts index 8a6743d3758f..985e08fd4537 100644 --- a/infrastructure/supportForm.ts +++ b/infrastructure/supportForm.ts @@ -8,9 +8,9 @@ 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. +// 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 @@ -20,12 +20,23 @@ import { supportFormHandler } from "./support-form/handler"; // 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, opts?: pulumi.ComponentResourceOptions) { + 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 @@ -108,6 +119,8 @@ export class SupportFormApi extends pulumi.ComponentResource { environment: { variables: { SUPPORT_FORM_ORIGIN_SECRET: this.originSecret.result, + INTERCOM_API_KEY: args.intercomApiKey, + INTERCOM_TICKET_TYPE_ID: args.intercomTicketTypeId, }, }, }, From 854b69adb2572eba907608c5c515756702db58fb Mon Sep 17 00:00:00 2001 From: mtan Date: Mon, 24 Aug 2026 11:59:23 -0500 Subject: [PATCH 6/9] add api keys --- infrastructure/Pulumi.www-production.yaml | 3 +++ infrastructure/Pulumi.www-testing.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/infrastructure/Pulumi.www-production.yaml b/infrastructure/Pulumi.www-production.yaml index afaa248bfd79..e8aa28279104 100644 --- a/infrastructure/Pulumi.www-production.yaml +++ b/infrastructure/Pulumi.www-production.yaml @@ -19,3 +19,6 @@ config: www.pulumi.com:wafRateLimit: "500" www.pulumi.com:enableDataWarehouseAccess: "true" www.pulumi.com:enableSupportForm: "true" + www.pulumi.com:intercomTicketTypeId: "4573798" + www.pulumi.com:intercomApiKey: + secure: AAABAA7KQr1JJ9TWlatqOiROnh8sF6s+AouoBkZeUfqg22GlQ9wut4oQZr2BSC5HyY/LS1npuORxPBaKW85gzRePhX6N4M7xYQ/eqgOWRy/hQk/NDZbdC0qRytc= diff --git a/infrastructure/Pulumi.www-testing.yaml b/infrastructure/Pulumi.www-testing.yaml index b1ffc3e5a925..d73bedb46820 100644 --- a/infrastructure/Pulumi.www-testing.yaml +++ b/infrastructure/Pulumi.www-testing.yaml @@ -14,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: AAABAA7KQr1JJ9TWlatqOiROnh8sF6s+AouoBkZeUfqg22GlQ9wut4oQZr2BSC5HyY/LS1npuORxPBaKW85gzRePhX6N4M7xYQ/eqgOWRy/hQk/NDZbdC0qRytc= \ No newline at end of file From fb2719105564896a4508d3e36ed6f81abae80166 Mon Sep 17 00:00:00 2001 From: Megan Tan Date: Mon, 24 Aug 2026 13:03:52 -0500 Subject: [PATCH 7/9] Update infrastructure/support-form/handler.ts Co-authored-by: unblocked[bot] <98133410+unblocked[bot]@users.noreply.github.com> --- infrastructure/support-form/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/support-form/handler.ts b/infrastructure/support-form/handler.ts index 5b62a4dd9336..1297ff8fa27c 100644 --- a/infrastructure/support-form/handler.ts +++ b/infrastructure/support-form/handler.ts @@ -131,6 +131,7 @@ export async function supportFormHandler(event: FunctionUrlEvent): Promise Date: Mon, 24 Aug 2026 15:27:46 -0500 Subject: [PATCH 8/9] modify api keys --- infrastructure/Pulumi.www-production.yaml | 2 +- infrastructure/Pulumi.www-testing.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/Pulumi.www-production.yaml b/infrastructure/Pulumi.www-production.yaml index e8aa28279104..aa7600b3a210 100644 --- a/infrastructure/Pulumi.www-production.yaml +++ b/infrastructure/Pulumi.www-production.yaml @@ -21,4 +21,4 @@ config: www.pulumi.com:enableSupportForm: "true" www.pulumi.com:intercomTicketTypeId: "4573798" www.pulumi.com:intercomApiKey: - secure: AAABAA7KQr1JJ9TWlatqOiROnh8sF6s+AouoBkZeUfqg22GlQ9wut4oQZr2BSC5HyY/LS1npuORxPBaKW85gzRePhX6N4M7xYQ/eqgOWRy/hQk/NDZbdC0qRytc= + secure: AAABAB9ONteabt646yqBaCGDr1tGeCaJjLBNR5EYv+cTcjrgNjClny1z6S9ZlpFn/zUkFtIUowpCC/J016qWeKBu/MrORxW2onYzDIrBBdJ+exlbMzz25lcfRxE= diff --git a/infrastructure/Pulumi.www-testing.yaml b/infrastructure/Pulumi.www-testing.yaml index d73bedb46820..6ba5462c30a5 100644 --- a/infrastructure/Pulumi.www-testing.yaml +++ b/infrastructure/Pulumi.www-testing.yaml @@ -16,4 +16,4 @@ config: www.pulumi.com:websiteLogsBucketName: pulumi-test-io-website-logs www.pulumi.com:intercomTicketTypeId: "4573798" www.pulumi.com:intercomApiKey: - secure: AAABAA7KQr1JJ9TWlatqOiROnh8sF6s+AouoBkZeUfqg22GlQ9wut4oQZr2BSC5HyY/LS1npuORxPBaKW85gzRePhX6N4M7xYQ/eqgOWRy/hQk/NDZbdC0qRytc= \ No newline at end of file + secure: AAABAJ2bHeR1rJ6zKuK1eW5I83ueGZ3bwh8ELILQeauY3Ca+cnmF+YyaS4GNAgPKN03geZ0mBxEli2g1kSBftGApsV3q3tvw4ImqXTtj7+dckCroD8xx97P+YVc= From b013067f3905a301f8d891ee41d270476d6a3d08 Mon Sep 17 00:00:00 2001 From: mtan Date: Mon, 24 Aug 2026 20:02:26 -0500 Subject: [PATCH 9/9] fix validation error --- infrastructure/support-form/validation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/support-form/validation.ts b/infrastructure/support-form/validation.ts index 6bbf02f44cbb..a24cd1d57e5b 100644 --- a/infrastructure/support-form/validation.ts +++ b/infrastructure/support-form/validation.ts @@ -54,7 +54,7 @@ export type ValidationResult = // 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([ +const KNOWN_KEYS = [ "email", "name", "organization", @@ -62,7 +62,7 @@ const KNOWN_KEYS = new Set([ "subject", "description", "website", -]); +]; // Strips a pasted console URL ("https://app.pulumi.com/my-org/...") or // stray slashes down to the bare organization name. @@ -108,7 +108,7 @@ export function validateSubmission(input: unknown): ValidationResult { } for (const key of Object.keys(input)) { - if (!KNOWN_KEYS.has(key)) { + if (!KNOWN_KEYS.includes(key)) { return { ok: false, fields: { _form: `Unexpected field "${key}".` } }; } }