From 0aee4414dc12de542f297b72d07690cf7ae68fac Mon Sep 17 00:00:00 2001 From: elcabasa Date: Fri, 29 May 2026 14:51:44 +0100 Subject: [PATCH 1/4] Update .gitignore to include alerts dependencies, modify frontend Vite config to set envPrefix, and enhance alerts package.json with new build and migration scripts --- .gitignore | 3 +++ alerts/README.md | 53 +++++++++++++++++++++++++++++++++++++++++ alerts/package.json | 12 +++++++--- frontend/.env.example | 2 ++ frontend/vite.config.ts | 1 + 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 alerts/README.md create mode 100644 frontend/.env.example diff --git a/.gitignore b/.gitignore index 1a2758a..8512575 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ src/.DS_Store frontend/node_modules/ frontend/dist/ frontend/package-lock.json + +alerts/node_modules/ +alerts/package-lock.json diff --git a/alerts/README.md b/alerts/README.md new file mode 100644 index 0000000..d46d9c3 --- /dev/null +++ b/alerts/README.md @@ -0,0 +1,53 @@ +# Turbolong APY Alerts Worker + +Cloudflare Worker for email and web-push APY alerts (negative net APY per pool/asset/leverage bracket). + +## Setup + +```bash +npm install +``` + +1. **D1** — Create the database (once), paste `database_id` into `wrangler.toml`: + + ```bash + npm run db:create + npm run db:migrate:remote + ``` + +2. **Secrets** (never commit these): + + ```bash + wrangler secret put RESEND_API_KEY + wrangler secret put VAPID_PRIVATE_KEY # JWK JSON from `npm run vapid:generate` + ``` + + `VAPID_PUBLIC_KEY` is set in `wrangler.toml` and must match the key pair used for `VAPID_PRIVATE_KEY`. + +3. **Deploy** + + ```bash + npm run build + npm run deploy + ``` + +## Web push (E5) + +- `GET /vapid-public-key` — public VAPID key for `pushManager.subscribe` +- `POST /push/subscribe` — body: `{ subscription, pool_id, asset_symbol, leverage_bracket }` +- `GET /push/unsubscribe?token=` — remove subscription + +Cron (every 15 min) sends push for the same negative-APY events as email, with the same 24h throttle. + +## Local dev + +```bash +wrangler dev +``` + +Point the frontend at the local worker: + +```bash +# frontend/.env.local +VITE_ALERTS_WORKER_URL=http://127.0.0.1:8787 +``` diff --git a/alerts/package.json b/alerts/package.json index c57f47e..fbe0ef1 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -5,11 +5,17 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "build": "wrangler deploy --dry-run", "db:create": "wrangler d1 create turbolong-alerts", - "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql" + "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql", + "db:migrate:remote": "wrangler d1 execute turbolong-alerts --remote --file=src/schema.sql", + "vapid:generate": "npx @pushforge/builder vapid" }, "devDependencies": { - "wrangler": "^3.99.0", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "wrangler": "^3.99.0" + }, + "dependencies": { + "@pushforge/builder": "^2.0.5" } } diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..af9a03b --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Optional: override alerts worker URL (default: https://turbolong-alerts.workers.dev) +# VITE_ALERTS_WORKER_URL=http://127.0.0.1:8787 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a8c5c52..6ba3f3e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; export default defineConfig({ base: process.env.GITHUB_PAGES ? "/over_leveraging/" : "/", + envPrefix: ["VITE_"], define: { // Some Stellar SDK internals check for global global: "globalThis", From 48641efad79a38266b8eff622c42a2d7fabf1f2c Mon Sep 17 00:00:00 2001 From: elcabasa Date: Fri, 29 May 2026 14:52:21 +0100 Subject: [PATCH 2/4] Enhance alert system with web-push notifications and validation improvements - Added new routes for web-push subscription management, including `/vapid-public-key` and `/push/subscribe`. - Implemented validation for alert targets to ensure correct pool IDs, asset symbols, and leverage brackets. - Updated email subscription handling to utilize the new validation function. - Modified frontend to support push notifications, including service worker registration and user permission requests. - Updated `wrangler.toml` to include VAPID keys for push notifications. --- alerts/src/index.ts | 295 ++++++++++++++++++++++++++++++++++--------- alerts/wrangler.toml | 3 + frontend/src/main.ts | 90 ++++++++++++- 3 files changed, 324 insertions(+), 64 deletions(-) diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..7217d4e 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -2,22 +2,29 @@ * Turbolong APY Alert Worker * * Routes: - * POST /subscribe — register an alert subscription - * GET /verify?token= — verify email - * GET /unsubscribe?token= — remove subscription + * POST /subscribe — register an email alert subscription + * GET /verify?token= — verify email + * GET /unsubscribe?token= — remove email subscription + * GET /vapid-public-key — VAPID public key for web push + * POST /push/subscribe — register a web-push subscription + * GET /push/unsubscribe?token= — remove web-push subscription * * Cron (every 15 min): - * Fetch pool reserve rates, compute APY per bracket, alert subscribers. + * Fetch pool reserve rates, compute APY per bracket, alert email + push subscribers. */ import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts"; import { sendVerificationEmail, sendApyAlert } from "./email.ts"; +import { sendApyPush } from "./push.ts"; interface Env { DB: D1Database; RESEND_API_KEY: string; RESEND_FROM: string; FRONTEND_ORIGIN: string; + VAPID_PUBLIC_KEY: string; + VAPID_PRIVATE_KEY: string; + VAPID_SUBJECT?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -42,7 +49,7 @@ function htmlResponse(html: string, status = 200): Response { function corsHeaders(env: Env): Record { return { "Access-Control-Allow-Origin": env.FRONTEND_ORIGIN, - "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; } @@ -68,7 +75,25 @@ function workerUrl(request: Request): string { return `${url.protocol}//${url.host}`; } -// ── Route handlers ─────────────────────────────────────────────────────────── +function validateAlertTarget( + pool_id: string, + asset_symbol: string, + leverage_bracket: unknown, +): { ok: true; lev: number } | { ok: false; error: string } { + if (!KNOWN_POOL_IDS.has(pool_id)) { + return { ok: false, error: "Unknown pool" }; + } + if (!KNOWN_SYMBOLS.has(asset_symbol)) { + return { ok: false, error: "Unknown asset" }; + } + const lev = Number(leverage_bracket); + if (!LEVERAGE_BRACKETS.includes(lev)) { + return { ok: false, error: "Invalid leverage bracket. Must be one of: " + LEVERAGE_BRACKETS.join(", ") }; + } + return { ok: true, lev }; +} + +// ── Email route handlers ───────────────────────────────────────────────────── async function handleSubscribe(request: Request, env: Env): Promise { let body: any; @@ -80,20 +105,12 @@ async function handleSubscribe(request: Request, env: Env): Promise { const { email, pool_id, asset_symbol, leverage_bracket } = body; - // Validate if (!email || !EMAIL_RE.test(email)) { return jsonResponse({ ok: false, error: "Invalid email" }, 400, env); } - if (!KNOWN_POOL_IDS.has(pool_id)) { - return jsonResponse({ ok: false, error: "Unknown pool" }, 400, env); - } - if (!KNOWN_SYMBOLS.has(asset_symbol)) { - return jsonResponse({ ok: false, error: "Unknown asset" }, 400, env); - } - const lev = Number(leverage_bracket); - if (!LEVERAGE_BRACKETS.includes(lev)) { - return jsonResponse({ ok: false, error: "Invalid leverage bracket. Must be one of: " + LEVERAGE_BRACKETS.join(", ") }, 400, env); - } + + const target = validateAlertTarget(pool_id, asset_symbol, leverage_bracket); + if (!target.ok) return jsonResponse({ ok: false, error: target.error }, 400, env); const verifyToken = generateToken(); const unsubToken = generateToken(); @@ -104,13 +121,12 @@ async function handleSubscribe(request: Request, env: Env): Promise { VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(email, pool_id, asset_symbol, leverage_bracket) DO UPDATE SET verify_token = ?5, unsub_token = ?6, verified = 0 - `).bind(email, pool_id, asset_symbol, lev, verifyToken, unsubToken).run(); + `).bind(email, pool_id, asset_symbol, target.lev, verifyToken, unsubToken).run(); } catch (e: any) { console.error("DB insert failed:", e); return jsonResponse({ ok: false, error: "Database error" }, 500, env); } - // Send verification email const base = workerUrl(request); const verifyUrl = `${base}/verify?token=${verifyToken}`; @@ -180,10 +196,189 @@ async function handleUnsubscribe(request: Request, env: Env): Promise `); } +// ── Web-push route handlers ────────────────────────────────────────────────── + +async function handleVapidPublicKey(env: Env): Promise { + if (!env.VAPID_PUBLIC_KEY) { + return jsonResponse({ ok: false, error: "VAPID not configured" }, 503, env); + } + return jsonResponse({ ok: true, publicKey: env.VAPID_PUBLIC_KEY }, 200, env); +} + +async function handlePushSubscribe(request: Request, env: Env): Promise { + let body: any; + try { + body = await request.json(); + } catch { + return jsonResponse({ ok: false, error: "Invalid JSON" }, 400, env); + } + + const { subscription, pool_id, asset_symbol, leverage_bracket } = body; + const endpoint = subscription?.endpoint; + const p256dh = subscription?.keys?.p256dh; + const auth = subscription?.keys?.auth; + + if (!endpoint || !p256dh || !auth) { + return jsonResponse({ ok: false, error: "Invalid push subscription" }, 400, env); + } + + const target = validateAlertTarget(pool_id, asset_symbol, leverage_bracket); + if (!target.ok) return jsonResponse({ ok: false, error: target.error }, 400, env); + + const unsubToken = generateToken(); + + try { + await env.DB.prepare(` + INSERT INTO push_subscriptions (endpoint, p256dh, auth, pool_id, asset_symbol, leverage_bracket, unsub_token) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(endpoint, pool_id, asset_symbol, leverage_bracket) DO UPDATE + SET p256dh = ?2, auth = ?3, unsub_token = ?7, last_alerted_at = NULL + `).bind(endpoint, p256dh, auth, pool_id, asset_symbol, target.lev, unsubToken).run(); + } catch (e: any) { + console.error("Push DB insert failed:", e); + return jsonResponse({ ok: false, error: "Database error" }, 500, env); + } + + const base = workerUrl(request); + return jsonResponse({ + ok: true, + message: "Push alerts enabled for this position.", + unsubscribeUrl: `${base}/push/unsubscribe?token=${unsubToken}`, + }, 200, env); +} + +async function handlePushUnsubscribe(request: Request, env: Env): Promise { + const url = new URL(request.url); + const token = url.searchParams.get("token"); + + if (!token) return htmlResponse("

