SkateSpots/src/lib/proAuth.ts
SkateSpots 53c451ccfc fix(map+badges+sasquatch): attribution contrast/overlap, dedupe 🦧, label AI balance, persist NIP-98
- 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).
2026-09-28 20:16:15 +07:00

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;
}