Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions content/support/new/_index.md
Original file line number Diff line number Diff line change
@@ -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/
---
1 change: 1 addition & 0 deletions infrastructure/Pulumi.www-production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions infrastructure/Pulumi.www-testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions infrastructure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -994,6 +1049,7 @@ const distributionArgs: aws.cloudfront.DistributionArgs = {
...guidesOrigins,
...answersOrigins,
...versionedDocsOrigins,
...supportFormOrigins,
],

// Default object to serve when no path is given.
Expand All @@ -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,
Expand Down Expand Up @@ -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();
4 changes: 3 additions & 1 deletion infrastructure/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
141 changes: 141 additions & 0 deletions infrastructure/support-form/handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
requestContext?: {
http?: {
method?: string;
path?: string;
sourceIp?: string;
};
};
}

export interface FunctionUrlResult {
statusCode: number;
headers: Record<string, string>;
body: string;
}

function jsonResponse(statusCode: number, body: object, extraHeaders: Record<string, string> = {}): 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<FunctionUrlResult> {
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<string, unknown>).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 });
}
Loading
Loading