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:
SkateSpots 2026-09-28 13:32:05 +07:00
parent f46f6a8967
commit ec06a63b01
15 changed files with 566 additions and 18 deletions

View file

@ -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
View 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
View 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
View 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
View 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
View 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})`);

View 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
View 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"]
}

View file

@ -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",

View file

@ -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
View 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."

View file

@ -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

View file

@ -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
View 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`;
}

View file

@ -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