- 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).
186 lines
7.2 KiB
TypeScript
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]);
|
|
}
|