From 8a544fe9fa268c5652659fe169cc83feaa3b0b69 Mon Sep 17 00:00:00 2001 From: Darkdante9 Date: Fri, 29 May 2026 23:22:58 +0000 Subject: [PATCH] feat: metrics endpoint, RFC 8058 unsubscribe, CI workflow, and Biome linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #65 — Prometheus /metrics endpoint and Grafana dashboard - Add GET /metrics to the Cloudflare Worker that emits subscriber-level counters in Prometheus text format (v0.0.4): turbolong_subscribers_total turbolong_subscribers_by_pool{pool="…"} turbolong_subscribers_by_leverage{leverage="…x"} turbolong_subscribers_by_asset{asset="…"} - Auth: set METRICS_TOKEN secret for Bearer-token gating; unset = public. - Rate-limiting: delegate to a Cloudflare WAF rule (no Worker state needed). - Add dashboards/turbolong-overview.json — importable Grafana dashboard with stat, bar-chart, and table panels wired to the above metrics. #67 — RFC 8058 one-click unsubscribe - sendEmail() now accepts an optional extraHeaders map forwarded to the Resend API's headers field. - sendApyAlert() attaches List-Unsubscribe and List-Unsubscribe-Post headers on every alert email so Gmail, Apple Mail, and other major clients render a native unsubscribe button. - /unsubscribe now handles POST (RFC 8058 one-click flow): silently deletes the subscription and returns HTTP 200 with no body — no confirmation page required. #68 — GitHub Actions CI - Add .github/workflows/ci.yml with four jobs triggered on push to main and every pull request: frontend — npm ci, tsc --noEmit, vite build, vitest run rust — cargo check, cargo clippy -D warnings, cargo test worker — npm ci, tsc --noEmit (alerts Worker) lint — biome lint + biome format check across frontend/src and alerts/src #69 — Biome formatter and linter - Add biome.json at the repo root: 2-space indent, double quotes, semicolons, 100-char line width, recommended lint rules with noExplicitAny disabled (idiomatic for Worker / RPC glue code). - Add @biomejs/biome ^1.9.4 to devDependencies in frontend/package.json and alerts/package.json. - Add lint, format, and format:check npm scripts to both packages. Closes #65 Closes #67 Closes #68 Closes #69 --- .github/workflows/ci.yml | 95 +++++++++ alerts/package.json | 6 +- alerts/src/email.ts | 13 +- alerts/src/index.ts | 91 ++++++++- biome.json | 39 ++++ dashboards/turbolong-overview.json | 302 +++++++++++++++++++++++++++++ frontend/package.json | 6 +- 7 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 biome.json create mode 100644 dashboards/turbolong-overview.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77ca1e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + frontend: + name: Frontend (typecheck + build + test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Build + run: npm run build + + - name: Test + run: npm test + + rust: + name: Rust (check + clippy + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Check + run: cargo check + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Test + run: cargo test + + worker: + name: Worker (typecheck) + runs-on: ubuntu-latest + defaults: + run: + working-directory: alerts + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: alerts/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + lint: + name: Lint + Format (Biome) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Biome + run: npm install --global @biomejs/biome + + - name: Lint + run: biome lint --no-errors-on-unmatched frontend/src alerts/src + + - name: Format check + run: biome format --no-errors-on-unmatched frontend/src alerts/src diff --git a/alerts/package.json b/alerts/package.json index c57f47e..2799c96 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -6,9 +6,13 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "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", + "lint": "biome lint .", + "format": "biome format . --write", + "format:check": "biome format ." }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "wrangler": "^3.99.0", "typescript": "^5.7.3" } diff --git a/alerts/src/email.ts b/alerts/src/email.ts index 3cf5c61..8a1c756 100644 --- a/alerts/src/email.ts +++ b/alerts/src/email.ts @@ -12,7 +12,13 @@ interface SendResult { error?: string; } -async function sendEmail(env: Env, to: string, subject: string, html: string): Promise { +async function sendEmail( + env: Env, + to: string, + subject: string, + html: string, + extraHeaders?: Record, +): Promise { const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { @@ -24,6 +30,7 @@ async function sendEmail(env: Env, to: string, subject: string, html: string): P to: [to], subject, html, + ...(extraHeaders ? { headers: extraHeaders } : {}), }), }); @@ -98,5 +105,9 @@ export async function sendApyAlert( to, `\u26A0 Negative APY: ${assetSymbol} at ${leverage}x on ${poolName}`, html, + { + "List-Unsubscribe": `<${unsubscribeUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, ); } diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..7e462ce 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -18,6 +18,8 @@ interface Env { RESEND_API_KEY: string; RESEND_FROM: string; FRONTEND_ORIGIN: string; + /** Optional Bearer token required to access /metrics. If unset, endpoint is public. */ + METRICS_TOKEN?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -159,12 +161,20 @@ async function handleUnsubscribe(request: Request, env: Env): Promise const url = new URL(request.url); const token = url.searchParams.get("token"); - if (!token) return htmlResponse("