Missing token.

", 400); + + const result = await env.DB.prepare( + "DELETE FROM push_subscriptions WHERE unsub_token = ?1" + ).bind(token).run(); + + if (!result.meta.changes) { + return htmlResponse("

Push subscription not found or already removed.

", 404); + } + + return htmlResponse(` + + +Unsubscribed + +

Push Unsubscribed

+

You will no longer receive push APY alerts for this subscription.

+ +`); +} + // ── Cron handler ───────────────────────────────────────────────────────────── -async function handleCron(env: Env): Promise { +async function alertEmailSubscribers( + env: Env, + pool: (typeof POOLS)[number], + asset: (typeof POOLS)[number]["assets"][number], + bracket: number, + netApy: number, + rates: ReserveRates, + base: string, +): Promise { + const subs = await env.DB.prepare(` + SELECT id, email, unsub_token + FROM subscriptions + WHERE pool_id = ?1 + AND asset_symbol = ?2 + AND leverage_bracket = ?3 + AND verified = 1 + AND (last_alerted_at IS NULL OR last_alerted_at < datetime('now', '-24 hours')) + `).bind(pool.id, asset.symbol, bracket).all(); + + if (!subs.results?.length) return; + + console.log(`[cron] Email alerting ${subs.results.length} subscriber(s) for ${asset.symbol}@${bracket}x on ${pool.name}`); + + for (const sub of subs.results) { + const unsubUrl = `${base}/unsubscribe?token=${sub.unsub_token}`; + const result = await sendApyAlert( + { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, + sub.email as string, + { + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + netApy, + supplyApr: rates.netSupplyApr, + borrowCost: rates.netBorrowCost, + unsubscribeUrl: unsubUrl, + appUrl: env.FRONTEND_ORIGIN, + }, + ); + + if (result.ok) { + await env.DB.prepare( + "UPDATE subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1" + ).bind(sub.id).run(); + } else { + console.error(`[cron] Failed to send email alert to ${sub.email}:`, result.error); + } + } +} + +async function alertPushSubscribers( + env: Env, + pool: (typeof POOLS)[number], + asset: (typeof POOLS)[number]["assets"][number], + bracket: number, + netApy: number, +): Promise { + if (!env.VAPID_PRIVATE_KEY) return; + + const subs = await env.DB.prepare(` + SELECT id, endpoint, p256dh, auth + FROM push_subscriptions + WHERE pool_id = ?1 + AND asset_symbol = ?2 + AND leverage_bracket = ?3 + AND (last_alerted_at IS NULL OR last_alerted_at < datetime('now', '-24 hours')) + `).bind(pool.id, asset.symbol, bracket).all(); + + if (!subs.results?.length) return; + + console.log(`[cron] Push alerting ${subs.results.length} subscriber(s) for ${asset.symbol}@${bracket}x on ${pool.name}`); + + for (const sub of subs.results) { + const result = await sendApyPush( + env, + { + endpoint: sub.endpoint as string, + p256dh: sub.p256dh as string, + auth: sub.auth as string, + }, + { + poolName: pool.name, + assetSymbol: asset.symbol, + leverage: bracket, + netApy, + appUrl: env.FRONTEND_ORIGIN, + }, + ); + + if (result.ok) { + await env.DB.prepare( + "UPDATE push_subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1" + ).bind(sub.id).run(); + } else if (result.gone) { + await env.DB.prepare("DELETE FROM push_subscriptions WHERE id = ?1").bind(sub.id).run(); + console.log(`[cron] Removed expired push subscription ${sub.id}`); + } else { + console.error(`[cron] Failed to send push alert to ${sub.endpoint}:`, result.error); + } + } +} + +async function handleCron(env: Env, requestBase?: string): Promise { console.log("[cron] APY alert check starting..."); + const base = requestBase ?? "https://turbolong-alerts.workers.dev"; for (const pool of POOLS) { for (const asset of pool.assets) { @@ -203,50 +398,12 @@ async function handleCron(env: Env): Promise { for (const bracket of LEVERAGE_BRACKETS) { const netApy = computeNetApy(rates, bracket); - if (netApy >= 0) continue; // APY is positive, no alert needed + if (netApy >= 0) continue; console.log(`[cron] Negative APY: ${asset.symbol} at ${bracket}x on ${pool.name} = ${netApy.toFixed(2)}%`); - // Find verified subscribers who haven't been alerted in the last 24h - const subs = await env.DB.prepare(` - SELECT id, email, unsub_token - FROM subscriptions - WHERE pool_id = ?1 - AND asset_symbol = ?2 - AND leverage_bracket = ?3 - AND verified = 1 - AND (last_alerted_at IS NULL OR last_alerted_at < datetime('now', '-24 hours')) - `).bind(pool.id, asset.symbol, bracket).all(); - - if (!subs.results?.length) continue; - - console.log(`[cron] Alerting ${subs.results.length} subscriber(s) for ${asset.symbol}@${bracket}x on ${pool.name}`); - - for (const sub of subs.results) { - const unsubUrl = `https://turbolong-alerts.workers.dev/unsubscribe?token=${sub.unsub_token}`; - const result = await sendApyAlert( - { RESEND_API_KEY: env.RESEND_API_KEY, RESEND_FROM: env.RESEND_FROM }, - sub.email as string, - { - poolName: pool.name, - assetSymbol: asset.symbol, - leverage: bracket, - netApy, - supplyApr: rates.netSupplyApr, - borrowCost: rates.netBorrowCost, - unsubscribeUrl: unsubUrl, - appUrl: env.FRONTEND_ORIGIN, - }, - ); - - if (result.ok) { - await env.DB.prepare( - "UPDATE subscriptions SET last_alerted_at = datetime('now') WHERE id = ?1" - ).bind(sub.id).run(); - } else { - console.error(`[cron] Failed to send alert to ${sub.email}:`, result.error); - } - } + await alertEmailSubscribers(env, pool, asset, bracket, netApy, rates, base); + await alertPushSubscribers(env, pool, asset, bracket, netApy); } } } @@ -260,7 +417,6 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - // CORS preflight if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders(env) }); } @@ -278,6 +434,21 @@ export default { case "/unsubscribe": return handleUnsubscribe(request, env); + case "/vapid-public-key": + if (request.method !== "GET") { + return jsonResponse({ error: "Method not allowed" }, 405, env); + } + return handleVapidPublicKey(env); + + case "/push/subscribe": + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405, env); + } + return handlePushSubscribe(request, env); + + case "/push/unsubscribe": + return handlePushUnsubscribe(request, env); + default: return jsonResponse({ error: "Not found" }, 404); } diff --git a/alerts/wrangler.toml b/alerts/wrangler.toml index 0f8030d..87ba4d9 100644 --- a/alerts/wrangler.toml +++ b/alerts/wrangler.toml @@ -13,4 +13,7 @@ database_id = "" [vars] RESEND_FROM = "alerts@turbolong.com" FRONTEND_ORIGIN = "https://app.turbolong.com" +# URL-safe base64 public key (`npm run vapid:generate`). Private JWK via `wrangler secret put VAPID_PRIVATE_KEY` +VAPID_PUBLIC_KEY = "BP3TImDzbGrMCJY1kNs3MLIlKZXJ2fwvnlBWFJTCCmOAqNaOp6Y7vIepSHBuViRuJIAmrfUMYCIYLrt-EyJhEfE" # RESEND_API_KEY: set via `wrangler secret put RESEND_API_KEY` +# VAPID_PRIVATE_KEY: JWK JSON string via `wrangler secret put VAPID_PRIVATE_KEY` diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..84169fd 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2761,7 +2761,28 @@ document.addEventListener("keydown", (e) => { // ── APY Alert subscription ────────────────────────────────────────────────── -const ALERTS_WORKER_URL = "https://turbolong-alerts.workers.dev"; +const ALERTS_WORKER_URL = + import.meta.env.VITE_ALERTS_WORKER_URL ?? "https://turbolong-alerts.workers.dev"; + +function urlBase64ToUint8Array(base64: string): Uint8Array { + const pad = "=".repeat((4 - (base64.length % 4)) % 4); + const raw = atob((base64 + pad).replace(/-/g, "+").replace(/_/g, "/")); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +async function registerAlertServiceWorker(): Promise { + if (!("serviceWorker" in navigator)) return null; + try { + return await navigator.serviceWorker.register("/sw.js"); + } catch (e) { + console.warn("Service worker registration failed:", e); + return null; + } +} + +registerAlertServiceWorker(); $("alert-bell-btn").addEventListener("click", () => { $("alert-pool-name").textContent = selectedPool.name; @@ -2823,6 +2844,71 @@ $("alert-subscribe-btn").addEventListener("click", async () => { toast(`Subscription failed: ${e.message?.slice(0, 100)}`, "error"); } finally { btn.disabled = false; - btn.textContent = "Subscribe"; + btn.textContent = "Subscribe with email"; + } +}); + +$("alert-push-btn").addEventListener("click", async () => { + if (!userAddress || demoMode) { + toast("Connect your wallet to enable push alerts.", "info"); + return; + } + if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) { + toast("Push notifications are not supported in this browser.", "error"); + return; + } + + const leverageBracket = Number(($("alert-leverage") as HTMLSelectElement).value); + const btn = $("alert-push-btn") as HTMLButtonElement; + btn.disabled = true; + btn.textContent = "Enabling..."; + + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + toast("Notification permission denied.", "error"); + return; + } + + const reg = await navigator.serviceWorker.ready; + const keyRes = await fetch(`${ALERTS_WORKER_URL}/vapid-public-key`); + const keyData = await keyRes.json() as { ok?: boolean; publicKey?: string; error?: string }; + if (!keyData.ok || !keyData.publicKey) { + toast(keyData.error || "Push is not configured on the server.", "error"); + return; + } + + let subscription = await reg.pushManager.getSubscription(); + if (!subscription) { + subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(keyData.publicKey), + }); + } + + const subJson = subscription.toJSON(); + const res = await fetch(`${ALERTS_WORKER_URL}/push/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subscription: subJson, + pool_id: selectedPool.id, + asset_symbol: selectedAsset.symbol, + leverage_bracket: leverageBracket, + }), + }); + + const data = await res.json() as { ok?: boolean; error?: string }; + if (data.ok) { + toast("Push alerts enabled for this position.", "success"); + $("alert-modal-overlay").classList.add("hidden"); + } else { + toast(data.error || "Push subscription failed.", "error"); + } + } catch (e: any) { + toast(`Push subscription failed: ${e.message?.slice(0, 100)}`, "error"); + } finally { + btn.disabled = false; + btn.textContent = "Enable push notifications"; } }); From 7efa5552cce30a6f78cddb14e261f75e6b391727 Mon Sep 17 00:00:00 2001 From: elcabasa Date: Fri, 29 May 2026 14:52:48 +0100 Subject: [PATCH 3/4] Refactor alert system to improve push notification handling and validation - Enhanced web-push notification management with new routes for subscription. - Implemented validation for alert targets, ensuring accurate pool IDs and asset symbols. - Updated email subscription logic to incorporate new validation. - Modified frontend to support push notifications, including service worker registration. - Adjusted `wrangler.toml` to include necessary VAPID keys for push notifications. --- alerts/package-lock.json | 15 +++++++ alerts/src/push.ts | 86 ++++++++++++++++++++++++++++++++++++++++ alerts/src/schema.sql | 17 ++++++++ frontend/index.html | 6 ++- frontend/public/sw.js | 36 +++++++++++++++++ frontend/src/style.css | 6 +++ 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 alerts/src/push.ts create mode 100644 frontend/public/sw.js diff --git a/alerts/package-lock.json b/alerts/package-lock.json index e8df802..a1279ef 100644 --- a/alerts/package-lock.json +++ b/alerts/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "turbolong-alerts", "version": "1.0.0", + "dependencies": { + "@pushforge/builder": "^2.0.5" + }, "devDependencies": { "typescript": "^5.7.3", "wrangler": "^3.99.0" @@ -1002,6 +1005,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@pushforge/builder": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@pushforge/builder/-/builder-2.0.5.tgz", + "integrity": "sha512-9Q6lBFWUMKUL5vBknwAt+nlS7eWCrNuf1lCMAhC1Iypn8gtYJ0rdVHAnsGFMxPKhbtgkgpU3r6UVduDTQ8jgzw==", + "license": "MIT", + "bin": { + "pushforge": "dist/lib/commandLine/keys.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", diff --git a/alerts/src/push.ts b/alerts/src/push.ts new file mode 100644 index 0000000..13a65ec --- /dev/null +++ b/alerts/src/push.ts @@ -0,0 +1,86 @@ +/** + * Web Push delivery via @pushforge/builder (Web Crypto, Workers-compatible). + */ + +import { buildPushHTTPRequest } from "@pushforge/builder"; + +export interface PushEnv { + VAPID_PRIVATE_KEY: string; + VAPID_PUBLIC_KEY: string; + VAPID_SUBJECT?: string; +} + +export interface PushSubscriptionRow { + endpoint: string; + p256dh: string; + auth: string; +} + +export interface SendResult { + ok: boolean; + status?: number; + error?: string; + gone?: boolean; +} + +function privateJWK(env: PushEnv): JsonWebKey { + return JSON.parse(env.VAPID_PRIVATE_KEY) as JsonWebKey; +} + +function adminContact(env: PushEnv): string { + return env.VAPID_SUBJECT ?? "mailto:alerts@turbolong.com"; +} + +export async function sendWebPush( + env: PushEnv, + sub: PushSubscriptionRow, + payload: { title: string; body: string; url?: string }, +): Promise { + if (!env.VAPID_PRIVATE_KEY) { + return { ok: false, error: "VAPID keys not configured" }; + } + + try { + const { endpoint, headers, body } = await buildPushHTTPRequest({ + privateJWK: privateJWK(env), + subscription: { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth }, + }, + message: { + payload, + adminContact: adminContact(env), + }, + }); + + const res = await fetch(endpoint, { method: "POST", headers, body }); + + if (res.ok) return { ok: true, status: res.status }; + if (res.status === 404 || res.status === 410) { + return { ok: false, status: res.status, gone: true, error: "Subscription expired" }; + } + const text = await res.text().catch(() => ""); + return { ok: false, status: res.status, error: `Push ${res.status}: ${text}` }; + } catch (e: any) { + return { ok: false, error: e.message ?? String(e) }; + } +} + +export async function sendApyPush( + env: PushEnv, + sub: PushSubscriptionRow, + opts: { + poolName: string; + assetSymbol: string; + leverage: number; + netApy: number; + appUrl: string; + }, +): Promise { + const { poolName, assetSymbol, leverage, netApy, appUrl } = opts; + return sendWebPush(env, sub, { + title: `Negative APY: ${assetSymbol} at ${leverage}x`, + body: `${assetSymbol} on ${poolName} is at ${netApy.toFixed(2)}% net APY. Tap to open Turbolong.`, + url: appUrl, + }); +} diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..ea9a1c9 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -14,3 +14,20 @@ CREATE TABLE IF NOT EXISTS subscriptions ( CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev ON subscriptions(pool_id, asset_symbol, leverage_bracket); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + pool_id TEXT NOT NULL, + asset_symbol TEXT NOT NULL, + leverage_bracket REAL NOT NULL, + unsub_token TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + last_alerted_at TEXT, + UNIQUE(endpoint, pool_id, asset_symbol, leverage_bracket) +); + +CREATE INDEX IF NOT EXISTS idx_push_subs_pool_asset_lev + ON push_subscriptions(pool_id, asset_symbol, leverage_bracket); diff --git a/frontend/index.html b/frontend/index.html index f904f23..832372e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -787,8 +787,10 @@

