fix(pro): stack Pro proofs + owner-grant fallback
#1 Extend now actually extends. useSasquatchPro collected receipts and took the max of (receipt.created_at + tierDays), so buying again before expiry under-delivered. It now stacks every valid proof with renewal semantics (expiry = max(expiry, proofTime) + days), client and server alike. #2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32 kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely dependent on the Lightning provider publishing zap receipts. New owner-only GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook reads via querySkateContent. Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and the Netlify function accept the new payload and a bare receipt (old APKs). Proxy was deployed first and verified healthy before this commit.
This commit is contained in:
parent
8d63efa18a
commit
124cac74fa
10 changed files with 435 additions and 88 deletions
16
AGENTS.md
16
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**
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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<number | null> {
|
||||
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<number | null> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
143
src/components/GrantProCard.tsx
Normal file
143
src/components/GrantProCard.tsx
Normal file
|
|
@ -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 (
|
||||
<Card className="border-white/10 bg-white/2">
|
||||
<CardContent className="py-3.5 space-y-2.5">
|
||||
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
||||
<BadgeCheck className="h-4 w-4 text-[#ccff00]" />
|
||||
Grant Sasquatch Pro
|
||||
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
|
||||
Owner
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manual fallback for a verified payment the zap receipt missed. Publishes an owner-signed
|
||||
grant; the user can't forge it.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<Input
|
||||
value={days}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<Button
|
||||
onClick={grant}
|
||||
disabled={busy}
|
||||
className="h-9 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <BadgeCheck className="h-4 w-4" />}
|
||||
Grant
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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<CrewAgentMessage[]>([]);
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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<ProStatus>(() => {
|
||||
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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>> {
|
||||
const { pubkey, signEvent, receiptEvent } = opts;
|
||||
const { pubkey, signEvent, proof } = opts;
|
||||
const headers: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
<CVMHealthCheck />
|
||||
<SasquatchAiStatusCard />
|
||||
<GrantProCard />
|
||||
<AdminReviewCard />
|
||||
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
||||
<div className="px-4 py-3.5 border-b border-white/5">
|
||||
|
|
|
|||
Loading…
Reference in a new issue