- Map attribution: raise contrast (grey-500/80 → grey-200/90) and add `isolate` to the map container so its internal z-indexes (Leaflet panes, overlays up to z-2000) stay inside the map — the © OpenStreetMap line was painting OVER the Sasquatch panel (z-60). Panel/bubble drop to z-40 so Radix dialogs (z-50) now sit above it instead of under it. - Sasquatch header: remove the duplicate 🦧 (the avatar + context subtitle carry it), and label the owner-only chip '⚡ AI <n>' (it is the AI credit balance, not a wallet balance). - proAuth: persist the NIP-98 auth in localStorage for its validity window, so Amber signs once per ~11h instead of on every app load (the panel itself never signed — this is the first-message auth).
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
/**
|
|
* Pro auth for the AI proxy.
|
|
*
|
|
* When the proxy's Pro gate is enabled (`SASQUATCH_REQUIRE_PRO=1`) every
|
|
* `/api/ai` request must carry:
|
|
* • `x-sasquatch-auth` — a base64 NIP-98 (kind 27235) event proving the caller;
|
|
* • `x-sasquatch-pro` — base64 of the Pro zap receipt proving an active pass.
|
|
*
|
|
* The NIP-98 event is signed ONCE and reused for its whole validity window —
|
|
* persisted to localStorage so an interactive signer (Amber) does not pop again
|
|
* on every app load. (It expires with the 12 h window the proxy enforces.)
|
|
*/
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { sasquatchAiBase } from '@/lib/sasquatchAi';
|
|
import type { ProProof } from '@/lib/sasquatchPro';
|
|
|
|
type SignEvent = (event: {
|
|
kind: number;
|
|
created_at: number;
|
|
tags: string[][];
|
|
content: string;
|
|
}) => Promise<NostrEvent>;
|
|
|
|
let cachedAuthHeader: string | null = null;
|
|
let cachedAuthPubkey: string | null = null;
|
|
|
|
/** Persisted NIP-98 auth (keyed to the pubkey, valid while inside the window). */
|
|
const AUTH_STORAGE_KEY = 'skatespots:sasquatch-nip98';
|
|
/** Reuse for 11 h to stay safely inside the proxy's 12 h acceptance window. */
|
|
const AUTH_MAX_AGE_SECONDS = 11 * 3600;
|
|
|
|
function loadStoredAuth(pubkey: string): string | null {
|
|
try {
|
|
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as { pubkey?: string; header?: string; createdAt?: number };
|
|
if (parsed?.pubkey !== pubkey || !parsed.header || !parsed.createdAt) return null;
|
|
if (Math.abs(Date.now() / 1000 - parsed.createdAt) > AUTH_MAX_AGE_SECONDS) return null;
|
|
return parsed.header;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function storeAuth(pubkey: string, header: string, createdAt: number): void {
|
|
try {
|
|
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ pubkey, header, createdAt }));
|
|
} catch {
|
|
/* private mode — the in-memory cache still works for this session */
|
|
}
|
|
}
|
|
|
|
function toBase64(value: string): string {
|
|
// Event JSON is ASCII (hex pubkeys/ids, tags), so btoa is safe here.
|
|
return btoa(value);
|
|
}
|
|
|
|
export async function buildProAuthHeaders(opts: {
|
|
pubkey?: string;
|
|
signEvent?: SignEvent;
|
|
/** Zap receipts + owner grants proving Pro (see useSasquatchPro). */
|
|
proof?: ProProof | null;
|
|
}): Promise<Record<string, string>> {
|
|
const { pubkey, signEvent, proof } = opts;
|
|
const headers: Record<string, string> = {};
|
|
if (!pubkey || !signEvent) return headers;
|
|
|
|
if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) {
|
|
// Reuse a stored signature first — otherwise Amber pops on every app load
|
|
// for a signer that would happily reuse the same NIP-98 for 12 h.
|
|
const stored = loadStoredAuth(pubkey);
|
|
if (stored) {
|
|
cachedAuthHeader = stored;
|
|
cachedAuthPubkey = pubkey;
|
|
} else {
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
const event = await signEvent({
|
|
kind: 27235,
|
|
created_at: createdAt,
|
|
tags: [
|
|
['u', sasquatchAiBase()],
|
|
['method', 'POST'],
|
|
],
|
|
content: '',
|
|
});
|
|
cachedAuthHeader = toBase64(JSON.stringify(event));
|
|
cachedAuthPubkey = pubkey;
|
|
storeAuth(pubkey, cachedAuthHeader, createdAt);
|
|
}
|
|
}
|
|
|
|
headers['x-sasquatch-auth'] = cachedAuthHeader;
|
|
// 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;
|
|
}
|