Missing token.

", 400); + if (!token) { + if (request.method === "POST") return new Response("Missing token", { status: 400 }); + return htmlResponse("

Missing token.

", 400); + } const result = await env.DB.prepare( "DELETE FROM subscriptions WHERE unsub_token = ?1" ).bind(token).run(); + // RFC 8058 one-click: mail client POSTs List-Unsubscribe=One-Click; respond 200 with no body + if (request.method === "POST") { + return new Response(null, { status: result.meta.changes ? 200 : 404 }); + } + if (!result.meta.changes) { return htmlResponse("

Subscription not found or already removed.

", 404); } @@ -180,6 +190,76 @@ async function handleUnsubscribe(request: Request, env: Env): Promise `); } +// ── Metrics handler ────────────────────────────────────────────────────────── + +/** + * Exposes subscriber-level metrics in Prometheus text format (v0.0.4). + * + * Auth: set METRICS_TOKEN secret → endpoint requires "Authorization: Bearer ". + * If METRICS_TOKEN is unset the endpoint is public (safe for read-only counters). + * Rate-limiting: configure a Cloudflare WAF rate-limit rule on /metrics at the + * dashboard level (e.g. 60 req/min per IP) — no Worker state needed. + */ +async function handleMetrics(request: Request, env: Env): Promise { + if (env.METRICS_TOKEN) { + const auth = request.headers.get("Authorization") ?? ""; + if (auth !== `Bearer ${env.METRICS_TOKEN}`) { + return new Response("Unauthorized", { + status: 401, + headers: { "WWW-Authenticate": 'Bearer realm="metrics"' }, + }); + } + } + + const [totalRow, poolRows, leverageRows, assetRows] = await Promise.all([ + env.DB.prepare( + "SELECT COUNT(*) as n FROM subscriptions WHERE verified = 1" + ).first<{ n: number }>(), + env.DB.prepare( + "SELECT pool_id, COUNT(*) as n FROM subscriptions WHERE verified = 1 GROUP BY pool_id" + ).all<{ pool_id: string; n: number }>(), + env.DB.prepare( + "SELECT leverage_bracket, COUNT(*) as n FROM subscriptions WHERE verified = 1 GROUP BY leverage_bracket" + ).all<{ leverage_bracket: number; n: number }>(), + env.DB.prepare( + "SELECT asset_symbol, COUNT(*) as n FROM subscriptions WHERE verified = 1 GROUP BY asset_symbol" + ).all<{ asset_symbol: string; n: number }>(), + ]); + + const lines: string[] = []; + + lines.push("# HELP turbolong_subscribers_total Total verified alert subscribers"); + lines.push("# TYPE turbolong_subscribers_total gauge"); + lines.push(`turbolong_subscribers_total ${totalRow?.n ?? 0}`); + lines.push(""); + + lines.push("# HELP turbolong_subscribers_by_pool Verified subscribers per pool"); + lines.push("# TYPE turbolong_subscribers_by_pool gauge"); + for (const row of poolRows.results ?? []) { + const name = POOL_NAMES[row.pool_id] ?? row.pool_id; + lines.push(`turbolong_subscribers_by_pool{pool="${name}"} ${row.n}`); + } + lines.push(""); + + lines.push("# HELP turbolong_subscribers_by_leverage Verified subscribers per leverage bracket"); + lines.push("# TYPE turbolong_subscribers_by_leverage gauge"); + for (const row of leverageRows.results ?? []) { + lines.push(`turbolong_subscribers_by_leverage{leverage="${row.leverage_bracket}x"} ${row.n}`); + } + lines.push(""); + + lines.push("# HELP turbolong_subscribers_by_asset Verified subscribers per asset symbol"); + lines.push("# TYPE turbolong_subscribers_by_asset gauge"); + for (const row of assetRows.results ?? []) { + lines.push(`turbolong_subscribers_by_asset{asset="${row.asset_symbol}"} ${row.n}`); + } + lines.push(""); + + return new Response(lines.join("\n") + "\n", { + headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" }, + }); +} + // ── Cron handler ───────────────────────────────────────────────────────────── async function handleCron(env: Env): Promise { @@ -276,8 +356,17 @@ export default { return handleVerify(request, env); case "/unsubscribe": + if (request.method !== "GET" && request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405, env); + } return handleUnsubscribe(request, env); + case "/metrics": + if (request.method !== "GET") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + return handleMetrics(request, env); + default: return jsonResponse({ error: "Not found" }, 404); } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..9e6c232 --- /dev/null +++ b/biome.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { + "enabled": false + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "noParameterAssign": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "files": { + "ignore": [ + "**/node_modules", + "**/dist", + "**/.wrangler", + "**/target" + ] + } +} diff --git a/dashboards/turbolong-overview.json b/dashboards/turbolong-overview.json new file mode 100644 index 0000000..339e383 --- /dev/null +++ b/dashboards/turbolong-overview.json @@ -0,0 +1,302 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "Prometheus data source scraping /metrics", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "barchart", + "name": "Bar chart", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { "type": "grafana", "uid": "-- Grafana --" }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Turbolong protocol transparency dashboard — subscriber metrics from /metrics", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 100 } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "turbolong_subscribers_total", + "instant": true, + "legendFormat": "Verified Subscribers", + "refId": "A" + } + ], + "title": "Total Verified Subscribers", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 1, + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 9, "x": 6, "y": 0 }, + "id": 2, + "options": { + "barRadius": 0.1, + "barWidth": 0.6, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { "mode": "single", "sort": "none" }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 200 + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "turbolong_subscribers_by_pool", + "instant": true, + "legendFormat": "{{pool}}", + "refId": "A" + } + ], + "title": "Subscribers by Pool", + "type": "barchart" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 1, + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 9, "x": 15, "y": 0 }, + "id": 3, + "options": { + "barRadius": 0.1, + "barWidth": 0.6, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { "mode": "single", "sort": "none" }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 200 + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "turbolong_subscribers_by_leverage", + "instant": true, + "legendFormat": "{{leverage}}", + "refId": "A" + } + ], + "title": "Subscribers by Leverage Bracket", + "type": "barchart" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "Value" }] + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "expr": "turbolong_subscribers_by_asset", + "instant": true, + "legendFormat": "{{asset}}", + "refId": "A" + } + ], + "title": "Subscribers by Asset", + "transformations": [ + { + "id": "labelsToFields", + "options": { "valueLabel": "asset" } + }, + { + "id": "organize", + "options": { + "excludeByName": { "Time": true }, + "renameByName": { "asset": "Asset", "Value": "Subscribers" } + } + } + ], + "type": "table" + } + ], + "refresh": "1m", + "schemaVersion": 38, + "tags": ["turbolong", "defi", "stellar"], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Turbolong Overview", + "uid": "turbolong-overview", + "version": 1 +} diff --git a/frontend/package.json b/frontend/package.json index 433427d..7165489 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "lint": "biome lint .", + "format": "biome format . --write", + "format:check": "biome format ." }, "dependencies": { "@creit-tech/stellar-wallets-kit": "npm:@jsr/creit-tech__stellar-wallets-kit@^2.0.1", @@ -14,6 +17,7 @@ "@stellar/stellar-sdk": "^14.6.1" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "typescript": "^5.7.3", "vite": "^6.2.0", "vitest": "^3.0.0"