feat(ai-proxy): run the AI proxy on the CVM host, ready to switch over
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.
- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
(typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
move it. Fully reversible.
Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
This commit is contained in:
parent
f46f6a8967
commit
ec06a63b01
15 changed files with 566 additions and 18 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -557,6 +557,23 @@ session with **no other memory of this project**. So:
|
|||
host costs a moment, not a blank spot. Blossom is content-addressed, so the
|
||||
mirror URL is derived from the sha256 in the path (handles `/<sha>` and
|
||||
`/<sha>.<ext>`).
|
||||
- **AI proxy on our own host (`ai-proxy/`, 2026-09-28):** the same proxy the
|
||||
Netlify Function runs, rebuilt as a standalone **Bun** service on the CVM host so
|
||||
AI calls stop spending Netlify compute/bandwidth credits and the Routstr key
|
||||
lives on our box. `ai-proxy/server.ts` (`Bun.serve` on 127.0.0.1:8787; NIP-98 +
|
||||
Pro gate + model allowlist + per-IP throttle), deployed with **`npm run deploy:ai`**
|
||||
(`scripts/deploy-ai-proxy.sh`) to `/home/ubuntu/skatespots-ai`, systemd unit
|
||||
`skatespots-ai`, type-checked by `ai-proxy/tsconfig.json` (`typecheck:ai`, wired
|
||||
into `npm test`); `ai-proxy/` is eslint-ignored like `cvm-server/`. Env
|
||||
(`SASQUATCH_*`) copied from Netlify env to `~/skatespots-ai/.env` (600);
|
||||
**verified live** (`POST /api/ai/status` → the real Routstr balance). The client
|
||||
picks the base in **one place — `src/lib/sasquatchAi.ts`** (`sasquatchAiBase()`,
|
||||
also used for the NIP-98 `u` tag): set
|
||||
`VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai` to switch the app
|
||||
over; unset keeps the Netlify Function (fully reversible). ⚠️ **Not switched
|
||||
over yet** — still needs a DNS name → the CVM host + **Caddy** for TLS
|
||||
(`ai-proxy/Caddyfile`, `ai-proxy/DEPLOY.md`); until then the app calls `/api/ai`
|
||||
on Netlify.
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||
|
|
|
|||
14
ai-proxy/.env.example
Normal file
14
ai-proxy/.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# SkateSpots AI proxy — environment (copy to .env on the CVM host; never commit).
|
||||
#
|
||||
# The upstream is OpenAI-compatible. For sats-paid inference point it at a Routstr
|
||||
# node (NOT the aggregator api.routstr.com, which is down).
|
||||
SASQUATCH_AI_BASE=https://routstr.githappens.space/v1
|
||||
SASQUATCH_AI_KEY=
|
||||
# Comma-separated model allowlist — must be models the node actually serves.
|
||||
SASQUATCH_MODELS=
|
||||
# 1 = require a NIP-98 identity plus an active Pro pass (platform owners bypass).
|
||||
SASQUATCH_REQUIRE_PRO=1
|
||||
|
||||
# Bind to localhost only; Caddy terminates TLS and reverse-proxies here.
|
||||
AI_PROXY_HOST=127.0.0.1
|
||||
AI_PROXY_PORT=8787
|
||||
18
ai-proxy/Caddyfile
Normal file
18
ai-proxy/Caddyfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Caddyfile for the AI proxy host.
|
||||
#
|
||||
# Replace the site address with the DNS name you point at this server (for
|
||||
# example ai.skate-spots.org). Caddy obtains a Let's Encrypt certificate
|
||||
# automatically via the HTTP-01 challenge, so ports 80 and 443 must be reachable
|
||||
# from the internet (open them in the provider firewall too, not just ufw).
|
||||
#
|
||||
# Install + run:
|
||||
# sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
# curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
# curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
# sudo apt update && sudo apt install -y caddy
|
||||
# sudo cp Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
|
||||
|
||||
ai.skate-spots.org {
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
73
ai-proxy/DEPLOY.md
Normal file
73
ai-proxy/DEPLOY.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# SkateSpots AI proxy — placement on the CVM host
|
||||
|
||||
Why: the Netlify Function (`netlify/functions/ai.mjs`) works, but every AI call
|
||||
spends Netlify **compute + response bandwidth** credits. This service is the same
|
||||
proxy running on the CVM host we already own, so AI traffic never touches Netlify
|
||||
and the Routstr key lives on our box.
|
||||
|
||||
```
|
||||
browser / APK ──HTTPS──▶ Caddy (TLS) ──http──▶ 127.0.0.1:8787 (Bun)
|
||||
│
|
||||
└──HTTPS──▶ Routstr node (sats)
|
||||
```
|
||||
|
||||
## What's needed (once)
|
||||
|
||||
1. **A hostname** pointing at the CVM host, e.g. `ai.skate-spots.org` → the
|
||||
host's public IPv4. Add it wherever `skate-spots.org` DNS is managed.
|
||||
2. **Ports 80 + 443 open** to the internet (firewall at the provider; `ufw` is
|
||||
inactive on the host).
|
||||
3. **Caddy** for automatic TLS (see `Caddyfile` for the install snippet).
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
npm run deploy:ai # uploads server.ts + package.json + unit, installs, restarts
|
||||
bash scripts/deploy-ai-proxy.sh --logs
|
||||
```
|
||||
|
||||
The **first** deploy needs `/home/ubuntu/skatespots-ai/.env` to exist (the unit
|
||||
reads it). Create it from `ai-proxy/.env.example` with the real Routstr key — that
|
||||
key is the one currently in Netlify env `SASQUATCH_AI_KEY`:
|
||||
|
||||
```bash
|
||||
ssh <host> 'cat > ~/skatespots-ai/.env' <<'EOF'
|
||||
SASQUATCH_AI_BASE=https://routstr.githappens.space/v1
|
||||
SASQUATCH_AI_KEY=<the sk-… Routstr session key>
|
||||
SASQUATCH_MODELS=
|
||||
SASQUATCH_REQUIRE_PRO=1
|
||||
AI_PROXY_HOST=127.0.0.1
|
||||
AI_PROXY_PORT=8787
|
||||
EOF
|
||||
ssh <host> 'chmod 600 ~/skatespots-ai/.env'
|
||||
```
|
||||
|
||||
## Point the app at it
|
||||
|
||||
The client resolves its AI base in one place (`src/lib/sasquatchAi.ts`). Set the
|
||||
build-time override and redeploy the web app:
|
||||
|
||||
```
|
||||
VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai
|
||||
```
|
||||
|
||||
Leave it unset and the app keeps using `/api/ai` (the Netlify Function), so the
|
||||
move is reversible and can be rolled out gradually. The proxy's CORS allowlist
|
||||
already includes `https://skate-spots.org` and the local/Capacitor origins.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# from anywhere, once TLS is up:
|
||||
curl -s -X POST https://ai.skate-spots.org/api/ai/status
|
||||
# → {"configured":true,...} (owner balance; public on purpose)
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `SASQUATCH_REQUIRE_PRO=1` keeps the Pro gate (owner pubkeys bypass). A client
|
||||
without a valid NIP-98 header gets 401; without an active Pro pass, 402.
|
||||
- The proxy is per-IP throttled at 20 req/min. It's a single long-lived process,
|
||||
so the throttle actually holds (unlike the serverless version).
|
||||
- `netlify/functions/ai.mjs` stays in the repo as the fallback path; nothing
|
||||
breaks if this service is down and the client is still pointed at `/api/ai`.
|
||||
12
ai-proxy/package.json
Normal file
12
ai-proxy/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "skatespots-ai-proxy",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run server.ts",
|
||||
"dev": "bun --watch run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.13.0"
|
||||
}
|
||||
}
|
||||
282
ai-proxy/server.ts
Normal file
282
ai-proxy/server.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// SkateSpots AI proxy — runs on the CVM host (Bun), behind Caddy for TLS.
|
||||
//
|
||||
// This is the same job the Netlify Function `netlify/functions/ai.mjs` does, moved
|
||||
// onto infrastructure we own so a user-facing AI call no longer spends Netlify
|
||||
// compute/bandwidth credits — and so the sats-paid Routstr key lives on our box,
|
||||
// not a third party's.
|
||||
//
|
||||
// • POST /api/ai — chat proxy (curated model allowlist; optional Pro gate)
|
||||
// • POST /api/ai/status — owner-only upstream balance (low-balance nudge)
|
||||
//
|
||||
// The upstream is provider-agnostic. For sats-paid inference it points at a
|
||||
// Routstr node (SASQUATCH_AI_BASE + SASQUATCH_AI_KEY).
|
||||
import { verifyEvent } from 'nostr-tools';
|
||||
|
||||
// Bun global (typed minimally so this file needs no bun-types dependency).
|
||||
declare const Bun: {
|
||||
serve(options: {
|
||||
port: number;
|
||||
hostname?: string;
|
||||
fetch: (request: Request) => Response | Promise<Response>;
|
||||
}): { stop: () => void };
|
||||
};
|
||||
|
||||
const PORT = Number(process.env.AI_PROXY_PORT || 8787);
|
||||
const HOSTNAME = process.env.AI_PROXY_HOST || '127.0.0.1';
|
||||
|
||||
const AI_BASE = (
|
||||
process.env.SASQUATCH_AI_BASE ||
|
||||
process.env.OPENROUTER_BASE_URL ||
|
||||
'https://openrouter.ai/api/v1'
|
||||
).replace(/\/+$/, '');
|
||||
const AI_URL = `${AI_BASE}/chat/completions`;
|
||||
const AI_KEY = process.env.SASQUATCH_AI_KEY || process.env.OPENROUTER_API_KEY;
|
||||
|
||||
const DEFAULT_MODELS = [
|
||||
'meta-llama/llama-3.3-70b-instruct',
|
||||
'deepseek/deepseek-chat-v3-0324',
|
||||
'x-ai/grok-4.5',
|
||||
'moonshotai/kimi-k2',
|
||||
'qwen/qwen-2.5-72b-instruct',
|
||||
'mistralai/mistral-nemo',
|
||||
'meta-llama/llama-3.1-8b-instruct',
|
||||
];
|
||||
const ALLOWED_MODELS = new Set(
|
||||
(process.env.SASQUATCH_MODELS || DEFAULT_MODELS.join(','))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// ── Pro enforcement ───────────────────────────────────────────────────────────
|
||||
const REQUIRE_PRO = process.env.SASQUATCH_REQUIRE_PRO === '1';
|
||||
const PLATFORM_PRO_ADDRESS = 'skatespots@minibits.cash';
|
||||
/** Primary platform pubkey — zap requests must tag it; owners always pass. */
|
||||
const PLATFORM_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
||||
const PLATFORM_OWNER_PUBKEYS = new Set([
|
||||
PLATFORM_PUBKEY,
|
||||
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b',
|
||||
]);
|
||||
const DAY_SECONDS = 86_400;
|
||||
/** Tier days ↔ sats. Keep in sync with PRO_TIERS in src/lib/sasquatchPro.ts. */
|
||||
const PRO_TIERS = [
|
||||
{ days: 7, sats: 2000 },
|
||||
{ days: 30, sats: 5000 },
|
||||
{ days: 90, sats: 12000 },
|
||||
{ days: 365, sats: 36000 },
|
||||
];
|
||||
|
||||
let providerPubkeyCache: string | null | undefined;
|
||||
async function getProProviderPubkey(): Promise<string | null> {
|
||||
if (providerPubkeyCache !== undefined) return providerPubkeyCache;
|
||||
try {
|
||||
const [user, domain] = PLATFORM_PRO_ADDRESS.split('@');
|
||||
const res = await fetch(`https://${domain}/.well-known/lnurlp/${user}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
const j = (await res.json()) as { nostrPubkey?: string };
|
||||
providerPubkeyCache = j.nostrPubkey || null;
|
||||
} catch {
|
||||
providerPubkeyCache = null;
|
||||
}
|
||||
return providerPubkeyCache;
|
||||
}
|
||||
|
||||
function decodeB64Json(value: string) {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function tagVal(tags: string[][] | undefined, name: string): string | undefined {
|
||||
return tags?.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
function satsFromBolt11(pr: string): number {
|
||||
const m = /^lnbc(\d+)([munp]?)/i.exec(String(pr).toLowerCase());
|
||||
if (!m) return 0;
|
||||
const n = Number(m[1]);
|
||||
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1;
|
||||
return Math.round(n * mult * 1e8);
|
||||
}
|
||||
|
||||
/** Verify a NIP-98 auth header → pubkey, or null. */
|
||||
function verifyNip98(authHeader: string | null): string | null {
|
||||
if (!authHeader) return null;
|
||||
const ev = decodeB64Json(authHeader);
|
||||
if (!ev || ev.kind !== 27235) return null;
|
||||
const method = tagVal(ev.tags, 'method');
|
||||
if (method && method !== 'POST') return null;
|
||||
const u = tagVal(ev.tags, 'u') ?? '';
|
||||
if (u && !u.endsWith('/api/ai')) return null;
|
||||
if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null;
|
||||
try {
|
||||
return verifyEvent(ev) ? ev.pubkey : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a Pro pass (provider-authored zap receipt bound to the caller). */
|
||||
async function verifyPro(receiptHeader: string | null, userPubkey: string): Promise<number | null> {
|
||||
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;
|
||||
try {
|
||||
if (!verifyEvent(receipt)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const desc = tagVal(receipt.tags, 'description');
|
||||
if (!desc) return null;
|
||||
let zapReq;
|
||||
try {
|
||||
zapReq = JSON.parse(desc);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!zapReq || zapReq.pubkey !== userPubkey) return null;
|
||||
try {
|
||||
if (!verifyEvent(zapReq)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!zapReq.tags?.some((t: string[]) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
|
||||
|
||||
let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11') ?? '');
|
||||
if (!paidSats) {
|
||||
const msat = Number(tagVal(zapReq.tags, 'amount'));
|
||||
if (Number.isFinite(msat) && msat > 0) paidSats = Math.floor(msat / 1000);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Origins allowed to call the proxy (web + local dev + Capacitor native).
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
'https://skate-spots.org',
|
||||
'http://localhost:8080',
|
||||
'http://localhost:5173',
|
||||
'capacitor://localhost',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
]);
|
||||
function corsHeaders(origin: string): Record<string, string> {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.has(origin) ? origin : 'https://skate-spots.org',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, x-sasquatch-auth, x-sasquatch-pro',
|
||||
Vary: 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, status: number, headers: Record<string, string>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Per-IP throttle. A single long-lived process (unlike serverless), so this is a
|
||||
// more meaningful damper than the Netlify version.
|
||||
const WINDOW_MS = 60_000;
|
||||
const LIMIT = 20;
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
async function handleStatus(headers: Record<string, string>): Promise<Response> {
|
||||
if (!AI_KEY) return json({ configured: false }, 200, headers);
|
||||
try {
|
||||
const r = await fetch(`${AI_BASE}/balance/info`, {
|
||||
headers: { Authorization: `Bearer ${AI_KEY}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const j = (await r.json().catch(() => ({}))) as { balance?: number };
|
||||
const balanceSats = Math.floor(Number(j.balance ?? 0) / 1000);
|
||||
return json({ configured: true, balanceSats, low: balanceSats < 500, provider: AI_BASE }, 200, headers);
|
||||
} catch {
|
||||
return json({ configured: true, balanceSats: null, low: false }, 200, headers);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChat(request: Request, headers: Record<string, string>): Promise<Response> {
|
||||
if (REQUIRE_PRO) {
|
||||
const pubkey = verifyNip98(request.headers.get('x-sasquatch-auth'));
|
||||
if (!pubkey) return json({ error: 'auth required' }, 401, headers);
|
||||
if (!PLATFORM_OWNER_PUBKEYS.has(pubkey)) {
|
||||
const proUntil = await verifyPro(request.headers.get('x-sasquatch-pro'), pubkey);
|
||||
if (!proUntil || proUntil < Math.floor(Date.now() / 1000)) {
|
||||
return json({ error: 'pro required' }, 402, headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = AI_KEY;
|
||||
if (!key) return json({ error: 'AI proxy not configured' }, 503, headers);
|
||||
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
const now = Date.now();
|
||||
const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
|
||||
if (recent.length >= LIMIT) return json({ error: 'Too many requests' }, 429, headers);
|
||||
recent.push(now);
|
||||
hits.set(ip, recent);
|
||||
|
||||
let body: { model?: string; messages?: unknown[]; max_tokens?: number; stream?: boolean };
|
||||
try {
|
||||
body = (await request.json()) as typeof body;
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON' }, 400, headers);
|
||||
}
|
||||
|
||||
const requested = String(body.model ?? '');
|
||||
const model = ALLOWED_MODELS.has(requested) ? requested : ALLOWED_MODELS.values().next().value;
|
||||
const messages = Array.isArray(body.messages) ? body.messages.slice(-20) : [];
|
||||
const maxTokens = Math.min(Number(body.max_tokens) || 400, 800);
|
||||
const stream = body.stream === true;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(AI_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://skate-spots.org',
|
||||
'X-Title': 'SkateSpots',
|
||||
},
|
||||
body: JSON.stringify({ model, messages, max_tokens: maxTokens, stream }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch (err) {
|
||||
return json({ error: `Upstream failed: ${String(err)}` }, 502, headers);
|
||||
}
|
||||
|
||||
const passthrough = { ...headers };
|
||||
const contentType = upstream.headers.get('content-type');
|
||||
if (contentType) passthrough['Content-Type'] = contentType;
|
||||
return new Response(upstream.body, { status: upstream.status, headers: passthrough });
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
hostname: HOSTNAME,
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const origin = request.headers.get('origin') || '';
|
||||
const headers = corsHeaders(origin);
|
||||
const path = new URL(request.url).pathname;
|
||||
|
||||
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers });
|
||||
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, headers);
|
||||
|
||||
if (path === '/api/ai/status') return handleStatus(headers);
|
||||
if (path === '/api/ai') return handleChat(request, headers);
|
||||
return json({ error: 'Not found' }, 404, headers);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[ai-proxy] listening on http://${HOSTNAME}:${PORT} (upstream ${AI_BASE})`);
|
||||
28
ai-proxy/skatespots-ai.service
Normal file
28
ai-proxy/skatespots-ai.service
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# systemd unit for the SkateSpots AI proxy (Bun, on the CVM host).
|
||||
# Installed by scripts/deploy-ai-proxy.sh — do not hand-edit on the host.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Unit]
|
||||
Description=SkateSpots AI proxy (Routstr)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/skatespots-ai
|
||||
EnvironmentFile=/home/ubuntu/skatespots-ai/.env
|
||||
ExecStart=/home/ubuntu/.bun/bin/bun run server.ts
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=skatespots-ai
|
||||
|
||||
# Basic hardening — mirrors the CVM unit (bun stays happy; binds localhost only).
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
ai-proxy/tsconfig.json
Normal file
15
ai-proxy/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
// Type-checks the standalone Bun AI proxy. Separate from the app's root
|
||||
// tsconfig (which only includes `src`) and run by `npm test` via `typecheck:ai`.
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["server.ts"]
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ export default tseslint.config(
|
|||
// `typecheck:cvm` script, wired into `npm test`), not by the web ESLint
|
||||
// config (it leans on `any` and has no React rules).
|
||||
"cvm-server",
|
||||
// Standalone Bun AI proxy — same deal: own tsconfig (`typecheck:ai`).
|
||||
"ai-proxy",
|
||||
// Static utility HTML (icon generators, sticker sheets, QR pages) that
|
||||
// are intentionally minimal and don't need app-level meta/OG tags.
|
||||
"public/**/*.html",
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@
|
|||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"deploy:cvm": "bash scripts/deploy-cvm.sh",
|
||||
"deploy:ai": "bash scripts/deploy-ai-proxy.sh",
|
||||
"typecheck:cvm": "tsc -p cvm-server/tsconfig.json",
|
||||
"typecheck:ai": "tsc -p ai-proxy/tsconfig.json",
|
||||
"check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js",
|
||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js",
|
||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||
|
|
|
|||
59
scripts/deploy-ai-proxy.sh
Executable file
59
scripts/deploy-ai-proxy.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy the SkateSpots AI proxy to the CVM host (same box as the CVM server).
|
||||
#
|
||||
# It is the same class of service as deploy-cvm.sh: a Bun process under systemd,
|
||||
# reachable only via Caddy (TLS) on the public side. Config/host come from
|
||||
# ~/.config/skatespots/cvm.env (never committed).
|
||||
#
|
||||
# npm run deploy:ai # or: bash scripts/deploy-ai-proxy.sh
|
||||
# bash scripts/deploy-ai-proxy.sh --logs
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
CONF="${HOME}/.config/skatespots/cvm.env"
|
||||
if [[ -f "$CONF" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONF"
|
||||
fi
|
||||
|
||||
: "${CVM_HOST:?Set CVM_HOST (e.g. in ~/.config/skatespots/cvm.env) — the host is never committed}"
|
||||
CVM_SSH_KEY="${CVM_SSH_KEY:-$HOME/.ssh/skatespots-new}"
|
||||
AI_DIR="${AI_DIR:-/home/ubuntu/skatespots-ai}"
|
||||
AI_SERVICE="${AI_SERVICE:-skatespots-ai}"
|
||||
|
||||
SSH=(ssh -i "$CVM_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=12 "$CVM_HOST")
|
||||
|
||||
if [[ "${1:-}" == "--logs" ]]; then
|
||||
"${SSH[@]}" "journalctl -u $AI_SERVICE -n 40 --no-pager"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "→ Ensuring $AI_DIR exists"
|
||||
"${SSH[@]}" "mkdir -p $AI_DIR"
|
||||
|
||||
echo "→ Uploading ai-proxy/{server.ts,package.json,skatespots-ai.service}"
|
||||
scp -i "$CVM_SSH_KEY" -o BatchMode=yes \
|
||||
"$REPO_ROOT/ai-proxy/server.ts" \
|
||||
"$REPO_ROOT/ai-proxy/package.json" \
|
||||
"$REPO_ROOT/ai-proxy/skatespots-ai.service" \
|
||||
"$CVM_HOST:$AI_DIR/"
|
||||
|
||||
echo "→ bun install"
|
||||
"${SSH[@]}" "cd $AI_DIR && ~/.bun/bin/bun install --silent 2>&1 | tail -3 || true"
|
||||
|
||||
echo "→ Installing the unit and (re)starting $AI_SERVICE"
|
||||
"${SSH[@]}" "sudo cp $AI_DIR/skatespots-ai.service /etc/systemd/system/ \
|
||||
&& sudo systemctl daemon-reload \
|
||||
&& sudo systemctl enable --now $AI_SERVICE >/dev/null 2>&1 || true; \
|
||||
sudo systemctl restart $AI_SERVICE \
|
||||
&& sleep 2 && echo \"active: \$(systemctl is-active $AI_SERVICE)\""
|
||||
|
||||
echo "→ Local health check (through the service, on the host)"
|
||||
"${SSH[@]}" "curl -s -o /dev/null -w 'status endpoint: %{http_code}\n' -X POST http://127.0.0.1:8787/api/ai/status || true"
|
||||
|
||||
echo "→ Recent log:"
|
||||
"${SSH[@]}" "journalctl -u $AI_SERVICE -n 15 --no-pager | tail -6"
|
||||
|
||||
echo "✅ AI proxy deployed. Public TLS is served by Caddy — see ai-proxy/DEPLOY.md."
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import { sasquatchAiStatusUrl } from '@/lib/sasquatchAi';
|
||||
|
||||
/** Report shape from the proxy's owner-only `/api/ai/status`. */
|
||||
export interface SasquatchAiStatus {
|
||||
|
|
@ -11,9 +11,7 @@ export interface SasquatchAiStatus {
|
|||
provider?: string;
|
||||
}
|
||||
|
||||
const AI_STATUS_URL = Capacitor.isNativePlatform()
|
||||
? 'https://skate-spots.org/api/ai/status'
|
||||
: '/api/ai/status';
|
||||
const AI_STATUS_URL = sasquatchAiStatusUrl();
|
||||
|
||||
/**
|
||||
* Owner-only: the Sasquatch AI (upstream) account balance, so the operator can be
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* signer (Amber) doesn't pop on every message.
|
||||
*/
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { sasquatchAiBase } from '@/lib/sasquatchAi';
|
||||
|
||||
type SignEvent = (event: {
|
||||
kind: number;
|
||||
|
|
@ -40,7 +41,7 @@ export async function buildProAuthHeaders(opts: {
|
|||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', 'https://skate-spots.org/api/ai'],
|
||||
['u', sasquatchAiBase()],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
|
|
|
|||
30
src/lib/sasquatchAi.ts
Normal file
30
src/lib/sasquatchAi.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { isNativePlatform } from '@/lib/platform';
|
||||
|
||||
/**
|
||||
* Base URL of the Sasquatch AI proxy — the single place the app decides where AI
|
||||
* requests go.
|
||||
*
|
||||
* Default: the original Netlify Function at `/api/ai` (same-origin on web; the
|
||||
* absolute production origin on native, which has no same-origin server).
|
||||
*
|
||||
* To move AI off Netlify onto our own host (ai-proxy/ on the CVM host), set the
|
||||
* build-time override:
|
||||
* VITE_SASQUATCH_AI_BASE=https://ai.skate-spots.org/api/ai
|
||||
* This is a pure routing switch and trivially reversible: the proxy accepts any
|
||||
* host — it only checks that a NIP-98 `u` value ends in `/api/ai`.
|
||||
*
|
||||
* Returns an absolute URL, so it's safe to use both for `fetch()` and as the
|
||||
* NIP-98 `u` tag.
|
||||
*/
|
||||
export function sasquatchAiBase(): string {
|
||||
const override = (import.meta.env as Record<string, string | undefined>).VITE_SASQUATCH_AI_BASE;
|
||||
if (override) return override.replace(/\/+$/, '');
|
||||
if (isNativePlatform()) return 'https://skate-spots.org/api/ai';
|
||||
const origin = typeof location !== 'undefined' ? location.origin : 'https://skate-spots.org';
|
||||
return `${origin}/api/ai`;
|
||||
}
|
||||
|
||||
/** Owner balance endpoint (`…/api/ai/status`). */
|
||||
export function sasquatchAiStatusUrl(): string {
|
||||
return `${sasquatchAiBase()}/status`;
|
||||
}
|
||||
|
|
@ -4,20 +4,16 @@
|
|||
* surface can reuse the same model list + fallback behaviour.
|
||||
*/
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { sasquatchAiBase } from '@/lib/sasquatchAi';
|
||||
|
||||
// ─── AI models, via our own server-side proxy ────────────────────────────────
|
||||
// SECURITY: no API key is ever shipped to the client. Requests go to our Netlify
|
||||
// Function at `/api/ai`, which runs against Netlify's AI Gateway server-side and
|
||||
// only allows a curated model list (see netlify/functions/ai.mjs). If the proxy
|
||||
// is unavailable we fall back to the keyless Pollinations provider below, so
|
||||
// Sasquatch still works with zero secrets shipped.
|
||||
//
|
||||
// The native (Capacitor) build has no same-origin `/api/ai`, so it calls the
|
||||
// absolute production URL; the web build stays same-origin.
|
||||
const AI_PROXY_URL = Capacitor.isNativePlatform()
|
||||
? 'https://skate-spots.org/api/ai'
|
||||
: '/api/ai';
|
||||
// SECURITY: no API key is ever shipped to the client. Requests go to our proxy
|
||||
// (`/api/ai`), which holds the Routstr key server-side and only allows a curated
|
||||
// model list. Where that proxy lives is decided in one place — lib/sasquatchAi.ts
|
||||
// (the Netlify Function by default, or our own ai-proxy/ service on the CVM host
|
||||
// via VITE_SASQUATCH_AI_BASE). If it's unavailable we fall back to the keyless
|
||||
// Pollinations provider below, so Sasquatch still works with zero secrets shipped.
|
||||
const AI_PROXY_URL = sasquatchAiBase();
|
||||
|
||||
// Models Sasquatch requests through the proxy, in preference order. These are
|
||||
// the slugs served by our upstream (a Routstr node — see AGENTS.md), which are
|
||||
|
|
|
|||
Loading…
Reference in a new issue