diff --git a/AGENTS.md b/AGENTS.md index 513e279..26512fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,6 +195,22 @@ session with **no other memory of this project**. So: Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt, and its **expiry is derived from the paid amount → tier**, never the user-signed `pro-expiry` tag. + - **Pro proofs STACK (fixed 2026-09-28).** `useSasquatchPro` now collects ALL + valid proofs and accumulates `expiry = max(expiry, proofTime) + tierDays` + (renewal semantics) — before, it took the **max** of + `receipt.created_at + days`, so buying again before expiry silently + under-delivered ("Extend" did not extend). The proof sent to the proxy is + now `{ receipts, grants }` (base64) instead of one receipt; **both servers + (`ai-proxy/server.ts` and `netlify/functions/ai.mjs`) accept the new payload + AND a bare receipt**, so installed APKs keep working. ⚠️ Deploy the **proxy + before the web client** (old proxy + new payload = 402). + - **Owner-grant fallback (NIP-32, same date):** `org.skatespots.pro` kind-1985 + labels authored by an owner pubkey, tagged `p` = user + `expiry`, are a + trusted Pro source (`useSasquatchPro` + both servers honour them) — the + safety net if the LN provider ever stops emitting zap receipts. Published by + the owner-only **`GrantProCard`** (Settings → Advanced), written to + `getWriteRelays()` and read via `querySkateContent` (community relay + user + relays); the self-signed `32671` remains advisory-only. `sasquatchBrain.ts` caps each request (15 s) and the whole attempt (35 s); the system prompt is a real `system` message; relay results are signature-verified (`verifyEvent`) before use. The first pass **streams** diff --git a/ai-proxy/server.ts b/ai-proxy/server.ts index ce36bab..fcf4892 100644 --- a/ai-proxy/server.ts +++ b/ai-proxy/server.ts @@ -66,6 +66,10 @@ const PRO_TIERS = [ { days: 365, sats: 36000 }, ]; +/** Owner-grant fallback (NIP-32 label). Keep in sync with src/lib/sasquatchPro.ts. */ +const PRO_LABEL_NAMESPACE = 'org.skatespots.pro'; +const PRO_LABEL_VALUE = 'pro'; + let providerPubkeyCache: string | null | undefined; async function getProProviderPubkey(): Promise { if (providerPubkeyCache !== undefined) return providerPubkeyCache; @@ -117,21 +121,22 @@ function verifyNip98(authHeader: string | null): string | null { } } -/** Verify a Pro pass (provider-authored zap receipt bound to the caller). */ -async function verifyPro(receiptHeader: string | null, userPubkey: string): Promise { - if (!receiptHeader) return null; - const receipt = decodeB64Json(receiptHeader); - if (!receipt || receipt.kind !== 9735) return null; - const providerPubkey = await getProProviderPubkey(); +/** Tier days for a valid, provider-authored receipt bound to the user. */ +function receiptTier( + receipt: { pubkey: string; created_at: number; tags: string[][] }, + userPubkey: string, + providerPubkey: string, +): { at: number; days: number } | null { if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; try { - if (!verifyEvent(receipt)) return null; + if (!verifyEvent(receipt as never)) return null; } catch { return null; } + const desc = tagVal(receipt.tags, 'description'); if (!desc) return null; - let zapReq; + let zapReq: { pubkey?: string; tags?: string[][] }; try { zapReq = JSON.parse(desc); } catch { @@ -139,11 +144,11 @@ async function verifyPro(receiptHeader: string | null, userPubkey: string): Prom } if (!zapReq || zapReq.pubkey !== userPubkey) return null; try { - if (!verifyEvent(zapReq)) return null; + if (!verifyEvent(zapReq as never)) return null; } catch { return null; } - if (!zapReq.tags?.some((t: string[]) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; + if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11') ?? ''); if (!paidSats) { @@ -152,7 +157,62 @@ async function verifyPro(receiptHeader: string | null, userPubkey: string): Prom } const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; - return receipt.created_at + tier.days * DAY_SECONDS; + return { at: receipt.created_at, days: tier.days }; +} + +/** Expiry from an owner-issued kind-1985 Pro grant, or null. */ +function grantExpiry( + grant: { kind?: number; pubkey?: string; tags?: string[][] }, + userPubkey: string, +): number | null { + if (grant?.kind !== 1985 || !grant.pubkey) return null; + if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null; + try { + if (!verifyEvent(grant as never)) return null; + } catch { + return null; + } + const tags = grant.tags ?? []; + const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]); + if (!ns.includes(PRO_LABEL_NAMESPACE)) return null; + const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]); + if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null; + if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null; + const expiry = Number(tagVal(tags, 'expiry')); + return Number.isFinite(expiry) && expiry > 0 ? expiry : null; +} + +/** + * Verify a Pro proof (zap receipts + owner grants) → STACKED expiry, or null. + * + * Accepts both the new `{ receipts, grants }` payload and a bare receipt event + * (older installed clients), and adds proofs up with renewal semantics so a + * second purchase genuinely extends a pass. + */ +async function verifyPro(proofHeader: string | null, userPubkey: string): Promise { + if (!proofHeader) return null; + const payload = decodeB64Json(proofHeader) as + | { kind?: number; receipts?: unknown[]; grants?: unknown[] } + | null; + if (!payload) return null; + + const receipts = payload.kind === 9735 ? [payload] : Array.isArray(payload.receipts) ? payload.receipts : []; + const grants = Array.isArray(payload.grants) ? payload.grants : []; + + let expiry = 0; + const providerPubkey = await getProProviderPubkey(); + if (providerPubkey && receipts.length) { + const tiers = receipts + .map((r) => receiptTier(r as never, userPubkey, providerPubkey)) + .filter((t): t is { at: number; days: number } => !!t) + .sort((a, b) => a.at - b.at); + for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS; + } + for (const g of grants) { + const e = grantExpiry(g as never, userPubkey); + if (e && e > expiry) expiry = e; + } + return expiry || null; } // Origins allowed to call the proxy (web + local dev + Capacitor native). diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs index b93a40e..9f82eef 100644 --- a/netlify/functions/ai.mjs +++ b/netlify/functions/ai.mjs @@ -69,6 +69,10 @@ const PRO_TIERS = [ { days: 365, sats: 36000 }, ]; +/** Owner-grant fallback (NIP-32 label). Keep in sync with src/lib/sasquatchPro.ts. */ +const PRO_LABEL_NAMESPACE = 'org.skatespots.pro'; +const PRO_LABEL_VALUE = 'pro'; + let providerPubkeyCache; /** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */ async function getProProviderPubkey() { @@ -124,13 +128,8 @@ function verifyNip98(authHeader) { } } -/** Verify a Pro pass (provider-authored zap receipt bound to the caller, paying - * the platform ≥ the smallest tier) → proUntil (unix sec), or null. */ -async function verifyPro(receiptHeader, userPubkey) { - if (!receiptHeader) return null; - const receipt = decodeB64Json(receiptHeader); - if (!receipt || receipt.kind !== 9735) return null; - const providerPubkey = await getProProviderPubkey(); +/** Tier days for a valid, provider-authored receipt bound to the user. */ +function receiptTier(receipt, userPubkey, providerPubkey) { if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; try { if (!verifyEvent(receipt)) return null; @@ -161,7 +160,54 @@ async function verifyPro(receiptHeader, userPubkey) { } const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; - return receipt.created_at + tier.days * DAY_SECONDS; + return { at: receipt.created_at, days: tier.days }; +} + +/** Expiry from an owner-issued kind-1985 Pro grant, or null. */ +function grantExpiry(grant, userPubkey) { + if (grant?.kind !== 1985 || !grant.pubkey) return null; + if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null; + try { + if (!verifyEvent(grant)) return null; + } catch { + return null; + } + const tags = grant.tags ?? []; + const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]); + if (!ns.includes(PRO_LABEL_NAMESPACE)) return null; + const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]); + if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null; + if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null; + const expiry = Number(tagVal(tags, 'expiry')); + return Number.isFinite(expiry) && expiry > 0 ? expiry : null; +} + +/** + * Verify a Pro proof (zap receipts + owner grants) → STACKED expiry, or null. + * Accepts the new `{ receipts, grants }` payload and a bare receipt (old clients). + */ +async function verifyPro(proofHeader, userPubkey) { + if (!proofHeader) return null; + const payload = decodeB64Json(proofHeader); + if (!payload) return null; + + const receipts = payload.kind === 9735 ? [payload] : Array.isArray(payload.receipts) ? payload.receipts : []; + const grants = Array.isArray(payload.grants) ? payload.grants : []; + + let expiry = 0; + const providerPubkey = await getProProviderPubkey(); + if (providerPubkey && receipts.length) { + const tiers = receipts + .map((r) => receiptTier(r, userPubkey, providerPubkey)) + .filter(Boolean) + .sort((a, b) => a.at - b.at); + for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS; + } + for (const g of grants) { + const e = grantExpiry(g, userPubkey); + if (e && e > expiry) expiry = e; + } + return expiry || null; } // Origins allowed to call the proxy (web + local dev + Capacitor native). diff --git a/src/components/GrantProCard.tsx b/src/components/GrantProCard.tsx new file mode 100644 index 0000000..e4bb467 --- /dev/null +++ b/src/components/GrantProCard.tsx @@ -0,0 +1,143 @@ +/** + * GrantProCard — owner-only: hand out Sasquatch Pro manually. + * + * The SAFE fallback for when the Lightning provider publishes no zap receipt: + * the owner publishes a NIP-32 (kind 1985) label in `org.skatespots.pro` tagged + * to the user with an `expiry`. Only an owner key can author it, so a user can't + * forge it (unlike the self-signed kind-32671 receipt). `useSasquatchPro` and the + * AI proxy both honour it. + */ + +import { useState } from 'react'; +import { nip19 } from 'nostr-tools'; +import { useNostr } from '@nostrify/react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; +import { toast } from '@/hooks/useToast'; +import { PRO_LABEL_NAMESPACE, PRO_LABEL_VALUE, DAY_SECONDS } from '@/lib/sasquatchPro'; +import { BadgeCheck, Loader2 } from 'lucide-react'; + +function decodeTarget(value: string): string | null { + const v = value.trim(); + if (/^[0-9a-f]{64}$/i.test(v)) return v.toLowerCase(); + try { + const d = nip19.decode(v); + if (d.type === 'npub') return d.data; + if (d.type === 'nprofile') return d.data.pubkey; + } catch { + /* not bech32 */ + } + return null; +} + +export function GrantProCard() { + const { user } = useCurrentUser(); + const { nostr } = useNostr(); + const { getWriteRelays } = useSkateRelays(); + const [target, setTarget] = useState(''); + const [days, setDays] = useState('30'); + const [busy, setBusy] = useState(false); + + const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); + if (!isOwner) return null; + + const grant = async () => { + const pubkey = decodeTarget(target); + if (!pubkey) { + toast({ title: 'Enter a valid npub (or hex pubkey)', variant: 'destructive' }); + return; + } + const n = Number(days); + if (!Number.isFinite(n) || n <= 0) { + toast({ title: 'Days must be a positive number', variant: 'destructive' }); + return; + } + if (!user?.signer) return; + + setBusy(true); + try { + const now = Math.floor(Date.now() / 1000); + const expiry = now + Math.round(n) * DAY_SECONDS; + const event = await user.signer.signEvent({ + kind: 1985, + content: `Sasquatch Pro grant — ${n} days`, + created_at: now, + tags: [ + ['L', PRO_LABEL_NAMESPACE], + ['l', PRO_LABEL_VALUE, PRO_LABEL_NAMESPACE], + ['p', pubkey], + ['expiry', String(expiry)], + ['alt', `SkateSpots: Sasquatch Pro grant until ${new Date(expiry * 1000).toISOString()}`], + ], + }); + // Publish where users READ (community relay + owner relays). + const results = await Promise.allSettled( + getWriteRelays().map((url) => + nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }), + ), + ); + if (!results.some((r) => r.status === 'fulfilled')) throw new Error('No relay accepted the grant.'); + toast({ + title: 'Pro granted ⚡', + description: `${nip19.npubEncode(pubkey).slice(0, 14)}… until ${new Date(expiry * 1000).toLocaleDateString()}`, + }); + setTarget(''); + } catch (err) { + toast({ + title: 'Grant failed', + description: err instanceof Error ? err.message : 'Try again.', + variant: 'destructive', + }); + } finally { + setBusy(false); + } + }; + + return ( + + +

+ + Grant Sasquatch Pro + + Owner + +

+

+ Manual fallback for a verified payment the zap receipt missed. Publishes an owner-signed + grant; the user can't forge it. +

+
+ setTarget(e.target.value)} + placeholder="npub1… (or hex)" + className="h-9 flex-1 border-white/10 bg-black/40 text-sm text-white" + spellCheck={false} + disabled={busy} + /> + setDays(e.target.value)} + type="number" + inputMode="numeric" + className="h-9 w-full border-white/10 bg-black/40 text-sm text-white sm:w-24" + disabled={busy} + /> + +
+
+
+ ); +} diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index e0bff17..9270bf3 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -637,7 +637,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const { user } = useCurrentUser(); const cvm = useCVMClient(); const personal = usePersonalContext(); - const { isPro, receiptEvent } = useSasquatchPro(); + const { isPro, proof } = useSasquatchPro(); // Owner-only: show the AI (Routstr) balance on the panel — fetched only while // the panel is open, so it never prompts at startup. const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); @@ -978,7 +978,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const authHeaders = await buildProAuthHeaders({ pubkey: user?.pubkey, signEvent: user ? (e) => user.signer.signEvent(e) : undefined, - receiptEvent, + proof, }); // The system prompt goes in a real `system` message rather than being // smuggled into a user turn — the model then treats the relay/derived @@ -1376,7 +1376,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, cvm, personal, isPro, receiptEvent, user, appendMessage, runTripPlanner]); + }, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner]); // Listen for external events from FAQ/About pages useEffect(() => { diff --git a/src/hooks/useCrewAgentChat.ts b/src/hooks/useCrewAgentChat.ts index bb2f8f0..4773100 100644 --- a/src/hooks/useCrewAgentChat.ts +++ b/src/hooks/useCrewAgentChat.ts @@ -170,7 +170,7 @@ Rules: export function useCrewAgentChat(ctx: CrewContext) { const cvm = useCVMClient(); const { user } = useCurrentUser(); - const { isPro, receiptEvent } = useSasquatchPro(); + const { isPro, proof } = useSasquatchPro(); const [messages, setMessages] = useState([]); const [isThinking, setIsThinking] = useState(false); @@ -217,7 +217,7 @@ export function useCrewAgentChat(ctx: CrewContext) { ? await buildProAuthHeaders({ pubkey: user?.pubkey, signEvent: user ? (e) => user.signer.signEvent(e) : undefined, - receiptEvent, + proof, }) : {}; const system = buildCrewSystemPrompt(ctx); @@ -277,7 +277,7 @@ ${JSON.stringify(toolResult, null, 2)} setIsThinking(false); } }, - [ctx, isThinking, append, runTool, isPro, receiptEvent, user], + [ctx, isThinking, append, runTool, isPro, proof, user], ); return { messages, isThinking, send }; diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts index 2bd067f..353ccec 100644 --- a/src/hooks/useSasquatchPro.ts +++ b/src/hooks/useSasquatchPro.ts @@ -5,7 +5,7 @@ * * 1. OWNER: platform owners always have Pro. * - * 2. ZAP RECEIPT (NIP-57, kind 9735) — the ONLY network-trusted source. To + * 2. ZAP RECEIPT (NIP-57, kind 9735) — the primary network-trusted source. To * count, a receipt must ALL of: * • be AUTHORED by the platform's own Lightning provider pubkey * (resolved from the Pro Lightning address), so a user can't publish a @@ -13,24 +13,38 @@ * • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment → * identity) that tags the platform (`p`); * • encode a paid amount of at least the smallest Pro tier. - * Expiry is read from the user-signed request's `pro-expiry` tag. * - * SECURITY: the self-signed kind-32671 receipt is NObodyy's proof of payment — - * any user can sign one with an arbitrary `expiry`, which previously let anyone - * grant themselves permanent Pro. It is therefore treated as ADVISORY ONLY and - * never grants access on its own. Genuine buyers are covered by the zap receipt - * (or by owner status); the fallback existed only for providers that emit no zap - * receipt, which is not worth a self-grant bypass. + * 3. OWNER GRANT (NIP-32 kind 1985, `org.skatespots.pro`) — the SAFE fallback + * when the provider publishes no receipt: only an owner pubkey can author + * it, and the user cannot forge it. Carries an `expiry` (unix seconds). + * + * STACKING: multiple receipts/grants ADD UP (renewal semantics), so buying again + * before your pass lapses genuinely extends it. We accumulate + * `expiry = max(expiry, proofTime) + days`. + * + * SECURITY: the self-signed kind-32671 receipt is NObody's proof of payment — any + * user can sign one with an arbitrary `expiry`, which previously let anyone grant + * themselves permanent Pro. It is treated as ADVISORY ONLY and never grants + * access on its own. */ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; +import { verifyEvent } from 'nostr-tools'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { getZapProviderPubkey } from '@/lib/lightningAddress'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; -import { PRO_LIGHTNING_ADDRESS, PRO_TIERS, DAY_SECONDS } from '@/lib/sasquatchPro'; +import { + PRO_LIGHTNING_ADDRESS, + PRO_TIERS, + DAY_SECONDS, + PRO_LABEL_NAMESPACE, + PRO_LABEL_VALUE, + type ProProof, +} from '@/lib/sasquatchPro'; import type { NostrEvent } from '@nostrify/nostrify'; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; @@ -38,36 +52,32 @@ const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; /** Smallest purchasable tier — the minimum a valid Pro zap must pay. */ const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats)); +/** Proof of Pro sent to the AI proxy so its gate can re-verify server-side. + * (Defined in lib so proAuth can import it without a hook dependency.) */ +export type { ProProof } from '@/lib/sasquatchPro'; + export interface ProStatus { /** True if the user currently has active Pro access. */ isPro: boolean; - /** Unix seconds when access expires (max across sources), or null. */ + /** Unix seconds when access expires (stacked across proofs), or null. */ expiresAt: number | null; /** How access was granted. */ - source: 'owner' | 'zap' | null; + source: 'owner' | 'zap' | 'grant' | null; isLoading: boolean; - /** The zap receipt that granted access — sent to the AI proxy as proof. */ - receiptEvent?: NostrEvent | null; + /** Proof to send to the AI proxy (zap receipts + owner grants). */ + proof?: ProProof | null; } function tagVal(tags: string[][], name: string): string | undefined { return tags.find((t) => t[0] === name)?.[1]; } -/** - * Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued - * proof of payment for THIS user to the platform. Returns null unless every - * check passes: - * • receipt authored by the platform's Lightning provider pubkey; - * • embedded zap request signed by the user (payment↔identity binding); - * • zap request tags the platform pubkey (`p`); - * • paid amount (from the bolt11 tag, else the request `amount` tag) ≥ minimum. - */ -function expiryFromZapReceipt( +/** Tier days for a valid receipt, or null if the receipt proves nothing. */ +function receiptTier( receipt: NostrEvent, userPubkey: string, providerPubkey: string, -): number | null { +): { at: number; days: number } | null { // The receipt itself MUST be authored by the platform's Lightning provider. if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; @@ -83,8 +93,7 @@ function expiryFromZapReceipt( // Zap request must be signed by the user and tag the platform. if (zapReq.pubkey !== userPubkey) return null; - const tagsPlatform = zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY); - if (!tagsPlatform) return null; + if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; // Verify a real amount was paid: prefer the receipt's bolt11, else the // request's amount tag (millisats). Must meet the smallest tier. @@ -106,17 +115,36 @@ function expiryFromZapReceipt( if (paidSats < MIN_PRO_SATS) return null; // Enforce the tier from what was ACTUALLY paid. Never trust the user-signed - // `pro-expiry` tag — that let a 7-day payment self-assert a year. Pick the - // largest tier the payment covers and run the pass from when the receipt was - // issued (so paying later genuinely extends access). + // `pro-expiry` tag — that let a 7-day payment self-assert a year. const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; - return receipt.created_at + tier.days * DAY_SECONDS; + + return { at: receipt.created_at, days: tier.days }; +} + +/** Expiry from an owner-issued kind-1985 Pro grant, or null if not valid. */ +function grantExpiry(grant: NostrEvent, userPubkey: string): number | null { + if (grant.kind !== 1985) return null; + if (!PLATFORM_OWNER_PUBKEYS.includes(grant.pubkey)) return null; + try { + if (!verifyEvent(grant)) return null; + } catch { + return null; + } + const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v); + if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null; + const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v); + if (value.length && !value.includes(PRO_LABEL_VALUE)) return null; + const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey); + if (!taggedUser) return null; + const expiry = Number(tagVal(grant.tags, 'expiry')); + return Number.isFinite(expiry) && expiry > 0 ? expiry : null; } export function useSasquatchPro(): ProStatus { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { querySkateContent } = useSkateRelays(); const pubkey = user?.pubkey; // Platform owners always have Pro. @@ -128,49 +156,76 @@ export function useSasquatchPro(): ProStatus { staleTime: 60_000, queryFn: async ( c, - ): Promise<{ - expiresAt: number | null; - source: ProStatus['source']; - receipt?: NostrEvent; - }> => { - if (!pubkey) return { expiresAt: null, source: null }; + ): Promise<{ expiresAt: number | null; source: ProStatus['source']; proof: ProProof | null }> => { + if (!pubkey) return { expiresAt: null, source: null, proof: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); - // The ONLY network-trusted source is a zap receipt authored by the - // platform's Lightning provider. Resolve that provider pubkey first; if we - // can't, we cannot verify any receipt and grant no access (owners are - // handled separately above). - const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); - if (!providerPubkey) return { expiresAt: null, source: null }; - - // Zap receipts (9735) tagging the user as the zapper (#P), authored by the - // provider (#authors) — verified thoroughly in expiryFromZapReceipt. - const events = await nostr.query( - [{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }], + // Owner grants (kind 1985) — the fallback path. Read via querySkateContent + // (community relay + the user's relays), which is where the owner publishes + // them — the default pool would miss them for most users. + const grantsPromise = querySkateContent( + [ + { + kinds: [1985], + authors: PLATFORM_OWNER_PUBKEYS, + '#L': [PRO_LABEL_NAMESPACE], + '#p': [pubkey], + limit: 20, + }, + ], { signal }, - ); + ).catch(() => [] as NostrEvent[]); - let best = 0; - let bestReceipt: NostrEvent | undefined; - for (const ev of events) { - const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey); - if (exp && exp > best) { - best = exp; - bestReceipt = ev; + // The primary source: zap receipts authored by the platform's Lightning + // provider. Resolve that provider pubkey first; without it we can't verify + // any receipt (owners are handled separately). + const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); + const receipts = providerPubkey + ? await nostr + .query( + [{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 100 }], + { signal }, + ) + .catch(() => [] as NostrEvent[]) + : []; + + const validReceipts = providerPubkey + ? receipts.filter((r) => receiptTier(r, pubkey, providerPubkey)) + : []; + + const allGrants = await grantsPromise; + const validGrants = allGrants.filter((g) => grantExpiry(g, pubkey) !== null); + + // Stack everything (renewal semantics): sort proofs by time and accumulate, + // so a second purchase genuinely extends the pass. + let expiry = 0; + if (providerPubkey) { + const tiers = validReceipts + .map((r) => receiptTier(r, pubkey, providerPubkey)!) + .sort((a, b) => a.at - b.at); + for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS; + } + let fromReceipts = expiry > 0; + for (const g of validGrants) { + const e = grantExpiry(g, pubkey); + if (e && e > expiry) { + expiry = e; + fromReceipts = false; } } + if (!expiry) return { expiresAt: null, source: null, proof: null }; return { - expiresAt: best || null, - source: best ? ('zap' as const) : null, - receipt: bestReceipt, + expiresAt: expiry, + source: fromReceipts ? 'zap' : 'grant', + proof: { receipts: validReceipts, grants: validGrants }, }; }, }); return useMemo(() => { if (isOwner) { - return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null }; + return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, proof: null }; } const now = Math.floor(Date.now() / 1000); const expiresAt = data?.expiresAt ?? null; @@ -180,7 +235,7 @@ export function useSasquatchPro(): ProStatus { expiresAt, source: isPro ? data?.source ?? null : null, isLoading, - receiptEvent: isPro ? data?.receipt ?? null : null, + proof: isPro ? data?.proof ?? null : null, }; }, [isOwner, data, isLoading]); } diff --git a/src/lib/proAuth.ts b/src/lib/proAuth.ts index 6afbfe2..fc4326d 100644 --- a/src/lib/proAuth.ts +++ b/src/lib/proAuth.ts @@ -11,6 +11,7 @@ */ import type { NostrEvent } from '@nostrify/nostrify'; import { sasquatchAiBase } from '@/lib/sasquatchAi'; +import type { ProProof } from '@/lib/sasquatchPro'; type SignEvent = (event: { kind: number; @@ -30,9 +31,10 @@ function toBase64(value: string): string { export async function buildProAuthHeaders(opts: { pubkey?: string; signEvent?: SignEvent; - receiptEvent?: NostrEvent | null; + /** Zap receipts + owner grants proving Pro (see useSasquatchPro). */ + proof?: ProProof | null; }): Promise> { - const { pubkey, signEvent, receiptEvent } = opts; + const { pubkey, signEvent, proof } = opts; const headers: Record = {}; if (!pubkey || !signEvent) return headers; @@ -51,6 +53,11 @@ export async function buildProAuthHeaders(opts: { } headers['x-sasquatch-auth'] = cachedAuthHeader; - if (receiptEvent) headers['x-sasquatch-pro'] = toBase64(JSON.stringify(receiptEvent)); + // Send ALL proof so the proxy can stack it the same way the client does. + if (proof && (proof.receipts.length > 0 || proof.grants.length > 0)) { + headers['x-sasquatch-pro'] = toBase64( + JSON.stringify({ receipts: proof.receipts, grants: proof.grants }), + ); + } return headers; } diff --git a/src/lib/sasquatchPro.ts b/src/lib/sasquatchPro.ts index c7b3132..fe28cf1 100644 --- a/src/lib/sasquatchPro.ts +++ b/src/lib/sasquatchPro.ts @@ -12,9 +12,17 @@ * future verifier can validate it retroactively. */ +import type { NostrEvent } from '@nostrify/nostrify'; + /** Platform Lightning address that receives Pro payments (same as the fee address). */ export { PLATFORM_LIGHTNING_ADDRESS as PRO_LIGHTNING_ADDRESS } from '@/lib/platform'; +/** Proof of Pro sent to the AI proxy so its gate can re-verify server-side. */ +export interface ProProof { + receipts: NostrEvent[]; + grants: NostrEvent[]; +} + export interface ProTier { id: string; label: string; @@ -41,3 +49,13 @@ export function tierById(id: string): ProTier | undefined { /** Seconds in a day. */ export const DAY_SECONDS = 24 * 60 * 60; + +/** + * Owner-grant fallback (NIP-32 label). If the Lightning provider ever stops + * publishing zap receipts, Pro can still be granted by the platform owner + * publishing a kind-1985 label in this namespace, tagged to the user with an + * `expiry` (unix seconds). Trusted because it is authored by an owner pubkey — + * and unforgeable by the user, unlike the self-signed kind-32671 receipt. + */ +export const PRO_LABEL_NAMESPACE = 'org.skatespots.pro'; +export const PRO_LABEL_VALUE = 'pro'; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b3daa65..89fb7be 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { SecuritySettings } from '@/components/SecuritySettings'; import { RelatrSettings } from '@/components/RelatrSettings'; import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; +import { GrantProCard } from '@/components/GrantProCard'; import { AdminReviewCard } from '@/components/AdminReviewCard'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; @@ -622,6 +623,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) { {/* Owner-only — render null for everyone except platform owners. */} +