APY Alerts

- -

We never store your wallet address.

+ +

or

+ +

We never store your wallet address. Push works on mobile without an app.

diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..550555c --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,36 @@ +/* Turbolong APY alert service worker */ + +self.addEventListener("push", (event) => { + let data = { title: "Turbolong", body: "APY alert", url: "/" }; + try { + if (event.data) data = { ...data, ...event.data.json() }; + } catch { + // ignore malformed payload + } + + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: "/logo.svg", + badge: "/logo.svg", + data: { url: data.url || "/" }, + tag: "turbolong-apy-alert", + renotify: true, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data?.url || "/"; + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { + for (const client of list) { + if (client.url.includes(self.location.origin) && "focus" in client) { + return client.focus(); + } + } + if (clients.openWindow) return clients.openWindow(url); + }), + ); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..9aedc91 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -973,6 +973,12 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 border-radius: var(--r-xs); color: var(--text); font-family: var(--mono); cursor: pointer; } +.alert-or { + text-align: center; + font-size: 12px; + color: var(--text-3); + margin: 12px 0; +} .alert-hint { font-size: 12px; color: var(--text-3); text-align: center; margin: 12px 0 0; line-height: 1.5; From f322f10e085821f8958e358b12e5e2fc2bb61a1f Mon Sep 17 00:00:00 2001 From: elcabasa Date: Fri, 29 May 2026 15:09:50 +0100 Subject: [PATCH 4/4] chore(alerts): add remote deploy script and VAPID setup docs Add setup:remote script, dev vars example, and gitignore for local secrets so web-push can be deployed after wrangler login. Co-authored-by: Cursor --- .gitignore | 3 ++ alerts/.dev.vars.example | 2 ++ alerts/README.md | 9 ++++-- alerts/package.json | 5 ++-- alerts/scripts/deploy-remote.ps1 | 50 ++++++++++++++++++++++++++++++++ alerts/wrangler.toml | 2 +- 6 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 alerts/.dev.vars.example create mode 100644 alerts/scripts/deploy-remote.ps1 diff --git a/.gitignore b/.gitignore index 8512575..ce82130 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ src/.DS_Store frontend/node_modules/ frontend/dist/ frontend/package-lock.json +frontend/.env.local alerts/node_modules/ alerts/package-lock.json +alerts/.dev.vars +alerts/.wrangler/ diff --git a/alerts/.dev.vars.example b/alerts/.dev.vars.example new file mode 100644 index 0000000..9ce7817 --- /dev/null +++ b/alerts/.dev.vars.example @@ -0,0 +1,2 @@ +RESEND_API_KEY=re_xxxxxxxx +VAPID_PRIVATE_KEY={"alg":"ES256","key_ops":["sign"],"ext":true,"kty":"EC","x":"...","y":"...","crv":"P-256","d":"..."} diff --git a/alerts/README.md b/alerts/README.md index d46d9c3..58d85a0 100644 --- a/alerts/README.md +++ b/alerts/README.md @@ -24,13 +24,16 @@ npm install `VAPID_PUBLIC_KEY` is set in `wrangler.toml` and must match the key pair used for `VAPID_PRIVATE_KEY`. -3. **Deploy** +3. **Deploy** (after `npx wrangler login`): ```bash - npm run build - npm run deploy + npm run setup:remote ``` + This creates D1 (if needed), runs the remote migration, uploads `VAPID_PRIVATE_KEY` from `.dev.vars`, prompts for `RESEND_API_KEY`, and deploys. + + Or step by step: `npm run build` then `npm run deploy`. + ## Web push (E5) - `GET /vapid-public-key` — public VAPID key for `pushManager.subscribe` diff --git a/alerts/package.json b/alerts/package.json index fbe0ef1..5676698 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -7,9 +7,10 @@ "deploy": "wrangler deploy", "build": "wrangler deploy --dry-run", "db:create": "wrangler d1 create turbolong-alerts", - "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql", + "db:migrate": "wrangler d1 execute turbolong-alerts --local --file=src/schema.sql", "db:migrate:remote": "wrangler d1 execute turbolong-alerts --remote --file=src/schema.sql", - "vapid:generate": "npx @pushforge/builder vapid" + "vapid:generate": "npx @pushforge/builder vapid", + "setup:remote": "powershell -ExecutionPolicy Bypass -File scripts/deploy-remote.ps1" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/alerts/scripts/deploy-remote.ps1 b/alerts/scripts/deploy-remote.ps1 new file mode 100644 index 0000000..22c4d9d --- /dev/null +++ b/alerts/scripts/deploy-remote.ps1 @@ -0,0 +1,50 @@ +# Deploy Turbolong alerts worker (D1 + VAPID secrets + worker). +# Prereq: npx wrangler login (or CLOUDFLARE_API_TOKEN in env) + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot\.. + +Write-Host "Checking Wrangler auth..." +$whoami = npx wrangler whoami 2>&1 | Out-String +if ($whoami -match "not authenticated") { + Write-Host "Run: npx wrangler login" + exit 1 +} + +if ((Get-Content wrangler.toml -Raw) -match 'database_id = "&1 | Out-String + Write-Host $out + if ($out -match 'database_id = "([a-f0-9-]+)"') { + $id = $Matches[1] + (Get-Content wrangler.toml -Raw) -replace 'database_id = "<[^"]+>"', "database_id = `"$id`"" | + Set-Content wrangler.toml -NoNewline + Write-Host "Updated wrangler.toml with database_id $id" + } else { + Write-Host "Could not parse database_id from wrangler output. Paste it into wrangler.toml manually." + exit 1 + } +} + +Write-Host "Migrating remote D1..." +npm run db:migrate:remote + +$devVars = Get-Content .dev.vars -Raw +if ($devVars -notmatch 'VAPID_PRIVATE_KEY=(.+)') { + Write-Host "Missing VAPID_PRIVATE_KEY in alerts/.dev.vars — run: npm run vapid:generate" + exit 1 +} +$jwk = $Matches[1].Trim() + +Write-Host "Setting VAPID_PRIVATE_KEY secret..." +$jwk | npx wrangler secret put VAPID_PRIVATE_KEY + +if (-not $env:SKIP_RESEND_SECRET) { + Write-Host "Set RESEND_API_KEY if not already set (interactive):" + npx wrangler secret put RESEND_API_KEY +} + +Write-Host "Deploying worker..." +npm run deploy + +Write-Host "Done. Worker: https://turbolong-alerts.workers.dev" diff --git a/alerts/wrangler.toml b/alerts/wrangler.toml index 87ba4d9..3e8c47f 100644 --- a/alerts/wrangler.toml +++ b/alerts/wrangler.toml @@ -14,6 +14,6 @@ database_id = "" RESEND_FROM = "alerts@turbolong.com" FRONTEND_ORIGIN = "https://app.turbolong.com" # URL-safe base64 public key (`npm run vapid:generate`). Private JWK via `wrangler secret put VAPID_PRIVATE_KEY` -VAPID_PUBLIC_KEY = "BP3TImDzbGrMCJY1kNs3MLIlKZXJ2fwvnlBWFJTCCmOAqNaOp6Y7vIepSHBuViRuJIAmrfUMYCIYLrt-EyJhEfE" +VAPID_PUBLIC_KEY = "BNR21aZuQZMC7UzKshdQAi40Irko_OSm0b0xFLza69Lnemrw947grj4utI1ZpjVgsrWd4jOtjS1XGS_PJAABPHk" # RESEND_API_KEY: set via `wrangler secret put RESEND_API_KEY` # VAPID_PRIVATE_KEY: JWK JSON string via `wrangler secret put VAPID_PRIVATE_KEY`