Skip to content

Custom support-request form at /support/new with Intercom-backed /api/support - #21030

Merged
MeganYTan merged 29 commits into
masterfrom
claude/support-form-plan-jxc143
Aug 26, 2026
Merged

Custom support-request form at /support/new with Intercom-backed /api/support#21030
MeganYTan merged 29 commits into
masterfrom
claude/support-form-plan-jxc143

Conversation

@CamSoper

@CamSoper CamSoper commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Proposed changes

WIP — do not merge. Custom support-request form for the Zendesk → Intercom migration (context). Intercom has no prebuilt request form, so this builds our own and files submissions as Intercom tickets.

Form page — /support/new/ (content/support/new/_index.md + layouts/page/support-new.html)

  • Hand-built <form> — email, full name, Pulumi org, priority, subject, description — styled entirely with the shared .form-* / .btn / .card primitives, no new CSS. All copy lives in front matter; two-card layout (form → confirmation) modeled on /extend-trial.
  • theme/src/ts/support-form.ts (registered in main.ts): per-field validation mirroring the server rules (aria-invalid + .form-error, focus-first-invalid, clear-on-input), org-name normalization (pasted https://app.pulumi.com/<org> URLs reduce to the org name), sessionStorage draft persistence so a failed submit never loses the description, a character counter, ?priority=/?subject= prefill, a honeypot, and a graceful error banner for environments where the endpoint doesn't exist (PR previews, local make serve).
  • block_external_search_index: true until cutover makes this the canonical support entry point.

Backend — POST /api/support (infrastructure/supportForm.ts + infrastructure/support-form/)

  • A Lambda (Function URL) mounted same-origin through a new CloudFront origin + /api/support* behavior on the existing distribution — no CORS, and traffic stays behind the site's WAF rate limiting. The Function URL is AuthType NONE but rejects any request lacking the x-origin-verify shared secret that only CloudFront injects (secret generated by @pulumi/random, delivered via Lambda env var; comma-separated rotation supported).
  • Handler checks: origin secret → method → content type → 256 KB body cap → JSON parse → honeypot → field validation, with field-level 422 {fields} errors the frontend maps back onto inputs. All responses are Cache-Control: no-store (new api-response-headers policy; the API behavior also drops the edge-redirect Lambda association and markdown-negotiation functions that baseCacheBehavior would otherwise attach).
  • Intercom ticket filing (support-form/intercom.ts): finds or creates the submitter's contact by email, then files a ticket carrying the subject, description, org, and priority. Credentials come from stack config (intercomApiKey as a secret, intercomTicketTypeId) and are delivered as Lambda env vars — never in this repo, never shipped to the frontend. Accepted submissions are also logged as structured JSON (support_request_accepted) to a pre-created CloudWatch log group with 90-day retention (submissions contain PII); a ticket-creation failure returns 502 and logs support_request_ticket_failed with the payload so it can be replayed.
  • Gated by enableSupportForm stack config, on for both www-testing and www-production (each with its own Intercom ticket type), so the endpoint and the link cutover go live together in one merge. Dev stacks and PR previews are unaffected — they get no origin or behavior at all, and the form degrades to the error banner.
  • Unit tests: cd infrastructure && yarn test-support-form — 30 tests covering validation, every handler status path (403/405/400/413/422/502/200, honeypot, base64), and the Intercom client (contact found, contact created, request shape, each leg's failure). The Intercom API is faked by stubbing globalThis.fetch in the tests, so handler.ts and intercom.ts carry no test seam and the Lambda's serialized closure is unaffected.
  • .github/workflows/support-form-tests.yml runs that suite on any PR touching infrastructure/support-form/ — standalone rather than part of the PR gate, which needs AWS/Pulumi credentials and skips on forks.

Agent path — the ## For agents section of /llms.txt documents POST /api/support: the six fields, the priority enum, the org-name rules, and the status codes. This is the fix for a hazard the honeypot creates, not a nicety. The trap is a plain <input name="website"> that anything reading raw HTML sees, so an agent filing a ticket for someone would fill it in, get a 200 with a real-looking id, and report success for a ticket that was never created. It can't be fixed inside the form — any signal that rescues a good agent also tells a bot it was caught — so agents get a different door, and the documented shape never mentions that field. The trap's label also moves from "Website" to "Leave this field empty"; naive bots fill every field regardless, so it costs nothing against them.

Link cutover (partial) — footer, help-links.html, hand-raise-section.html, /extend-trial, and the /support/ redirect now point at /support/new/. The remaining Zendesk links in content/security/, choose-edition.md, and getting-support.md stay put until cutover.

Verified: make lint clean, make build renders /support/new/, webpack bundle compiles, infra tsc clean, 30/30 unit tests pass.

Merging this turns on a live, public POST /api/support on www.pulumi.com that files real Intercom tickets, and repoints /support/ away from Zendesk — deliberately, so the backend and the links land together rather than leaving a form that renders but can't submit. Verify on pulumi-test.io before merging.

Follow-ups (not in this PR): attachment upload (the Intercom spec hasn't defined where files go), the three docs pages still pointing at support.pulumi.com, optional OAC hardening of the Function URL, and a post-deploy health check for the endpoint.

Unreleased product version (optional)

n/a

Related issues (optional)

Internal Slack thread: https://pulumi.slack.com/archives/D0BRR746EAD/p1787266575569169

…port 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtyVE5R4oYsgzBU7x1AC45
@github-actions github-actions Bot added the review:triaging Claude Triage is currently classifying the PR label Aug 21, 2026
@CamSoper CamSoper added do-not-merge and removed review:triaging Claude Triage is currently classifying the PR labels Aug 21, 2026 — with Claude
@github-actions github-actions Bot added domain:website PR touches marketing, pricing, legal, or competitive landing pages domain:mixed PR touches more than one domain domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:in-progress Claude review is currently running labels Aug 21, 2026

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues found

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 21, 2026
@pulumi-bot

pulumi-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Lighthouse Performance Report

Commit: b7bbe13 | Metric definitions

Page Device Score FCP LCP TBT CLS SI
Homepage Mobile 🔴 46 3.0s 4.9s 1142ms 0.026 7.6s
Homepage Desktop 🟢 94 0.5s 1.0s 133ms 0.068 1.3s
Install Pulumi Mobile 🟡 56 7.4s 10.6s 30ms 0.027 9.6s
Install Pulumi Desktop 🟡 85 1.1s 1.8s 0ms 0.006 2.3s
AWS Get Started Mobile 🟡 58 7.6s 10.4s 9ms 0.000 7.6s
AWS Get Started Desktop 🟡 87 1.1s 1.7s 0ms 0.047 2.1s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtyVE5R4oYsgzBU7x1AC45
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Fixed the outstanding finding in e31402d: the category label now reads "Pulumi Documentation/Blog". The label was carried over verbatim from the Zendesk form, typo included; value: docs is unchanged, so the closed enum in infrastructure/support-form/validation.ts, the client-side mirror, and the unit tests are unaffected.

Notes on the four low-confidence findings, none of which I've acted on:

  • "Please" in two field labels — also verbatim from the Zendesk form. Happy to reword to imperative ("Run pulumi about in the directory containing the Pulumi project and share the output") per the Google style guide, but since the intent was to mirror the existing form's copy exactly, I'd rather Megan make that call as part of the copy review.
  • Unused LIMITS.organization — correct that the 40-char bound is enforced only by ORGANIZATION_PATTERN. Worth resolving, but I'd fold it into the same pass that replaces the stub with the real Intercom call rather than churn the file now.
  • test-support-form not wired into CI — fair. It's deliberate for the WIP: the infra program has no CI test step today, and adding one is a change to shared workflow config that deserves its own review rather than riding along here.
  • enableSupportForm: true in production — flagging that this PR is WIP / do-not-merge precisely because of this. The form page is block_external_search_index: true and nothing links to it yet, but if reviewers would rather land the endpoint disabled in production and flip it at cutover, that's a one-line config change and I'll make it.

Generated by Claude Code

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 21, 2026
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:no-blockers Claude review completed cleanly; outstanding is empty labels Aug 24, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The honeypot is indiscriminate. It is a plain <input name="website"> labelled
"Website" in the raw HTML, and anything that reads raw HTML sees it -- including
a legitimate agent filing a ticket for someone. That agent fills in a plausible
URL, gets HTTP 200 with a real-looking id, and reports success for a ticket that
was never created. Silent data loss with a fabricated confirmation, which is
worse than an outright failure.

It cannot be fixed inside the form: any signal that tells a good agent it was
trapped tells a bot the same thing. So the fix is a different door. The "For
agents" section of /llms.txt now documents POST /api/support -- the six fields,
the priority enum, the org-name rules, and the status codes -- and an agent
following it sends exactly those six keys and never touches the honeypot. The
documentation deliberately does not mention that field.

Relabels the trap from "Website" to "Leave this field empty" as well. Naive bots
fill every field regardless of what it says, so this costs nothing against them
and rescues anything that reads a label before writing to it. The label is
inside an aria-hidden sr-only block, so no person or screen reader sees either
version.

Documenting the endpoint does not weaken it. Obscurity was never the control:
the path is already in the shipped JS bundle, it is same-origin, and CloudFront
injects the origin secret for anything running in a browser. WAF rate limiting
is the actual defense.

Every documented claim was checked against the deployed endpoint: the six field
names are accepted, a pasted console URL is normalized to the bare org name, and
an unknown top-level key is rejected with 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CamSoper and others added 2 commits August 26, 2026 00:46
The client module had no test coverage at all, and its draft, prefill and
validation logic was rewritten twice in one day on review findings -- verified
only by reading. Every defect found in it was an interaction bug rather than a
bad function: the draft restore, the query-param prefill and the rendered
<option> list all read and write the same controls, and the bugs lived in which
one won. So these drive the real module against a real DOM (jsdom, already a
root dependency) rather than unit-testing pieces in isolation, which would have
caught none of them.

20 tests, pinning the regressions specifically: ?priority= actually prefills a
<select>; a recovered draft outranks the URL; a deliberately chosen default is
NOT reverted by the URL; an untouched field never enters the draft; a restored
draft survives the next save; the priority list comes from the rendered options
rather than a hardcoded copy; recap values are set as text, not markup; and the
form still works when sessionStorage throws.

Writing them found a real bug in code from earlier today. The draft save was
debounced on `input` only, while touch-tracking listened to `input` and
`change` -- so a visitor who changed the priority and nothing else never had
that choice written to the draft, and lost it on a failed submit. A <select> is
not guaranteed to fire `input`. Both listeners now schedule the save.

The suite runs in CI beside the server one, and the existing "did anything
actually run" assertion now covers both logs, so neither can quietly report
zero tests.

Two notes on the setup. The theme is pinned to TypeScript 3.9, which cannot
parse the .d.ts syntax current @types/node ships -- and skipLibCheck does not
help, those being syntax errors rather than type errors. tsconfig.test.json
therefore sets "types": [] and node-test-shims.d.ts declares the handful of Node
APIs the tests touch; bumping the theme's compiler would remove the need, but
that compiler also builds the production bundle. And because the fixture is
hand-written, a final test reads layouts/page/support-new.html and asserts every
id and data- hook the module binds to is still present, so the two halves of the
DOM contract cannot drift apart without something failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reviews of the current head: one on the infrastructure and the published
agent docs, one mutation-testing both suites. The mutation pass confirmed the
security-critical paths are genuinely pinned -- the honeypot, the origin-secret
gate, IP provenance, the 502 and the recap's XSS surface all fail a test when
broken -- and found the suites blind to value drift and to the validation ->
side-effect boundary. Fixes for both halves.

Bugs:

- The link-checker WAF exemption is keyed on a User-Agent string anyone can
  send, and it sits at priority 0 with action allow, so it lifted the rate limit
  that is the only thing throttling /api/support. Harmless while everything
  behind the WAF was a static GET; not harmless in front of an unauthenticated
  endpoint that creates Intercom contacts. Scoped to everything except /api/.

- normalizeOrganization's schemeless branch did not allow www., so a pasted
  "www.app.pulumi.com/my-org" survived as far as the host name and then failed
  validation on its dots -- a character-set error for an ordinary paste. The two
  patterns are now one.

- A draft naming an option the page no longer renders counted as restored even
  though the control never took the value, which suppressed the ?priority=
  prefill. Someone following an urgent link would have filed a normal ticket.

- The origin request policy was created at module scope, so every stack with
  enableSupportForm off built a CloudFront policy nothing references.

- clientAddress split on the last colon unconditionally, so a viewer address
  that arrived without a port lost its final group and was still labelled
  attributed. The trailing segment now has to look like a port, and RFC 3986
  bracketing is stripped.

- Submitting is guarded by an in-flight flag rather than only by the disabled
  button, since the cost of being wrong is a duplicate ticket.

/llms.txt corrections, all checked against the code: 403 is reachable (the WAF
rate limit, and an origin-secret rotation window) and now documented with
back-off guidance; a form-level 422 is keyed _form, not a field name, which is
exactly the case the surrounding paragraph emphasises; organization must start
with a letter or number.

Test gaps, each verified by re-running the mutation that previously survived:

- Nothing asserted that the *validated* value crosses into Intercom rather than
  the caller's raw JSON -- every handler test posted an already-normalized
  payload, so normalization was a no-op in all of them.
- The 403 gate was tested at one body shape, so a body-conditional escape hatch
  was invisible. It is now proven independent of the payload.
- Four of five length caps were unenforced, and the organization test used
  LIMITS.organization + 1, which pins the rule's shape but not its value. The
  boundaries are literals now, and LIMITS itself is pinned because /llms.txt
  publishes those numbers.
- KNOWN_KEYS is pinned; a "rejects an unknown key" test cannot see the way that
  rule actually erodes, which is a key being added.
- The client half of the honeypot could be deleted outright: the only test
  asserted absence on a clean submission, so nothing proved the browser ever
  sends the signal the server is well tested for.
- assert.ok(layout.includes("data-support-form")) could never fail -- it is a
  prefix of all seven other hooks, and even a delimiter check passes on the
  layout's own comment. Removing that attribute makes the whole form inert, so
  it is now anchored to the <form> tag. Tag identity and option values are
  checked too: a <select> turned into an <input> kept every id intact.
- Two prefill tests asserted only that priority stayed "normal", which is also
  what you see if the module never runs. Both have a positive control now.
- Also covered: clearing the draft on success, the banner's two unreached
  failure paths, non-JSON-object payloads, and method/content-type case
  normalization.

The theme half had no equivalent of check-suites-compiled.js while using the
same explicit files array -- a second *.test.ts nobody registered would never
compile, never run, and never fail, because the count assertion is satisfied by
the existing tests. The guard now takes its directories as arguments and moves
to scripts/, where both packages call it. Verified by adding an unregistered
suite and watching it fail.

Server 36 -> 47 tests, client 20 -> 27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
@CamSoper

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Third adversarial pass fixed. Two reviewers ran against 46f7510: one on the infrastructure and the published agent docs, one mutation-testing both suites.

The mutation pass confirmed the security-critical paths are genuinely pinned — the honeypot, the origin-secret gate, IP provenance, the 502, and the recap's XSS surface all fail a test when broken. What it found was blindness to value drift and to the validation → side-effect boundary.

Bugs fixed

  • The link-checker WAF exemption is keyed on a User-Agent anyone can send, at priority 0 with action: allow — so it lifted the rate limit that is the only thing throttling /api/support. Fine in front of static GETs; not in front of an unauthenticated endpoint that creates Intercom contacts. Now scoped to everything except /api/.
  • normalizeOrganization did not accept www. without a scheme, so a pasted www.app.pulumi.com/my-org became the host name and 422s on its dots.
  • A draft naming an option the page no longer renders counted as restored, suppressing the ?priority= prefill — someone following an urgent link filed a normal ticket.
  • The origin request policy was built at module scope, so stacks with the form disabled created a CloudFront policy nothing references.
  • clientAddress split on the last colon unconditionally; a portless viewer address lost its final group and was still labelled attributed.
  • Submitting is now guarded by an in-flight flag, not only the disabled button.

/llms.txt corrections (all checked against the code): 403 is reachable and now documented with back-off guidance; a form-level 422 is keyed _form, not a field name; organization must start with a letter or number.

Test gaps, each verified by re-running the mutation that previously survived:

  • Nothing asserted the validated value reaches Intercom rather than the raw payload — every handler test posted an already-normalized body, so normalization was a no-op in all of them.
  • The 403 gate was tested at one body shape, so a body-conditional escape hatch was invisible.
  • Four of five length caps were unenforced; the organization test used LIMITS.organization + 1, pinning the rule's shape but not its value.
  • assert.ok(layout.includes("data-support-form")) could never fail — it is a prefix of all seven other hooks. Removing that attribute makes the whole form inert.
  • Two prefill tests asserted only that priority stayed "normal", which is also what you see if the module never runs.

The theme half had no check-suites-compiled equivalent while using the same explicit files array. The guard is now parameterized in scripts/, called by both packages, and verified by adding an unregistered suite and watching it fail.

Server 36 → 47 tests, client 20 → 27. Staging redeploy and browser/API red-teaming next.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

CamSoper and others added 3 commits August 26, 2026 02:02
All three are mine, introduced by the last commit.

The theme half's path filter was narrower than what its guard inspects.
check-test-suites-compiled walks every *.test.ts under theme/src/ts at any
depth and fails on one missing from tsconfig.test.json's files array, but the
workflow watched only support-form.test.ts by name -- so the PR adding
theme/src/ts/lightbox.test.ts would land green and turn this job red on the next
unrelated support-form PR, which would then look like the culprit. That is the
exact failure the server filter was widened to avoid, reintroduced on the other
half. Now theme/src/ts/**/*.test.ts. Scoped to test files rather than all of
theme/src/ts because tsconfig.test.json compiles a fixed three-file list: an
unrelated theme module cannot break this suite, only an unregistered suite can.

The /api/ exclusion on the link-checker WAF exemption byte-matched with no text
transformations, guarding a priority-0 terminating allow -- so a path shape the
match failed to recognise would hand the rate-limit exemption back to anyone
setting the header. Added URL_DECODE and LOWERCASE per AWS's guidance for
URI-path matching. Whether /API/ or /%61pi/ actually reach the Lambda depends on
CloudFront's own normalization, so this is defence in depth rather than a closed
bypass. Rule set re-validated with wafv2 check-capacity: accepted, 26 WCU.

Adding a positive control to "a deliberately chosen default is not reverted by
the query string" made it byte-identical to the precedence test above it, so any
mutation killing one killed the other. Rewritten as the case its comment always
described but the fixture never exercised: the visitor opens ?priority=urgent,
picks normal themselves, and returns to the same link. That drives the touched
set -- normal is the rendered default, so nothing else distinguishes a chosen
default from an untouched control -- rather than re-testing draft precedence
from a hand-written draft. Verified by removing the change->markTouched listener
and watching it fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
Two adversarial passes against the deployed staging endpoint and form.

Duplicate-ticket paths, both of which end with a filed request sitting in the
form waiting to be sent again:

- Browser Back after a successful submission returned a fully repopulated form.
  sessionStorage was cleared, but browsers restore form state themselves for a
  history entry, and nothing on screen said the request had already been filed.
  The controls are reset before the card is hidden, so there is nothing left to
  restore.
- clearDraft removed the key without cancelling the pending debounced save, so a
  submit landing inside the 500ms window had the timer fire afterwards and write
  the draft straight back. Reproducible by typing the last character and
  submitting immediately.

The honeypot announced itself twice over. It was checked before validation, so a
knowingly invalid payload returned 200 with the trap set and 422 without it --
one request pair revealed the field. And the fake success omitted ticketId,
which the published contract says a real success carries. It now validates
first and mints a plausible id, so the drop is indistinguishable from an accept.

Client/server drift, in the direction the module's own header warns about --
the client stricter than the API, hard-blocking values the server accepts:

- normalizeOrganization had the same two-pattern bug fixed server-side last
  commit, so "www.app.pulumi.com/my-org" was rejected outright.
- Only email and organization mirrored a maximum, so an over-long subject, name
  or description reached the API by prefill or programmatic fill and came back
  422. The email pattern now matches the server's tightened one too.

Input handling. Nothing stripped control characters or bidi overrides, and the
values land in an Intercom ticket a support engineer reads -- and from there
often a Slack relay or a terminal. NUL truncates, a bare CR overwrites the
visible line, ESC opens ANSI sequences, and U+202E reorders a rendered line
without changing its bytes. All are stripped; tab and newline are kept because
the description is Markdown. Email additionally rejects the separators that mean
something downstream (angle brackets, quotes, comma, semicolon).

Also: the content-type gate matched a prefix, so application/jsonlines and
application/json-patch+json were accepted as JSON; the unknown-key error echoed
an unbounded attacker-controlled key into every consumer's logs; and the Intercom
client had no per-call timeout, so a hung call was killed by the runtime outside
the handler's try/catch -- returning the platform's 502 instead of the documented
envelope and never logging support_request_ticket_failed, leaving a contact
created with no ticket and no trace.

Smaller UI fixes from the QA pass: the no-JS path posted urlencoded data to a
JSON API and navigated to a raw error with everything typed lost, so the form is
hidden without script and the Slack alternative offered instead; the failure
banner's Slack URL was bare text at the one moment a visitor needs it; the
character counter was stale after a draft restore and read "1 characters left";
the counter was not announced to assistive tech; focus was dropped to <body>
after every failed submit; ?name= was not prefillable and a prefilled console URL
was not normalized until blur.

Test harness: mount() now closes the previous jsdom window. The module reads a
BARE global sessionStorage and mount() re-points that global at each new window,
so a pending draft-save timer left by an earlier test fired during a later one
and wrote the OLD form's values into the NEW test's storage. Every draft
assertion downstream was reading another test's work; this was found by a new
test failing for a reason that had nothing to do with its subject.

Server 47 -> 53 tests, client 27 -> 32. Each new test verified by re-running the
mutation it is meant to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
No blockers: XSS came back clean on every reflected path, twelve hostile drafts
were all handled without pollution or an uncaught throw, and sixteen concurrent
submit attempts produced exactly one API call. Four real findings, three fixed
here.

The honeypot was named "website", which is precisely what a password manager
stores and matches on. An autofilled trap destroys a real request completely and
silently: the user is shown the confirmation, their draft is deleted so the text
is unrecoverable, no ticket exists, and the analytics counter agrees with the
lie. Renamed to leave_blank, which has no autofill semantics. This costs nothing
against the naive bots the trap actually catches -- they fill every field
regardless of what it is called -- and a competent spammer was never fooled by
the old name either, since the field is sr-only and now documented in /llms.txt.

The prefill parameters are consumed rather than left in the address bar, which
closes three things at once. They are the requester's own email, name, org and
subject line, so leaving them there put PII in the referrer and in anything
reading location.search. A Back navigation re-applied them, so a visitor who had
already filed a request landed on a repopulated form -- form.reset() defends
against the browser's own restore, but not against the URL doing it again. And
the browser's session-history form restore runs after this code, overwriting a
prefilled <select> with its reset value: ?priority=urgent came back as a normal
ticket, the exact failure the comment above restoreDraft was written to prevent.

The client now applies the same sanitizeText the server does. Previously the
confirmation recap could render a bidi override the ticket would not contain --
the wrong way round for a screen whose only job is to confirm what was sent.

Left for a decision rather than fixed: the page's X-Frame-Options: DENY is inert
because a CSP frame-ancestors directive is also present, and that allowlist
includes a third-party LMS. The two headers disagree about intent and the
allowlist wins. Excluding /support/new/ from it is a site-wide CSP change that
belongs with whoever owns the academy embed, not in this PR.

Also unfixed here: the Segment page call fires before this module runs, so the
prefill PII still reaches it on the initial view. Stripping it properly means
touching the shared analytics snippet.

Client 32 -> 36 tests. Both new behaviours verified by re-running the mutation
they are meant to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
@CamSoper

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Three more adversarial passes since the last refresh: an API red team and a UI QA pass against deployed staging, then a browser red team against the fixed build. Fixes in 136302597bf and 71e3cb5a165.

Two duplicate-ticket paths. Browser Back after a successful submit returned a fully repopulated form — sessionStorage was cleared correctly, but browsers restore form state themselves for a history entry, so the controls are now reset before the card is hidden. And clearDraft() removed the key without cancelling the debounced 500ms save, so a submit inside that window had the timer fire afterwards and write the draft straight back.

The honeypot announced itself twice. It was checked before validation, so an invalid payload returned 200 with the trap set and 422 without it — a one-request-pair oracle. And the fake success omitted ticketId, which the published contract says a real success carries. It now validates first and mints a plausible id; verified live that the two responses are byte-identical. Separately, the field was named website — exactly what a password manager stores and matches on, and an autofilled trap destroys a real request silently (confirmation shown, draft deleted, no ticket). Renamed to leave_blank, which costs nothing against the naive bots it actually catches.

Input handling. Nothing stripped control characters or bidi overrides, and those values land in an Intercom ticket a support engineer reads — and from there a Slack relay or a terminal. A bare CR overwrites the visible line; U+202E reorders it. Stripped on both sides now, tab and newline kept because the description is Markdown. Email additionally rejects the separators that mean something downstream.

Client stricter than the API — the direction the module's own header warns about. www.app.pulumi.com/my-org was hard-blocked client-side, and only two of five length caps were mirrored.

Prefill parameters are now consumed rather than left in the URL. They are the requester's email, name, org and subject, so they were riding along in the referrer; a Back navigation re-applied them past form.reset(); and the browser's session-history restore runs after this code, turning ?priority=urgent into a normal ticket on the way back.

Also: content-type matched a prefix (application/jsonlines accepted as JSON); the unknown-key error echoed an unbounded attacker-controlled key into logs; and the Intercom client had no per-call timeout, so a hung call is killed outside the handler's try/catch — platform 502 instead of the documented envelope, support_request_ticket_failed never logged, contact created with no ticket.

Test harness bug worth flagging: the module reads a bare global sessionStorage and mount() re-points that global per test, so a pending draft timer from an earlier test wrote its form values into a later test's storage. Draft assertions were cross-contaminated. Found only because a new test failed for a reason unrelated to its subject.

Server 47 → 53 tests, client 27 → 36. Every new test verified by re-running the mutation it is meant to catch.

Deliberately not fixed here, flagged for owners: the page sets X-Frame-Options: DENY and a CSP frame-ancestors allowlist including a third-party LMS — the CSP wins and the two disagree about intent, but narrowing it is a site-wide change; and the Segment page call fires before this module runs, so prefill PII still reaches it on the initial view. Also unverifiable here: enableWaf is true only on production, so the WAF fix ships untested (rule set validated via wafv2 check-capacity, and it only narrows an allow rule).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Optional style suggestions from the pre-merge review — apply or dismiss; none of them block.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

Both introduced by the previous commit.

The client validated the raw control value while buildPayload posted the
sanitized one, so every length check measured a different string than the one
sent -- and it broke in both directions. A 201-character subject containing one
stripped character was blocked here although the server would have measured it
as 200 and accepted it, which is the "client stricter than the API" failure the
file's own header calls out. And a 12-character description with five stripped
characters passed here, sanitized to seven on the server, and came back a 422
for a rule this module mirrors precisely to avoid that round trip. validateField
now sanitizes first, so the string it measures is the string that gets posted.

The new honeypot test asserted autocomplete was absent, with a message claiming
the fixture mirrors the layout. It did not: the layout sets autocomplete="off"
and the fixture omitted it, so the assertion pinned the omission, checked the
fixture against itself, and would have turned red for anyone who fixed the
drift. The fixture now carries the attribute, the test asserts "off", and the
layout half moved into the contract test where the real file is read -- verified
by removing the attribute from the layout and watching that test fail.

The parity test uses a value at the limit padded with a stripped character,
since the existing "a".repeat(201) case cannot see this class of bug.

Client 36 -> 37 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
@CamSoper

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Both findings from the last review are fixed at c8601c9e93f. Both were mine, introduced by the red-team commit.

[L238] client validated a different string than it posted. buildPayload sanitized, validateField did not, so every length check measured a longer string than the one sent — broken in both directions exactly as described. validateField now sanitizes first. Added a parity test using a value at the limit padded with a stripped character, since the existing "a".repeat(201) case cannot see this class of bug.

[L724] the honeypot autocomplete assertion pinned the fixture's omission. Correct on all three counts — it checked the fixture against itself, its message pointed at a layout that did not exist, and it would have turned red for anyone who fixed the drift. The fixture now carries autocomplete="off", the test asserts "off", and the layout half moved into the contract test where the real file is read. Verified by removing the attribute from the layout specifically (my first probe hit the organization field, which carries the same attribute earlier in the file — the precise re-run fails correctly).

Client 36 → 37 tests.

I am now working through every remaining item on this review, including the two carried ⚠️ items and the 💡 pre-existing list, and will respond to each individually — fixed, or refuted with reasoning.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Optional style suggestions from the pre-merge review — apply or dismiss; none of them block.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

Closes the three outstanding items, all five low-confidence ones, and the
pre-existing note.

Three comments the previous push falsified. The layout's honeypot comment still
named a "Website" field that the rename removed, and claimed /llms.txt never
mentions the field -- the same commit's llms.txt edit names it deliberately, so
a legitimate agent knows not to send it; the sentence is inverted rather than
deleted. KNOWN_KEYS said the handler checks the honeypot "before validation
runs", which the oracle fix reversed, and called membership a tolerance when it
is load-bearing: drop leave_blank from that array and a trapped submission takes
the unknown-key path to a 422, restoring the status-code oracle the reorder
closed. And check-test-suites-compiled.js still described itself as walking
support-form/ after it became generic over srcDir.

The replaceState dropped the entire query string rather than the five prefill
keys, so a campaign link to this page lost utm_*, gclid and anything else after
page load -- Segment stamps location.search onto every event at call time. Now
only the prefill keys are deleted; the privacy win is unaffected because the PII
is all in those five.

sanitizeText covered the Trojan Source set but not the weaker bidi marks. U+061C,
U+200E and U+200F reorder only neutral characters, but flipping the punctuation
in a URL is the same "displays something other than what it contains" failure,
and none is a character anyone types. Both mirrors moved together, and a test now
reads the two files and asserts the character classes are identical -- that
property is what makes the "recap shows what was filed" test meaningful.

syntheticTicketId built all 15 digits uniformly, so one in ten began with a zero
where a real Intercom id never does. Confirmed live: the testing workspace
returned 215475647261127, 215475647300185 and 372996254723247, and a synthetic id
observed on staging was 042090389459334. First digit is now 1-9 and the test
regex rejects a leading zero. The residual timing tell is documented rather than
closed: padding the drop path to match three round trips to api.intercom.io holds
a Lambda invocation open to serve a bot, and the trap only catches naive ones.

The confirmation page no longer promises a response time. This was filed as
unverifiable; data/pulumi_pricing.yaml actually contradicts it -- contracted
support is Enterprise and above, and the normal-ticket SLA there is "1 or 5
business days", so "usually within one business day" overstates it for every
reader of a form any anonymous visitor can reach. It now says how soon depends
on the edition, with a comment recording that a published figure needs support's
sign-off and has to match that file.

The blog link whose text named contracted support tiers now points at /pricing/,
which describes them, rather than at a generic request form. The other three
posts swap "Pulumi Support" and "our support team" from the Zendesk portal to the
same destination -- like-for-like replacements of a link that disappears at
cutover, so no updated: stamp: that field is for changes worth surfacing to
readers, and a routing swap is not one.

Also took the pre-existing RBAC qualifier while on the line, since
data/pulumi_pricing.yaml scopes configurable RBAC to Enterprise and above.

Client 37 -> 40 tests. Each fix verified by re-running the mutation it catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
@CamSoper

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Every finding on this review worked through at de0eb470a74. Item by item.

🚨 Outstanding — all three fixed

[L238] validator/payload sanitization seam — fixed (c8601c9e93f). validateField now sanitizes first, so it measures the string that gets posted. New parity test uses a value at the limit padded with a stripped character; the existing "a".repeat(201) case cannot see this class.

[L724] self-referential autocomplete assertion — fixed (c8601c9e93f). Correct on all three counts. Fixture carries autocomplete="off", the test asserts "off", and the layout half moved into the contract test. Verified by removing the attribute from the honeypot line specifically — my first probe hit the organization field, which carries the same attribute earlier in the file.

[L124] three false comments — all three fixed.

  1. The honeypot comment no longer names a "Website" field, and the /llms.txt sentence is inverted rather than deleted: it names the field precisely so a legitimate agent knows never to send it.
  2. KNOWN_KEYS now says membership is load-bearing, not a tolerance — drop leave_blank and a trapped submission takes the unknown-key path to a 422, restoring the oracle the reorder closed.
  3. check-test-suites-compiled.js:55 now describes srcDir.

⚠️ Low-confidence — three fixed, one documented, two answered

[L433] whole-query-string stripagreed and fixed. Only the five prefill keys are deleted now. Added the companion test you named: ?utm_source=newsletter&priority=urgent&gclid=abc123 keeps both campaign params and loses only priority.

[L247] leading zeroagreed and fixed, and I can settle the width question. Real ids from the testing workspace: 215475647261127, 215475647300185, 372996254723247 — 15 digits, never a leading zero. A synthetic one observed live on staging was 042090389459334, so the tell was real and not theoretical. First digit is now 1–9 and the test regex rejects a leading zero.

[L247] timingaccepted, not closed, and now recorded in the comment as you suggested. Padding the drop path to match three round trips to api.intercom.io holds a Lambda invocation open to serve a bot, which costs more than the tell is worth against a trap that only catches naive ones.

[L121] bidi marksagreed and fixed. U+061C, U+200E, U+200F added. Both mirrors moved together, and there is now a test that reads both files and asserts the character classes are identical — that property is what makes the "recap shows what was filed" test meaningful, so it should not depend on someone noticing.

[L66] one-business-dayfixed, and stronger than filed. This was marked unverifiable; data/pulumi_pricing.yaml actually contradicts it. Contracted support is available_from: enterprise, and even there support-normal-sla reads "1 or 5 business days". So on a form any anonymous visitor can reach, "usually within one business day" overstates it for every reader — most have community support only. The line now reads "A support engineer reviews it and replies by email. How soon depends on your Pulumi Cloud edition." with a comment recording that any published figure needs support's sign-off and must match that file.

[L71] blog linkshalf fixed, half refuted.

  • Fixed: hidden-costs-of-infrastructure-management L102. You were right that this one is a meaning change — the link text names contracted tiers. It now points at /pricing/, which describes them, not at a generic request form.
  • Refuted on the updated: stamp. The other three swap "Pulumi Support" / "our support team" from the Zendesk portal to the same destination. Those are like-for-like replacements of a link that disappears at cutover, and AGENTS.md scopes updated: to changes "worth surfacing to readers" — a routing swap is not one. An S3 redirect would also work, but it leaves four posts pointing at a host we are decommissioning, which is the thing this PR exists to stop.

💡 Pre-existing

[L102] RBAC edition qualifiertaken. We are on the line anyway and data/pulumi_pricing.yaml has rbac: available_from: enterprise. Now reads "built-in RBAC (Enterprise edition and above)".

📋 Triaged verifier findings

Nothing to action — all 21 were correctly triaged Spurious or Mis-sourced, and the production-404 diagnosis is right: /support/new/ is created by this PR.

Client 37 → 40 tests, server 53. Each fix verified by re-running the mutation it catches.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Optional style suggestions from the pre-merge review — apply or dismiss; none of them block.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

data/pulumi_pricing.yaml:770-775 draws a distinction the previous wording
blurred: the built-in Admin/Member/Billing Manager roles are available in every
edition, and it is the *configurable* RBAC that starts at Enterprise. So
"built-in RBAC (Enterprise edition and above)" priced the thing every edition
has and left a Team-edition reader believing they have no role model at all.
One word: configurable.

Also tightened the SCIM troubleshooting line this PR already touches. Unlike
/content/blog/, which AGENTS.md treats as historical, the style guide applies to
docs, and "please contact our customer support for assistance" carries both a
"please" and a wordy tail. The two blog prose nits Vale raised alongside it are
left alone: those lines are historical, the double space predates this branch,
and the only change we made there was the link itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ncpq6vLuwyNafJ82pLp4CZ
@CamSoper

Copy link
Copy Markdown
Contributor Author

@claude #update-review

[L102] RBAC noun — you are right, and the fix is at b7bbe133680. data/pulumi_pricing.yaml:770-775 draws exactly the distinction the sentence blurred: the built-in Admin/Member/Billing Manager roles are available in every edition, and it is the configurable RBAC that starts at Enterprise. So "built-in RBAC (Enterprise edition and above)" priced the thing every edition already has and would leave a Team-edition reader believing they have no role model at all — worse than the unqualified original. Now "configurable RBAC (Enterprise edition and above)".

Worth noting this one came from a suggestion in the previous review and I took it without checking the noun against the row's own comment. That comment is three lines above the available_from I did check.

Style suggestions — took one, left two, on the same historical-vs-current split AGENTS.md draws:

  • Taken: content/docs/administration/guides/scim/troubleshooting.md:25. A docs file, a line this PR already touches, and the style guide applies — it carried both a "please" and a wordy tail. Now "If you encounter difficulties resolving these issues, contact customer support."
  • Left: both content/blog/ items. AGENTS.md treats /content/blog/ as historical, the only change this PR makes there is the link itself, and the journaling/index.md:328 double space predates this branch — it is on the line in the diff but is in the - side too.

Everything else on the review is closed. Final staging deploy is running for a manual pass in the morning.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

@MeganYTan MeganYTan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain:infra PR touches workflows, scripts, infra, Makefile, or build config domain:mixed PR touches more than one domain domain:website PR touches marketing, pricing, legal, or competitive landing pages review:no-blockers Claude review completed cleanly; outstanding is empty

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants