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 5676698..2512dc4 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -7,15 +7,20 @@ "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", + "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" "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", "setup:remote": "powershell -ExecutionPolicy Bypass -File scripts/deploy-remote.ps1" }, - "devDependencies": { - "typescript": "^5.7.3", - "wrangler": "^3.99.0" - }, "dependencies": { "@pushforge/builder": "^2.0.5" } 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 7217d4e..581a9d4 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -22,6 +22,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; VAPID_PUBLIC_KEY: string; VAPID_PRIVATE_KEY: string; VAPID_SUBJECT?: string; @@ -175,12 +177,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); } @@ -432,6 +442,9 @@ 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 "/vapid-public-key": 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"