diff --git a/api-deathwatch/.env.example b/api-deathwatch/.env.example new file mode 100644 index 000000000..f7a7d9009 --- /dev/null +++ b/api-deathwatch/.env.example @@ -0,0 +1,17 @@ +# GitHub App credentials +# Get these from: github.com → Settings → Developer Settings → GitHub Apps → your app + +# The numeric App ID shown on your GitHub App's settings page +GITHUB_APP_ID= + +# The secret you set when creating the webhook +GITHUB_WEBHOOK_SECRET= + +# Path to the private key .pem file you downloaded from GitHub App settings +GITHUB_PRIVATE_KEY_PATH=./private-key.pem + +# TinyFish API key — get one free at https://agent.tinyfish.ai/api-keys +TINYFISH_API_KEY= + +# Port (optional, defaults to 3000) +PORT=3000 diff --git a/api-deathwatch/.gitignore b/api-deathwatch/.gitignore new file mode 100644 index 000000000..f98e04b2b --- /dev/null +++ b/api-deathwatch/.gitignore @@ -0,0 +1,4 @@ +.env +*.pem +.last-runs.json +node_modules/ \ No newline at end of file diff --git a/api-deathwatch/README.md b/api-deathwatch/README.md new file mode 100644 index 000000000..3297205a8 --- /dev/null +++ b/api-deathwatch/README.md @@ -0,0 +1,155 @@ +# API Deathwatch + +A GitHub App that silently monitors every API and hosted service in your `package.json` — and opens a GitHub Issue in your repo before something breaks in production. + +APIs don't die overnight. They decay slowly. Free tiers quietly shrink. GitHub issues pile up unanswered. Developers ask for alternatives on HN. None of that crashes your app, but together it's a reliable signal — weeks or months before you're doing an emergency migration at 2am. + +**API Deathwatch gives each service a health score out of 10 and tells you exactly what to do.** + +--- + +## How it works + +1. Install the app on your repo +2. It reads your `package.json` and identifies hosted services (Stripe, Supabase, Twilio, etc.) +3. TinyFish agents run in parallel checking 4 signals per service: status page, changelog/deprecation notices, Hacker News sentiment, pricing page +4. Results are posted as a GitHub Issue in your repo with scores, warnings, and recommended actions +5. Re-runs every 7 days automatically + +--- + +## Example issue output + +``` +## Summary + +| Service | Score | Status | Action | +|------------|--------|-----------------|-------------------------------| +| heroku | 3.2/10 | 🔴 Critical | Begin migration planning now | +| mailchimp | 5.8/10 | 🟠 Concern | Start evaluating alternatives | +| stripe | 9.1/10 | ✅ Healthy | No action needed | +``` + +--- + +## TinyFish API Usage + +The app uses `@tiny-fish/sdk` with `client.agent.run()` — the synchronous endpoint — since this is a background Node.js process with no frontend to stream to. Four agents run in parallel per service: + +```javascript +const { TinyFish, RunStatus } = require("@tiny-fish/sdk"); + +const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); + +const run = await client.agent.run({ + url, + goal, + browser_profile: "stealth", +}); + +if (run.status === RunStatus.COMPLETED) { + return run.result; // structured JSON from the agent +} +``` + +Each service gets 4 agents running in parallel via `Promise.allSettled`: +- **Status page** — navigates to the service's status page, reads current status and recent incidents +- **Deprecation signals** — scans Google for shutdown notices, price increases, EOL announcements +- **HN sentiment** — reads Hacker News Algolia for complaints, alternatives threads, shutdown rumours +- **Pricing page** — checks if free tier still exists and looks for pricing change signals + +--- + +## Setup (self-hosted) + +### 1. Clone and install + +```bash +git clone https://github.com/YOUR_USERNAME/api-deathwatch +cd api-deathwatch +npm install +``` + +### 2. Create the GitHub App + +Go to **github.com → Settings → Developer Settings → GitHub Apps → New GitHub App** + +Fill in: +- **Name**: API Deathwatch (or anything you want) +- **Homepage URL**: your server URL or `https://github.com/YOUR_USERNAME/api-deathwatch` +- **Webhook URL**: `https://YOUR_SERVER/api/webhook` (use smee.io for local dev — see below) +- **Webhook secret**: generate a random string and save it +- **Permissions**: + - Repository contents: **Read** + - Issues: **Read & Write** +- **Subscribe to events**: Installation, Repository + +Click **Create GitHub App**, then: +- Note the **App ID** shown at the top +- Scroll down and click **Generate a private key** — save the `.pem` file in the project root + +### 3. Configure environment + +```bash +cp .env.example .env +``` + +Fill in `.env`: +``` +GITHUB_APP_ID= +GITHUB_WEBHOOK_SECRET= +GITHUB_PRIVATE_KEY_PATH=./private-key.pem +TINYFISH_API_KEY= +``` + +Get a TinyFish key at https://agent.tinyfish.ai/api-keys + +### 4. Run locally with webhook tunnel + +GitHub needs to reach your local server. Use smee.io: + +```bash +# Terminal 1 — start the tunnel +npx smee -u https://smee.io/YOUR_CHANNEL --path /api/webhook --port 3000 + +# Terminal 2 — start the app +npm run dev +``` + +Go back to your GitHub App settings and set the **Webhook URL** to your smee.io URL. + +### 5. Install on a repo + +Go to your GitHub App's page → **Install App** → choose your repo. + +The app will immediately: +1. Read your `package.json` +2. Run health checks via TinyFish agents +3. Open a GitHub Issue with the results + +### 6. Deploy to production + +Deploy to [Railway](https://railway.app), [Render](https://render.com), or any Node.js host. Set the environment variables in your host's dashboard, upload your `private-key.pem` (as an env var or secret file), and update the **Webhook URL** in your GitHub App settings to your live server URL. + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Runtime | Node.js + Express | +| GitHub integration | Octokit Webhooks (GitHub Apps API) | +| Web agents | TinyFish Agent API (`@tiny-fish/sdk`) | +| Scheduling | node-cron (7-day schedule) | + +--- + +## Environment Variables + +| Variable | Description | +|---|---| +| `GITHUB_APP_ID` | Numeric App ID from GitHub App settings | +| `GITHUB_WEBHOOK_SECRET` | Secret set when creating the webhook | +| `GITHUB_PRIVATE_KEY_PATH` | Path to the `.pem` private key file | +| `TINYFISH_API_KEY` | TinyFish API key — get one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | +| `PORT` | Server port (default: 3000) | diff --git a/api-deathwatch/package-lock.json b/api-deathwatch/package-lock.json new file mode 100644 index 000000000..5be18f504 --- /dev/null +++ b/api-deathwatch/package-lock.json @@ -0,0 +1,1439 @@ +{ + "name": "api-deathwatch", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-deathwatch", + "version": "1.0.0", + "dependencies": { + "@octokit/webhooks": "^11.0.0", + "dotenv": "^16.0.0", + "express": "^4.18.0", + "node-cron": "^3.0.0", + "node-fetch": "^2.7.0", + "smee-client": "^2.0.0" + }, + "devDependencies": { + "nodemon": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", + "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==", + "license": "MIT" + }, + "node_modules/@octokit/request-error": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/types": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", + "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^18.0.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-11.1.2.tgz", + "integrity": "sha512-vbZvWXp9ypL8TU0DW9Pr2Fdxpu3PZOnS7oN27feqiWyKL5bsUIXw/BrY4cOTadxeWRpBdaXGY1O5Ek/Xlpus5w==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^3.0.0", + "@octokit/webhooks-methods": "^3.0.0", + "@octokit/webhooks-types": "7.0.3", + "aggregate-error": "^3.1.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-3.0.3.tgz", + "integrity": "sha512-2vM+DCNTJ5vL62O5LagMru6XnYhV4fJslK+5YUkTa6rWlW2S+Tqs1lF9Wr9OGqHfVwpBj3TeztWfVON/eUoW1Q==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.0.3.tgz", + "integrity": "sha512-yDw/89TBaMotdT1/4gxVaiemBhswBd+jPazhL60/rS5WqXC6JZuj5CkDql7Dhc9vAbbkrJBkWsa2sppM/g8jxQ==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "license": "ISC" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smee-client": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/smee-client/-/smee-client-2.0.4.tgz", + "integrity": "sha512-RxXCs0mfaxpI8JF4SeTM51XtRiprzW5g20HVt4aTQ36EB+RaN0aj0m/4EbXLGdfPlqahQ09d3UnJYmALN2CbYw==", + "license": "ISC", + "dependencies": { + "commander": "^12.0.0", + "eventsource": "^2.0.2", + "validator": "^13.11.0" + }, + "bin": { + "smee": "bin/smee.js" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/api-deathwatch/package.json b/api-deathwatch/package.json new file mode 100644 index 000000000..f2b80efeb --- /dev/null +++ b/api-deathwatch/package.json @@ -0,0 +1,24 @@ +{ + "name": "api-deathwatch", + "version": "1.0.0", + "description": "GitHub App that monitors API health and warns you before services die", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "@octokit/webhooks": "^11.0.0", + "dotenv": "^16.0.0", + "express": "^4.18.0", + "node-cron": "^3.0.0", + "smee-client": "^2.0.0", + "@tiny-fish/sdk": "latest" + }, + "devDependencies": { + "nodemon": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/api-deathwatch/src/github-auth.js b/api-deathwatch/src/github-auth.js new file mode 100644 index 000000000..dda683e94 --- /dev/null +++ b/api-deathwatch/src/github-auth.js @@ -0,0 +1,97 @@ +const fetch = require("node-fetch"); + +function createAppJWT() { + const crypto = require("crypto"); + const appId = process.env.GITHUB_APP_ID; + + // Support both file path (local dev) and raw key string (Railway/production) + let privateKey; + if (process.env.GITHUB_PRIVATE_KEY) { + // Railway — key stored as env var, newlines encoded as \n + privateKey = process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"); + } else { + const fs = require("fs"); + const keyPath = process.env.GITHUB_PRIVATE_KEY_PATH || "./private-key.pem"; + privateKey = fs.readFileSync(keyPath, "utf8"); + } + + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, + exp: now + 60, + iss: appId, + }; + + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${body}`; + + const sign = crypto.createSign("RSA-SHA256"); + sign.update(signingInput); + const signature = sign.sign(privateKey, "base64url"); + + return `${signingInput}.${signature}`; +} + +async function getInstallationToken(installationId) { + const jwt = createAppJWT(); + + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + } + ); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Failed to get installation token: ${err}`); + } + + const data = await res.json(); + return data.token; +} + +async function githubRequest(path, installationId, options = {}) { + const token = await getInstallationToken(installationId); + + const res = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`GitHub API error (${res.status}) for ${path}: ${err}`); + } + + return res.json(); +} + +async function getAllInstallations() { + const jwt = createAppJWT(); + + const res = await fetch("https://api.github.com/app/installations", { + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!res.ok) return []; + return res.json(); +} + +module.exports = { getInstallationToken, githubRequest, getAllInstallations }; \ No newline at end of file diff --git a/api-deathwatch/src/handlers/installation.js b/api-deathwatch/src/handlers/installation.js new file mode 100644 index 000000000..faf546b1d --- /dev/null +++ b/api-deathwatch/src/handlers/installation.js @@ -0,0 +1,170 @@ +const { githubRequest } = require("../github-auth"); +const { checkServices } = require("../health-check"); +const { createIssue } = require("../issue-creator"); +const fs = require("fs"); +const path = require("path"); + +// Track last-run times per repo to enforce 7-day minimum between checks +const LAST_RUN_FILE = path.join(__dirname, "../../.last-runs.json"); + +function getLastRuns() { + try { + return JSON.parse(fs.readFileSync(LAST_RUN_FILE, "utf8")); + } catch { + return {}; + } +} + +function setLastRun(repoFullName) { + const runs = getLastRuns(); + runs[repoFullName] = Date.now(); + fs.writeFileSync(LAST_RUN_FILE, JSON.stringify(runs, null, 2)); +} + +function shouldSkip(repoFullName) { + const runs = getLastRuns(); + const last = runs[repoFullName]; + if (!last) return false; + const sevenDays = 7 * 24 * 60 * 60 * 1000; + const elapsed = Date.now() - last; + if (elapsed < sevenDays) { + const daysLeft = Math.ceil((sevenDays - elapsed) / (24 * 60 * 60 * 1000)); + console.log(`Skipping ${repoFullName} — checked ${Math.floor(elapsed / 86400000)}d ago, next check in ${daysLeft}d`); + return true; + } + return false; +} + +async function handleInstallation(installationId, repoFullName, force = false) { + console.log(`Running health check for: ${repoFullName}`); + + // Enforce 7-day minimum between checks (unless forced) + if (!force && shouldSkip(repoFullName)) return; + + try { + const [owner, repo] = repoFullName.split("/"); + let services = []; + + try { + const fileData = await githubRequest( + `/repos/${owner}/${repo}/contents/package.json`, + installationId + ); + const content = Buffer.from(fileData.content, "base64").toString("utf8"); + const pkg = JSON.parse(content); + services = extractServices(pkg); + } catch (err) { + console.log(`Error reading package.json in ${repoFullName}: ${err.message}`); + return; + } + + if (services.length === 0) { + console.log(`No monitored services found in ${repoFullName}`); + return; + } + + console.log(`Found ${services.length} services: ${services.join(", ")}`); + + // Mark as run NOW (before checks) so re-installs don't double-fire + setLastRun(repoFullName); + + // Run checks in batches of 2 to avoid hammering TinyFish credits + const results = await checkServices(services); + + const warnings = results.filter((r) => r.score !== null && r.score < 7); + await createIssue(installationId, owner, repo, results, warnings.length === 0); + + console.log(`Done with ${repoFullName}. ${warnings.length} warnings.`); + } catch (err) { + console.error(`Error processing ${repoFullName}:`, err.message); + } +} + +// Map: package name pattern → clean service name for TinyFish search +// Key = substring to match in package name, Value = clean search name +const SERVICE_MAP = { + // Cloud platforms + "heroku": "Heroku", + "railway": "Railway", + "render": "Render", + "fly.io": "Fly.io", + // Auth + "auth0": "Auth0", + "clerk": "Clerk", + "supabase": "Supabase", + "firebase": "Firebase", + // Databases + "mongoose": "MongoDB", + "mongodb": "MongoDB", + "prisma": "Prisma", + "planetscale": "PlanetScale", + "neon": "Neon", + "turso": "Turso", + // Email + "sendgrid": "SendGrid", + "mailchimp": "Mailchimp", + "resend": "Resend", + "postmark": "Postmark", + // Payments + "stripe": "Stripe", + "paypal": "PayPal", + "square": "Square", + // Communication + "twilio": "Twilio", + "vonage": "Vonage", + "pusher": "Pusher", + "ably": "Ably", + // AI / ML + "openai": "OpenAI", + "anthropic": "Anthropic", + "replicate": "Replicate", + "huggingface": "Hugging Face", + "groq": "Groq", + // Storage / CDN + "cloudinary": "Cloudinary", + "aws-sdk": "AWS", + "@aws-sdk": "AWS", + "azure": "Azure", + // Monitoring / Analytics + "sentry": "Sentry", + "datadog": "Datadog", + "newrelic": "New Relic", + "posthog": "PostHog", + "mixpanel": "Mixpanel", + "segment": "Segment", + // Search + "algolia": "Algolia", + "typesense": "Typesense", + "meilisearch": "Meilisearch", + // CMS + "contentful": "Contentful", + "sanity": "Sanity", + "strapi": "Strapi", + // Maps + "mapbox": "Mapbox", + "@googlemaps": "Google Maps", +}; + +function extractServices(pkg) { + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + }; + + const found = new Map(); // use Map to deduplicate by clean name + + for (const dep of Object.keys(allDeps)) { + const depLower = dep.toLowerCase(); + for (const [pattern, cleanName] of Object.entries(SERVICE_MAP)) { + if (depLower.includes(pattern) && !found.has(cleanName)) { + found.set(cleanName, true); + break; + } + } + } + + // Cap at 6 services per run to keep credit usage reasonable + return Array.from(found.keys()).slice(0, 6); +} + +module.exports = { handleInstallation }; \ No newline at end of file diff --git a/api-deathwatch/src/health-check.js b/api-deathwatch/src/health-check.js new file mode 100644 index 000000000..4059e59e1 --- /dev/null +++ b/api-deathwatch/src/health-check.js @@ -0,0 +1,113 @@ +const { TinyFish, RunStatus } = require("@tiny-fish/sdk"); + +async function runAgent(url, goal) { + const apiKey = process.env.TINYFISH_API_KEY; + if (!apiKey) throw new Error("TINYFISH_API_KEY not set"); + + console.log(` -> Agent: ${url.slice(0, 70)}...`); + + const client = new TinyFish({ apiKey }); + + const run = await client.agent.run({ + url, + goal, + browser_profile: "stealth", + }); + + if (run.status !== RunStatus.COMPLETED) { + console.log(` -> Agent failed: ${run.error?.message || "unknown"}`); + return null; + } + + console.log(` -> Agent done`); + return run.result || null; +} + +// Check all services in parallel +async function checkServices(services) { + console.log(`Checking ${services.length} services in parallel...`); + const checks = services.map((service) => checkOneService(service)); + const results = await Promise.allSettled(checks); + return results.map((r, i) => { + if (r.status === "fulfilled") return r.value; + console.error(`Failed to check ${services[i]}:`, r.reason?.message); + return { service: services[i], score: null, error: r.reason?.message || "Check failed", signals: {} }; + }); +} + +async function checkOneService(service) { + console.log(`Checking: ${service}`); + + const [statusResult, blogResult, hnResult, pricingResult] = await Promise.allSettled([ + // Agent 1 — Status page + runAgent( + `https://www.google.com/search?q=${encodeURIComponent(service + " status page")}`, + `You are on Google search results. Read only visible titles and snippets — do NOT click, do NOT scroll. +Find the official ${service} status page URL. Navigate to it. +On the status page: read current status and last 2 visible incidents only. Do NOT click anything. Stop immediately. +Return ONLY valid JSON: {"current_status": "operational|degraded|outage|unknown", "recent_incidents": ["..."], "incident_count_visible": 0}` + ), + + // Agent 2 — Deprecation/blog signals + runAgent( + `https://www.google.com/search?q=${encodeURIComponent(service + " deprecated OR shutdown OR pricing change OR end of life 2024 OR 2025 OR 2026")}`, + `You are on Google search results. Read only visible titles and snippets — do NOT click anything. +Look for: price increases, tier removals, deprecations, shutdown notices, API version EOL. Ignore unrelated posts. Cap at 8 results. Stop immediately. +Return ONLY valid JSON: {"deprecation_found": false, "signals": [{"title": "...", "summary": "...", "severity": "low|medium|high"}]}` + ), + + // Agent 3 — Hacker News sentiment + runAgent( + `https://hn.algolia.com/?q=${encodeURIComponent(service + " dying OR shutdown OR alternative OR pricing")}&dateRange=pastYear&type=story`, + `You are on Hacker News Algolia search results. Read only visible story titles and point counts — do NOT click, do NOT scroll. +Look for: shutdown rumours, pricing complaints, people asking for alternatives. Cap at 10 results. Stop immediately. +Return ONLY valid JSON: {"sentiment": "positive|neutral|negative", "negative_stories": [{"title": "...", "points": 0}]}` + ), + + // Agent 4 — Pricing page + runAgent( + `https://www.google.com/search?q=${encodeURIComponent(service + " pricing")}`, + `You are on Google search results. Read only visible results — do NOT click, do NOT scroll. +Find the official ${service} pricing page URL. Navigate to it. +Read only visible plan names and prices. Look for: "free tier removed", "price increase", "plan discontinued". Do NOT scroll, do NOT click. Stop immediately. +Return ONLY valid JSON: {"free_tier_exists": true, "pricing_change_signals": [], "plans_visible": ["..."]}` + ), + ]); + + const status = statusResult.status === "fulfilled" ? statusResult.value : null; + const blog = blogResult.status === "fulfilled" ? blogResult.value : null; + const hn = hnResult.status === "fulfilled" ? hnResult.value : null; + const pricing = pricingResult.status === "fulfilled" ? pricingResult.value : null; + + console.log(`Done: ${service} — status=${!!status} blog=${!!blog} hn=${!!hn} pricing=${!!pricing}`); + + return { service, score: scoreService({ status, blog, hn, pricing }), signals: { status, blog, hn, pricing } }; +} + +function scoreService({ status, blog, hn, pricing }) { + let score = 10; + if (status) { + if (status.current_status === "outage") score -= 3; + else if (status.current_status === "degraded") score -= 1.5; + if (status.incident_count_visible > 3) score -= 0.5; + } + if (blog) { + if (blog.deprecation_found) score -= 4; + else { + score -= (blog.signals || []).filter(s => s.severity === "high").length * 1.5; + score -= (blog.signals || []).filter(s => s.severity === "medium").length * 0.5; + } + } + if (hn) { + if (hn.sentiment === "negative") score -= 2; + else if (hn.sentiment === "neutral") score -= 0.5; + score -= (hn.negative_stories || []).filter(s => s.points > 100).length * 0.5; + } + if (pricing) { + if (!pricing.free_tier_exists) score -= 1; + score -= Math.min((pricing.pricing_change_signals || []).length * 0.5, 2); + } + return Math.max(0, Math.min(10, Math.round(score * 10) / 10)); +} + +module.exports = { checkServices }; diff --git a/api-deathwatch/src/index.js b/api-deathwatch/src/index.js new file mode 100644 index 000000000..7152af0c3 --- /dev/null +++ b/api-deathwatch/src/index.js @@ -0,0 +1,58 @@ +require("dotenv").config(); +const express = require("express"); +const { Webhooks } = require("@octokit/webhooks"); +const cron = require("node-cron"); +const { handleInstallation } = require("./handlers/installation"); +const { runWeeklyChecks } = require("./scheduler"); + +const app = express(); +const port = process.env.PORT || 3000; + +const webhooks = new Webhooks({ + secret: process.env.GITHUB_WEBHOOK_SECRET, +}); + +// On install — run immediately (first run, no 7-day skip) +webhooks.on("installation.created", async ({ payload }) => { + console.log(`App installed by: ${payload.installation.account.login}`); + for (const repo of payload.repositories || []) { + await handleInstallation(payload.installation.id, repo.full_name, true); + } +}); + +// When repos are added to an existing installation +webhooks.on("installation_repositories.added", async ({ payload }) => { + for (const repo of payload.repositories_added || []) { + await handleInstallation(payload.installation.id, repo.full_name, true); + } +}); + +// Webhook receiver +app.post("/api/webhook", express.raw({ type: "application/json" }), async (req, res) => { + try { + await webhooks.verifyAndReceive({ + id: req.headers["x-github-delivery"], + name: req.headers["x-github-event"], + signature: req.headers["x-hub-signature-256"], + payload: req.body.toString(), + }); + res.status(200).send("OK"); + } catch (err) { + console.error("Webhook error:", err.message); + res.status(400).send("Bad Request"); + } +}); + +app.get("/", (req, res) => { + res.send("API Deathwatch is running."); +}); + +// Every Monday at 9am — scheduler enforces 7-day gap per repo internally +cron.schedule("0 9 * * 1", async () => { + console.log("Running weekly health checks..."); + await runWeeklyChecks(); +}); + +app.listen(port, () => { + console.log(`API Deathwatch listening on port ${port}`); +}); \ No newline at end of file diff --git a/api-deathwatch/src/issue-creator.js b/api-deathwatch/src/issue-creator.js new file mode 100644 index 000000000..645ff4cf8 --- /dev/null +++ b/api-deathwatch/src/issue-creator.js @@ -0,0 +1,176 @@ +const { githubRequest } = require("./github-auth"); + +function scoreEmoji(score) { + if (score === null) return "❓"; + if (score >= 8) return "✅"; + if (score >= 6) return "⚠️"; + if (score >= 4) return "🟠"; + return "🔴"; +} + +function scoreLabel(score) { + if (score === null) return "Unknown"; + if (score >= 8) return "Healthy"; + if (score >= 6) return "Watch"; + if (score >= 4) return "Concern"; + return "Critical"; +} + +function actionLabel(score) { + if (score === null) return "Manual check recommended"; + if (score >= 8) return "No action needed"; + if (score >= 6) return "Monitor monthly"; + if (score >= 4) return "Start evaluating alternatives"; + return "Begin migration planning now"; +} + +function formatSignalBullets(signals) { + const bullets = []; + + const { status, blog, hn, pricing } = signals; + + if (status) { + if (status.current_status === "outage") bullets.push("🔴 **Status page shows active outage**"); + else if (status.current_status === "degraded") bullets.push("🟠 Status page shows degraded performance"); + else if (status.current_status === "operational") bullets.push("✅ Status page: operational"); + if (status.incident_count_visible > 3) bullets.push(`⚠️ ${status.incident_count_visible} recent incidents visible`); + } + + if (blog?.deprecation_found) { + bullets.push("🔴 **Deprecation or shutdown announcement found**"); + } else if (blog?.signals?.length > 0) { + const high = blog.signals.filter((s) => s.severity === "high"); + const med = blog.signals.filter((s) => s.severity === "medium"); + if (high.length > 0) bullets.push(`🟠 ${high[0].summary || high[0].title}`); + if (med.length > 0) bullets.push(`⚠️ ${med[0].summary || med[0].title}`); + } + + if (hn?.sentiment === "negative" && hn.negative_stories?.length > 0) { + const top = hn.negative_stories[0]; + bullets.push(`💬 HN: "${top.title}" (${top.points} points)`); + } + + if (pricing) { + if (!pricing.free_tier_exists) bullets.push("💸 Free tier may no longer exist"); + if (pricing.pricing_change_signals?.length > 0) { + bullets.push(`💸 Pricing signal: ${pricing.pricing_change_signals[0]}`); + } + } + + if (bullets.length === 0) bullets.push("No warning signals found"); + + return bullets.map((b) => `- ${b}`).join("\n"); +} + +function buildIssueBody(results, allHealthy) { + const now = new Date().toUTCString(); + const nextCheck = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toDateString(); + + // Sort: lowest score first + const sorted = [...results].sort((a, b) => { + if (a.score === null) return -1; + if (b.score === null) return 1; + return a.score - b.score; + }); + + const warnings = sorted.filter((r) => r.score !== null && r.score < 7); + const healthy = sorted.filter((r) => r.score !== null && r.score >= 7); + const errored = sorted.filter((r) => r.score === null); + + let body = `# 🔭 API Deathwatch Report\n\n`; + body += `> Scanned ${results.length} service${results.length !== 1 ? "s" : ""} from your \`package.json\` · ${now}\n`; + body += `> Next scan: **${nextCheck}**\n\n`; + + // Summary table + body += `## Summary\n\n`; + body += `| Service | Score | Status | Action |\n`; + body += `|---------|-------|--------|--------|\n`; + for (const r of sorted) { + const score = r.score !== null ? `${r.score}/10` : "N/A"; + body += `| **${r.service}** | ${score} | ${scoreEmoji(r.score)} ${scoreLabel(r.score)} | ${actionLabel(r.score)} |\n`; + } + + body += `\n---\n\n`; + + // Warning details + if (warnings.length > 0) { + body += `## ⚠️ Services Requiring Attention\n\n`; + for (const r of warnings) { + body += `### ${scoreEmoji(r.score)} ${r.service} — ${r.score}/10\n\n`; + body += `**Signals found:**\n\n`; + body += formatSignalBullets(r.signals); + body += `\n\n**Recommended action:** ${actionLabel(r.score)}\n\n`; + body += `---\n\n`; + } + } + + // Healthy services (collapsed) + if (healthy.length > 0) { + body += `## ✅ Healthy Services\n\n`; + body += `
\nView ${healthy.length} healthy service${healthy.length !== 1 ? "s" : ""}\n\n`; + for (const r of healthy) { + body += `**${r.service}** — ${r.score}/10 · No action needed\n\n`; + } + body += `
\n\n`; + } + + // Errored + if (errored.length > 0) { + body += `## ❓ Could Not Check\n\n`; + for (const r of errored) { + body += `- **${r.service}**: ${r.error || "Check failed"} — please verify manually\n`; + } + body += `\n`; + } + + body += `---\n\n`; + body += `*Powered by [API Deathwatch](https://github.com/apps/api-deathwatch) · `; + body += `[Uninstall](https://github.com/settings/installations) to stop receiving reports*`; + + return body; +} + +async function createIssue(installationId, owner, repo, results, allHealthy) { + const warnings = results.filter((r) => r.score !== null && r.score < 7); + const criticals = results.filter((r) => r.score !== null && r.score < 4); + + // Build title + let title; + if (criticals.length > 0) { + title = `🔴 API Deathwatch: ${criticals.map((r) => r.service).join(", ")} need urgent attention`; + } else if (warnings.length > 0) { + title = `⚠️ API Deathwatch: ${warnings.length} service${warnings.length !== 1 ? "s" : ""} flagged for review`; + } else { + title = `✅ API Deathwatch: All services healthy`; + } + + // Labels to apply (create them if they don't exist) + const labels = ["api-deathwatch"]; + if (criticals.length > 0) labels.push("critical"); + + // Try to ensure labels exist + try { + await githubRequest(`/repos/${owner}/${repo}/labels`, installationId, { + method: "POST", + body: JSON.stringify({ name: "api-deathwatch", color: "0075ca", description: "API health monitoring" }), + }); + } catch { + // Label probably already exists, that's fine + } + + const body = buildIssueBody(results, allHealthy); + + const issue = await githubRequest(`/repos/${owner}/${repo}/issues`, installationId, { + method: "POST", + body: JSON.stringify({ + title, + body, + labels: ["api-deathwatch"], + }), + }); + + console.log(`Created issue #${issue.number}: ${title}`); + return issue; +} + +module.exports = { createIssue }; diff --git a/api-deathwatch/src/scheduler.js b/api-deathwatch/src/scheduler.js new file mode 100644 index 000000000..d6c6cca55 --- /dev/null +++ b/api-deathwatch/src/scheduler.js @@ -0,0 +1,37 @@ +const { getAllInstallations, githubRequest } = require("./github-auth"); +const { handleInstallation } = require("./handlers/installation"); + +async function runWeeklyChecks() { + console.log("Starting weekly checks for all installations..."); + + let installations; + try { + installations = await getAllInstallations(); + } catch (err) { + console.error("Failed to fetch installations:", err.message); + return; + } + + console.log(`Found ${installations.length} installations`); + + for (const installation of installations) { + try { + const { repositories } = await githubRequest( + `/installation/repositories?per_page=100`, + installation.id + ); + + for (const repo of repositories || []) { + // force=false — handleInstallation will skip if checked within 7 days + await handleInstallation(installation.id, repo.full_name, false); + await new Promise((r) => setTimeout(r, 2000)); + } + } catch (err) { + console.error(`Error with installation ${installation.id}:`, err.message); + } + } + + console.log("Weekly checks complete."); +} + +module.exports = { runWeeklyChecks }; \ No newline at end of file