SkateSpots/src/hooks/useSasquatchPro.ts
SkateSpots ccc04cc172 feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1).
  Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the
  caller is a platform owner, an active Pro pass (x-sasquatch-pro = a
  provider-authored zap receipt) verified OFFLINE — signature, user binding,
  platform tag, and paid-amount → tier. Off by default.
- src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per
  message) and attaches the Pro receipt.
- sasquatchBrain: threads auth headers through the proxy (streaming + non).
- useSasquatchPro: exposes the winning receipt event.
- Verified: 200 with the gate off, 401 with it on (env reset afterwards).
2026-09-27 14:18:33 +07:00

186 lines
7.2 KiB
TypeScript

/**
* useSasquatchPro — is the logged-in user a current Pro supporter?
* ─────────────────────────────────────────────────────────────────────────────
* Pro access is granted ONLY by cryptographically-verifiable proof of payment:
*
* 1. OWNER: platform owners always have Pro.
*
* 2. ZAP RECEIPT (NIP-57, kind 9735) — the ONLY 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
* fake receipt themselves;
* • 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.
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
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 type { NostrEvent } from '@nostrify/nostrify';
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));
export interface ProStatus {
/** True if the user currently has active Pro access. */
isPro: boolean;
/** Unix seconds when access expires (max across sources), or null. */
expiresAt: number | null;
/** How access was granted. */
source: 'owner' | 'zap' | null;
isLoading: boolean;
/** The zap receipt that granted access — sent to the AI proxy as proof. */
receiptEvent?: NostrEvent | 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(
receipt: NostrEvent,
userPubkey: string,
providerPubkey: string,
): number | null {
// The receipt itself MUST be authored by the platform's Lightning provider.
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
const desc = tagVal(receipt.tags, 'description');
if (!desc) return null;
let zapReq: NostrEvent;
try {
zapReq = JSON.parse(desc) as NostrEvent;
} catch {
return null;
}
// 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;
// Verify a real amount was paid: prefer the receipt's bolt11, else the
// request's amount tag (millisats). Must meet the smallest tier.
let paidSats = 0;
const bolt11 = tagVal(receipt.tags, 'bolt11');
if (bolt11) {
try {
paidSats = getSatoshisFromBolt11(bolt11);
} catch {
paidSats = 0;
}
}
if (paidSats === 0) {
const amountMsats = Number(tagVal(zapReq.tags, 'amount'));
if (Number.isFinite(amountMsats) && amountMsats > 0) {
paidSats = Math.floor(amountMsats / 1000);
}
}
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).
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;
}
export function useSasquatchPro(): ProStatus {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Platform owners always have Pro.
const isOwner = !!pubkey && PLATFORM_OWNER_PUBKEYS.includes(pubkey);
const { data, isLoading } = useQuery({
queryKey: ['sasquatch-pro', pubkey],
enabled: !!pubkey && !isOwner,
staleTime: 60_000,
queryFn: async (
c,
): Promise<{
expiresAt: number | null;
source: ProStatus['source'];
receipt?: NostrEvent;
}> => {
if (!pubkey) return { expiresAt: null, source: 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 }],
{ signal },
);
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;
}
}
return {
expiresAt: best || null,
source: best ? ('zap' as const) : null,
receipt: bestReceipt,
};
},
});
return useMemo<ProStatus>(() => {
if (isOwner) {
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null };
}
const now = Math.floor(Date.now() / 1000);
const expiresAt = data?.expiresAt ?? null;
const isPro = !!expiresAt && expiresAt > now;
return {
isPro,
expiresAt,
source: isPro ? data?.source ?? null : null,
isLoading,
receiptEvent: isPro ? data?.receipt ?? null : null,
};
}, [isOwner, data, isLoading]);
}