- Overview Report requires an active premium subscription.
+ Overview Report requires a premium subscription or a one-time report run.
- Try free for 7 days, then $4.99/month to unlock comprehensive analysis of all your genetic results.
+ Try free for 7 days, then $4.99/month, or run this report once for $4.99 from the Analyze page.
@@ -147,15 +297,7 @@ export default function OverviewReportPage() {
Organizes your associations by healthspan domain: cardiovascular, metabolic, neurological, immune, musculoskeletal, and cancer susceptibility. Synthesizes patterns within and across domains.
{/* Top Traits Report */}
@@ -173,15 +315,7 @@ export default function OverviewReportPage() {
Takes your 100 strongest genetic associations by effect size and synthesizes what they reveal about your biology. Good starting point if you have not added health history yet.
-
-
-
+ {renderPaidReportActions('top_traits', handleGenerateTopTraitsReport, 'Generate Top Traits Report')}
{/* Comprehensive Overview Report (experimental, at bottom) */}
@@ -199,15 +333,7 @@ export default function OverviewReportPage() {
Analyzes all your saved genetic results across categories: health, lifestyle, appearance, personality, and more. Works best after running broad analysis. Currently under development.
-
-
-
+ {renderPaidReportActions('overview', handleGenerateReport, 'Generate Overview Report')}
@@ -215,6 +341,8 @@ export default function OverviewReportPage() {
setShowOverviewReportModal(false)}
+ hasOneTimeAccess={reportAccess.overview > 0}
+ onConsumeOneTimeAccess={() => consumeReportPass('overview')}
/>
setShowHealthspanReportModal(false)}
+ onConsumeOneTimeAccess={() => consumeReportPass('healthspan')}
+ hasPremiumAccess={hasPremiumAccess}
/>
setShowTopTraitsReportModal(false)}
+ onConsumeOneTimeAccess={() => consumeReportPass('top_traits')}
+ hasPremiumAccess={hasPremiumAccess}
/>
setTourOpen(false)} />
diff --git a/lib/report-access.ts b/lib/report-access.ts
new file mode 100644
index 0000000..7a312a5
--- /dev/null
+++ b/lib/report-access.ts
@@ -0,0 +1,15 @@
+export const ONE_TIME_REPORT_PRICE_CENTS = 499;
+
+export const PAID_REPORT_TYPES = ['healthspan', 'top_traits', 'overview'] as const;
+
+export type PaidReportType = typeof PAID_REPORT_TYPES[number];
+
+export const PAID_REPORT_LABELS: Record = {
+ healthspan: 'Healthspan Report',
+ top_traits: 'Top Traits Report',
+ overview: 'Comprehensive Overview Report',
+};
+
+export function isPaidReportType(value: unknown): value is PaidReportType {
+ return typeof value === 'string' && (PAID_REPORT_TYPES as readonly string[]).includes(value);
+}
From 48adad69f7dfebab296f31e12e9072e4f177023f Mon Sep 17 00:00:00 2001
From: Vishakh
Date: Thu, 9 Jul 2026 15:19:56 -0400
Subject: [PATCH 2/6] Bug fixes.
---
app/api/report-access/route.ts | 136 ++++++++++++++++++++---
app/components/HealthspanReportModal.tsx | 8 ++
app/components/OverviewReportModal.tsx | 25 +++--
app/components/TopTraitsReportModal.tsx | 8 ++
app/overview-report/page.tsx | 81 ++++++++++++--
lib/dynamic-auth.ts | 69 ++++++++++++
package-lock.json | 10 ++
package.json | 1 +
8 files changed, 309 insertions(+), 29 deletions(-)
create mode 100644 lib/dynamic-auth.ts
diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts
index 8706dd1..ad233fb 100644
--- a/app/api/report-access/route.ts
+++ b/app/api/report-access/route.ts
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
+import { randomUUID } from 'crypto';
import Stripe from 'stripe';
import { isPaidReportType, PAID_REPORT_TYPES, PaidReportType } from '@/lib/report-access';
+import { verifyWalletAuth } from '@/lib/dynamic-auth';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
apiVersion: '2025-02-24.acacia',
@@ -16,6 +18,14 @@ function stripeSearchValue(value: string) {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
+function isReportPayment(payment: Stripe.PaymentIntent, walletAddress: string) {
+ return (
+ payment.status === 'succeeded' &&
+ payment.metadata.purpose === 'report_one_time' &&
+ payment.metadata.walletAddress === walletAddress
+ );
+}
+
async function findUnusedReportPayments(walletAddress: string, reportType?: PaidReportType) {
const clauses = [
`metadata['walletAddress']:'${stripeSearchValue(walletAddress)}'`,
@@ -35,9 +45,83 @@ async function findUnusedReportPayments(walletAddress: string, reportType?: Paid
return result.data.filter(payment => !payment.metadata.consumedAt);
}
+// Stripe search is eventually consistent and can lag behind a checkout that
+// just completed. When the client passes the checkout session id from the
+// success redirect, resolve the payment intent directly so the new pass is
+// visible immediately.
+async function findPaymentFromCheckoutSession(sessionId: string, walletAddress: string) {
+ try {
+ const session = await stripe.checkout.sessions.retrieve(sessionId, {
+ expand: ['payment_intent'],
+ });
+
+ if (session.payment_status !== 'paid') return null;
+
+ const payment = session.payment_intent;
+ if (!payment || typeof payment === 'string') return null;
+ if (!isReportPayment(payment, walletAddress)) return null;
+ if (payment.metadata.consumedAt) return null;
+
+ return payment;
+ } catch (error) {
+ console.error('Failed to resolve checkout session:', error);
+ return null;
+ }
+}
+
+async function collectUnusedPayments(
+ walletAddress: string,
+ sessionId: string | undefined,
+ reportType?: PaidReportType
+) {
+ const payments = await findUnusedReportPayments(walletAddress, reportType);
+
+ if (typeof sessionId === 'string' && sessionId.startsWith('cs_')) {
+ const sessionPayment = await findPaymentFromCheckoutSession(sessionId, walletAddress);
+ if (
+ sessionPayment &&
+ (!reportType || sessionPayment.metadata.reportType === reportType) &&
+ !payments.some(payment => payment.id === sessionPayment.id)
+ ) {
+ payments.push(sessionPayment);
+ }
+ }
+
+ return payments;
+}
+
+// Consumes one pass with an optimistic concurrency guard. Stripe metadata
+// updates are last-writer-wins, so after stamping the payment intent we read
+// it back and only treat the consumption as ours when our token survived.
+async function consumeOnePayment(payments: Stripe.PaymentIntent[]) {
+ const candidates = [...payments].sort((a, b) => a.created - b.created);
+
+ for (const candidate of candidates) {
+ const fresh = await stripe.paymentIntents.retrieve(candidate.id);
+ if (fresh.metadata.consumedAt) continue;
+
+ const consumeToken = randomUUID();
+ await stripe.paymentIntents.update(candidate.id, {
+ metadata: {
+ ...fresh.metadata,
+ consumedAt: new Date().toISOString(),
+ consumeToken,
+ },
+ });
+
+ const verified = await stripe.paymentIntents.retrieve(candidate.id);
+ if (verified.metadata.consumeToken === consumeToken) {
+ return candidate.id;
+ }
+ }
+
+ return null;
+}
+
export async function POST(request: NextRequest) {
try {
- const { walletAddress, reportType, action = 'check' } = await request.json();
+ const { walletAddress, reportType, action = 'check', sessionId, paymentIntentId } =
+ await request.json();
const normalizedWallet = validateWalletAddress(walletAddress);
if (!normalizedWallet) {
@@ -49,7 +133,7 @@ export async function POST(request: NextRequest) {
}
if (action === 'check') {
- const payments = await findUnusedReportPayments(normalizedWallet);
+ const payments = await collectUnusedPayments(normalizedWallet, sessionId);
const availableReports = Object.fromEntries(
PAID_REPORT_TYPES.map(type => [
type,
@@ -63,34 +147,60 @@ export async function POST(request: NextRequest) {
});
}
+ // Consuming and releasing passes mutate paid state, so they require proof
+ // that the caller owns the wallet.
+ if (action === 'consume' || action === 'release') {
+ const auth = await verifyWalletAuth(request.headers.get('authorization'), normalizedWallet);
+ if (!auth.ok) {
+ return NextResponse.json({ error: auth.error }, { status: auth.status || 401 });
+ }
+ }
+
if (action === 'consume') {
if (!isPaidReportType(reportType)) {
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 });
}
- const payments = await findUnusedReportPayments(normalizedWallet, reportType);
- const paymentToConsume = payments.sort((a, b) => a.created - b.created)[0];
+ const payments = await collectUnusedPayments(normalizedWallet, sessionId, reportType);
+ const consumedPaymentIntentId = await consumeOnePayment(payments);
- if (!paymentToConsume) {
+ if (!consumedPaymentIntentId) {
return NextResponse.json(
{ success: false, error: 'No unused report pass found' },
{ status: 402 }
);
}
- await stripe.paymentIntents.update(paymentToConsume.id, {
- metadata: {
- ...paymentToConsume.metadata,
- consumedAt: new Date().toISOString(),
- },
- });
-
return NextResponse.json({
success: true,
- consumedPaymentIntentId: paymentToConsume.id,
+ consumedPaymentIntentId,
});
}
+ // Returns a pass to the wallet when report generation failed after the
+ // pass was consumed.
+ if (action === 'release') {
+ if (typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) {
+ return NextResponse.json({ error: 'Invalid payment intent id' }, { status: 400 });
+ }
+
+ const payment = await stripe.paymentIntents.retrieve(paymentIntentId);
+ if (!isReportPayment(payment, normalizedWallet)) {
+ return NextResponse.json({ error: 'Payment not found for this wallet' }, { status: 404 });
+ }
+
+ if (payment.metadata.consumedAt) {
+ await stripe.paymentIntents.update(payment.id, {
+ metadata: {
+ consumedAt: '',
+ consumeToken: '',
+ },
+ });
+ }
+
+ return NextResponse.json({ success: true });
+ }
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error: any) {
console.error('Report access check failed:', error);
diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx
index 7c68da9..6e806b6 100644
--- a/app/components/HealthspanReportModal.tsx
+++ b/app/components/HealthspanReportModal.tsx
@@ -17,6 +17,7 @@ interface HealthspanReportModalProps {
onClose: () => void;
hasPremiumAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
+ onReleaseOneTimeAccess?: () => Promise;
}
export default function HealthspanReportModal({
@@ -24,6 +25,7 @@ export default function HealthspanReportModal({
onClose,
hasPremiumAccess = false,
onConsumeOneTimeAccess,
+ onReleaseOneTimeAccess,
}: HealthspanReportModalProps) {
const router = useRouter();
const { savedResults } = useResults();
@@ -54,6 +56,7 @@ export default function HealthspanReportModal({
if (inFlightRef.current) return;
inFlightRef.current = true;
+ let consumedPass = false;
if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
@@ -69,6 +72,7 @@ export default function HealthspanReportModal({
inFlightRef.current = false;
return;
}
+ consumedPass = true;
}
setPhase('generating');
@@ -90,6 +94,10 @@ export default function HealthspanReportModal({
const domainCount = Object.values(res.domainCounts).filter(c => c.elevated + c.protective >= 2).length;
trackHealthspanReportGenerated(res.selected.length, domainCount);
} catch (err) {
+ // Return the pass so a failed generation does not burn a paid run.
+ if (consumedPass && onReleaseOneTimeAccess) {
+ onReleaseOneTimeAccess().catch(() => {});
+ }
const msg = err instanceof Error ? err.message : 'Generation failed.';
setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg);
setPhase('error');
diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx
index aeb686a..d8e4d00 100644
--- a/app/components/OverviewReportModal.tsx
+++ b/app/components/OverviewReportModal.tsx
@@ -3,11 +3,9 @@
import { useRef, useState } from "react";
import { useResults } from "./ResultsContext";
import { useCustomization } from "./CustomizationContext";
-import { useAuth } from "./AuthProvider";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { trackOverviewReportGenerated } from "@/lib/analytics";
-import { hasValidPromoAccess } from "@/lib/promo-access";
type GenerationPhase = 'idle' | 'map' | 'reduce' | 'complete' | 'error';
@@ -27,19 +25,22 @@ interface ProgressState {
interface OverviewReportModalProps {
isOpen: boolean;
onClose: () => void;
+ hasPremiumAccess?: boolean;
hasOneTimeAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
+ onReleaseOneTimeAccess?: () => Promise;
}
export default function OverviewReportModal({
isOpen,
onClose,
+ hasPremiumAccess = false,
hasOneTimeAccess = false,
onConsumeOneTimeAccess,
+ onReleaseOneTimeAccess,
}: OverviewReportModalProps) {
const { savedResults } = useResults();
const { customization } = useCustomization();
- const { hasActiveSubscription } = useAuth();
const [isGenerating, setIsGenerating] = useState(false);
const generationInFlightRef = useRef(false);
@@ -61,8 +62,16 @@ export default function OverviewReportModal({
}
generationInFlightRef.current = true;
- const hasPromoAccess = hasValidPromoAccess();
- if (!hasActiveSubscription && !hasPromoAccess && onConsumeOneTimeAccess) {
+ let consumedPass = false;
+ // Return the pass so a failed generation does not burn a paid run.
+ const releaseConsumedPass = () => {
+ if (consumedPass && onReleaseOneTimeAccess) {
+ consumedPass = false;
+ onReleaseOneTimeAccess().catch(() => {});
+ }
+ };
+
+ if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
if (!consumed) {
@@ -83,6 +92,7 @@ export default function OverviewReportModal({
generationInFlightRef.current = false;
return;
}
+ consumedPass = true;
}
setIsGenerating(true);
@@ -136,6 +146,7 @@ export default function OverviewReportModal({
clearInterval(timerInterval);
generationInFlightRef.current = false;
setIsGenerating(false);
+ releaseConsumedPass();
}
}
);
@@ -151,6 +162,7 @@ export default function OverviewReportModal({
}));
generationInFlightRef.current = false;
setIsGenerating(false);
+ releaseConsumedPass();
}
};
@@ -557,8 +569,7 @@ export default function OverviewReportModal({
if (!isOpen) return null;
// Check subscription
- const hasPromoAccess = hasValidPromoAccess();
- const isBlocked = !hasActiveSubscription && !hasPromoAccess && !hasOneTimeAccess;
+ const isBlocked = !hasPremiumAccess && !hasOneTimeAccess;
return (
diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx
index cb57400..e385164 100644
--- a/app/components/TopTraitsReportModal.tsx
+++ b/app/components/TopTraitsReportModal.tsx
@@ -16,6 +16,7 @@ interface TopTraitsReportModalProps {
onClose: () => void;
hasPremiumAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
+ onReleaseOneTimeAccess?: () => Promise;
}
export default function TopTraitsReportModal({
@@ -23,6 +24,7 @@ export default function TopTraitsReportModal({
onClose,
hasPremiumAccess = false,
onConsumeOneTimeAccess,
+ onReleaseOneTimeAccess,
}: TopTraitsReportModalProps) {
const router = useRouter();
const { savedResults } = useResults();
@@ -53,6 +55,7 @@ export default function TopTraitsReportModal({
if (inFlightRef.current) return;
inFlightRef.current = true;
+ let consumedPass = false;
if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
@@ -68,6 +71,7 @@ export default function TopTraitsReportModal({
inFlightRef.current = false;
return;
}
+ consumedPass = true;
}
setPhase('generating');
@@ -88,6 +92,10 @@ export default function TopTraitsReportModal({
setPhase('complete');
trackTopTraitsReportGenerated(res.selected.length);
} catch (err) {
+ // Return the pass so a failed generation does not burn a paid run.
+ if (consumedPass && onReleaseOneTimeAccess) {
+ onReleaseOneTimeAccess().catch(() => {});
+ }
const msg = err instanceof Error ? err.message : 'Generation failed.';
setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg);
setPhase('error');
diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx
index 07f4fa1..4d0b34b 100644
--- a/app/overview-report/page.tsx
+++ b/app/overview-report/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import MenuBar from "../components/MenuBar";
import Footer from "../components/Footer";
@@ -12,6 +12,7 @@ import HealthspanReportModal from "../components/HealthspanReportModal";
import TopTraitsReportModal from "../components/TopTraitsReportModal";
import { OverviewReportIcon } from "../components/Icons";
import { useAuth } from "../components/AuthProvider";
+import { getAuthToken } from "@dynamic-labs/sdk-react-core";
import { useResults } from "../components/ResultsContext";
import { hasValidPromoAccess } from "@/lib/promo-access";
import GuidedTour from "../components/GuidedTour";
@@ -39,7 +40,9 @@ export default function OverviewReportPage() {
const [reportAccess, setReportAccess] = useState(emptyReportAccess);
const [checkingReportAccess, setCheckingReportAccess] = useState(false);
const [purchaseMessage, setPurchaseMessage] = useState(null);
+ const [checkoutSessionId, setCheckoutSessionId] = useState(null);
const [purchasingReportType, setPurchasingReportType] = useState(null);
+ const consumedPassRef = useRef>>({});
const [tourOpen, setTourOpen] = useState(false);
const walletAddress = user?.verifiedCredentials?.find((c: any) => c.address)?.address;
@@ -64,13 +67,22 @@ export default function OverviewReportPage() {
const params = new URLSearchParams(window.location.search);
const purchaseState = params.get('report_purchase');
const reportType = params.get('report_type');
+ const sessionId = params.get('session_id');
+
+ if (!purchaseState) return;
if (purchaseState === 'success' && reportType && reportType in PAID_REPORT_LABELS) {
setPurchaseMessage(`${PAID_REPORT_LABELS[reportType as PaidReportType]} is unlocked for one run.`);
+ if (sessionId) {
+ setCheckoutSessionId(sessionId);
+ }
} else if (purchaseState === 'cancelled') {
setPurchaseMessage('Report payment was cancelled. No charge was made.');
}
- }, []);
+
+ // Remove the purchase params so a refresh does not replay the banner.
+ router.replace(window.location.pathname, { scroll: false });
+ }, [router]);
useEffect(() => {
const refreshReportAccess = async () => {
@@ -84,7 +96,11 @@ export default function OverviewReportPage() {
const response = await fetch('/api/report-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ walletAddress, action: 'check' }),
+ body: JSON.stringify({
+ walletAddress,
+ action: 'check',
+ sessionId: checkoutSessionId || undefined,
+ }),
});
const data = await response.json();
if (response.ok && data.success) {
@@ -101,7 +117,7 @@ export default function OverviewReportPage() {
};
refreshReportAccess();
- }, [walletAddress, purchaseMessage]);
+ }, [walletAddress, purchaseMessage, checkoutSessionId]);
const hasPremiumAccess = hasActiveSubscription || hasPromoAccess;
const hasResults = savedResults.length > 0;
@@ -149,16 +165,30 @@ export default function OverviewReportPage() {
return false;
}
+ const authToken = getAuthToken();
+ if (!authToken) {
+ throw new Error('Please sign in again to use your report pass.');
+ }
+
const response = await fetch('/api/report-access', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ walletAddress, reportType, action: 'consume' }),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({
+ walletAddress,
+ reportType,
+ action: 'consume',
+ sessionId: checkoutSessionId || undefined,
+ }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Could not use report pass');
}
+ consumedPassRef.current[reportType] = data.consumedPaymentIntentId;
setReportAccess(prev => ({
...prev,
[reportType]: Math.max(0, prev[reportType] - 1),
@@ -167,6 +197,35 @@ export default function OverviewReportPage() {
return true;
};
+ const releaseReportPass = async (reportType: PaidReportType) => {
+ const paymentIntentId = consumedPassRef.current[reportType];
+ if (!paymentIntentId || !walletAddress) return;
+
+ try {
+ const authToken = getAuthToken();
+ if (!authToken) return;
+
+ const response = await fetch('/api/report-access', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({ walletAddress, paymentIntentId, action: 'release' }),
+ });
+ const data = await response.json();
+ if (response.ok && data.success) {
+ delete consumedPassRef.current[reportType];
+ setReportAccess(prev => ({
+ ...prev,
+ [reportType]: prev[reportType] + 1,
+ }));
+ }
+ } catch (error) {
+ console.error('Failed to release report pass:', error);
+ }
+ };
+
const handleGenerateReport = () => {
if (!hasResults || !requireReportAccess('overview')) return;
setShowOverviewReportModal(true);
@@ -195,7 +254,7 @@ export default function OverviewReportPage() {
diff --git a/lib/dynamic-auth.ts b/lib/dynamic-auth.ts
new file mode 100644
index 0000000..7deb6e3
--- /dev/null
+++ b/lib/dynamic-auth.ts
@@ -0,0 +1,69 @@
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+// Server-side verification of Dynamic auth tokens.
+// Dynamic signs a JWT for each logged-in user; the public keys are published
+// at the environment's JWKS endpoint.
+
+const jwksCache = new Map>();
+
+function getJwks(environmentId: string) {
+ let jwks = jwksCache.get(environmentId);
+ if (!jwks) {
+ jwks = createRemoteJWKSet(
+ new URL(`https://app.dynamic.xyz/api/v0/sdk/${environmentId}/.well-known/jwks`)
+ );
+ jwksCache.set(environmentId, jwks);
+ }
+ return jwks;
+}
+
+export interface WalletAuthResult {
+ ok: boolean;
+ error?: string;
+ status?: number;
+}
+
+/**
+ * Verifies that the request carries a valid Dynamic auth token whose
+ * verified credentials include the given wallet address.
+ */
+export async function verifyWalletAuth(
+ authorizationHeader: string | null,
+ walletAddress: string
+): Promise {
+ const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID;
+ if (!environmentId) {
+ return { ok: false, error: 'Authentication is not configured', status: 500 };
+ }
+
+ const token = authorizationHeader?.startsWith('Bearer ')
+ ? authorizationHeader.slice('Bearer '.length).trim()
+ : null;
+
+ if (!token) {
+ return { ok: false, error: 'Authentication required', status: 401 };
+ }
+
+ try {
+ const { payload } = await jwtVerify(token, getJwks(environmentId));
+
+ const credentials = (payload as any).verified_credentials;
+ const normalizedWallet = walletAddress.toLowerCase();
+ const ownsWallet =
+ Array.isArray(credentials) &&
+ credentials.some(
+ (credential: any) =>
+ typeof credential?.address === 'string' &&
+ credential.address.toLowerCase() === normalizedWallet
+ );
+
+ if (!ownsWallet) {
+ return { ok: false, error: 'Wallet does not belong to the authenticated user', status: 403 };
+ }
+
+ return { ok: true };
+ } catch (error) {
+ console.error('Dynamic token verification failed:', error);
+ return { ok: false, error: 'Invalid or expired authentication token', status: 401 };
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index c6ea815..6cc505b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0",
"ethers": "^6.13.4",
+ "jose": "^6.2.3",
"next": "^16.2.1",
"openai": "^6.7.0",
"pako": "^2.1.0",
@@ -13576,6 +13577,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
diff --git a/package.json b/package.json
index dff3aad..01f6222 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"crypto-browserify": "^3.12.1",
"crypto-js": "^4.2.0",
"ethers": "^6.13.4",
+ "jose": "^6.2.3",
"next": "^16.2.1",
"openai": "^6.7.0",
"pako": "^2.1.0",
From 03954af7ba0b331035ea262df002c8786342ae2e Mon Sep 17 00:00:00 2001
From: Vishakh
Date: Thu, 9 Jul 2026 15:31:41 -0400
Subject: [PATCH 3/6] Bug fixes.
---
app/api/report-access/route.ts | 32 +++----------------
app/components/HealthspanReportModal.tsx | 8 -----
app/components/OverviewReportModal.tsx | 14 ---------
app/components/TopTraitsReportModal.tsx | 8 -----
app/overview-report/page.tsx | 36 +---------------------
lib/dynamic-auth.ts | 39 +++++++++++++++++++-----
6 files changed, 36 insertions(+), 101 deletions(-)
diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts
index ad233fb..398524c 100644
--- a/app/api/report-access/route.ts
+++ b/app/api/report-access/route.ts
@@ -120,7 +120,7 @@ async function consumeOnePayment(payments: Stripe.PaymentIntent[]) {
export async function POST(request: NextRequest) {
try {
- const { walletAddress, reportType, action = 'check', sessionId, paymentIntentId } =
+ const { walletAddress, reportType, action = 'check', sessionId } =
await request.json();
const normalizedWallet = validateWalletAddress(walletAddress);
@@ -147,9 +147,9 @@ export async function POST(request: NextRequest) {
});
}
- // Consuming and releasing passes mutate paid state, so they require proof
- // that the caller owns the wallet.
- if (action === 'consume' || action === 'release') {
+ // Consuming passes mutates paid state, so it requires proof that the
+ // caller owns the wallet.
+ if (action === 'consume') {
const auth = await verifyWalletAuth(request.headers.get('authorization'), normalizedWallet);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status || 401 });
@@ -177,30 +177,6 @@ export async function POST(request: NextRequest) {
});
}
- // Returns a pass to the wallet when report generation failed after the
- // pass was consumed.
- if (action === 'release') {
- if (typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) {
- return NextResponse.json({ error: 'Invalid payment intent id' }, { status: 400 });
- }
-
- const payment = await stripe.paymentIntents.retrieve(paymentIntentId);
- if (!isReportPayment(payment, normalizedWallet)) {
- return NextResponse.json({ error: 'Payment not found for this wallet' }, { status: 404 });
- }
-
- if (payment.metadata.consumedAt) {
- await stripe.paymentIntents.update(payment.id, {
- metadata: {
- consumedAt: '',
- consumeToken: '',
- },
- });
- }
-
- return NextResponse.json({ success: true });
- }
-
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error: any) {
console.error('Report access check failed:', error);
diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx
index 6e806b6..7c68da9 100644
--- a/app/components/HealthspanReportModal.tsx
+++ b/app/components/HealthspanReportModal.tsx
@@ -17,7 +17,6 @@ interface HealthspanReportModalProps {
onClose: () => void;
hasPremiumAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
- onReleaseOneTimeAccess?: () => Promise;
}
export default function HealthspanReportModal({
@@ -25,7 +24,6 @@ export default function HealthspanReportModal({
onClose,
hasPremiumAccess = false,
onConsumeOneTimeAccess,
- onReleaseOneTimeAccess,
}: HealthspanReportModalProps) {
const router = useRouter();
const { savedResults } = useResults();
@@ -56,7 +54,6 @@ export default function HealthspanReportModal({
if (inFlightRef.current) return;
inFlightRef.current = true;
- let consumedPass = false;
if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
@@ -72,7 +69,6 @@ export default function HealthspanReportModal({
inFlightRef.current = false;
return;
}
- consumedPass = true;
}
setPhase('generating');
@@ -94,10 +90,6 @@ export default function HealthspanReportModal({
const domainCount = Object.values(res.domainCounts).filter(c => c.elevated + c.protective >= 2).length;
trackHealthspanReportGenerated(res.selected.length, domainCount);
} catch (err) {
- // Return the pass so a failed generation does not burn a paid run.
- if (consumedPass && onReleaseOneTimeAccess) {
- onReleaseOneTimeAccess().catch(() => {});
- }
const msg = err instanceof Error ? err.message : 'Generation failed.';
setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg);
setPhase('error');
diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx
index d8e4d00..9b8d22f 100644
--- a/app/components/OverviewReportModal.tsx
+++ b/app/components/OverviewReportModal.tsx
@@ -28,7 +28,6 @@ interface OverviewReportModalProps {
hasPremiumAccess?: boolean;
hasOneTimeAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
- onReleaseOneTimeAccess?: () => Promise;
}
export default function OverviewReportModal({
@@ -37,7 +36,6 @@ export default function OverviewReportModal({
hasPremiumAccess = false,
hasOneTimeAccess = false,
onConsumeOneTimeAccess,
- onReleaseOneTimeAccess,
}: OverviewReportModalProps) {
const { savedResults } = useResults();
const { customization } = useCustomization();
@@ -62,15 +60,6 @@ export default function OverviewReportModal({
}
generationInFlightRef.current = true;
- let consumedPass = false;
- // Return the pass so a failed generation does not burn a paid run.
- const releaseConsumedPass = () => {
- if (consumedPass && onReleaseOneTimeAccess) {
- consumedPass = false;
- onReleaseOneTimeAccess().catch(() => {});
- }
- };
-
if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
@@ -92,7 +81,6 @@ export default function OverviewReportModal({
generationInFlightRef.current = false;
return;
}
- consumedPass = true;
}
setIsGenerating(true);
@@ -146,7 +134,6 @@ export default function OverviewReportModal({
clearInterval(timerInterval);
generationInFlightRef.current = false;
setIsGenerating(false);
- releaseConsumedPass();
}
}
);
@@ -162,7 +149,6 @@ export default function OverviewReportModal({
}));
generationInFlightRef.current = false;
setIsGenerating(false);
- releaseConsumedPass();
}
};
diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx
index e385164..cb57400 100644
--- a/app/components/TopTraitsReportModal.tsx
+++ b/app/components/TopTraitsReportModal.tsx
@@ -16,7 +16,6 @@ interface TopTraitsReportModalProps {
onClose: () => void;
hasPremiumAccess?: boolean;
onConsumeOneTimeAccess?: () => Promise;
- onReleaseOneTimeAccess?: () => Promise;
}
export default function TopTraitsReportModal({
@@ -24,7 +23,6 @@ export default function TopTraitsReportModal({
onClose,
hasPremiumAccess = false,
onConsumeOneTimeAccess,
- onReleaseOneTimeAccess,
}: TopTraitsReportModalProps) {
const router = useRouter();
const { savedResults } = useResults();
@@ -55,7 +53,6 @@ export default function TopTraitsReportModal({
if (inFlightRef.current) return;
inFlightRef.current = true;
- let consumedPass = false;
if (!hasPremiumAccess && onConsumeOneTimeAccess) {
try {
const consumed = await onConsumeOneTimeAccess();
@@ -71,7 +68,6 @@ export default function TopTraitsReportModal({
inFlightRef.current = false;
return;
}
- consumedPass = true;
}
setPhase('generating');
@@ -92,10 +88,6 @@ export default function TopTraitsReportModal({
setPhase('complete');
trackTopTraitsReportGenerated(res.selected.length);
} catch (err) {
- // Return the pass so a failed generation does not burn a paid run.
- if (consumedPass && onReleaseOneTimeAccess) {
- onReleaseOneTimeAccess().catch(() => {});
- }
const msg = err instanceof Error ? err.message : 'Generation failed.';
setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg);
setPhase('error');
diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx
index 4d0b34b..ab33b85 100644
--- a/app/overview-report/page.tsx
+++ b/app/overview-report/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import MenuBar from "../components/MenuBar";
import Footer from "../components/Footer";
@@ -42,7 +42,6 @@ export default function OverviewReportPage() {
const [purchaseMessage, setPurchaseMessage] = useState(null);
const [checkoutSessionId, setCheckoutSessionId] = useState(null);
const [purchasingReportType, setPurchasingReportType] = useState(null);
- const consumedPassRef = useRef>>({});
const [tourOpen, setTourOpen] = useState(false);
const walletAddress = user?.verifiedCredentials?.find((c: any) => c.address)?.address;
@@ -188,7 +187,6 @@ export default function OverviewReportPage() {
throw new Error(data.error || 'Could not use report pass');
}
- consumedPassRef.current[reportType] = data.consumedPaymentIntentId;
setReportAccess(prev => ({
...prev,
[reportType]: Math.max(0, prev[reportType] - 1),
@@ -197,35 +195,6 @@ export default function OverviewReportPage() {
return true;
};
- const releaseReportPass = async (reportType: PaidReportType) => {
- const paymentIntentId = consumedPassRef.current[reportType];
- if (!paymentIntentId || !walletAddress) return;
-
- try {
- const authToken = getAuthToken();
- if (!authToken) return;
-
- const response = await fetch('/api/report-access', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${authToken}`,
- },
- body: JSON.stringify({ walletAddress, paymentIntentId, action: 'release' }),
- });
- const data = await response.json();
- if (response.ok && data.success) {
- delete consumedPassRef.current[reportType];
- setReportAccess(prev => ({
- ...prev,
- [reportType]: prev[reportType] + 1,
- }));
- }
- } catch (error) {
- console.error('Failed to release report pass:', error);
- }
- };
-
const handleGenerateReport = () => {
if (!hasResults || !requireReportAccess('overview')) return;
setShowOverviewReportModal(true);
@@ -403,7 +372,6 @@ export default function OverviewReportPage() {
hasPremiumAccess={hasPremiumAccess}
hasOneTimeAccess={reportAccess.overview > 0}
onConsumeOneTimeAccess={() => consumeReportPass('overview')}
- onReleaseOneTimeAccess={() => releaseReportPass('overview')}
/>
setShowHealthspanReportModal(false)}
hasPremiumAccess={hasPremiumAccess}
onConsumeOneTimeAccess={() => consumeReportPass('healthspan')}
- onReleaseOneTimeAccess={() => releaseReportPass('healthspan')}
/>
setShowTopTraitsReportModal(false)}
hasPremiumAccess={hasPremiumAccess}
onConsumeOneTimeAccess={() => consumeReportPass('top_traits')}
- onReleaseOneTimeAccess={() => releaseReportPass('top_traits')}
/>
setTourOpen(false)} />
diff --git a/lib/dynamic-auth.ts b/lib/dynamic-auth.ts
index 7deb6e3..641575c 100644
--- a/lib/dynamic-auth.ts
+++ b/lib/dynamic-auth.ts
@@ -23,6 +23,36 @@ export interface WalletAuthResult {
status?: number;
}
+function credentialOwnsWallet(credential: any, normalizedWallet: string) {
+ return (
+ typeof credential?.address === 'string' &&
+ credential.address.toLowerCase() === normalizedWallet
+ );
+}
+
+function payloadOwnsWallet(payload: any, normalizedWallet: string) {
+ const credentialLists = [
+ payload.verifiedCredentials,
+ payload.verified_credentials,
+ payload.blockchainAccounts,
+ payload.blockchain_accounts,
+ ];
+
+ if (credentialOwnsWallet(payload.verifiedAccount, normalizedWallet)) {
+ return true;
+ }
+
+ if (credentialOwnsWallet(payload.verified_account, normalizedWallet)) {
+ return true;
+ }
+
+ return credentialLists.some(
+ credentials =>
+ Array.isArray(credentials) &&
+ credentials.some((credential: any) => credentialOwnsWallet(credential, normalizedWallet))
+ );
+}
+
/**
* Verifies that the request carries a valid Dynamic auth token whose
* verified credentials include the given wallet address.
@@ -47,15 +77,8 @@ export async function verifyWalletAuth(
try {
const { payload } = await jwtVerify(token, getJwks(environmentId));
- const credentials = (payload as any).verified_credentials;
const normalizedWallet = walletAddress.toLowerCase();
- const ownsWallet =
- Array.isArray(credentials) &&
- credentials.some(
- (credential: any) =>
- typeof credential?.address === 'string' &&
- credential.address.toLowerCase() === normalizedWallet
- );
+ const ownsWallet = payloadOwnsWallet(payload, normalizedWallet);
if (!ownsWallet) {
return { ok: false, error: 'Wallet does not belong to the authenticated user', status: 403 };
From 99d7bb60984f05dfedfce9a971cd67f97e0ca6f1 Mon Sep 17 00:00:00 2001
From: Vishakh
Date: Thu, 9 Jul 2026 15:43:26 -0400
Subject: [PATCH 4/6] Removed seven day trial.
---
app/api/stripe/create-subscription/route.ts | 9 ++++-----
app/components/OverviewReportModal.tsx | 2 +-
app/components/PaymentModal.tsx | 2 +-
app/components/PremiumFeatureHeader.tsx | 6 +++---
app/components/StripeSubscriptionForm.tsx | 16 ++++++++--------
app/dna-chat/page.tsx | 2 +-
app/overview-report/page.tsx | 2 +-
app/subscribe/layout.tsx | 6 +++---
app/subscribe/page.tsx | 2 +-
9 files changed, 23 insertions(+), 24 deletions(-)
diff --git a/app/api/stripe/create-subscription/route.ts b/app/api/stripe/create-subscription/route.ts
index 10cf3a6..106eaef 100644
--- a/app/api/stripe/create-subscription/route.ts
+++ b/app/api/stripe/create-subscription/route.ts
@@ -101,7 +101,6 @@ export async function POST(request: NextRequest) {
price: process.env.STRIPE_PRICE_ID,
},
],
- trial_period_days: 7,
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
@@ -111,8 +110,8 @@ export async function POST(request: NextRequest) {
metadata: {
walletAddress: walletAddress.toLowerCase(),
},
- // Always require payment method collection, even if first invoice is $0
- // This ensures we can charge after promotional period ends
+ // Always require payment method collection, even if the first invoice is $0.
+ // This ensures future subscription renewals can be charged.
collection_method: 'charge_automatically',
};
@@ -158,8 +157,8 @@ export async function POST(request: NextRequest) {
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent | null;
- // If there's no payment intent (e.g., $0 invoice due to 100% discount)
- // We need to create a SetupIntent to collect payment method for future charges
+ // If there is no payment intent, for example a $0 invoice from a 100%
+ // discount, collect a payment method for future charges.
if (!paymentIntent) {
console.log(`[Stripe] No payment intent for $0 invoice - creating SetupIntent to collect payment method`);
diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx
index 9b8d22f..895eed0 100644
--- a/app/components/OverviewReportModal.tsx
+++ b/app/components/OverviewReportModal.tsx
@@ -592,7 +592,7 @@ export default function OverviewReportModal({
Overview Report requires a premium subscription or a one-time report run.
- Try free for 7 days, then $4.99/month, or run this report once for $4.99 from the Analyze page.
+ Subscribe for $4.99/month, or run this report once for $4.99 from the Analyze page.