Native Zendesk sidebar
API2 natively serves GET and POST /zendesk-app/sidebar (ENG-3225), the
page the Zendesk marketplace app loads in the ticket sidebar. The page frames
Beam and bridges Zendesk App Framework (ZAF) ticket context, clipboard, and
Jira internal-note messages to it over postMessage. Rails
ZendeskAppController#sidebar remains deployed only as the retained fallback.
Contract
The HTML is the Rails template rulebase-api/app/views/zendesk_app/sidebar.html.erb
with @beam_base_url substituted from RULEBASE_BEAM_BASE_URL; a test asserts
byte-for-byte parity. Responses are text/html; charset=utf-8, carry no
X-Frame-Options (Rails deletes it so Zendesk can frame the page), and carry
no Content-Security-Policy, matching Rails (no CSP initializer is
configured). The only header deviation is the omitted Rack ETag.
In production every request must carry a token parameter (POST body wins
over query string, as Rails params does). Missing tokens return
400 Missing JWT token. Verification mirrors Rails exactly:
- Decode the token without verification only to read
iss. - Accept only
<subdomain>.zendesk.comwhere the subdomain is a valid lowercase DNS label. Anything else is a generic401. - Resolve the kept (
discarded_at IS NULL) Zendeskorganization_data_sourcesrow whoseuser_data->>'subdomain'equals the issuer subdomain, lowest id first. Unknown tenants are the same generic401. - Load that tenant’s own credentials (API2 active Zendesk connection, else
the Rails-encrypted
data.token) and fetchhttps://<subdomain>.zendesk.com/api/v2/apps/<ZENDESK_MARKETPLACE_APP_ID>/public_key.pemwith a 10 s timeout, no redirect following, and a 16 KiB body cap. Provider failures return502. Keys are never cached, so nothing can leak across tenants or outlive a rotation. - Verify the token with
RS256only (joserejectsnone/HS256 confusion) and standard time claims. Failures are the same generic401.
The tenant lookup is a single unlocked SELECT; no transaction, row lock, or
advisory lock is open while the Zendesk HTTP request runs.
Outside production (NODE_ENV !== "production") the page is served without
verification, exactly as Rails.env.production? gated it.
Telemetry
Every request emits one native_zendesk_sidebar log with method, status,
outcome, durationMs, and (once a tenant is resolved) subdomain,
railsOrganizationId, and railsOrganizationDataSourceId. Outcomes:
| Outcome | Status | Meaning |
|---|---|---|
served | 200 | Verified token, page served |
served_unverified | 200 | Non-production only |
missing_token | 400 | Rails Missing JWT token parity |
malformed_token, missing_issuer, untrusted_issuer | 401 | Rejected before any database read |
tenant_not_found | 401 | No kept Zendesk data source for the subdomain |
invalid_signature, claims_rejected | 401 | RS256 verification failed |
credentials_missing, credentials_error | 500 | Tenant exists but has no usable token |
provider_*, invalid_public_key | 502 | Zendesk public-key fetch failed; providerStatus carries the upstream status |
paused_unavailable, misconfigured_unavailable | 503 | Paused, or RULEBASE_BEAM_BASE_URL / ZENDESK_MARKETPLACE_APP_ID missing in production (the former *_fallback outcomes that forwarded to Rails were removed in ENG-3495) |
flipper_error | — | Flipper lookup failed; request continued natively |
Rollback
pause_api2_zendesk_sidebar is a regional Flipper kill switch (safe state
off, created 2026-09-17, remove by 2026-12-16). When enabled, requests return
503 (paused_unavailable); the route no longer forwards to Rails, whose
production services are held at zero replicas. A missing
RULEBASE_BEAM_BASE_URL or ZENDESK_MARKETPLACE_APP_ID in production returns
503 with misconfigured_unavailable. A code rollback is a revert of the
ENG-3495 PR and would only restore the attempt to forward.
Edge frameability incident (2026-09-20, ENG-3290)
At 01:36–01:40Z Zendesk reported the sidebar no longer loading; the agent
console showed api.rulebase.co refused to frame because of
X-Frame-Options: sameorigin. Route, auth and Beam configuration were
healthy: unauthenticated GET returned 400, a garbage token 401, and
valid POSTs logged outcome=served status=200 for rho7005 and
d3v-rulebase. A direct origin request carried no X-Frame-Options, while
the same request through https://api.rulebase.co did. Root cause: the public
API2 CloudFront distribution E1T87GN0P4WMPJ (aliases api.rulebase.co,
api2.rulebase.co) has no path behaviors and its default response-headers
policy is the AWS managed Managed-SecurityHeadersPolicy
(67f7725c-6f97-4210-82d7-5512b31e9d03), which adds FrameOptions SAMEORIGIN
to every response, including the one this route deliberately omits it from.
The earlier hypothesis that Infisical supplied RULEBASE_BEAM_BASE_URL=http://localhost:5173
was wrong: prod and production-eu both hold https://beam.rulebase.co
(the localhost value came from the CLI default dev environment), and
eu.beam.rulebase.co does not resolve. Do not pin it.
Fix, in two layers:
- Edge (primary): a dedicated
/zendesk-app/sidebar*ordered cache behavior that copies the default behavior’s origin, cache policy, origin-request policy and compression, allows all methods (POSTrequired), and attaches a custom response-headers policy equal to the managed one minusFrameOptions. The default behavior is unchanged, so frame protection is not removed globally. Flightcontrol cannot express this, so it is an operator-run bounded change kept ininfrastructure/cloudfront/zendesk-sidebar/(apply.shidempotent re-apply,check.shHTTP drift check,config-check.shAWS-side drift check). Landed 2026-09-20T01:50Z by explicit human authorization via the AWS CLI (not automation): custom policyRulebase-ZendeskSidebar-Embeddable(76343034-8912-4a3f-bc02-588f973429b5, managed headers minusFrameOptions) attached to the new behavior on USE1T87GN0P4WMPJand EUEX09ZU4R31WRP; bothDeployed. Post-fix probes: US/EU query-stringGET→ 400 with noX-Frame-Optionsand all other security headers;/healthstillSAMEORIGINin both regions; Infisical unchanged.check.shrun from the repo against both public hosts passes. Customer-path acceptance: after the CloudFront deployment Chidi reloaded the Zendesk sidebar in a real browser and confirmed it works. Drift guard: the scheduledzendesk-sidebar-edge-checkGitHub workflow runscheck.shevery 6 hours and on dispatch; a failure means the behavior was reconciled away andapply.shmust be re-run (human-gated). - Origin (defence in depth): the served page now sends
Content-Security-Policy: frame-ancestors https://*.zendesk.com. Browsers ignoreX-Frame-Optionswhenframe-ancestorsis present, so the sidebar stays frameable by Zendesk (and only Zendesk) even if the edge exception is lost. Other response headers are unchanged.
Remaining verification (bounded, read-only): native_zendesk_sidebar shows
outcome=served status=200 with no misconfigured_fallback; no
legacy_rails_gateway event for /zendesk-app/sidebar; and
infrastructure/cloudfront/zendesk-sidebar/check.sh passes (no
X-Frame-Options on GET/POST sidebar responses including the 200 HTML
when ZENDESK_SIDEBAR_TOKEN is supplied, frame-ancestors present, security
headers intact, /health still SAMEORIGIN). Rollback: remove the ordered
behavior and the custom policy; the CSP header is a code revert.
Production rollout plan
- Set
RULEBASE_BEAM_BASE_URLon the API2 service in each region to the Railsbeam_base_urlfor that region (UShttps://beam.rulebase.co). This is a config change and needs its own approval; until it lands, production requests emitmisconfigured_fallbackand Rails keeps serving. - Deploy with
pause_api2_zendesk_sidebaroff in both regions. - Open a Zendesk ticket in a customer account per region and confirm the
sidebar renders Beam and ticket context loads.
curl -s -o /dev/null -w '%{http_code}' https://<api2>/zendesk-app/sidebarwith no token should return400;-X POST -d token=garbageshould return401. - Reconcile against the Rails baseline (US rolling 24 h ending
2026-09-17T20:10Z: 677200and 2400; EU: 2400, 0200).servedper region should track the Rails200count,missing_tokenthe400count, andtenant_not_found/invalid_signatureshould stay near zero. Anyprovider_*,credentials_*, ormisconfigured_*outcome above a handful per day is a regression to investigate before the window counts. - Confirm
legacy_rails_gatewayevents for/zendesk-app/sidebardrop to zero in both regions across a complete rolling 24-hour and seven-day window. - Roll back in an affected region by enabling
pause_api2_zendesk_sidebar; a code rollback reverts the PR.
Local checks
cd rulebase-web/rulebase-api2
pnpm vitest run src/routes/zendesk-app-sidebar src/lib/zendesk-sidebar src/app.route-ownership.test.ts
pnpm typecheck