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,
|
Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt,
|
||||||
and its **expiry is derived from the paid amount → tier**, never the
|
and its **expiry is derived from the paid amount → tier**, never the
|
||||||
user-signed `pro-expiry` tag.
|
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
|
`sasquatchBrain.ts` caps each request (15 s) and the whole attempt
|
||||||
(35 s); the system prompt is a real `system` message; relay results are
|
(35 s); the system prompt is a real `system` message; relay results are
|
||||||
signature-verified (`verifyEvent`) before use. The first pass **streams**
|
signature-verified (`verifyEvent`) before use. The first pass **streams**
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ const PRO_TIERS = [
|
||||||
{ days: 365, sats: 36000 },
|
{ 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;
|
let providerPubkeyCache: string | null | undefined;
|
||||||
async function getProProviderPubkey(): Promise<string | null> {
|
async function getProProviderPubkey(): Promise<string | null> {
|
||||||
if (providerPubkeyCache !== undefined) return providerPubkeyCache;
|
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). */
|
/** Tier days for a valid, provider-authored receipt bound to the user. */
|
||||||
async function verifyPro(receiptHeader: string | null, userPubkey: string): Promise<number | null> {
|
function receiptTier(
|
||||||
if (!receiptHeader) return null;
|
receipt: { pubkey: string; created_at: number; tags: string[][] },
|
||||||
const receipt = decodeB64Json(receiptHeader);
|
userPubkey: string,
|
||||||
if (!receipt || receipt.kind !== 9735) return null;
|
providerPubkey: string,
|
||||||
const providerPubkey = await getProProviderPubkey();
|
): { at: number; days: number } | null {
|
||||||
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
||||||
try {
|
try {
|
||||||
if (!verifyEvent(receipt)) return null;
|
if (!verifyEvent(receipt as never)) return null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const desc = tagVal(receipt.tags, 'description');
|
const desc = tagVal(receipt.tags, 'description');
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
let zapReq;
|
let zapReq: { pubkey?: string; tags?: string[][] };
|
||||||
try {
|
try {
|
||||||
zapReq = JSON.parse(desc);
|
zapReq = JSON.parse(desc);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -139,11 +144,11 @@ async function verifyPro(receiptHeader: string | null, userPubkey: string): Prom
|
||||||
}
|
}
|
||||||
if (!zapReq || zapReq.pubkey !== userPubkey) return null;
|
if (!zapReq || zapReq.pubkey !== userPubkey) return null;
|
||||||
try {
|
try {
|
||||||
if (!verifyEvent(zapReq)) return null;
|
if (!verifyEvent(zapReq as never)) return null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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') ?? '');
|
let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11') ?? '');
|
||||||
if (!paidSats) {
|
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);
|
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
|
||||||
if (!tier) return null;
|
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).
|
// Origins allowed to call the proxy (web + local dev + Capacitor native).
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ const PRO_TIERS = [
|
||||||
{ days: 365, sats: 36000 },
|
{ 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;
|
let providerPubkeyCache;
|
||||||
/** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */
|
/** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */
|
||||||
async function getProProviderPubkey() {
|
async function getProProviderPubkey() {
|
||||||
|
|
@ -124,13 +128,8 @@ function verifyNip98(authHeader) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verify a Pro pass (provider-authored zap receipt bound to the caller, paying
|
/** Tier days for a valid, provider-authored receipt bound to the user. */
|
||||||
* the platform ≥ the smallest tier) → proUntil (unix sec), or null. */
|
function receiptTier(receipt, userPubkey, providerPubkey) {
|
||||||
async function verifyPro(receiptHeader, userPubkey) {
|
|
||||||
if (!receiptHeader) return null;
|
|
||||||
const receipt = decodeB64Json(receiptHeader);
|
|
||||||
if (!receipt || receipt.kind !== 9735) return null;
|
|
||||||
const providerPubkey = await getProProviderPubkey();
|
|
||||||
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
||||||
try {
|
try {
|
||||||
if (!verifyEvent(receipt)) return null;
|
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);
|
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
|
||||||
if (!tier) return null;
|
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).
|
// 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 { user } = useCurrentUser();
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
const personal = usePersonalContext();
|
const personal = usePersonalContext();
|
||||||
const { isPro, receiptEvent } = useSasquatchPro();
|
const { isPro, proof } = useSasquatchPro();
|
||||||
// Owner-only: show the AI (Routstr) balance on the panel — fetched only while
|
// Owner-only: show the AI (Routstr) balance on the panel — fetched only while
|
||||||
// the panel is open, so it never prompts at startup.
|
// the panel is open, so it never prompts at startup.
|
||||||
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
||||||
|
|
@ -978,7 +978,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const authHeaders = await buildProAuthHeaders({
|
const authHeaders = await buildProAuthHeaders({
|
||||||
pubkey: user?.pubkey,
|
pubkey: user?.pubkey,
|
||||||
signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
|
signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
|
||||||
receiptEvent,
|
proof,
|
||||||
});
|
});
|
||||||
// The system prompt goes in a real `system` message rather than being
|
// The system prompt goes in a real `system` message rather than being
|
||||||
// smuggled into a user turn — the model then treats the relay/derived
|
// smuggled into a user turn — the model then treats the relay/derived
|
||||||
|
|
@ -1376,7 +1376,7 @@ Instructions:
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
setStreamingText('');
|
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
|
// Listen for external events from FAQ/About pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ Rules:
|
||||||
export function useCrewAgentChat(ctx: CrewContext) {
|
export function useCrewAgentChat(ctx: CrewContext) {
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { isPro, receiptEvent } = useSasquatchPro();
|
const { isPro, proof } = useSasquatchPro();
|
||||||
const [messages, setMessages] = useState<CrewAgentMessage[]>([]);
|
const [messages, setMessages] = useState<CrewAgentMessage[]>([]);
|
||||||
const [isThinking, setIsThinking] = useState(false);
|
const [isThinking, setIsThinking] = useState(false);
|
||||||
|
|
||||||
|
|
@ -217,7 +217,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
|
||||||
? await buildProAuthHeaders({
|
? await buildProAuthHeaders({
|
||||||
pubkey: user?.pubkey,
|
pubkey: user?.pubkey,
|
||||||
signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
|
signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
|
||||||
receiptEvent,
|
proof,
|
||||||
})
|
})
|
||||||
: {};
|
: {};
|
||||||
const system = buildCrewSystemPrompt(ctx);
|
const system = buildCrewSystemPrompt(ctx);
|
||||||
|
|
@ -277,7 +277,7 @@ ${JSON.stringify(toolResult, null, 2)}
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ctx, isThinking, append, runTool, isPro, receiptEvent, user],
|
[ctx, isThinking, append, runTool, isPro, proof, user],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { messages, isThinking, send };
|
return { messages, isThinking, send };
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*
|
*
|
||||||
* 1. OWNER: platform owners always have Pro.
|
* 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:
|
* count, a receipt must ALL of:
|
||||||
* • be AUTHORED by the platform's own Lightning provider pubkey
|
* • be AUTHORED by the platform's own Lightning provider pubkey
|
||||||
* (resolved from the Pro Lightning address), so a user can't publish a
|
* (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 →
|
* • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment →
|
||||||
* identity) that tags the platform (`p`);
|
* identity) that tags the platform (`p`);
|
||||||
* • encode a paid amount of at least the smallest Pro tier.
|
* • 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 —
|
* 3. OWNER GRANT (NIP-32 kind 1985, `org.skatespots.pro`) — the SAFE fallback
|
||||||
* any user can sign one with an arbitrary `expiry`, which previously let anyone
|
* when the provider publishes no receipt: only an owner pubkey can author
|
||||||
* grant themselves permanent Pro. It is therefore treated as ADVISORY ONLY and
|
* it, and the user cannot forge it. Carries an `expiry` (unix seconds).
|
||||||
* 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
|
* STACKING: multiple receipts/grants ADD UP (renewal semantics), so buying again
|
||||||
* receipt, which is not worth a self-grant bypass.
|
* 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 { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { verifyEvent } from 'nostr-tools';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
import { getZapProviderPubkey } from '@/lib/lightningAddress';
|
import { getZapProviderPubkey } from '@/lib/lightningAddress';
|
||||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
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';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
|
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. */
|
/** Smallest purchasable tier — the minimum a valid Pro zap must pay. */
|
||||||
const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats));
|
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 {
|
export interface ProStatus {
|
||||||
/** True if the user currently has active Pro access. */
|
/** True if the user currently has active Pro access. */
|
||||||
isPro: boolean;
|
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;
|
expiresAt: number | null;
|
||||||
/** How access was granted. */
|
/** How access was granted. */
|
||||||
source: 'owner' | 'zap' | null;
|
source: 'owner' | 'zap' | 'grant' | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/** The zap receipt that granted access — sent to the AI proxy as proof. */
|
/** Proof to send to the AI proxy (zap receipts + owner grants). */
|
||||||
receiptEvent?: NostrEvent | null;
|
proof?: ProProof | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tagVal(tags: string[][], name: string): string | undefined {
|
function tagVal(tags: string[][], name: string): string | undefined {
|
||||||
return tags.find((t) => t[0] === name)?.[1];
|
return tags.find((t) => t[0] === name)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Tier days for a valid receipt, or null if the receipt proves nothing. */
|
||||||
* Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued
|
function receiptTier(
|
||||||
* 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,
|
receipt: NostrEvent,
|
||||||
userPubkey: string,
|
userPubkey: string,
|
||||||
providerPubkey: string,
|
providerPubkey: string,
|
||||||
): number | null {
|
): { at: number; days: number } | null {
|
||||||
// The receipt itself MUST be authored by the platform's Lightning provider.
|
// The receipt itself MUST be authored by the platform's Lightning provider.
|
||||||
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
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.
|
// Zap request must be signed by the user and tag the platform.
|
||||||
if (zapReq.pubkey !== userPubkey) return null;
|
if (zapReq.pubkey !== userPubkey) return null;
|
||||||
const tagsPlatform = zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY);
|
if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
|
||||||
if (!tagsPlatform) return null;
|
|
||||||
|
|
||||||
// Verify a real amount was paid: prefer the receipt's bolt11, else the
|
// Verify a real amount was paid: prefer the receipt's bolt11, else the
|
||||||
// request's amount tag (millisats). Must meet the smallest tier.
|
// request's amount tag (millisats). Must meet the smallest tier.
|
||||||
|
|
@ -106,17 +115,36 @@ function expiryFromZapReceipt(
|
||||||
if (paidSats < MIN_PRO_SATS) return null;
|
if (paidSats < MIN_PRO_SATS) return null;
|
||||||
|
|
||||||
// Enforce the tier from what was ACTUALLY paid. Never trust the user-signed
|
// 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
|
// `pro-expiry` tag — that let a 7-day payment self-assert a year.
|
||||||
// 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);
|
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
|
||||||
if (!tier) return null;
|
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 {
|
export function useSasquatchPro(): ProStatus {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const { querySkateContent } = useSkateRelays();
|
||||||
const pubkey = user?.pubkey;
|
const pubkey = user?.pubkey;
|
||||||
|
|
||||||
// Platform owners always have Pro.
|
// Platform owners always have Pro.
|
||||||
|
|
@ -128,49 +156,76 @@ export function useSasquatchPro(): ProStatus {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: async (
|
queryFn: async (
|
||||||
c,
|
c,
|
||||||
): Promise<{
|
): Promise<{ expiresAt: number | null; source: ProStatus['source']; proof: ProProof | null }> => {
|
||||||
expiresAt: number | null;
|
if (!pubkey) return { expiresAt: null, source: null, proof: null };
|
||||||
source: ProStatus['source'];
|
|
||||||
receipt?: NostrEvent;
|
|
||||||
}> => {
|
|
||||||
if (!pubkey) return { expiresAt: null, source: null };
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
// The ONLY network-trusted source is a zap receipt authored by the
|
// Owner grants (kind 1985) — the fallback path. Read via querySkateContent
|
||||||
// platform's Lightning provider. Resolve that provider pubkey first; if we
|
// (community relay + the user's relays), which is where the owner publishes
|
||||||
// can't, we cannot verify any receipt and grant no access (owners are
|
// them — the default pool would miss them for most users.
|
||||||
// handled separately above).
|
const grantsPromise = querySkateContent(
|
||||||
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
|
[
|
||||||
if (!providerPubkey) return { expiresAt: null, source: null };
|
{
|
||||||
|
kinds: [1985],
|
||||||
// Zap receipts (9735) tagging the user as the zapper (#P), authored by the
|
authors: PLATFORM_OWNER_PUBKEYS,
|
||||||
// provider (#authors) — verified thoroughly in expiryFromZapReceipt.
|
'#L': [PRO_LABEL_NAMESPACE],
|
||||||
const events = await nostr.query(
|
'#p': [pubkey],
|
||||||
[{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }],
|
limit: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
).catch(() => [] as NostrEvent[]);
|
||||||
|
|
||||||
let best = 0;
|
// The primary source: zap receipts authored by the platform's Lightning
|
||||||
let bestReceipt: NostrEvent | undefined;
|
// provider. Resolve that provider pubkey first; without it we can't verify
|
||||||
for (const ev of events) {
|
// any receipt (owners are handled separately).
|
||||||
const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey);
|
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
|
||||||
if (exp && exp > best) {
|
const receipts = providerPubkey
|
||||||
best = exp;
|
? await nostr
|
||||||
bestReceipt = ev;
|
.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 {
|
return {
|
||||||
expiresAt: best || null,
|
expiresAt: expiry,
|
||||||
source: best ? ('zap' as const) : null,
|
source: fromReceipts ? 'zap' : 'grant',
|
||||||
receipt: bestReceipt,
|
proof: { receipts: validReceipts, grants: validGrants },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return useMemo<ProStatus>(() => {
|
return useMemo<ProStatus>(() => {
|
||||||
if (isOwner) {
|
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 now = Math.floor(Date.now() / 1000);
|
||||||
const expiresAt = data?.expiresAt ?? null;
|
const expiresAt = data?.expiresAt ?? null;
|
||||||
|
|
@ -180,7 +235,7 @@ export function useSasquatchPro(): ProStatus {
|
||||||
expiresAt,
|
expiresAt,
|
||||||
source: isPro ? data?.source ?? null : null,
|
source: isPro ? data?.source ?? null : null,
|
||||||
isLoading,
|
isLoading,
|
||||||
receiptEvent: isPro ? data?.receipt ?? null : null,
|
proof: isPro ? data?.proof ?? null : null,
|
||||||
};
|
};
|
||||||
}, [isOwner, data, isLoading]);
|
}, [isOwner, data, isLoading]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
*/
|
*/
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { sasquatchAiBase } from '@/lib/sasquatchAi';
|
import { sasquatchAiBase } from '@/lib/sasquatchAi';
|
||||||
|
import type { ProProof } from '@/lib/sasquatchPro';
|
||||||
|
|
||||||
type SignEvent = (event: {
|
type SignEvent = (event: {
|
||||||
kind: number;
|
kind: number;
|
||||||
|
|
@ -30,9 +31,10 @@ function toBase64(value: string): string {
|
||||||
export async function buildProAuthHeaders(opts: {
|
export async function buildProAuthHeaders(opts: {
|
||||||
pubkey?: string;
|
pubkey?: string;
|
||||||
signEvent?: SignEvent;
|
signEvent?: SignEvent;
|
||||||
receiptEvent?: NostrEvent | null;
|
/** Zap receipts + owner grants proving Pro (see useSasquatchPro). */
|
||||||
|
proof?: ProProof | null;
|
||||||
}): Promise<Record<string, string>> {
|
}): Promise<Record<string, string>> {
|
||||||
const { pubkey, signEvent, receiptEvent } = opts;
|
const { pubkey, signEvent, proof } = opts;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (!pubkey || !signEvent) return headers;
|
if (!pubkey || !signEvent) return headers;
|
||||||
|
|
||||||
|
|
@ -51,6 +53,11 @@ export async function buildProAuthHeaders(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
headers['x-sasquatch-auth'] = cachedAuthHeader;
|
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;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,17 @@
|
||||||
* future verifier can validate it retroactively.
|
* future verifier can validate it retroactively.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/** Platform Lightning address that receives Pro payments (same as the fee address). */
|
/** Platform Lightning address that receives Pro payments (same as the fee address). */
|
||||||
export { PLATFORM_LIGHTNING_ADDRESS as PRO_LIGHTNING_ADDRESS } from '@/lib/platform';
|
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 {
|
export interface ProTier {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -41,3 +49,13 @@ export function tierById(id: string): ProTier | undefined {
|
||||||
|
|
||||||
/** Seconds in a day. */
|
/** Seconds in a day. */
|
||||||
export const DAY_SECONDS = 24 * 60 * 60;
|
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 { RelatrSettings } from '@/components/RelatrSettings';
|
||||||
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
|
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
|
||||||
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
|
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
|
||||||
|
import { GrantProCard } from '@/components/GrantProCard';
|
||||||
import { AdminReviewCard } from '@/components/AdminReviewCard';
|
import { AdminReviewCard } from '@/components/AdminReviewCard';
|
||||||
import { LanguageSelector } from '@/components/LanguageToggle';
|
import { LanguageSelector } from '@/components/LanguageToggle';
|
||||||
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
||||||
|
|
@ -622,6 +623,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
|
||||||
{/* Owner-only — render null for everyone except platform owners. */}
|
{/* Owner-only — render null for everyone except platform owners. */}
|
||||||
<CVMHealthCheck />
|
<CVMHealthCheck />
|
||||||
<SasquatchAiStatusCard />
|
<SasquatchAiStatusCard />
|
||||||
|
<GrantProCard />
|
||||||
<AdminReviewCard />
|
<AdminReviewCard />
|
||||||
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
<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">
|
<div className="px-4 py-3.5 border-b border-white/5">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue