feat(commercial): attribute in-app checkouts to the surface that started them

Stripe checkouts could not be told apart by origin: every session was
stamped checkout_origin pulserelay_landing, so an upgrade started from an
RBAC gate looked identical to one started from the website. Gate CTAs now
stamp a closed-vocabulary source token (gate-<feature>, estate-card, or the
plans-page default) onto the owned billing plan route; the plan page threads
it into the purchase-start handoff, and the license server persists it on the
checkout intent and stamps Stripe metadata checkout_source.

Attribution is authenticated-session-only by construction. The public
/pricing route and the public pricing URL never carry it, and
getSelfHostedPurchaseStartUrl scrubs the parameter from forwarded query
strings so a crafted website link cannot claim in-app origin. Server-side,
source is validated against the same kebab vocabulary, skip-listed from the
Pulse Account portal redirect exactly as feature already is, and travels
only inside the handoff body. It is request-scoped and persists nothing on
the install; the cancel return echoes it so a retry keeps its origin.

The handoff field is omitempty because the license server decodes strictly,
so source-less installs stay compatible with a server either side of the
field being added. That ordering is recorded in pulse-pro OPERATIONS.md.
This commit is contained in:
rcourtman
2026-08-07 19:41:04 +01:00
parent 5b07bdc3d8
commit d5bc3e3862
15 changed files with 446 additions and 34 deletions
@@ -5945,3 +5945,11 @@ unified resources) and the pinned thresholds
(`TestContract_BusinessScaleEstateThresholds`) are identical; only the source
of the numbers moved, so the card cohort and the telemetry cohort cannot
drift.
### Purchase-start attribution does not touch agent routes
The `source` attribution parameter added to
`internal/api/licensing_handlers.go` (an extension point of this subsystem's
`internal/api/` surface) is confined to the self-hosted purchase handoff:
it is read from the purchase-start query, validated, and passed to the
license-server portal handoff. No agent enrollment, report, ack, or update
route reads or emits it, and the agent-facing payload shapes are unchanged.
@@ -8984,3 +8984,20 @@ router echoes it end-to-end on enrollment acks
(`TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart`). Agents
use it to trigger an immediate self-update check after a server upgrade. The
field is additive: agents that predate it ignore it.
### Checkout start forwards attribution in the handoff body, never the portal URL
`GET /auth/license-purchase-start` accepts an optional `source` query
parameter alongside `feature`. It is validated in
`internal/api/licensing_handlers.go` against the same closed kebab vocabulary
the frontend emits (`normalizeSelfHostedPurchaseSource`); anything else is
dropped rather than forwarded. Like `feature`, `source` is added to the
skip list in `pulseAccountUpgradeURLForRequest`, so it never reaches the
browser-visible Pulse Account portal redirect; it travels only inside the
`CreateCheckoutPortalHandoff` request body, where `omitempty` keeps
source-less installs compatible with a license server that predates the
field. The cancelled-checkout return URL echoes a valid source back onto the
plan route (`licensePurchaseActivationRedirectPathWithSource`) so a retry
keeps the surface that started it. Unrelated query parameters keep their
existing pass-through behavior.
`TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal` pins
both the forwarding and the drop of a malformed value.
@@ -1405,6 +1405,29 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
scope-limited session gets the temporary header reveal instead. The
blocked-route redirect and the banner block must stay active for a
scope-limited session even when kiosk is off.
30. Keep checkout source attribution closed-vocabulary, authenticated-only,
and out of browser-visible portal URLs. In-app upgrade CTAs stamp a
`source` token (`gate-<feature>`, `estate-card`, or the `plans-page`
default) onto the owned billing plan route and the authenticated
`/auth/license-purchase-start` URL
(`frontend-modern/src/utils/pricingHandoff.ts`); the public `/pricing`
route and the public pricing URL never carry it, and
`getSelfHostedPurchaseStartUrl` scrubs it from forwarded query strings so
a crafted website link cannot claim in-app attribution. Server-side,
`HandleCheckoutStart` validates the token against the closed kebab
pattern, sends it only inside the `CreateCheckoutPortalHandoff` body
(`omitempty`, so source-less requests stay compatible with older license
servers), skip-lists it from the Pulse Account portal redirect query, and
echoes it onto the cancel return so a retry keeps its origin
(`TestHandleCheckoutStart_SourceAttributionReachesHandoffNeverPortal`).
The license server persists it on the checkout intent and stamps Stripe
session metadata `checkout_source`, with `checkout_origin` now honestly
split: `pulse_app` for intent-resolved sessions, `pulserelay_landing`
only for the public landing funnel
(`TestHandleCheckoutSessionCreate_CarriesSourceAttributionToStripe`,
`TestCreateStripeCheckoutSessionKeepsLandingOriginWithoutIntent`).
Deploy ordering: the license server must carry the `source` field before
a Pulse release that sends it, because the handoff body decodes strictly.
## Current State
@@ -5045,3 +5045,12 @@ schema-v8 telemetry `business_estate` field. The capability read path stays a
pure in-memory classification over monitor state — no storage surface, store
handle, or persistence behavior is introduced or altered
(`TestContract_BusinessScaleEstateThresholds` pins the unchanged thresholds).
### Purchase-start attribution persists nothing on the install
The `source` attribution parameter handled in
`internal/api/licensing_handlers.go` (named in this contract's extension
points) is request-scoped: it is validated, forwarded in the portal-handoff
body, and echoed onto the cancel return URL. It is never written to config
persistence, the license store, or any recovery artifact, so the install's
storage surface is unchanged. Attribution is retained only by the commercial
backend, on the checkout intent it already owns.
+24 -11
View File
@@ -1,17 +1,25 @@
{
"version": 1,
"result": "passed",
"base_sha": "2aa43058b7bb700dcd19b8068178e92d14ded583",
"verified_at": "2026-08-07T14:03:33Z",
"base_sha": "5b07bdc3d8eab45ee004c3c71f22fd2f4d318371",
"verified_at": "2026-08-07T18:40:21Z",
"changed_paths": [
"frontend-modern/src/api/settings.ts"
"frontend-modern/src/components/BusinessEstateCard.tsx",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
"frontend-modern/src/utils/pricingHandoff.ts"
],
"content_sha256": {
"frontend-modern/src/api/settings.ts": "de9f9d21e26270464bc7402a969dd56f356494e976e4bb483884850d8b148901"
"frontend-modern/src/components/BusinessEstateCard.tsx": "a3717b19a728ef33f775ccce714080b6affd0a56a3a3d8d7a678625a9a6af900",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts": "bfed84fd6aefa15e7b2f7b3ba2c91d8f6e2379172c45bbce3822c7cb1151f776",
"frontend-modern/src/utils/pricingHandoff.ts": "7e5c6178f8811179d64894ab136a2ff4263181b61bdc7298a2d341aca9e9b0cc"
},
"routes": [
"/",
"/settings (System > General panel via settings search)"
"/settings (Security > Roles, RBAC inline gate)",
"/settings (System > General, branding gate)",
"/settings/pulse-intelligence/billing/plan?source=gate-rbac",
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan&source=gate-rbac",
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan"
],
"viewports": [
{
@@ -24,13 +32,18 @@
}
],
"states": [
"free Community tier served by an isolated worktree backend (PULSE_MOCK_MODE=true, 6 mock PVE nodes) on port 7698",
"sessionCapabilities.businessEstate=true confirmed via /api/security/status after the threshold helper moved to internal/monitoring",
"telemetry preview payload rendered in Settings > System > General showing schema_version 8 with business_estate: true alongside pve_nodes: 6 and paid_license: false"
"free Community tier with presentationPolicy.hideUpgrade=false, served by an isolated worktree backend on port 7698 with 6 mock PVE nodes",
"RBAC inline feature gate rendered on the Roles panel for an unentitled install",
"plan route reached with gate attribution present, and separately with no source at all to exercise the plans-page default",
"business-estate card in its eligible state (star prompt already dismissed, first-seen recorded on a prior day)",
"branding gate for white_label, an unmapped feature key that routes straight to purchase-start rather than the plan page"
],
"interactions": [
"logged in as admin, opened Settings, searched for General, opened the Usage data and privacy section",
"clicked Preview payload and read the full rendered CURRENT HEARTBEAT PAYLOAD JSON including the new business_estate field",
"re-exercised the plan route and workspace at 375x812 to confirm the settings surfaces still render at narrow width"
"clicked the RBAC gate 'View plans' CTA and confirmed its href carried ?source=gate-rbac, then confirmed the source survived canonical billing-route resolution on arrival",
"read the purchase-start hrefs rendered on the plan page: /auth/license-purchase-start?source=gate-rbac&feature=self_hosted_plan",
"loaded the same plan route with no source parameter and confirmed the purchase-start hrefs fell back to source=plans-page",
"clicked 'See business plans' on the business-estate card and confirmed it navigated to the plan route with ?source=estate-card and dismissed the card permanently",
"inspected the branding gate href and confirmed unmapped keys carry attribution too: /auth/license-purchase-start?source=gate-white-label&feature=white_label",
"re-exercised the attributed plan route at 375x812 and confirmed the purchase-start hrefs and layout hold at narrow width"
]
}
@@ -8,7 +8,10 @@ import {
import { useWebSocket } from '@/contexts/appRuntime';
import { sessionCapabilities } from '@/stores/sessionCapabilities';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff';
import {
getSelfHostedBillingHref,
PURCHASE_HANDOFF_SOURCE_ESTATE_CARD,
} from '@/utils/pricingHandoff';
import { ActionIconButton, Button } from '@/components/shared/Button';
import BriefcaseIcon from 'lucide-solid/icons/briefcase';
import XIcon from 'lucide-solid/icons/x';
@@ -104,7 +107,7 @@ export function BusinessEstateCard() {
const handleSeePlans = () => {
setDismissed(true);
setShowCard(false);
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE);
navigate(getSelfHostedBillingHref('plan', { source: PURCHASE_HANDOFF_SOURCE_ESTATE_CARD }));
};
createEffect(() => {
@@ -13,6 +13,7 @@ import {
getSelfHostedBillingHref,
getPublicPricingUrl,
getSelfHostedPurchaseStartUrl,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
@@ -1150,7 +1151,11 @@ describe('ProLicensePanel', () => {
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED,
title: 'Checkout cancelled',
actionLabel: 'View plans',
actionHref: getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
actionHref: getSelfHostedPurchaseStartUrl(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
undefined,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
),
redirectedHref: getSelfHostedBillingHref('plan', {
intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
}),
@@ -1159,7 +1164,11 @@ describe('ProLicensePanel', () => {
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED,
title: 'Upgrade return expired',
actionLabel: 'View plans',
actionHref: getSelfHostedPurchaseStartUrl(),
actionHref: getSelfHostedPurchaseStartUrl(
undefined,
undefined,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
),
},
{
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
@@ -1176,7 +1185,11 @@ describe('ProLicensePanel', () => {
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE,
title: 'Pulse Account unavailable',
actionLabel: 'Try again',
actionHref: getSelfHostedPurchaseStartUrl(),
actionHref: getSelfHostedPurchaseStartUrl(
undefined,
undefined,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
),
},
])(
'shows the purchase arrival notice for $purchase',
@@ -1432,7 +1445,11 @@ describe('ProLicensePanel', () => {
compareLinks.some(
(link) =>
link.getAttribute('href') ===
getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
getSelfHostedPurchaseStartUrl(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
undefined,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
),
),
).toBe(true);
expect(screen.queryByRole('button', { name: 'Hide counting rules' })).not.toBeInTheDocument();
@@ -32,7 +32,9 @@ import {
getSelfHostedBillingHref,
getSelfHostedBillingPlanDetail,
getSelfHostedBillingPlanIntent,
getSelfHostedBillingPlanSource,
getSelfHostedBillingPurchaseArrival,
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
resolveSelfHostedBillingSection,
resolveSelfHostedPurchaseStartDestination,
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
@@ -164,6 +166,12 @@ export function useProLicensePanelState() {
resolveSelfHostedBillingSection(location.pathname, location.search, location.hash),
);
// Checkout attribution: a gate CTA or the estate card stamps ?source= onto
// the plan route; organic arrivals attribute to the plans page itself.
const purchaseHandoffSource = createMemo(
() => getSelfHostedBillingPlanSource(location.search) ?? PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
);
const activeSection = createMemo<SelfHostedBillingSection>(() => {
return requestedSection() === 'usage' ? 'plan' : requestedSection();
});
@@ -207,6 +215,8 @@ export function useProLicensePanelState() {
actionLabel: SELF_HOSTED_PRO_BILLING_PRESENTATION.planSelectionPromptActionLabel,
actionDestination: resolveSelfHostedPurchaseStartDestination(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
undefined,
purchaseHandoffSource(),
),
};
});
@@ -331,12 +341,20 @@ export function useProLicensePanelState() {
case SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED:
return {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseCancelledActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(intent),
destination: resolveSelfHostedPurchaseStartDestination(
intent,
undefined,
purchaseHandoffSource(),
),
};
case SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED:
return {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseExpiredActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(intent),
destination: resolveSelfHostedPurchaseStartDestination(
intent,
undefined,
purchaseHandoffSource(),
),
};
case SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED:
return {
@@ -351,7 +369,11 @@ export function useProLicensePanelState() {
case SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE:
return {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseUnavailableActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(intent),
destination: resolveSelfHostedPurchaseStartDestination(
intent,
undefined,
purchaseHandoffSource(),
),
};
default:
return null;
@@ -410,6 +432,8 @@ export function useProLicensePanelState() {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
undefined,
purchaseHandoffSource(),
),
}
: null,
@@ -175,7 +175,9 @@ describe('BusinessEstateCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'See business plans' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan');
expect(mockNavigate).toHaveBeenCalledWith(
'/settings/pulse-intelligence/billing/plan?source=estate-card',
);
expect(queryCard()).toBeNull();
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
});
@@ -46,19 +46,27 @@ describe('pricingHandoff', () => {
expect(getInProductPricingDestination('cloud')).toBeUndefined();
});
it('routes paid self-hosted feature upgrades to the in-product billing plan page', () => {
expect(getUpgradeFallbackDestination('relay')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getUpgradeFallbackDestination('mobile_app')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
it('routes paid self-hosted feature upgrades to the in-product billing plan page with gate attribution', () => {
expect(getUpgradeFallbackDestination('relay')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-relay`,
);
expect(getUpgradeFallbackDestination('mobile_app')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-mobile-app`,
);
expect(getUpgradeFallbackDestination('push_notifications')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-push-notifications`,
);
expect(getUpgradeFallbackDestination('ai_alerts')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-ai-alerts`,
);
expect(getUpgradeFallbackDestination('ai_alerts')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getUpgradeFallbackDestination('ai_autofix')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`,
);
expect(getUpgradeFallbackDestination('rbac')).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`,
);
expect(getUpgradeFallbackDestination('rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
expect(getUpgradeFallbackDestination('advanced_reporting')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-reporting`,
);
});
@@ -83,15 +91,61 @@ describe('pricingHandoff', () => {
'agent_profiles',
'external_probe',
]);
const expectedGateSources: Record<string, string> = {
relay: 'gate-relay',
mobile_app: 'gate-mobile-app',
push_notifications: 'gate-push-notifications',
ai_alerts: 'gate-ai-alerts',
ai_autofix: 'gate-ai-autofix',
long_term_metrics: 'gate-long-term-metrics',
rbac: 'gate-rbac',
audit_logging: 'gate-audit-logging',
advanced_reporting: 'gate-reporting',
agent_profiles: 'gate-agent-profiles',
external_probe: 'gate-external-probe',
};
for (const key of paidCatalogFeatureKeys) {
expect(getUpgradeFallbackDestination(key)).toBe(
key === 'ai_autofix'
? SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF
: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
? `${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`
: `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=${expectedGateSources[key]}`,
);
}
});
it('threads checkout source attribution through owned billing hrefs and purchase start', () => {
// Plan hrefs carry a validated source; the usage section never does.
expect(getSelfHostedBillingHref('plan', { source: 'estate-card' })).toBe(
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=estate-card`,
);
expect(getSelfHostedBillingHref('usage', { source: 'estate-card' })).toBe(
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
);
// Canonical route resolution preserves a valid plan-route source.
expect(
resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_PLAN_HREF, '?source=gate-rbac'),
).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`);
// An explicit source lands on the purchase-start URL.
expect(getSelfHostedPurchaseStartUrl('self_hosted_plan', undefined, 'plans-page')).toBe(
`${SELF_HOSTED_PURCHASE_START_PATH}?source=plans-page&feature=self_hosted_plan`,
);
// A forwarded query string never smuggles attribution in (public /pricing).
expect(
getSelfHostedPurchaseStartUrl(
'unknown_pro_feature',
new URLSearchParams('source=gate-rbac&utm_content=legacy-bookmark'),
),
).toBe(
`${SELF_HOSTED_PURCHASE_START_PATH}?utm_content=legacy-bookmark&feature=unknown_pro_feature`,
);
// Malformed sources are dropped rather than forwarded.
expect(getSelfHostedPurchaseStartUrl('relay', undefined, 'Bad Source!')).toBe(
`${SELF_HOSTED_PURCHASE_START_PATH}?feature=relay`,
);
// The public /pricing resolver stays unsourced for mapped features.
expect(getPricingRouteDestination('?feature=rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
});
it('keeps retired trial-expired out of upgrade fallbacks while preserving neutral legacy arrival', () => {
expect(getPricingRouteDestination('?feature=trial_expired')).toBe(
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
+91 -2
View File
@@ -25,6 +25,7 @@ export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM =
export const SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM =
SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM;
export const SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM = 'intent';
export const SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM = 'source';
export const SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM = 'purchase';
export const SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL = 'counting-rules';
export const SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL = 'recovery';
@@ -53,6 +54,49 @@ export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BIL
export const SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF = `${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}?${SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`;
export const SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`;
// Checkout source attribution. Gate CTAs stamp a closed-vocabulary `source`
// onto the billing plan route (or directly onto the purchase-start URL for
// unmapped keys); the plan page threads it into the purchase-start handoff so
// the license server can attribute the Stripe checkout to the surface that
// started it. The param is authenticated-session-only: the public /pricing
// route and the public pricing URL never carry it, and forwarded pre-auth
// query strings are scrubbed of it in getSelfHostedPurchaseStartUrl.
export const PURCHASE_HANDOFF_SOURCE_PLANS_PAGE = 'plans-page';
export const PURCHASE_HANDOFF_SOURCE_ESTATE_CARD = 'estate-card';
const PURCHASE_HANDOFF_GATE_SOURCES: Record<string, string> = {
rbac: 'gate-rbac',
advanced_reporting: 'gate-reporting',
mobile_app: 'gate-mobile-app',
push_notifications: 'gate-push-notifications',
ai_alerts: 'gate-ai-alerts',
ai_autofix: 'gate-ai-autofix',
relay: 'gate-relay',
audit_logging: 'gate-audit-logging',
agent_profiles: 'gate-agent-profiles',
external_probe: 'gate-external-probe',
long_term_metrics: 'gate-long-term-metrics',
white_label: 'gate-white-label',
};
const PURCHASE_HANDOFF_SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
export function normalizePurchaseHandoffSource(
source: string | null | undefined,
): string | undefined {
const normalized = source?.trim();
if (!normalized || !PURCHASE_HANDOFF_SOURCE_PATTERN.test(normalized)) return undefined;
return normalized;
}
export function getPurchaseHandoffGateSource(
feature: string | null | undefined,
): string | undefined {
const normalizedFeature = feature?.trim();
if (!normalizedFeature) return undefined;
return PURCHASE_HANDOFF_GATE_SOURCES[normalizedFeature];
}
const IN_PRODUCT_PRICING_DESTINATIONS: Record<string, string> = {
self_hosted_plan: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
// Paid self-hosted feature keys: route to the owned billing plan page instead
@@ -119,6 +163,7 @@ function normalizeFeatureKey(feature: string | null | undefined): string | undef
export function getSelfHostedPurchaseStartUrl(
feature?: string | null,
searchParams?: URLSearchParams,
source?: string | null,
): string {
const url = new URL(SELF_HOSTED_PURCHASE_START_PATH, INTERNAL_HREF_BASE);
if (searchParams) {
@@ -130,6 +175,15 @@ export function getSelfHostedPurchaseStartUrl(
}
}
// Attribution is set only by authenticated in-app callers via the explicit
// argument; a forwarded query string (the public /pricing route) never
// carries it through.
url.searchParams.delete(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM);
const normalizedSource = normalizePurchaseHandoffSource(source);
if (normalizedSource) {
url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalizedSource);
}
const normalizedFeature = normalizeFeatureKey(feature);
if (normalizedFeature) {
url.searchParams.set('feature', normalizedFeature);
@@ -140,8 +194,9 @@ export function getSelfHostedPurchaseStartUrl(
export function resolveSelfHostedPurchaseStartDestination(
feature?: string | null,
searchParams?: URLSearchParams,
source?: string | null,
): UpgradeDestination {
return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams), {
return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams, source), {
hardNavigation: true,
newTab: true,
preserveOpener: true,
@@ -174,12 +229,30 @@ export function getManagedHostingRequestUrl(): string {
return DEFAULT_MANAGED_HOSTING_REQUEST_URL;
}
// withPurchaseHandoffSource stamps a gate source onto an in-product billing
// plan href. Only the authenticated upgrade-CTA fallback below uses it; the
// public /pricing route resolves the same destinations unsourced so a website
// arrival is never attributed to an in-app gate.
function withPurchaseHandoffSource(destination: string, source: string | undefined): string {
const normalized = normalizePurchaseHandoffSource(source);
if (!normalized) return destination;
const url = new URL(destination, INTERNAL_HREF_BASE);
if (!isSelfHostedBillingPath(url.pathname)) return destination;
url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalized);
return `${url.pathname}${url.search}`;
}
export function getUpgradeFallbackDestination(feature?: string | null): string | undefined {
if (isRetiredPricingFeature(feature)) return undefined;
if (normalizeFeatureKey(feature) === 'cloud') {
return getManagedHostingRequestUrl();
}
return getInProductPricingDestination(feature) || getSelfHostedPurchaseStartUrl(feature);
const gateSource = getPurchaseHandoffGateSource(feature);
const inProductDestination = getInProductPricingDestination(feature);
if (inProductDestination) {
return withPurchaseHandoffSource(inProductDestination, gateSource);
}
return getSelfHostedPurchaseStartUrl(feature, undefined, gateSource);
}
export function getSelfHostedBillingUsageDetail(
@@ -206,6 +279,14 @@ export function getSelfHostedBillingPlanIntent(search: string): SelfHostedBillin
return null;
}
export function getSelfHostedBillingPlanSource(search: string): string | null {
return (
normalizePurchaseHandoffSource(
billingSearch(search).get(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM),
) ?? null
);
}
export function getSelfHostedBillingPurchaseArrival(
search: string,
): SelfHostedBillingPurchaseArrival | null {
@@ -228,6 +309,7 @@ export function getSelfHostedBillingHref(
detail?: SelfHostedBillingDetail | null;
intent?: SelfHostedBillingPlanIntent | null;
purchase?: SelfHostedBillingPurchaseArrival | null;
source?: string | null;
} = {},
): string {
const baseRoute =
@@ -259,6 +341,11 @@ export function getSelfHostedBillingHref(
params.set(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, options.purchase);
}
const source = section === 'plan' ? normalizePurchaseHandoffSource(options.source) : undefined;
if (source) {
params.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, source);
}
const search = params.toString();
return `${baseRoute}${search ? `?${search}` : ''}`;
}
@@ -326,6 +413,7 @@ export function resolveCanonicalSelfHostedBillingHref(
? SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL
: null;
const planIntent = section === 'plan' ? getSelfHostedBillingPlanIntent(search) : null;
const planSource = section === 'plan' ? getSelfHostedBillingPlanSource(search) : null;
const planDetail =
section === 'plan' &&
(getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL ||
@@ -338,6 +426,7 @@ export function resolveCanonicalSelfHostedBillingHref(
detail: section === 'usage' ? usageDetail : planDetail,
intent: planIntent,
purchase,
source: planSource,
});
}
+70
View File
@@ -22520,3 +22520,73 @@ func TestUnifiedAgentHandlers_HandleReportAckOmitsEmptyServerVersion(t *testing.
t.Fatalf("ack unexpectedly carries serverVersion: %v", ack["serverVersion"])
}
}
// Checkout source attribution is an in-app funnel signal, not a browser-visible
// one: it must reach the license server only inside the portal-handoff body and
// must never appear on the Pulse Account portal redirect the browser follows.
func TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal(t *testing.T) {
cases := []struct {
name string
querySource string
wantSource string
wantCancelURL string
}{
{
name: "valid gate source",
querySource: "gate-rbac",
wantSource: "gate-rbac",
wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled&source=gate-rbac",
},
{
name: "malformed source is dropped",
querySource: "Bad Source!",
wantSource: "",
wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
handler := createTestHandler(t)
handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"})
var capturedReq struct {
Feature string `json:"feature"`
CancelURL string `json:"cancel_url"`
Source string `json:"source"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
t.Fatalf("decode checkout portal handoff request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"portal_handoff_id": "cph_test_source"})
}))
defer server.Close()
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
req := httptest.NewRequest(
http.MethodGet,
"https://pulse.example.com/auth/license-purchase-start?feature=rbac&source="+url.QueryEscape(tc.querySource),
nil,
)
rec := httptest.NewRecorder()
handler.HandleCheckoutStart(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusSeeOther, rec.Body.String())
}
redirectURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if got := redirectURL.Query().Get("source"); got != "" {
t.Fatalf("source = %q, want omitted from portal query", got)
}
if capturedReq.Source != tc.wantSource {
t.Fatalf("handoff source = %q, want %q", capturedReq.Source, tc.wantSource)
}
if capturedReq.CancelURL != tc.wantCancelURL {
t.Fatalf("cancel_url = %q, want %q", capturedReq.CancelURL, tc.wantCancelURL)
}
})
}
}
+31 -1
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
@@ -29,6 +30,7 @@ const (
licensePurchaseReturnTokenField = "purchase_return_token"
pulseAccountUpgradeService = "upgrade"
pulseAccountPortalFeatureQueryParam = "feature"
pulseAccountPortalSourceQueryParam = "source"
pulseAccountPortalHandoffIDField = "portal_handoff_id"
pulseAccountPortalHandoffURLQueryParam = "purchase_handoff_url"
pulseAccountPortalServiceQueryParam = "service"
@@ -292,6 +294,7 @@ func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values)
for key, values := range query {
switch key {
case pulseAccountPortalFeatureQueryParam,
pulseAccountPortalSourceQueryParam,
pulseAccountPortalHandoffIDField,
pulseAccountPortalHandoffURLQueryParam,
pulseAccountPortalServiceQueryParam,
@@ -354,13 +357,36 @@ func normalizeSelfHostedPurchaseFeature(feature string) string {
}
}
// selfHostedPurchaseSourcePattern mirrors the frontend attribution vocabulary
// (pricingHandoff.ts): lowercase kebab tokens such as "gate-rbac",
// "estate-card", or "plans-page". Anything else is dropped, not forwarded.
var selfHostedPurchaseSourcePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
func normalizeSelfHostedPurchaseSource(source string) string {
trimmed := strings.TrimSpace(source)
if !selfHostedPurchaseSourcePattern.MatchString(trimmed) {
return ""
}
return trimmed
}
func licensePurchaseActivationRedirectPath(feature, purchaseResult string) string {
return licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, "")
}
// licensePurchaseActivationRedirectPathWithSource additionally carries the
// checkout source attribution back onto the plan route so a retry after a
// cancelled checkout keeps the surface that originally drove it.
func licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, source string) string {
normalizedFeature := normalizeSelfHostedPurchaseFeature(feature)
query := url.Values{}
switch normalizedFeature {
case "self_hosted_plan":
query.Set("intent", "self_hosted_plan")
}
if normalizedSource := normalizeSelfHostedPurchaseSource(source); normalizedSource != "" {
query.Set(pulseAccountPortalSourceQueryParam, normalizedSource)
}
switch strings.TrimSpace(purchaseResult) {
case selfHostedBillingPurchaseActivated,
selfHostedBillingPurchaseCancelled,
@@ -1075,6 +1101,9 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
feature := normalizeSelfHostedPurchaseFeature(
r.URL.Query().Get(pulseAccountPortalFeatureQueryParam),
)
purchaseSource := normalizeSelfHostedPurchaseSource(
r.URL.Query().Get(pulseAccountPortalSourceQueryParam),
)
writeUnavailable := func(statusCode int, message string) {
writeLicensePurchaseStartFailurePage(w, statusCode, feature, message)
}
@@ -1131,7 +1160,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
cancelURL, err := publicAbsoluteURLForPath(
r,
h.cfg,
licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseCancelled),
licensePurchaseActivationRedirectPathWithSource(feature, selfHostedBillingPurchaseCancelled, purchaseSource),
)
if err != nil {
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account cancellation return url")
@@ -1169,6 +1198,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
SuccessURL: activationURLTemplate,
CancelURL: cancelURL,
PurchaseReturnJTI: purchaseReturnJTI,
Source: purchaseSource,
})
if err != nil {
log.Error().Err(err).Str("feature", feature).Msg("Failed to create Pulse Account checkout portal handoff")
+5
View File
@@ -49,6 +49,11 @@ type CheckoutPortalHandoffRequest struct {
SuccessURL string `json:"success_url"`
CancelURL string `json:"cancel_url"`
PurchaseReturnJTI string `json:"purchase_return_jti"`
// Source is the closed-vocabulary in-app surface attribution for the
// checkout (e.g. "gate-rbac", "estate-card", "plans-page"). omitempty keeps
// requests without attribution compatible with license servers that predate
// the field, since the receiver decodes strictly.
Source string `json:"source,omitempty"`
}
// CheckoutPortalHandoffResponse is the canonical opaque portal handoff used to
@@ -523,6 +523,54 @@ func TestClientCreateCheckoutPortalHandoff(t *testing.T) {
}
}
func TestClientCreateCheckoutPortalHandoffSourceAttribution(t *testing.T) {
// The license server decodes this body strictly (DisallowUnknownFields), so
// an install with no attribution must omit `source` entirely rather than
// send an empty string. That keeps source-less handoffs working against a
// license server deployed either side of the field being added.
cases := []struct {
name string
source string
wantKey bool
wantSource string
}{
{name: "attributed handoff carries the source", source: "gate-rbac", wantKey: true, wantSource: "gate-rbac"},
{name: "unattributed handoff omits the key", source: "", wantKey: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(CheckoutPortalHandoffResponse{PortalHandoffID: "cph_test_source"})
}))
defer server.Close()
client := NewLicenseServerClient(server.URL)
if _, err := client.CreateCheckoutPortalHandoff(context.Background(), CheckoutPortalHandoffRequest{
Feature: "rbac",
SuccessURL: "https://pulse.example.com/auth/license-purchase-activate?purchase_return_token=prt_signed&session_id={CHECKOUT_SESSION_ID}",
CancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled",
PurchaseReturnJTI: "purchase_return_jti_123",
Source: tc.source,
}); err != nil {
t.Fatalf("CreateCheckoutPortalHandoff failed: %v", err)
}
got, present := body["source"]
if present != tc.wantKey {
t.Fatalf("source key present = %v, want %v (body=%v)", present, tc.wantKey, body)
}
if tc.wantKey && got != tc.wantSource {
t.Fatalf("source = %v, want %q", got, tc.wantSource)
}
})
}
}
func TestClientRefreshGrant(t *testing.T) {
t.Run("successful refresh", func(t *testing.T) {
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{