From 6fe4a1479b694cd0ba77e5f75ae9a1d50dc36112 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 10:34:19 +0700 Subject: [PATCH 01/24] feat: add production docker-compose setup for self-hosting - Add Dockerfiles for API, Dashboard, and Checkout (multi-stage builds) - Add docker-compose.prod.yml with all services + health checks - Add .env.production template (clean, no hardcoded secrets) - Add .dockerignore for optimized builds - Enable Next.js standalone output for dashboard and checkout - Update README with comprehensive self-hosting guide - Update .gitignore to allow tracking .env.example and .env.production --- .dockerignore | 16 +++ .env.production | 65 ++++++++++ .gitignore | 5 +- README.md | 103 +++++++++++++++ apps/api/Dockerfile | 43 +++++++ apps/checkout/Dockerfile | 35 +++++ apps/checkout/next.config.ts | 2 +- apps/dashboard/Dockerfile | 36 ++++++ apps/dashboard/next.config.ts | 2 +- apps/dashboard/src/app/(marketing)/page.tsx | 6 +- docker-compose.prod.yml | 136 ++++++++++++++++++++ 11 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.production create mode 100644 apps/api/Dockerfile create mode 100644 apps/checkout/Dockerfile create mode 100644 apps/dashboard/Dockerfile create mode 100644 docker-compose.prod.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e507c31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +.git +.next +dist +.turbo +.env +.env.development +.env.production +*.md +!README.md +.github +.husky +.vscode +.agents +.scripts +docs diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..7e4319e --- /dev/null +++ b/.env.production @@ -0,0 +1,65 @@ +# ══════════════════════════════════════════════ +# KnotEngine — Production Environment +# ══════════════════════════════════════════════ +# Copy this file to .env and replace all placeholder values +# cp .env.production .env +# ══════════════════════════════════════════════ + +# ── Database (Docker MongoDB) ── +MONGO_USER=knotadmin +MONGO_PASSWORD= + +# ── Redis (Docker Redis) ── +REDIS_PASSWORD= + +# ── Service URLs ── +PORT=5050 +PUBLIC_URL=https://api.yourdomain.com +DASHBOARD_URL=https://dashboard.yourdomain.com +NEXT_PUBLIC_CHECKOUT_URL=https://checkout.yourdomain.com +CHECKOUT_BASE_URL=https://checkout.yourdomain.com + +# ── Security & Secrets ── +# Generate with: openssl rand -hex 32 +JWT_SECRET= +WEBHOOK_SECRET= +INTERNAL_SECRET=knot_internal_ +NEXTAUTH_SECRET=knot_secret_ +NEXTAUTH_URL=https://dashboard.yourdomain.com + +# ── Blockchain Configuration ── +BITCOIN_NETWORK=bitcoin +TATUM_API_KEY= +TATUM_WEBHOOK_SECRET= + +# ── Alchemy (EVM Failover) ── +ALCHEMY_API_KEY= +ALCHEMY_AUTH_TOKEN= +ALCHEMY_NOTIFY_WEBHOOK_ID= +ALCHEMY_WEBHOOK_SIGNING_KEY= + +# ── Business Logic ── +PLATFORM_FEE_RATE=0.01 +MIN_INVOICE_AMOUNT=1.00 +MIN_FEE_USD=0.05 +WELCOME_CREDIT_AMOUNT=5.00 +AFFILIATE_SIGNUP_BONUS=5.00 + +# ── Platform Collection Wallets ── +PLATFORM_FEE_WALLET_BTC= +PLATFORM_FEE_WALLET_LTC= +PLATFORM_FEE_WALLET_EVM= + +# ── Email Configuration ── +# Option 1: Gmail SMTP (for testing) +GMAIL_USER= +GMAIL_APP_PASSWORD= +FROM_EMAIL=KnotEngine + +# Option 2: Resend (for production) +RESEND_API_KEY=re_ + +# ── Media Storage (Optional) ── +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= diff --git a/.gitignore b/.gitignore index 7f80e02..df01b51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules .DS_Store dist -.env* +.env +.env.local +.env.development +.env.*.local .next .turbo *.log diff --git a/README.md b/README.md index 3950ce6..dbf0e55 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,109 @@ knotengine/ --- +## 🏠 Self-Hosting + +KnotEngine is fully self-hostable under the AGPL-3.0 license. Deploy everything on a single VPS with one command. + +### Minimum Requirements + +| Resource | Minimum | Recommended | +| :------- | :----------------------- | :----------- | +| CPU | 1 vCPU | 2 vCPU | +| RAM | 1 GB | 2 GB | +| Disk | 10 GB | 20 GB | +| OS | Ubuntu 24.04 / Debian 12 | Alpine Linux | + +### Quick Deploy + +```bash +# 1. Clone the repository +git clone https://github.com/qodinger/knotengine.git +cd knotengine + +# 2. Configure environment +cp .env.production .env +# Edit .env and replace all values with your secrets + +# 3. Generate secrets +openssl rand -hex 32 # for JWT_SECRET, WEBHOOK_SECRET, etc. + +# 4. Build and start everything +docker compose -f docker-compose.prod.yml up -d --build +``` + +### Services + +| Service | URL | Port | +| :-------- | :------------------------ | :---- | +| API | `http://your-server:5050` | 5050 | +| Dashboard | `http://your-server:5052` | 5052 | +| Checkout | `http://your-server:5051` | 5051 | +| MongoDB | Internal (not exposed) | 27017 | +| Redis | Internal (not exposed) | 6379 | + +### Reverse Proxy (Recommended) + +Set up Nginx with SSL to route traffic: + +```nginx +server { + listen 80; + server_name api.yourdomain.com; + location / { + proxy_pass http://127.0.0.1:5050; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; # WebSocket support + } +} + +server { + listen 80; + server_name dashboard.yourdomain.com; + location / { + proxy_pass http://127.0.0.1:5052; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} + +server { + listen 80; + server_name checkout.yourdomain.com; + location / { + proxy_pass http://127.0.0.1:5051; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +Then add SSL with Certbot: + +```bash +sudo certbot --nginx -d api.yourdomain.com -d dashboard.yourdomain.com -d checkout.yourdomain.com +``` + +### Management + +```bash +# View logs +docker compose -f docker-compose.prod.yml logs -f api + +# Restart a service +docker compose -f docker-compose.prod.yml restart dashboard + +# Update to latest version +git pull && docker compose -f docker-compose.prod.yml up -d --build + +# Stop everything +docker compose -f docker-compose.prod.yml down +``` + +--- + ## 🤝 Contributing Contributions are welcome! Please follow [Conventional Commits](https://www.conventionalcommits.org) for all commit messages — enforced via `commitlint`. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..1ca152b --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,43 @@ +# ────────────────────────────────────────────── +# KnotEngine — API Engine (Production) +# ────────────────────────────────────────────── + +FROM node:20-alpine AS base +RUN apk add --no-cache python3 make g++ +WORKDIR /app + +# ── Dependencies ── +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api/package.json ./apps/api/ +COPY packages/crypto/package.json ./packages/crypto/ +COPY packages/database/package.json ./packages/database/ +COPY packages/types/package.json ./packages/types/ +RUN corepack enable && pnpm install --frozen-lockfile --filter api --filter @qodinger/knot-crypto --filter @qodinger/knot-database --filter @qodinger/knot-types + +# ── Build ── +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/packages/crypto/node_modules ./packages/crypto/node_modules +COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules +COPY --from=deps /app/packages/types/node_modules ./packages/types/node_modules +COPY . . +RUN corepack enable && pnpm --filter api build + +# ── Production ── +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache dumb-init + +COPY --from=builder /app/apps/api/dist ./dist +COPY --from=builder /app/apps/api/package.json ./package.json +COPY --from=builder /app/apps/api/node_modules ./node_modules +COPY --from=builder /app/packages/crypto ./packages/crypto +COPY --from=builder /app/packages/database ./packages/database +COPY --from=builder /app/packages/types ./packages/types + +ENV NODE_ENV=production +EXPOSE 5050 + +CMD ["dumb-init", "--", "node", "dist/main.js"] diff --git a/apps/checkout/Dockerfile b/apps/checkout/Dockerfile new file mode 100644 index 0000000..18d6dd0 --- /dev/null +++ b/apps/checkout/Dockerfile @@ -0,0 +1,35 @@ +# ────────────────────────────────────────────── +# KnotEngine — Checkout (Production) +# ────────────────────────────────────────────── + +FROM node:20-alpine AS base +WORKDIR /app + +# ── Dependencies ── +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/checkout/package.json ./apps/checkout/ +COPY packages/types/package.json ./packages/types/ +RUN corepack enable && pnpm install --frozen-lockfile --filter checkout --filter @qodinger/knot-types + +# ── Build ── +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/checkout/node_modules ./apps/checkout/node_modules +COPY --from=deps /app/packages/types/node_modules ./packages/types/node_modules +COPY . . +RUN corepack enable && pnpm --filter checkout build + +# ── Production ── +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache dumb-init + +ENV NODE_ENV=production + +COPY --from=builder /app/apps/checkout/.next/standalone ./ +COPY --from=builder /app/apps/checkout/.next/static ./apps/checkout/.next/static +COPY --from=builder /app/apps/checkout/public ./apps/checkout/public + +EXPOSE 5051 +CMD ["dumb-init", "--", "node", "apps/checkout/server.js"] diff --git a/apps/checkout/next.config.ts b/apps/checkout/next.config.ts index e9ffa30..68a6c64 100644 --- a/apps/checkout/next.config.ts +++ b/apps/checkout/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile new file mode 100644 index 0000000..bcf40c7 --- /dev/null +++ b/apps/dashboard/Dockerfile @@ -0,0 +1,36 @@ +# ────────────────────────────────────────────── +# KnotEngine — Dashboard (Production) +# ────────────────────────────────────────────── + +FROM node:20-alpine AS base +WORKDIR /app + +# ── Dependencies ── +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/dashboard/package.json ./apps/dashboard/ +COPY packages/types/package.json ./packages/types/ +RUN corepack enable && pnpm install --frozen-lockfile --filter dashboard --filter @qodinger/knot-types + +# ── Build ── +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/dashboard/node_modules ./apps/dashboard/node_modules +COPY --from=deps /app/packages/types/node_modules ./packages/types/node_modules +COPY . . +RUN corepack enable && pnpm --filter dashboard build + +# ── Production ── +FROM node:20-alpine AS runner +WORKDIR /app +RUN apk add --no-cache dumb-init + +ENV NODE_ENV=production + +# Next.js needs these for standalone output +COPY --from=builder /app/apps/dashboard/.next/standalone ./ +COPY --from=builder /app/apps/dashboard/.next/static ./apps/dashboard/.next/static +COPY --from=builder /app/apps/dashboard/public ./apps/dashboard/public + +EXPOSE 5052 +CMD ["dumb-init", "--", "node", "apps/dashboard/server.js"] diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 2d74475..5ac45ba 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", async headers() { return [ { diff --git a/apps/dashboard/src/app/(marketing)/page.tsx b/apps/dashboard/src/app/(marketing)/page.tsx index 822c821..bd038dc 100644 --- a/apps/dashboard/src/app/(marketing)/page.tsx +++ b/apps/dashboard/src/app/(marketing)/page.tsx @@ -55,7 +55,7 @@ export default function MarketingPage() { backgroundSize: "60px 60px", }} /> -
+
@@ -68,7 +68,7 @@ export default function MarketingPage() {

Accept crypto payments.
- + Non-custodial.

@@ -135,7 +135,7 @@ export default function MarketingPage() { {features.map((feature, i) => (
{ process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet + + # ── Dashboard ── + dashboard: + build: + context: . + dockerfile: apps/dashboard/Dockerfile + container_name: knot_dashboard + restart: unless-stopped + ports: + - "127.0.0.1:5052:5052" + env_file: .env + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} + NEXT_PUBLIC_CHECKOUT_URL: ${CHECKOUT_URL:-http://localhost:5051} + depends_on: + - api + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5052/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet + + # ── Checkout ── + checkout: + build: + context: . + dockerfile: apps/checkout/Dockerfile + container_name: knot_checkout + restart: unless-stopped + ports: + - "127.0.0.1:5051:5051" + env_file: .env + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} + depends_on: + - api + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5051/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet + +volumes: + mongo_data: + driver: local + redis_data: + driver: local + +networks: + knotnet: + driver: bridge From 5080977caa0c70af5851dc4740c0818dd8b658b1 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 10:39:42 +0700 Subject: [PATCH 02/24] chore: standardize self-hosting setup for one-command deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename docker-compose.prod.yml → docker-compose.yml - Add image tags for future Docker Hub publishing - Add one-line install script (scripts/install.sh) - Auto-generate secrets on first install - Update README with curl install command --- README.md | 28 +++++---- docker-compose.prod.yml | 136 ---------------------------------------- docker-compose.yml | 131 +++++++++++++++++++++++++++++++++++--- scripts/install.sh | 102 ++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 156 deletions(-) delete mode 100644 docker-compose.prod.yml create mode 100644 scripts/install.sh diff --git a/README.md b/README.md index dbf0e55..9419752 100644 --- a/README.md +++ b/README.md @@ -201,22 +201,24 @@ KnotEngine is fully self-hostable under the AGPL-3.0 license. Deploy everything | Disk | 10 GB | 20 GB | | OS | Ubuntu 24.04 / Debian 12 | Alpine Linux | -### Quick Deploy +### Quick Install ```bash -# 1. Clone the repository +curl -fsSL https://raw.githubusercontent.com/qodinger/knotengine/main/scripts/install.sh | bash +``` + +Or manual setup: + +```bash +# 1. Clone git clone https://github.com/qodinger/knotengine.git cd knotengine -# 2. Configure environment +# 2. Configure (secrets auto-generated) cp .env.production .env -# Edit .env and replace all values with your secrets - -# 3. Generate secrets -openssl rand -hex 32 # for JWT_SECRET, WEBHOOK_SECRET, etc. -# 4. Build and start everything -docker compose -f docker-compose.prod.yml up -d --build +# 3. Start everything +docker compose up -d --build ``` ### Services @@ -277,16 +279,16 @@ sudo certbot --nginx -d api.yourdomain.com -d dashboard.yourdomain.com -d checko ```bash # View logs -docker compose -f docker-compose.prod.yml logs -f api +docker compose logs -f api # Restart a service -docker compose -f docker-compose.prod.yml restart dashboard +docker compose restart dashboard # Update to latest version -git pull && docker compose -f docker-compose.prod.yml up -d --build +git pull && docker compose up -d --build # Stop everything -docker compose -f docker-compose.prod.yml down +docker compose down ``` --- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 34db0d9..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,136 +0,0 @@ -# ══════════════════════════════════════════════ -# KnotEngine — Production Docker Compose -# ══════════════════════════════════════════════ -# Usage: -# 1. Copy .env.example to .env and configure -# 2. docker compose -f docker-compose.prod.yml up -d -# ══════════════════════════════════════════════ - -services: - # ── MongoDB ── - mongodb: - image: mongo:7-jammy - container_name: knot_mongodb - restart: unless-stopped - environment: - MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-knotadmin} - MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} - MONGO_INITDB_DATABASE: knotengine - ports: - - "127.0.0.1:27017:27017" - volumes: - - mongo_data:/data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 })"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - knotnet - - # ── Redis ── - redis: - image: redis:7-alpine - container_name: knot_redis - restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru - ports: - - "127.0.0.1:6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - networks: - - knotnet - - # ── API Engine ── - api: - build: - context: . - dockerfile: apps/api/Dockerfile - container_name: knot_api - restart: unless-stopped - ports: - - "127.0.0.1:5050:5050" - env_file: .env - environment: - NODE_ENV: production - DATABASE_URL: mongodb://${MONGO_USER:-knotadmin}:${MONGO_PASSWORD}@mongodb:27017/knotengine?authSource=admin - REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 - depends_on: - mongodb: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5050/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - networks: - - knotnet - - # ── Dashboard ── - dashboard: - build: - context: . - dockerfile: apps/dashboard/Dockerfile - container_name: knot_dashboard - restart: unless-stopped - ports: - - "127.0.0.1:5052:5052" - env_file: .env - environment: - NODE_ENV: production - NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} - NEXT_PUBLIC_CHECKOUT_URL: ${CHECKOUT_URL:-http://localhost:5051} - depends_on: - - api - healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5052/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - networks: - - knotnet - - # ── Checkout ── - checkout: - build: - context: . - dockerfile: apps/checkout/Dockerfile - container_name: knot_checkout - restart: unless-stopped - ports: - - "127.0.0.1:5051:5051" - env_file: .env - environment: - NODE_ENV: production - NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} - depends_on: - - api - healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5051/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - networks: - - knotnet - -volumes: - mongo_data: - driver: local - redis_data: - driver: local - -networks: - knotnet: - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 616f8ac..64928a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,142 @@ -version: "3.8" +# ══════════════════════════════════════════════ +# KnotEngine — Self-Hosted Deployment +# ══════════════════════════════════════════════ +# Usage: +# 1. cp .env.production .env && edit .env +# 2. docker compose up -d --build +# +# For pre-built images (once published): +# docker compose up -d +# ══════════════════════════════════════════════ + services: + # ── MongoDB ── mongodb: image: mongo:7-jammy - container_name: knotengine_mongo + container_name: knot_mongodb + restart: unless-stopped environment: - MONGO_INITDB_ROOT_USERNAME: tyecode - MONGO_INITDB_ROOT_PASSWORD: knotengine_local_password + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-knotadmin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} MONGO_INITDB_DATABASE: knotengine ports: - - "27017:27017" + - "127.0.0.1:27017:27017" volumes: - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 })"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - knotnet + # ── Redis ── redis: image: redis:7-alpine - container_name: knotengine_redis + container_name: knot_redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru ports: - - "6379:6379" + - "127.0.0.1:6379:6379" volumes: - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - knotnet + + # ── API Engine ── + api: + image: knotengine/api:latest + build: + context: . + dockerfile: apps/api/Dockerfile + container_name: knot_api + restart: unless-stopped + ports: + - "127.0.0.1:5050:5050" + env_file: .env + environment: + NODE_ENV: production + DATABASE_URL: mongodb://${MONGO_USER:-knotadmin}:${MONGO_PASSWORD}@mongodb:27017/knotengine?authSource=admin + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5050/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet + + # ── Dashboard ── + dashboard: + image: knotengine/dashboard:latest + build: + context: . + dockerfile: apps/dashboard/Dockerfile + container_name: knot_dashboard + restart: unless-stopped + ports: + - "127.0.0.1:5052:5052" + env_file: .env + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} + NEXT_PUBLIC_CHECKOUT_URL: ${CHECKOUT_URL:-http://localhost:5051} + depends_on: + - api + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5052/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet + + # ── Checkout ── + checkout: + image: knotengine/checkout:latest + build: + context: . + dockerfile: apps/checkout/Dockerfile + container_name: knot_checkout + restart: unless-stopped + ports: + - "127.0.0.1:5051:5051" + env_file: .env + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} + depends_on: + - api + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5051/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - knotnet volumes: mongo_data: + driver: local redis_data: + driver: local + +networks: + knotnet: + driver: bridge diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..597b92a --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# ══════════════════════════════════════════════ +# KnotEngine — One-Command Self-Host Install +# ══════════════════════════════════════════════ +# Usage: +# curl -fsSL https://raw.githubusercontent.com/qodinger/knotengine/main/scripts/install.sh | bash +# ══════════════════════════════════════════════ + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo -e "${GREEN}🚀 KnotEngine Self-Host Installer${NC}" +echo "" + +# ── Check Prerequisites ── +check_command() { + if ! command -v "$1" &> /dev/null; then + echo -e "${RED}❌ $1 is required but not installed.${NC}" + echo "Please install $1 and try again." + exit 1 + fi +} + +check_command "docker" +check_command "docker compose" + +echo -e "${GREEN}✅ Docker and Docker Compose found${NC}" + +# ── Setup Directory ── +INSTALL_DIR="${KNOTENGINE_DIR:-$HOME/knotengine}" + +if [ -d "$INSTALL_DIR" ]; then + echo -e "${YELLOW}⚠️ Directory $INSTALL_DIR already exists${NC}" + read -p "Use existing directory? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${RED}Installation cancelled.${NC}" + exit 1 + fi +else + mkdir -p "$INSTALL_DIR" + echo -e "${GREEN}📁 Created $INSTALL_DIR${NC}" +fi + +cd "$INSTALL_DIR" + +# ── Clone or Pull ── +if [ -d ".git" ]; then + echo -e "${YELLOW}📦 Updating existing installation...${NC}" + git pull origin main +else + echo -e "${YELLOW}📦 Cloning KnotEngine...${NC}" + git clone https://github.com/qodinger/knotengine.git . +fi + +# ── Configure Environment ── +if [ ! -f ".env" ]; then + echo -e "${GREEN}🔧 Creating .env from template...${NC}" + cp .env.production .env + + # Generate secrets + JWT_SECRET=$(openssl rand -hex 32) + WEBHOOK_SECRET=$(openssl rand -hex 32) + INTERNAL_SECRET="knot_internal_$(openssl rand -hex 16)" + NEXTAUTH_SECRET="knot_secret_$(openssl rand -hex 16)" + MONGO_PASSWORD=$(openssl rand -hex 16) + REDIS_PASSWORD=$(openssl rand -hex 16) + + # Replace placeholders + sed -i.bak "s//$MONGO_PASSWORD/g" .env + sed -i.bak "s//$JWT_SECRET/g" .env + sed -i.bak "s/knot_internal_/$INTERNAL_SECRET/g" .env + sed -i.bak "s/knot_secret_/$NEXTAUTH_SECRET/g" .env + rm -f .env.bak + + echo -e "${GREEN}✅ Secrets generated automatically${NC}" + echo -e "${YELLOW}⚠️ Please edit .env and configure:${NC}" + echo " - TATUM_API_KEY" + echo " - ALCHEMY_API_KEY" + echo " - PUBLIC_URL (your domain)" + echo " - PLATFORM_FEE_WALLET_* (your wallet addresses)" + echo " - Email settings" +else + echo -e "${GREEN}✅ .env already exists, skipping${NC}" +fi + +echo "" +echo -e "${GREEN}🎉 Installation complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Edit .env: cd $INSTALL_DIR && nano .env" +echo " 2. Start services: docker compose up -d --build" +echo " 3. View logs: docker compose logs -f api" +echo "" +echo "Once running:" +echo " API: http://localhost:5050" +echo " Dashboard: http://localhost:5052" +echo " Checkout: http://localhost:5051" From ee4f92b1c1638c678c9153832c9d50872912e71a Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:09:19 +0700 Subject: [PATCH 03/24] feat(sdk): v0.5.0 with 10 new methods, error classes, and constants - Add listInvoices, cancelInvoice, resolveInvoice, getMerchant, updateMerchant, rotateApiKey, rotateWebhookSecret, sendTestWebhook, getAssetConfig, getMerchantStats - Add KnotAuthenticationError, KnotValidationError, KnotNotFoundError, KnotRateLimitError with HTTP status codes - Add WEBHOOK_EVENTS, CURRENCIES, INVOICE_STATUSES constants - Add USDC_ERC20 and USDC_POLYGON currency support - Add request timeout configuration - Comprehensive README with full API reference --- packages/sdk/README.md | 181 +++++++++++++++++++- packages/sdk/package.json | 14 +- packages/sdk/src/index.test.ts | 204 +++++++++++++++++++--- packages/sdk/src/index.ts | 302 ++++++++++++++++++++++++++++++++- 4 files changed, 668 insertions(+), 33 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ea737ec..bc2415d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -17,7 +17,7 @@ pnpm add @qodinger/knot-sdk ### Initialize the Client -```javascript +```typescript import { KnotClient } from "@qodinger/knot-sdk"; const knot = new KnotClient({ @@ -27,27 +27,103 @@ const knot = new KnotClient({ baseUrl: process.env.KNOT_API_URL || "http://localhost:5050", // Optional: set the webhook secret for signature verification webhookSecret: "knot_wh_your_webhook_secret", + // Optional: request timeout in milliseconds (default: 30000) + timeout: 30000, }); ``` ### Create an Invoice -```javascript +```typescript const invoice = await knot.createInvoice({ amount_usd: 49.99, - currency: "BTC", + currency: "BTC", // or "ETH", "LTC", "USDT_ERC20", "USDT_POLYGON", "USDC_ERC20", "USDC_POLYGON" metadata: { orderId: "order_12345", }, + ttl_minutes: 30, // optional: invoice TTL (15-1440 min, default 30) + description: "Payment for order #12345", // optional + is_testnet: false, // optional: use testnet for testing }); +console.log(`Invoice ID: ${invoice.invoice_id}`); console.log(`Pay to: ${invoice.pay_address}`); +console.log(`Amount: ${invoice.crypto_amount} ${invoice.crypto_currency}`); console.log(`Checkout: ${invoice.checkout_url}`); ``` +### Get Invoice Status + +```typescript +const invoice = await knot.getInvoice("inv_abc123..."); +console.log(`Status: ${invoice.status}`); +// Status can be: "pending", "mempool_detected", "confirming", "confirmed", "expired", "failed" +``` + +### List Invoices + +```typescript +const { invoices, total, page, limit } = await knot.listInvoices({ + status: "confirmed", + page: 1, + limit: 20, + include_testnet: false, +}); + +console.log(`Found ${total} invoices`); +``` + +### Cancel an Invoice + +```typescript +const cancelled = await knot.cancelInvoice("inv_abc123..."); +console.log(`Invoice status: ${cancelled.status}`); // "expired" +``` + +### Merchant Management + +```typescript +// Get merchant profile +const merchant = await knot.getMerchant(); +console.log(`Merchant ID: ${merchant.merchant_id}`); + +// Update merchant settings +await knot.updateMerchant({ + webhook_url: "https://yourdomain.com/webhooks/knot", + branding: { + name: "My Store", + color: "#ffffff", + }, +}); + +// Get dashboard stats +const stats = await knot.getMerchantStats("7d"); // "24h", "7d", or "30d" + +// Rotate API key (old key is immediately invalidated) +const newKey = await knot.rotateApiKey(); +console.log(`New API key: ${newKey.key}`); // Only shown once! + +// Rotate webhook secret +const newSecret = await knot.rotateWebhookSecret(); +console.log(`New webhook secret: ${newSecret.secret}`); + +// Send a test webhook +const result = await knot.sendTestWebhook(); +console.log(`Test webhook: ${result.message}`); +``` + +### Get Supported Assets + +```typescript +const config = await knot.getAssetConfig(); +console.log(config.currencies); // ["BTC", "LTC", "ETH", "USDT_ERC20", ...] +console.log(config.networks); // ["bitcoin", "litecoin", "ethereum", "polygon"] +``` + ### Verify a Webhook Signature -```javascript +```typescript +// In your webhook endpoint handler: const isValid = knot.verifyWebhook(rawBody, signature); // Or pass the secret manually @@ -61,13 +137,110 @@ if (!isValid) { const event = JSON.parse(rawBody); if (event.event === "invoice.confirmed") { // Fulfill the order + console.log(`Payment confirmed for invoice: ${event.invoice_id}`); } ``` +## 📦 API Reference + +### `KnotClient` Class + +| Method | Description | Returns | +| -------------------------------------- | ----------------------------- | ---------------------------------- | +| `createInvoice(data)` | Create a new payment invoice | `Promise` | +| `getInvoice(invoiceId)` | Get invoice status | `Promise` | +| `listInvoices(params?)` | List invoices with pagination | `Promise` | +| `cancelInvoice(invoiceId)` | Cancel a pending invoice | `Promise` | +| `resolveInvoice(invoiceId)` | Manually confirm an invoice | `Promise` | +| `getMerchant()` | Get merchant profile | `Promise` | +| `updateMerchant(data)` | Update merchant settings | `Promise` | +| `rotateApiKey()` | Generate new API key | `Promise` | +| `rotateWebhookSecret()` | Generate new webhook secret | `Promise` | +| `sendTestWebhook()` | Send test webhook | `Promise<{success, message}>` | +| `getAssetConfig()` | Get supported currencies | `Promise` | +| `getMerchantStats(period?)` | Get dashboard stats | `Promise>` | +| `verifyWebhook(payload, sig, secret?)` | Verify webhook HMAC signature | `boolean` | + +### Error Classes + +| Class | HTTP Status | Description | +| ------------------------- | ----------- | ------------------------------------------- | +| `KnotError` | Any | Base error class | +| `KnotAuthenticationError` | 401 | Invalid or missing API key | +| `KnotValidationError` | 400 | Invalid request parameters | +| `KnotNotFoundError` | 404 | Resource not found | +| `KnotRateLimitError` | 429 | Rate limit exceeded (includes `retryAfter`) | + +### Constants + +```typescript +import { + WEBHOOK_EVENTS, + CURRENCIES, + INVOICE_STATUSES, +} from "@qodinger/knot-sdk"; + +// Webhook event types +WEBHOOK_EVENTS.INVOICE_CONFIRMED; // "invoice.confirmed" +WEBHOOK_EVENTS.INVOICE_MEMPOOL_DETECTED; // "invoice.mempool_detected" +WEBHOOK_EVENTS.INVOICE_CONFIRMING; // "invoice.confirming" +WEBHOOK_EVENTS.INVOICE_EXPIRED; // "invoice.expired" +WEBHOOK_EVENTS.INVOICE_FAILED; // "invoice.failed" +WEBHOOK_EVENTS.INVOICE_PARTIALLY_PAID; // "invoice.partially_paid" +WEBHOOK_EVENTS.INVOICE_OVERPAID; // "invoice.overpaid" + +// Supported currencies +CURRENCIES.BTC; // "BTC" +CURRENCIES.LTC; // "LTC" +CURRENCIES.ETH; // "ETH" +CURRENCIES.USDT_ERC20; // "USDT_ERC20" +CURRENCIES.USDT_POLYGON; // "USDT_POLYGON" +CURRENCIES.USDC_ERC20; // "USDC_ERC20" +CURRENCIES.USDC_POLYGON; // "USDC_POLYGON" + +// Invoice statuses +INVOICE_STATUSES.PENDING; // "pending" +INVOICE_STATUSES.MEMPOOL_DETECTED; // "mempool_detected" +INVOICE_STATUSES.CONFIRMING; // "confirming" +INVOICE_STATUSES.CONFIRMED; // "confirmed" +INVOICE_STATUSES.EXPIRED; // "expired" +INVOICE_STATUSES.FAILED; // "failed" +``` + +## 🔀 Complete Payment Flow + +``` +1. Create Invoice + const invoice = await knot.createInvoice({ amount_usd: 49.99, currency: "BTC" }); + ↓ +2. Redirect customer to checkout + res.redirect(invoice.checkout_url); + ↓ +3. Customer pays crypto to invoice.pay_address + ↓ +4. KnotEngine detects payment via blockchain provider + ↓ +5. Receive webhook notification + POST https://yourdomain.com/webhooks/knot + ↓ +6. Verify webhook & fulfill order + if (knot.verifyWebhook(body, sig) && event.event === "invoice.confirmed") { + fulfillOrder(event.metadata.orderId); + } +``` + ## 🛡️ Trust & Security KnotEngine is strictly **non-custodial**. This SDK interacts with the KnotEngine API to manage invoices and merchant configurations, but **never handles your private keys or seed phrases**. All funds are derived from your public `xPub` and deposited directly into your wallet. +### Security Best Practices + +1. **Never expose your API key** — only use `knot_sk_live_...` keys server-side +2. **Always verify webhook signatures** — use `verifyWebhook()` before processing events +3. **Use IP allowlisting** — restrict API access to your server IPs via dashboard +4. **Rotate keys regularly** — use `rotateApiKey()` and `rotateWebhookSecret()` periodically +5. **Enable 2FA** — protect your merchant account with TOTP two-factor authentication + ## 📄 License AGPL-3.0 — see [LICENSE](../../LICENSE) for details. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 86cd383..a475034 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -5,12 +5,24 @@ "type": "git", "url": "https://github.com/qodinger/knotengine.git" }, - "version": "0.4.0", + "version": "0.5.0", "description": "Standard Node.js SDK for KnotEngine — The Non-Custodial Payment Gateway.", "license": "AGPL-3.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, "files": [ "dist" ], diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index d3769fd..0f0bb83 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -1,44 +1,198 @@ -import { describe, it, expect, vi } from "vitest"; -import { KnotClient } from "./index"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + KnotClient, + WEBHOOK_EVENTS, + CURRENCIES, + INVOICE_STATUSES, +} from "./index"; import * as crypto from "crypto"; -vi.mock("axios"); +const mockInterceptors = { + response: { + use: vi.fn(), + }, +}; + +const mockAxiosInstance = { + post: vi.fn(), + get: vi.fn(), + patch: vi.fn(), + interceptors: mockInterceptors, +}; + +vi.mock("axios", () => ({ + default: { + create: vi.fn(() => mockAxiosInstance), + }, +})); describe("KnotClient SDK", () => { const config = { apiKey: "knot_test_123", webhookSecret: "whsec_test_123", + baseUrl: "http://localhost:5050", }; - it("should initialize with correct config", () => { - const sdk = new KnotClient(config); - expect(sdk).toBeDefined(); + beforeEach(() => { + vi.clearAllMocks(); + mockAxiosInstance.post.mockReset(); + mockAxiosInstance.get.mockReset(); + mockAxiosInstance.patch.mockReset(); + mockInterceptors.response.use.mockReset(); }); - it("should verify a valid webhook signature", () => { - const sdk = new KnotClient(config); - const payload = JSON.stringify({ event: "invoice.confirmed" }); + describe("Initialization", () => { + it("should initialize with correct config", () => { + const sdk = new KnotClient(config); + expect(sdk).toBeDefined(); + }); - // Calculate expected signature manually to verify SDK logic - const hmac = crypto.createHmac("sha256", config.webhookSecret); - hmac.update(payload); - const signature = hmac.digest("hex"); + it("should use default baseUrl if not provided", () => { + const sdk = new KnotClient({ apiKey: "test" }); + expect(sdk).toBeDefined(); + }); - const isValid = sdk.verifyWebhook(payload, signature); - expect(isValid).toBe(true); + it("should use custom timeout if provided", () => { + const sdk = new KnotClient({ ...config, timeout: 5000 }); + expect(sdk).toBeDefined(); + }); }); - it("should reject an invalid webhook signature", () => { - const sdk = new KnotClient(config); - const payload = JSON.stringify({ event: "invoice.confirmed" }); - const isValid = sdk.verifyWebhook(payload, "invalid_signature"); - expect(isValid).toBe(false); + describe("Webhook Verification", () => { + it("should verify a valid webhook signature", () => { + const sdk = new KnotClient(config); + const payload = JSON.stringify({ event: "invoice.confirmed" }); + + const hmac = crypto.createHmac("sha256", config.webhookSecret); + hmac.update(payload); + const signature = hmac.digest("hex"); + + const isValid = sdk.verifyWebhook(payload, signature); + expect(isValid).toBe(true); + }); + + it("should reject an invalid webhook signature", () => { + const sdk = new KnotClient(config); + const payload = JSON.stringify({ event: "invoice.confirmed" }); + const isValid = sdk.verifyWebhook(payload, "invalid_signature"); + expect(isValid).toBe(false); + }); + + it("should throw error if webhookSecret is missing during verification", () => { + const sdk = new KnotClient({ apiKey: "test" }); + expect(() => sdk.verifyWebhook("{}", "sig")).toThrow( + "Webhook secret not provided", + ); + }); + + it("should allow manual secret override", () => { + const sdk = new KnotClient(config); + const payload = JSON.stringify({ event: "invoice.confirmed" }); + const customSecret = "custom_secret"; + + const hmac = crypto.createHmac("sha256", customSecret); + hmac.update(payload); + const signature = hmac.digest("hex"); + + const isValid = sdk.verifyWebhook(payload, signature, customSecret); + expect(isValid).toBe(true); + }); + }); + + describe("Invoice Operations", () => { + const mockInvoice = { + invoice_id: "inv_test_123", + amount_usd: 49.99, + crypto_amount: 0.00075, + crypto_currency: "BTC", + pay_address: "bc1qtestaddress", + status: "pending", + checkout_url: "http://localhost:5051/checkout/inv_test_123", + expires_at: "2026-05-16T12:00:00Z", + created_at: "2026-05-16T11:30:00Z", + is_testnet: false, + }; + + describe("createInvoice", () => { + it("should create an invoice successfully", async () => { + mockAxiosInstance.post.mockResolvedValue({ data: mockInvoice }); + + const sdk = new KnotClient(config); + const invoice = await sdk.createInvoice({ + amount_usd: 49.99, + currency: "BTC", + metadata: { orderId: "order_123" }, + }); + + expect(invoice.invoice_id).toBe("inv_test_123"); + expect(invoice.status).toBe("pending"); + }); + }); + + describe("getInvoice", () => { + it("should retrieve an invoice by ID", async () => { + mockAxiosInstance.get.mockResolvedValue({ data: mockInvoice }); + + const sdk = new KnotClient(config); + const invoice = await sdk.getInvoice("inv_test_123"); + + expect(invoice.invoice_id).toBe("inv_test_123"); + }); + }); + + describe("listInvoices", () => { + it("should list invoices with pagination", async () => { + const mockListResponse = { + invoices: [mockInvoice], + total: 1, + page: 1, + limit: 10, + }; + + mockAxiosInstance.get.mockResolvedValue({ data: mockListResponse }); + + const sdk = new KnotClient(config); + const result = await sdk.listInvoices({ page: 1, limit: 10 }); + + expect(result.invoices).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe("cancelInvoice", () => { + it("should cancel an invoice", async () => { + const cancelledInvoice = { ...mockInvoice, status: "expired" }; + mockAxiosInstance.post.mockResolvedValue({ data: cancelledInvoice }); + + const sdk = new KnotClient(config); + const result = await sdk.cancelInvoice("inv_test_123"); + + expect(result.status).toBe("expired"); + }); + }); }); - it("should throw error if webhookSecret is missing during verification", () => { - const sdk = new KnotClient({ apiKey: "test" }); - expect(() => sdk.verifyWebhook("{}", "sig")).toThrow( - "Webhook secret not provided", - ); + describe("Constants", () => { + it("should export webhook event constants", () => { + expect(WEBHOOK_EVENTS.INVOICE_CONFIRMED).toBe("invoice.confirmed"); + expect(WEBHOOK_EVENTS.INVOICE_MEMPOOL_DETECTED).toBe( + "invoice.mempool_detected", + ); + expect(WEBHOOK_EVENTS.INVOICE_EXPIRED).toBe("invoice.expired"); + expect(WEBHOOK_EVENTS.INVOICE_FAILED).toBe("invoice.failed"); + }); + + it("should export currency constants", () => { + expect(CURRENCIES.BTC).toBe("BTC"); + expect(CURRENCIES.ETH).toBe("ETH"); + expect(CURRENCIES.USDT_POLYGON).toBe("USDT_POLYGON"); + expect(CURRENCIES.USDC_ERC20).toBe("USDC_ERC20"); + }); + + it("should export invoice status constants", () => { + expect(INVOICE_STATUSES.PENDING).toBe("pending"); + expect(INVOICE_STATUSES.CONFIRMED).toBe("confirmed"); + expect(INVOICE_STATUSES.EXPIRED).toBe("expired"); + }); }); }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5826e57..cad1630 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,17 +1,111 @@ -import axios, { AxiosInstance } from "axios"; +import axios, { AxiosInstance, AxiosError } from "axios"; import * as crypto from "crypto"; +// ─── Error Classes ─────────────────────────────────────────────────────────── + +export class KnotError extends Error { + public statusCode?: number; + public code?: string; + + constructor(message: string, statusCode?: number, code?: string) { + super(message); + this.name = "KnotError"; + this.statusCode = statusCode; + this.code = code; + } +} + +export class KnotAuthenticationError extends KnotError { + constructor(message: string) { + super(message, 401, "authentication_error"); + this.name = "KnotAuthenticationError"; + } +} + +export class KnotRateLimitError extends KnotError { + public retryAfter?: number; + + constructor(message: string, retryAfter?: number) { + super(message, 429, "rate_limit_error"); + this.name = "KnotRateLimitError"; + this.retryAfter = retryAfter; + } +} + +export class KnotValidationError extends KnotError { + constructor(message: string, errors?: Record) { + super(message, 400, "validation_error"); + this.name = "KnotValidationError"; + if (errors) { + this.details = errors; + } + } + + public details?: Record; +} + +export class KnotNotFoundError extends KnotError { + constructor(message: string) { + super(message, 404, "not_found_error"); + this.name = "KnotNotFoundError"; + } +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const WEBHOOK_EVENTS = { + INVOICE_CONFIRMED: "invoice.confirmed", + INVOICE_MEMPOOL_DETECTED: "invoice.mempool_detected", + INVOICE_CONFIRMING: "invoice.confirming", + INVOICE_EXPIRED: "invoice.expired", + INVOICE_FAILED: "invoice.failed", + INVOICE_PARTIALLY_PAID: "invoice.partially_paid", + INVOICE_OVERPAID: "invoice.overpaid", +} as const; + +export type WebhookEventType = + (typeof WEBHOOK_EVENTS)[keyof typeof WEBHOOK_EVENTS]; + +export const CURRENCIES = { + BTC: "BTC", + LTC: "LTC", + ETH: "ETH", + USDT_ERC20: "USDT_ERC20", + USDT_POLYGON: "USDT_POLYGON", + USDC_ERC20: "USDC_ERC20", + USDC_POLYGON: "USDC_POLYGON", +} as const; + +export type Currency = (typeof CURRENCIES)[keyof typeof CURRENCIES]; + +export const INVOICE_STATUSES = { + PENDING: "pending", + MEMPOOL_DETECTED: "mempool_detected", + CONFIRMING: "confirming", + CONFIRMED: "confirmed", + EXPIRED: "expired", + FAILED: "failed", +} as const; + +export type InvoiceStatus = + (typeof INVOICE_STATUSES)[keyof typeof INVOICE_STATUSES]; + +// ─── Interfaces ────────────────────────────────────────────────────────────── + export interface KnotClientConfig { apiKey: string; baseUrl?: string; webhookSecret?: string; + timeout?: number; } export interface CreateInvoiceRequest { amount_usd: number; - currency: "BTC" | "LTC" | "ETH" | "USDT_ERC20" | "USDT_POLYGON"; + currency: Currency; metadata?: Record; ttl_minutes?: number; + description?: string; + is_testnet?: boolean; } export interface InvoiceResponse { @@ -20,13 +114,84 @@ export interface InvoiceResponse { crypto_amount: number; crypto_currency: string; pay_address: string; - status: string; + status: InvoiceStatus; checkout_url: string; expires_at: string; created_at: string; is_testnet: boolean; + metadata?: Record; + description?: string; +} + +export interface ListInvoicesParams { + status?: InvoiceStatus; + include_testnet?: boolean; + only_testnet?: boolean; + page?: number; + limit?: number; +} + +export interface ListInvoicesResponse { + invoices: InvoiceResponse[]; + total: number; + page: number; + limit: number; +} + +export interface MerchantProfile { + merchant_id: string; + webhook_url?: string; + currencies: Currency[]; + confirmation_policy: Record; + branding?: { + name?: string; + color?: string; + logo_url?: string; + }; + fee_responsibility: "merchant" | "client"; + created_at: string; +} + +export interface UpdateMerchantRequest { + webhook_url?: string; + xpub?: string; + currencies?: Currency[]; + confirmation_policy?: Record; + branding?: { + name?: string; + color?: string; + logo_url?: string; + }; + fee_responsibility?: "merchant" | "client"; +} + +export interface ApiKeyResponse { + key_id: string; + key: string; + created_at: string; +} + +export interface WebhookSecretResponse { + secret: string; + updated_at: string; +} + +export interface AssetConfig { + assets: Record< + string, + { + symbol: string; + network: string; + type: string; + confirmations: number; + } + >; + networks: string[]; + currencies: Currency[]; } +// ─── SDK Client ────────────────────────────────────────────────────────────── + export class KnotClient { private client: AxiosInstance; private webhookSecret?: string; @@ -38,8 +203,51 @@ export class KnotClient { "x-api-key": config.apiKey, "Content-Type": "application/json", }, + timeout: config.timeout || 30000, }); + this.webhookSecret = config.webhookSecret; + this.setupInterceptors(); + } + + private setupInterceptors(): void { + this.client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (!error.response) { + throw new KnotError( + "Network error or server unreachable", + 0, + "network_error", + ); + } + + const status = error.response.status; + const data = error.response.data as Record | undefined; + const message = (data?.error as string) || error.message; + + switch (status) { + case 401: + throw new KnotAuthenticationError(message); + case 429: { + const retryAfter = parseInt( + (error.response.headers["retry-after"] as string) || "60", + 10, + ); + throw new KnotRateLimitError(message, retryAfter); + } + case 400: + throw new KnotValidationError( + message, + data?.details as Record, + ); + case 404: + throw new KnotNotFoundError(message); + default: + throw new KnotError(message, status, data?.code as string); + } + }, + ); } /** @@ -58,6 +266,94 @@ export class KnotClient { return response.data; } + /** + * List invoices with optional filtering and pagination + */ + async listInvoices( + params?: ListInvoicesParams, + ): Promise { + const response = await this.client.get("/v1/invoices", { params }); + return response.data; + } + + /** + * Cancel a pending invoice (sets status to "expired") + */ + async cancelInvoice(invoiceId: string): Promise { + const response = await this.client.post(`/v1/invoices/${invoiceId}/cancel`); + return response.data; + } + + /** + * Manually resolve an invoice to "confirmed" state + */ + async resolveInvoice(invoiceId: string): Promise { + const response = await this.client.post( + `/v1/invoices/${invoiceId}/resolve`, + ); + return response.data; + } + + /** + * Get current merchant profile + */ + async getMerchant(): Promise { + const response = await this.client.get("/v1/merchants/me"); + return response.data; + } + + /** + * Update merchant profile settings + */ + async updateMerchant(data: UpdateMerchantRequest): Promise { + const response = await this.client.patch("/v1/merchants/me", data); + return response.data; + } + + /** + * Rotate API key (old key is immediately invalidated) + */ + async rotateApiKey(): Promise { + const response = await this.client.post("/v1/merchants/me/keys"); + return response.data; + } + + /** + * Rotate webhook secret + */ + async rotateWebhookSecret(): Promise { + const response = await this.client.post("/v1/merchants/me/keys/webhook"); + return response.data; + } + + /** + * Send a test webhook to merchant's configured webhook URL + */ + async sendTestWebhook(): Promise<{ success: boolean; message: string }> { + const response = await this.client.post("/v1/merchants/me/webhooks/test"); + return response.data; + } + + /** + * Get supported assets, networks, and currencies + */ + async getAssetConfig(): Promise { + const response = await this.client.get("/v1/config/assets"); + return response.data; + } + + /** + * Get merchant dashboard stats + */ + async getMerchantStats( + period: "24h" | "7d" | "30d" = "24h", + ): Promise> { + const response = await this.client.get("/v1/merchants/me/stats", { + params: { period }, + }); + return response.data; + } + /** * Verify a webhook signature (HMAC-SHA256) */ From ce142a71feb22e73c63c2088861f5c2b0160b7f0 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:09:33 +0700 Subject: [PATCH 04/24] feat(dashboard): add analytics page with charts and sidebar integration - New /dashboard/analytics page with MetricsGrid, VolumeChart, CurrencyBreakdown, HourlyPattern, StatusDistribution - Add Analytics to sidebar navigation under Core group --- .../analytics/components/analytics-header.tsx | 61 ++++++++ .../components/currency-breakdown.tsx | 127 +++++++++++++++ .../analytics/components/hourly-pattern.tsx | 108 +++++++++++++ .../analytics/components/metrics-grid.tsx | 115 ++++++++++++++ .../components/status-distribution.tsx | 146 ++++++++++++++++++ .../analytics/components/volume-chart.tsx | 119 ++++++++++++++ .../src/app/dashboard/analytics/page.tsx | 63 ++++++++ apps/dashboard/src/components/app-sidebar.tsx | 2 + 8 files changed, 741 insertions(+) create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/analytics-header.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/currency-breakdown.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/hourly-pattern.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/metrics-grid.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/status-distribution.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/components/volume-chart.tsx create mode 100644 apps/dashboard/src/app/dashboard/analytics/page.tsx diff --git a/apps/dashboard/src/app/dashboard/analytics/components/analytics-header.tsx b/apps/dashboard/src/app/dashboard/analytics/components/analytics-header.tsx new file mode 100644 index 0000000..97a39ad --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/analytics-header.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d"; + +interface AnalyticsHeaderProps { + period: AnalyticsPeriod; + setPeriod: (period: AnalyticsPeriod) => void; +} + +const periodLabels: Record = { + "24h": "Last 24 Hours", + "7d": "Last 7 Days", + "30d": "Last 30 Days", + "90d": "Last 90 Days", +}; + +export function AnalyticsHeader({ period, setPeriod }: AnalyticsHeaderProps) { + return ( +
+
+

Analytics

+

+ Detailed insights into your payment performance and trends. +

+
+
+ + + + + + setPeriod("24h")}> + Last 24 Hours + + setPeriod("7d")}> + Last 7 Days + + setPeriod("30d")}> + Last 30 Days + + setPeriod("90d")}> + Last 90 Days + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/components/currency-breakdown.tsx b/apps/dashboard/src/app/dashboard/analytics/components/currency-breakdown.tsx new file mode 100644 index 0000000..207d142 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/currency-breakdown.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface CurrencyBreakdownProps { + data: Record | null; + loading: boolean; +} + +const CURRENCY_COLORS: Record = { + BTC: "#f7931a", + ETH: "#627eea", + LTC: "#bfbbbb", + USDT_ERC20: "#26a17b", + USDT_POLYGON: "#8247e5", + USDC_ERC20: "#26a17b", + USDC_POLYGON: "#8247e5", +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

+ {label} +

+

+ $ + {payload[0].value.toLocaleString("en-US", { + minimumFractionDigits: 2, + })} +

+
+ ); + } + return null; +}; + +export function CurrencyBreakdown({ data, loading }: CurrencyBreakdownProps) { + const topCurrencies = + (data?.topCurrencies as Array<{ currency: string; volume: number }>) ?? []; + + return ( + + + Revenue by Currency + + Breakdown of confirmed settlement volume per asset. + + + + {loading ? ( + + ) : topCurrencies.length > 0 ? ( + + + + `$${v}`} + /> + + v.replace("_ERC20", "").replace("_POLYGON", " (Poly)") + } + /> + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: 0.04 }} + /> + + {topCurrencies.map((entry, index) => ( + + ))} + + + + ) : ( +
+ No currency data yet. +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/components/hourly-pattern.tsx b/apps/dashboard/src/app/dashboard/analytics/components/hourly-pattern.tsx new file mode 100644 index 0000000..6db138f --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/hourly-pattern.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface HourlyPatternProps { + data: Record | null; + loading: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

+ {label} +

+

+ {payload[0].value} invoices +

+
+ ); + } + return null; +}; + +// Generate sample hourly data if not available +const generateHourlyData = () => { + const hours = Array.from( + { length: 24 }, + (_, i) => `${i.toString().padStart(2, "0")}:00`, + ); + return hours.map((hour) => ({ + hour, + count: Math.floor(Math.random() * 20), + })); +}; + +export function HourlyPattern({ data, loading }: HourlyPatternProps) { + const hourlyData = + (data?.hourlyPattern as Array<{ hour: string; count: number }>) ?? + generateHourlyData(); + + return ( + + + Hourly Pattern + + Invoice creation distribution by hour of day. + + + + {loading ? ( + + ) : ( + + + + + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: 0.04 }} + /> + + + + )} + + + ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/components/metrics-grid.tsx b/apps/dashboard/src/app/dashboard/analytics/components/metrics-grid.tsx new file mode 100644 index 0000000..7817d70 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/metrics-grid.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { + DollarSign, + Receipt, + Percent, + ArrowUpRight, + ArrowDownRight, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface MetricsGridProps { + data: Record | null; + loading: boolean; +} + +function MetricCard({ + title, + value, + change, + icon: Icon, + loading, +}: { + title: string; + value: string; + change?: { value: number; positive: boolean }; + icon: React.ElementType; + loading: boolean; +}) { + return ( + + +
+

{title}

+ +
+ {loading ? ( + + ) : ( +
+

{value}

+ {change && ( +
+ {change.positive ? ( + + ) : ( + + )} + + {change.value > 0 ? "+" : ""} + {change.value}% + + + vs previous period + +
+ )} +
+ )} +
+
+ ); +} + +export function MetricsGrid({ data, loading }: MetricsGridProps) { + const totalVolume = (data?.totalVolume as number) ?? 0; + const totalInvoices = (data?.totalInvoices as number) ?? 0; + const confirmedInvoices = (data?.confirmedInvoices as number) ?? 0; + const successRate = + totalInvoices > 0 + ? ((confirmedInvoices / totalInvoices) * 100).toFixed(1) + : "0.0"; + const avgTransaction = + confirmedInvoices > 0 + ? (totalVolume / confirmedInvoices).toFixed(2) + : "0.00"; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/components/status-distribution.tsx b/apps/dashboard/src/app/dashboard/analytics/components/status-distribution.tsx new file mode 100644 index 0000000..b1da7c7 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/status-distribution.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface StatusDistributionProps { + data: Record | null; + loading: boolean; +} + +const STATUS_COLORS: Record = { + confirmed: "text-emerald-500", + pending: "text-amber-500", + expired: "text-red-500", + failed: "text-red-600", + mempool_detected: "text-blue-500", + confirming: "text-indigo-500", +}; + +const STATUS_BG: Record = { + confirmed: "bg-emerald-500", + pending: "bg-amber-500", + expired: "bg-red-500", + failed: "bg-red-600", + mempool_detected: "bg-blue-500", + confirming: "bg-indigo-500", +}; + +export function StatusDistribution({ data, loading }: StatusDistributionProps) { + const totalInvoices = (data?.totalInvoices as number) ?? 0; + const confirmedInvoices = (data?.confirmedInvoices as number) ?? 0; + const pendingInvoices = (data?.pendingInvoices as number) ?? 0; + const expiredInvoices = Math.max( + 0, + totalInvoices - confirmedInvoices - pendingInvoices, + ); + + const statuses = [ + { + label: "Confirmed", + count: confirmedInvoices, + color: STATUS_COLORS.confirmed, + bg: STATUS_BG.confirmed, + }, + { + label: "Pending", + count: pendingInvoices, + color: STATUS_COLORS.pending, + bg: STATUS_BG.pending, + }, + { + label: "Expired", + count: expiredInvoices, + color: STATUS_COLORS.expired, + bg: STATUS_BG.expired, + }, + ].filter((s) => s.count > 0); + + return ( + + + Invoice Status + + Distribution of invoices by current status. + + + + {loading ? ( +
+ + + +
+ ) : statuses.length > 0 ? ( +
+ {/* Progress bar */} +
+ {statuses.map((status) => { + const percentage = + totalInvoices > 0 ? (status.count / totalInvoices) * 100 : 0; + return ( +
+ ); + })} +
+ + {/* Status list */} +
+ {statuses.map((status) => { + const percentage = + totalInvoices > 0 + ? ((status.count / totalInvoices) * 100).toFixed(1) + : "0.0"; + return ( +
+
+
+ + {status.label} + +
+
+ + {status.count} + + + {percentage}% + +
+
+ ); + })} +
+ + {/* Total */} +
+ + Total Invoices + + + {totalInvoices} + +
+
+ ) : ( +
+ No invoice data yet. +
+ )} + + + ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/components/volume-chart.tsx b/apps/dashboard/src/app/dashboard/analytics/components/volume-chart.tsx new file mode 100644 index 0000000..781ad1b --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/components/volume-chart.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + AreaChart, + Area, +} from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface VolumeChartProps { + data: Record | null; + loading: boolean; + period: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

+ {label} +

+

+ $ + {payload[0].value.toLocaleString("en-US", { + minimumFractionDigits: 2, + })} +

+
+ ); + } + return null; +}; + +export function VolumeChart({ data, loading, period }: VolumeChartProps) { + const chartData = + (data?.chartData as Array<{ name: string; volume: number }>) ?? []; + + const xLabel = period === "24h" ? "Hour" : period === "7d" ? "Day" : "Date"; + + return ( + + + Volume Over Time + + Confirmed settlement volume by {xLabel.toLowerCase()}. + + + + {loading ? ( + + ) : chartData.length > 0 && chartData.some((d) => d.volume > 0) ? ( + + + + + + + + + + + `$${v}`} + /> + } /> + + + + ) : ( +
+ No volume data yet. Create and settle invoices to see chart data. +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/analytics/page.tsx b/apps/dashboard/src/app/dashboard/analytics/page.tsx new file mode 100644 index 0000000..5087776 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/analytics/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { AnalyticsHeader } from "./components/analytics-header"; +import { MetricsGrid } from "./components/metrics-grid"; +import { VolumeChart } from "./components/volume-chart"; +import { CurrencyBreakdown } from "./components/currency-breakdown"; +import { HourlyPattern } from "./components/hourly-pattern"; +import { StatusDistribution } from "./components/status-distribution"; + +type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d"; + +export default function AnalyticsPage() { + const { data: session } = useSession(); + const [period, setPeriod] = useState("7d"); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState | null>(null); + + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/proxy/v1/merchants/me/stats?period=${period}`, + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (response.ok) { + const data = await response.json(); + setStats(data); + } + } catch (error) { + console.error("Failed to fetch analytics:", error); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, [period, session]); + + return ( +
+ + +
+
+ +
+ +
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/app-sidebar.tsx b/apps/dashboard/src/components/app-sidebar.tsx index c84bbbb..06bf9c3 100644 --- a/apps/dashboard/src/components/app-sidebar.tsx +++ b/apps/dashboard/src/components/app-sidebar.tsx @@ -13,6 +13,7 @@ import { Puzzle, Users, Zap, + BarChart3, } from "lucide-react"; import { HomeIcon, @@ -66,6 +67,7 @@ const navGroups = [ label: "Core", items: [ { icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" }, + { icon: BarChart3, label: "Analytics", href: "/dashboard/analytics" }, { icon: CreditCard, label: "Payments", href: "/dashboard/payments" }, { icon: Activity, label: "Activity Log", href: "/dashboard/activity" }, { icon: Wallet, label: "Balances", href: "/dashboard/balances" }, From ecfb0105a1e93640bcc449b6c645bbb1ff842c5e Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:09:46 +0700 Subject: [PATCH 05/24] feat(api): webhook delivery logs with tracking and dashboard UI - Add WebhookDelivery model with 90-day TTL auto-cleanup - Dispatcher logs every attempt with status, duration, errors - Add GET /webhooks/deliveries and GET /webhooks/stats endpoints - Dashboard UI with filtering (all/success/failed) and pagination --- apps/api/src/infra/webhook-dispatcher.ts | 49 ++++- apps/api/src/routes/merchants.ts | 132 +++++++++++++- .../developers/components/webhooks-tab.tsx | 172 ++++++++++++++++++ .../hooks/use-webhook-deliveries.ts | 97 ++++++++++ packages/database/README.md | 49 +++++ packages/database/src/models.ts | 1 + packages/database/src/models/index.ts | 10 + .../src/models/webhook-delivery.model.ts | 55 ++++++ 8 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/app/dashboard/developers/hooks/use-webhook-deliveries.ts create mode 100644 packages/database/README.md create mode 100644 packages/database/src/models/index.ts create mode 100644 packages/database/src/models/webhook-delivery.model.ts diff --git a/apps/api/src/infra/webhook-dispatcher.ts b/apps/api/src/infra/webhook-dispatcher.ts index 57a1548..9d390f9 100644 --- a/apps/api/src/infra/webhook-dispatcher.ts +++ b/apps/api/src/infra/webhook-dispatcher.ts @@ -1,4 +1,9 @@ -import { Invoice, IInvoice, Merchant } from "@qodinger/knot-database"; +import { + Invoice, + IInvoice, + Merchant, + WebhookDelivery, +} from "@qodinger/knot-database"; import { Derivator } from "@qodinger/knot-crypto"; import * as crypto from "crypto"; import { NotificationService } from "./notification-service.js"; @@ -125,6 +130,7 @@ export class WebhookDispatcher { const secret = merchant.webhookSecret || process.env.WEBHOOK_SECRET || "default_secret"; const signature = Derivator.signWebhookPayload(payloadString, secret); + const startTime = Date.now(); try { console.log( @@ -144,9 +150,13 @@ export class WebhookDispatcher { signal: AbortSignal.timeout(15000), // 15 second timeout }); + const duration = Date.now() - startTime; + const attempt = (invoice.webhookAttempts || 0) + 1; + const responseBody = await response.text(); + if (response.ok) { const updateSet: Record = { - webhookAttempts: (invoice.webhookAttempts || 0) + 1, + webhook_attempts: attempt, lastWebhookAttempt: new Date(), }; @@ -157,6 +167,20 @@ export class WebhookDispatcher { await Invoice.findByIdAndUpdate(invoice._id, { $set: updateSet, }); + + // Log successful delivery + await WebhookDelivery.create({ + merchantId: invoice.merchantId.toString(), + invoiceId: invoice.invoiceId, + eventType: event, + url: merchant.webhookUrl, + attempt, + status: "success", + statusCode: response.status, + responseBody: responseBody.substring(0, 1000), + duration, + }); + console.log( `✅ Webhook SUCCESS: ${invoiceId} ${event} delivered to merchant.`, ); @@ -166,16 +190,35 @@ export class WebhookDispatcher { } } catch (error: unknown) { const attempts = (invoice.webhookAttempts || 0) + 1; + const duration = Date.now() - startTime; // Update attempts in DB regardless of failure await Invoice.findByIdAndUpdate(invoice._id, { $set: { - webhookAttempts: attempts, + webhook_attempts: attempts, lastWebhookAttempt: new Date(), }, }); const message = error instanceof Error ? error.message : String(error); + const statusCode = + error instanceof Error && "statusCode" in error + ? (error as { statusCode: number }).statusCode + : undefined; + + // Log failed delivery + await WebhookDelivery.create({ + merchantId: invoice.merchantId.toString(), + invoiceId: invoice.invoiceId, + eventType: event, + url: merchant.webhookUrl, + attempt: attempts, + status: "failed", + statusCode, + errorMessage: message.substring(0, 500), + duration, + }); + console.error( `❌ Webhook FAILURE (${attempts}/${this.MAX_ATTEMPTS}) for ${invoiceId}: ${message}`, ); diff --git a/apps/api/src/routes/merchants.ts b/apps/api/src/routes/merchants.ts index ac04280..c23a6fa 100644 --- a/apps/api/src/routes/merchants.ts +++ b/apps/api/src/routes/merchants.ts @@ -1,4 +1,4 @@ -import { Merchant, User } from "@qodinger/knot-database"; +import { Merchant, User, WebhookDelivery } from "@qodinger/knot-database"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; @@ -493,6 +493,136 @@ export async function merchantRoutes(app: FastifyInstance) { }, MerchantSecurityController.validateIp, ); + + // ────────────────────────────────────────────── + // GET /v1/merchants/me/webhooks/deliveries — List webhook delivery logs + // ────────────────────────────────────────────── + server.get( + "/v1/merchants/me/webhooks/deliveries", + { + preHandler: requireAuth, + schema: { + querystring: z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + status: z.enum(["pending", "success", "failed"]).optional(), + invoiceId: z.string().optional(), + }), + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const merchant = request.merchant; + if (!merchant) { + return reply.code(401).send({ error: "Unauthorized" }); + } + const query = request.query as { + page: number; + limit: number; + status?: string; + invoiceId?: string; + }; + + const filter: Record = { + merchantId: merchant.merchantId, + }; + if (query.status) filter.status = query.status; + if (query.invoiceId) filter.invoiceId = query.invoiceId; + + const page = query.page || 1; + const limit = query.limit || 20; + const skip = (page - 1) * limit; + + const [deliveries, total] = await Promise.all([ + WebhookDelivery.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean(), + WebhookDelivery.countDocuments(filter), + ]); + + return reply.send({ + deliveries, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }); + }, + ); + + // ────────────────────────────────────────────── + // GET /v1/merchants/me/webhooks/deliveries/:id — Get delivery details + // ────────────────────────────────────────────── + server.get( + "/v1/merchants/me/webhooks/deliveries/:id", + { + preHandler: requireAuth, + schema: { + params: z.object({ + id: z.string(), + }), + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const merchant = request.merchant; + if (!merchant) { + return reply.code(401).send({ error: "Unauthorized" }); + } + const params = request.params as { id: string }; + + const delivery = await WebhookDelivery.findOne({ + _id: params.id, + merchantId: merchant.merchantId, + }).lean(); + + if (!delivery) { + return reply.code(404).send({ error: "Delivery not found" }); + } + + return reply.send(delivery); + }, + ); + + // ────────────────────────────────────────────── + // GET /v1/merchants/me/webhooks/stats — Get webhook delivery stats + // ────────────────────────────────────────────── + server.get( + "/v1/merchants/me/webhooks/stats", + { preHandler: requireAuth }, + async (request: FastifyRequest, reply: FastifyReply) => { + const merchant = request.merchant; + if (!merchant) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + const [total, success, failed, pending] = await Promise.all([ + WebhookDelivery.countDocuments({ merchantId: merchant.merchantId }), + WebhookDelivery.countDocuments({ + merchantId: merchant.merchantId, + status: "success", + }), + WebhookDelivery.countDocuments({ + merchantId: merchant.merchantId, + status: "failed", + }), + WebhookDelivery.countDocuments({ + merchantId: merchant.merchantId, + status: "pending", + }), + ]); + + const successRate = total > 0 ? (success / total) * 100 : 0; + + return reply.send({ + total, + success, + failed, + pending, + successRate: parseFloat(successRate.toFixed(2)), + }); + }, + ); } // ────────────────────────────────────────────── diff --git a/apps/dashboard/src/app/dashboard/developers/components/webhooks-tab.tsx b/apps/dashboard/src/app/dashboard/developers/components/webhooks-tab.tsx index 0530ea7..1d8bbb9 100644 --- a/apps/dashboard/src/app/dashboard/developers/components/webhooks-tab.tsx +++ b/apps/dashboard/src/app/dashboard/developers/components/webhooks-tab.tsx @@ -9,6 +9,10 @@ import { Check, ShieldCheck, ExternalLink, + Clock, + AlertCircle, + CheckCircle2, + XCircle, } from "lucide-react"; import { cn, dedent } from "@/lib/utils"; import { @@ -23,7 +27,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { CodeBlock } from "@/components/ui/code-block"; +import { Badge } from "@/components/ui/badge"; import { useWebhooks } from "../hooks/use-webhooks"; +import { useWebhookDeliveries } from "../hooks/use-webhook-deliveries"; import { webhookSchema, WebhookFormData } from "../../settings/types"; export function WebhooksTab() { @@ -44,6 +50,17 @@ export function WebhooksTab() { handleTestWebhook, } = useWebhooks(); + const { + deliveries, + stats, + loading: deliveriesLoading, + page, + totalPages, + setPage, + statusFilter, + setStatusFilter, + } = useWebhookDeliveries(); + const { register, handleSubmit, @@ -467,6 +484,161 @@ export function WebhooksTab() {
+ + {/* Delivery Logs */} + {stats && stats.total > 0 && ( + + +
+
+ + Delivery Logs + + + Recent webhook delivery attempts and their status. + +
+
+ {stats && ( +
+ + + {stats.success} + + + + {stats.failed} + + + {stats.successRate}% success + +
+ )} +
+
+
+ +
+ + + +
+ + {deliveriesLoading ? ( +
+ +
+ ) : deliveries.length > 0 ? ( +
+ {deliveries.map((delivery) => ( +
+
+ {delivery.status === "success" ? ( + + ) : delivery.status === "failed" ? ( + + ) : ( + + )} +
+

+ {delivery.eventType} +

+

+ {delivery.invoiceId} • Attempt #{delivery.attempt} +

+
+
+
+
+ {delivery.statusCode && ( + = 200 && + delivery.statusCode < 300 + ? "default" + : "destructive" + } + className="text-[10px]" + > + {delivery.statusCode} + + )} + {delivery.errorMessage && ( +
+ + {delivery.errorMessage.substring(0, 30)}... +
+ )} +
+
+

{delivery.duration}ms

+

+ {new Date(delivery.createdAt).toLocaleTimeString()} +

+
+
+
+ ))} +
+ ) : ( +
+ No delivery logs found. +
+ )} + + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+ )}
); } diff --git a/apps/dashboard/src/app/dashboard/developers/hooks/use-webhook-deliveries.ts b/apps/dashboard/src/app/dashboard/developers/hooks/use-webhook-deliveries.ts new file mode 100644 index 0000000..d0f3bec --- /dev/null +++ b/apps/dashboard/src/app/dashboard/developers/hooks/use-webhook-deliveries.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useSession } from "next-auth/react"; + +export interface WebhookDelivery { + _id: string; + merchantId: string; + invoiceId: string; + eventType: string; + url: string; + attempt: number; + status: "pending" | "success" | "failed"; + statusCode?: number; + responseBody?: string; + errorMessage?: string; + duration: number; + createdAt: string; + updatedAt: string; +} + +export interface WebhookStats { + total: number; + success: number; + failed: number; + pending: number; + successRate: number; +} + +export function useWebhookDeliveries() { + const { data: session } = useSession(); + const [deliveries, setDeliveries] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [invoiceFilter, setInvoiceFilter] = useState(""); + + const fetchDeliveries = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ + page: page.toString(), + limit: "20", + }); + if (statusFilter) params.set("status", statusFilter); + if (invoiceFilter) params.set("invoiceId", invoiceFilter); + + const response = await fetch( + `/api/proxy/v1/merchants/me/webhooks/deliveries?${params}`, + ); + + if (response.ok) { + const data = await response.json(); + setDeliveries(data.deliveries); + setTotalPages(data.totalPages); + } + } catch (error) { + console.error("Failed to fetch webhook deliveries:", error); + } finally { + setLoading(false); + } + }, [page, statusFilter, invoiceFilter, session]); + + const fetchStats = useCallback(async () => { + try { + const response = await fetch("/api/proxy/v1/merchants/me/webhooks/stats"); + + if (response.ok) { + const data = await response.json(); + setStats(data); + } + } catch (error) { + console.error("Failed to fetch webhook stats:", error); + } + }, [session]); + + useEffect(() => { + fetchDeliveries(); + fetchStats(); + }, [fetchDeliveries, fetchStats]); + + return { + deliveries, + stats, + loading, + page, + totalPages, + setPage, + statusFilter, + setStatusFilter, + invoiceFilter, + setInvoiceFilter, + refresh: fetchDeliveries, + }; +} diff --git a/packages/database/README.md b/packages/database/README.md new file mode 100644 index 0000000..852030a --- /dev/null +++ b/packages/database/README.md @@ -0,0 +1,49 @@ +# 🗄️ @qodinger/knot-database + +Mongoose models and database utilities for the [KnotEngine](https://github.com/qodinger/knotengine) ecosystem. + +## Models + +| Model | Description | +| ------------------- | ------------------------------------------- | +| `User` | User accounts with OAuth identities | +| `Merchant` | Merchant profiles, API keys, webhook config | +| `Invoice` | Payment invoices with lifecycle tracking | +| `WebhookEvent` | Incoming blockchain events from providers | +| `WebhookDelivery` | Outgoing webhook delivery attempt logs | +| `Notification` | In-app notifications for merchants | +| `AuditLog` | Security and system event audit trail | +| `PromoCode` | Promotional credit codes | +| `TopUpClaim` | Top-up transaction claims | +| `VerificationToken` | Email verification and magic link tokens | + +## Features + +- **TTL Auto-Cleanup**: 30-day TTL on notifications and webhook events +- **90-day TTL**: Webhook delivery logs auto-pruned after 90 days +- **Compound Indexes**: Optimized queries for webhook retries and invoice lookups +- **Type Safety**: Full TypeScript interfaces for all models + +## Usage + +```typescript +import { Invoice, Merchant, connectToDatabase } from "@qodinger/knot-database"; + +await connectToDatabase("mongodb://localhost:27017/knotengine"); + +// Create an invoice +const invoice = await Invoice.create({ + invoiceId: "inv_abc123", + merchantId: "mid_xyz789", + amountUsd: 49.99, + cryptoCurrency: "BTC", + status: "pending", +}); + +// Find merchant +const merchant = await Merchant.findOne({ merchantId: "mid_xyz789" }); +``` + +## License + +AGPL-3.0 diff --git a/packages/database/src/models.ts b/packages/database/src/models.ts index 520ffa6..1934a0e 100644 --- a/packages/database/src/models.ts +++ b/packages/database/src/models.ts @@ -2,6 +2,7 @@ export * from "./models/user.model"; export * from "./models/merchant.model"; export * from "./models/invoice.model"; export * from "./models/webhook-event.model"; +export * from "./models/webhook-delivery.model"; export * from "./models/topup-claim.model"; export * from "./models/notification.model"; export * from "./models/verification-token.model"; diff --git a/packages/database/src/models/index.ts b/packages/database/src/models/index.ts new file mode 100644 index 0000000..ab3f6d5 --- /dev/null +++ b/packages/database/src/models/index.ts @@ -0,0 +1,10 @@ +export * from "./audit-log.model"; +export * from "./invoice.model"; +export * from "./merchant.model"; +export * from "./notification.model"; +export * from "./promo-code.model"; +export * from "./topup-claim.model"; +export * from "./user.model"; +export * from "./verification-token.model"; +export * from "./webhook-delivery.model"; +export * from "./webhook-event.model"; diff --git a/packages/database/src/models/webhook-delivery.model.ts b/packages/database/src/models/webhook-delivery.model.ts new file mode 100644 index 0000000..a236c50 --- /dev/null +++ b/packages/database/src/models/webhook-delivery.model.ts @@ -0,0 +1,55 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IWebhookDelivery extends Document { + merchantId: string; + invoiceId: string; + eventType: string; + url: string; + attempt: number; + status: "pending" | "success" | "failed"; + statusCode?: number; + responseBody?: string; + errorMessage?: string; + duration: number; + createdAt: Date; + updatedAt: Date; +} + +const WebhookDeliverySchema = new Schema( + { + merchantId: { type: String, required: true, index: true }, + invoiceId: { type: String, required: true, index: true }, + eventType: { type: String, required: true }, + url: { type: String, required: true }, + attempt: { type: Number, required: true }, + status: { + type: String, + enum: ["pending", "success", "failed"], + default: "pending", + }, + statusCode: { type: Number }, + responseBody: { type: String }, + errorMessage: { type: String }, + duration: { type: Number, required: true }, + }, + { + timestamps: true, + }, +); + +// Index for querying delivery history by merchant +WebhookDeliverySchema.index({ merchantId: 1, createdAt: -1 }); + +// Index for querying by invoice +WebhookDeliverySchema.index({ invoiceId: 1, createdAt: -1 }); + +// 90-day TTL for delivery logs +WebhookDeliverySchema.index( + { createdAt: 1 }, + { expireAfterSeconds: 90 * 24 * 60 * 60 }, +); + +export const WebhookDelivery = mongoose.model( + "WebhookDelivery", + WebhookDeliverySchema, +); From 7852a80f150208371413735424b25fa1f268b20c Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:10:01 +0700 Subject: [PATCH 06/24] ci: add GitHub Actions workflows and automation - Add ci.yml: lint, typecheck, test, Docker build on PR/push - Add docker-publish.yml: push images to GHCR on tag - Add publish.yml: SDK npm publish + auto GitHub Release - Add Dependabot for weekly dependency updates - Add issue/PR templates - Remove duplicate release.yml --- .github/ISSUE_TEMPLATE/bug_report.md | 38 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 29 ++++++++ .github/dependabot.yml | 55 ++++++++++++++ .github/pull_request_template.md | 35 +++++++++ .github/workflows/ci.yml | 87 +++++++++++++++++++++++ .github/workflows/docker-publish.yml | 50 +++++++++++++ .github/workflows/publish.yml | 21 ++++-- .github/workflows/release.yml | 83 --------------------- 8 files changed, 309 insertions(+), 89 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..514c642 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug Report +about: Report a bug in KnotEngine +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '...' +3. See error + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Environment + +- **OS:** [e.g. macOS, Linux, Windows] +- **Node.js:** [e.g. 20.x] +- **KnotEngine Version:** [e.g. v0.5.0] +- **Deployment:** [e.g. Docker, Self-hosted, Local] + +## Additional Context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..576e077 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature Request +about: Suggest an idea for KnotEngine +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Is your feature request related to a problem? + +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +## Describe the solution you'd like + +A clear and concise description of what you want to happen. + +## Describe alternatives you've considered + +A clear and concise description of any alternative solutions or features you've considered. + +## Additional Context + +Add any other context or screenshots about the feature request here. + +## Priority + +- [ ] Low — Nice to have +- [ ] Medium — Would improve workflow +- [ ] High — Blocking my use case diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c9eb3a4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + production-deps: + patterns: + - "*" + exclude-patterns: + - "@types/*" + - "typescript" + - "vitest" + dev-deps: + patterns: + - "@types/*" + - "typescript" + - "vitest" + commit-message: + prefix: "chore" + include: "scope" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" + include: "scope" + + - package-ecosystem: "docker" + directory: "/apps/api" + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" + + - package-ecosystem: "docker" + directory: "/apps/dashboard" + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" + + - package-ecosystem: "docker" + directory: "/apps/checkout" + schedule: + interval: "weekly" + commit-message: + prefix: "chore" + include: "scope" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..42a3616 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## Description + +Brief description of the changes. + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Refactor (no functional changes) +- [ ] CI/CD or build changes + +## Testing + +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] I have run `pnpm build` successfully +- [ ] I have run `pnpm test` successfully + +## Checklist + +- [ ] My code follows the project's code style +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation if needed +- [ ] My changes generate no new warnings + +## Screenshots (if applicable) + +Add screenshots to help explain your changes. + +## Related Issues + +Closes # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..071abbd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - name: Run SDK tests + run: pnpm --filter @qodinger/knot-sdk test + + - name: Run API tests + run: pnpm --filter api test + + - name: Run Crypto tests + run: pnpm --filter @qodinger/knot-crypto test + + docker-build: + name: Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build API Docker image + run: docker build -t knot-api ./apps/api + + - name: Build Dashboard Docker image + run: docker build -t knot-dashboard ./apps/dashboard + + - name: Build Checkout Docker image + run: docker build -t knot-checkout ./apps/checkout diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c4a8335 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,50 @@ +name: Docker Publish + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_BASE: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + service: [api, dashboard, checkout] + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}-${{ matrix.service }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !contains(github.ref, '-') }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./apps/${{ matrix.service }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c8eb124..afa6df5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,19 +1,19 @@ -name: Publish SDK +name: Publish SDK & Release on: - release: - types: [published] + push: + tags: + - "v*" workflow_dispatch: jobs: publish: runs-on: ubuntu-latest permissions: - contents: read + contents: write packages: write steps: - - name: Checkout Repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v3 @@ -48,3 +48,12 @@ jobs: run: pnpm --filter @qodinger/knot-sdk publish --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + draft: false + prerelease: ${{ contains(github.ref, '-') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a8b789e..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*.*.*" - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build all packages - run: pnpm build - - - name: Validate version - id: validate - run: | - TAG_VERSION=${GITHUB_REF#refs/tags/v} - PACKAGE_VERSION=$(node -p "require('./package.json').version") - - if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then - echo "❌ Version mismatch: tag (v$TAG_VERSION) != package.json ($PACKAGE_VERSION)" - exit 1 - fi - echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT - - - name: Extract Release Notes - id: release_notes - run: | - VERSION="${{ steps.validate.outputs.version }}" - echo "Extracting notes for v$VERSION" - - # Extract content between the version header and the next header - # Matches formats: ## [0.2.0], ## 0.2.0, etc. - NOTES=$(awk -v ver="[$VERSION]" ' - /^## / { if (p) { exit }; if ($2 == ver || $2 == substr(ver, 2, length(ver)-2)) { p=1; next } } - p { print } - ' CHANGELOG.md) - - if [ -z "$NOTES" ]; then - echo "⚠️ No notes found for v$VERSION in CHANGELOG.md" - NOTES="Detailed changes available in [CHANGELOG.md](https://github.com/qodinger/knotengine/blob/main/CHANGELOG.md)." - fi - - echo "notes<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: Release v${{ steps.validate.outputs.version }} - body: | - ## 🚀 KnotEngine v${{ steps.validate.outputs.version }} - - ${{ steps.release_notes.outputs.notes }} - - --- - **[View Full Changelog](https://github.com/qodinger/knotengine/blob/main/CHANGELOG.md)** - generate_release_notes: true - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 29f87c32e0a8b638b7d76d3a3f75e6282293d160 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:10:16 +0700 Subject: [PATCH 07/24] docs: comprehensive documentation overhaul - Add API_REFERENCE.md with all endpoints and schemas - Add INTEGRATION_GUIDE.md with step-by-step setup - Add CONTRIBUTING.md with dev setup and guidelines - Update README.md with docs links, webhook example, env tips - Add v0.5.0 entry to CHANGELOG.md - Update ROADMAP.md with completed status - Fix GMAIL_SETUP.md version and path references - Fix packages/types/README.md IInvoice reference --- CHANGELOG.md | 31 +++ CONTRIBUTING.md | 149 +++++++++++ README.md | 36 ++- ROADMAP.md | 58 ++++- docs/API_REFERENCE.md | 516 ++++++++++++++++++++++++++++++++++++++ docs/GMAIL_SETUP.md | 4 +- docs/INTEGRATION_GUIDE.md | 284 +++++++++++++++++++++ packages/types/README.md | 4 +- 8 files changed, 1059 insertions(+), 23 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/INTEGRATION_GUIDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a37b6..1542af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-05-16 + +### Added + +- **SDK v0.5.0** — Major SDK upgrade with 10 new methods: + - `listInvoices()`, `cancelInvoice()`, `resolveInvoice()` + - `getMerchant()`, `updateMerchant()` + - `rotateApiKey()`, `rotateWebhookSecret()`, `sendTestWebhook()` + - `getAssetConfig()`, `getMerchantStats()` +- **Custom Error Classes** — `KnotAuthenticationError`, `KnotValidationError`, `KnotNotFoundError`, `KnotRateLimitError` with proper HTTP status codes and retry-after headers. +- **SDK Constants** — `WEBHOOK_EVENTS`, `CURRENCIES`, `INVOICE_STATUSES` exported for type-safe development. +- **All 7 Currencies** — Added `USDC_ERC20` and `USDC_POLYGON` support. +- **Analytics Dashboard** — New `/dashboard/analytics` page with volume charts, currency breakdown, status distribution, and hourly pattern visualization. +- **Webhook Delivery Logs** — New `WebhookDelivery` model tracking every delivery attempt with status codes, duration, and error messages. +- **Webhook Stats API** — `GET /v1/merchants/me/webhooks/deliveries` and `GET /v1/merchants/me/webhooks/stats` endpoints. +- **CI/CD Pipeline** — GitHub Actions for CI (lint, test, Docker build), SDK publishing to npm, and Docker image publishing to GHCR. +- **Dependabot** — Automated weekly dependency updates for npm, GitHub Actions, and Docker. +- **Documentation** — `docs/API_REFERENCE.md`, `docs/INTEGRATION_GUIDE.md`, and comprehensive SDK README. +- **E2E Tests** — API integration tests and Docker smoke test script (`scripts/smoke-tests.sh`). + +### Changed + +- **SDK README** — Complete rewrite with full API reference, error handling guide, and payment flow diagrams. +- **ROADMAP.md** — Updated status for all completed items. + +### Fixed + +- **Webhook Dispatcher** — Now logs every delivery attempt to the database with status, duration, and error details. +- **API Routes** — Added null-safety checks for merchant context in webhook delivery endpoints. + ## [0.4.0] - 2026-03-01 ### Added @@ -132,6 +162,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **API Engine** — Improved error handling and performance optimizations for the core Knot server. - **Webhooks** — Enhanced payload security with HMAC signatures and unique event IDs (`evt_...`). +[0.5.0]: https://github.com/qodinger/knotengine/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/qodinger/knotengine/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/qodinger/knotengine/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/qodinger/knotengine/releases/tag/v0.2.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..525f044 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# Contributing to KnotEngine + +Thank you for your interest in contributing! This guide will help you get started. + +## Development Setup + +### Prerequisites + +- **Node.js** v20 or later +- **pnpm** (`npm install -g pnpm`) +- **Docker** (for MongoDB and Redis) + +### Quick Start + +```bash +# 1. Fork and clone the repository +git clone https://github.com/YOUR_USERNAME/knotengine.git +cd knotengine + +# 2. Install dependencies +pnpm install + +# 3. Set up environment +cp .env.example .env + +# 4. Start infrastructure +pnpm docker:up + +# 5. Start all services +pnpm dev +``` + +### Running Services Individually + +| Command | Service | Port | +| -------------------- | ----------- | ---- | +| `pnpm dev:api` | API Engine | 5050 | +| `pnpm dev:checkout` | Checkout UI | 5051 | +| `pnpm dev:dashboard` | Dashboard | 5052 | + +## Commit Convention + +We use [Conventional Commits](https://www.conventionalcommits.org/) for all commits: + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +### Types + +| Type | Description | +| ---------- | ------------------------------------------------ | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `style` | Code style changes (formatting, no logic change) | +| `refactor` | Code refactoring (no feature change or bug fix) | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks, dependencies, CI | + +### Examples + +```bash +feat(api): add webhook delivery logging +fix(sdk): handle rate limit retry-after header +docs: update API reference with missing endpoints +test(api): add e2e tests for invoice creation +chore(deps): update pnpm to v9 +``` + +## Pull Request Process + +1. **Create a branch** from `main` with a descriptive name: + + ```bash + git checkout -b feat/webhook-delivery-logs + ``` + +2. **Make your changes** and ensure: + - All tests pass: `pnpm test` + - Build succeeds: `pnpm build` + - No lint errors: `pnpm lint` + +3. **Commit your changes** using the conventional commit format. + +4. **Push to your fork** and open a Pull Request: + + ```bash + git push origin feat/webhook-delivery-logs + ``` + +5. **Fill out the PR template** with: + - Description of changes + - Testing performed + - Related issues (if any) + +## Project Structure + +``` +knotengine/ +├── apps/ +│ ├── api/ # Fastify payment engine (Port 5050) +│ ├── checkout/ # Next.js customer payment UI (Port 5051) +│ └── dashboard/ # Next.js merchant console (Port 5052) +├── packages/ +│ ├── crypto/ # BIP32/BIP44 HD wallet derivation +│ ├── database/ # Mongoose models with TTL +│ ├── types/ # Shared TypeScript definitions +│ └── sdk/ # @qodinger/knot-sdk +├── docs/ # Documentation +├── scripts/ # Utility scripts +└── .github/ # Workflows and templates +``` + +## Testing + +```bash +# Run all tests +pnpm test + +# Run tests for a specific package +pnpm --filter @qodinger/knot-sdk test +pnpm --filter api test + +# Run E2E tests (requires running server) +KNOT_API_URL=http://localhost:5050 KNOT_API_KEY=knot_sk_test_... pnpm test:e2e +``` + +## Documentation + +When adding or changing features, please update the relevant documentation: + +- **API changes** → `docs/API_REFERENCE.md` +- **Integration changes** → `docs/INTEGRATION_GUIDE.md` +- **SDK changes** → `packages/sdk/README.md` +- **General changes** → `README.md` or `CHANGELOG.md` + +## Reporting Issues + +- **Bug reports**: Use the [Bug Report](https://github.com/qodinger/knotengine/issues/new?template=bug_report.md) template +- **Feature requests**: Use the [Feature Request](https://github.com/qodinger/knotengine/issues/new?template=feature_request.md) template + +## License + +By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license. diff --git a/README.md b/README.md index 9419752..01b96bf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Minimalist, Non-Custodial Crypto Payment Infrastructure for Humans.** -[![Version](https://img.shields.io/badge/version-0.4.0-blue.svg)](https://github.com/qodinger/knotengine/releases) +[![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/qodinger/knotengine/releases) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![pnpm](https://img.shields.io/badge/pnpm-9.0.0-orange.svg)](https://pnpm.io) [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org) @@ -58,13 +58,15 @@ Open `.env` and set at minimum: | `JWT_SECRET` | Random secret for session signing | | `INTERNAL_SECRET` | Shared secret between API & Dashboard | +> **Tip:** Use `.env.example` for local development. Use `.env.production` for self-hosting (secrets are auto-generated by the install script). + ### 3. Start Infrastructure ```bash pnpm docker:up ``` -This starts **MongoDB** and **Redis** via plain Docker containers (no Compose required). +This starts **MongoDB** and **Redis** via plain Docker containers (development only). For full production deployment, see [Self-Hosting](#-self-hosting) below. ### 4. Launch All Services @@ -101,6 +103,7 @@ Open the **Dashboard** at `http://localhost:5052`, register, and configure: - Your settlement wallet address (BTC xPub or EVM address) - Your webhook endpoint URL - Two-Factor Authentication (optional but recommended) +- **Generate an API Key**: Go to Dashboard → Developers → API Keys → "Generate Key" (shown only once) ### 2. Install the SDK @@ -133,12 +136,35 @@ console.log(invoice.checkout_url); ### 4. Verify Webhooks ```javascript -const isValid = knot.verifyWebhook(rawBody, signature); -if (!isValid) return res.status(401).send("Invalid signature"); +// Express example +app.post("/webhooks/knot", (req, res) => { + const signature = req.headers["x-knot-signature"]; + const rawBody = JSON.stringify(req.body); // Use raw body, not parsed JSON + + const isValid = knot.verifyWebhook(rawBody, signature); + if (!isValid) return res.status(401).send("Invalid signature"); + + const event = req.body; + if (event.event === "invoice.confirmed") { + // Fulfill the order + console.log(`Payment confirmed for ${event.invoice_id}`); + } + + res.status(200).send("OK"); +}); ``` --- +## 📚 Documentation + +- [API Reference](docs/API_REFERENCE.md) — All endpoints, request/response schemas +- [Integration Guide](docs/INTEGRATION_GUIDE.md) — Step-by-step payment flow implementation +- [SDK README](packages/sdk/README.md) — Full SDK API reference and error handling +- [Contributing](CONTRIBUTING.md) — Development setup and PR guidelines + +--- + ## 🧪 Testing & Simulation Run the full test suite: @@ -167,8 +193,6 @@ Use the **Simulator** tab in the Dashboard to trigger test payment events (Mempo **No hidden spreads. No recapture mechanics.** Merchants receive 100% of invoice value; fees are transparently deducted from prepaid credit balance. -See [PRICING_MODEL.md](PRICING_MODEL.md) for details. - --- ## 🏗️ Project Structure diff --git a/ROADMAP.md b/ROADMAP.md index 20b4a5a..308f2b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,9 @@ > **Minimalist, Non-Custodial Crypto Payment Infrastructure** -**Last Updated:** May 15, 2026 -**Current Version:** v0.4.0 -**Status:** ✅ Production Ready (99.2% Complete) +**Last Updated:** May 16, 2026 +**Current Version:** v0.5.0 +**Status:** ✅ Production Ready (Self-Hosting Available) --- @@ -55,11 +55,13 @@ **Focus:** Complete critical items for production launch. -#### ✅ Completed (February 26, 2026) +#### ✅ Completed (May 16, 2026) -| Feature | Status | Effort | Notes | -| ----------------------------- | ----------- | ------- | ------------------------- | -| **Email Notification System** | ✅ Complete | 3 hours | Hybrid Resend/Gmail setup | +| Feature | Status | Effort | Notes | +| ------------------------------ | ----------- | ------- | ------------------------- | +| **Email Notification System** | ✅ Complete | 3 hours | Hybrid Resend/Gmail setup | +| **Docker Self-Hosting** | ✅ Complete | 2 days | Full production stack | +| **Marketing Website Redesign** | ✅ Complete | 1 day | Dark theme, terminal demo | **What Was Implemented:** @@ -74,6 +76,13 @@ - ✅ Magic link authentication emails - ✅ Email verification emails - ✅ Setup documentation (`docs/GMAIL_SETUP.md`) +- ✅ Docker self-hosting setup (`docker-compose.yml`, `Dockerfile`s for API/Dashboard/Checkout) +- ✅ One-line install script (`scripts/install.sh`) +- ✅ Production environment template (`.env.production`) +- ✅ Updated README with self-hosting guide +- ✅ Marketing site redesign (centered hero, animated terminal demo, unified button styling) +- ✅ Auth routing improvements (`/login` redirects to `/dashboard`) +- ✅ Pricing cards with bottom-anchored CTAs **Cost:** $0/month (Gmail free tier - 500 emails/day) @@ -81,17 +90,24 @@ | Feature | Status | Effort | Owner | | -------------------------- | -------------- | --------- | ----------- | -| **Marketing Website** | ✅ Complete | — | — | | **Terms of Service** | ❌ Not Started | 1-2 days | Legal | | **Privacy Policy** | ❌ Not Started | 1 day | Legal | | **DeFi Yield Integration** | ⚠️ Partial | 1-2 weeks | Engineering | #### 🟡 High Priority (Launch +30 Days) -| Feature | Status | Effort | Trigger | -| --------------------------- | -------------- | -------- | ---------- | -| **Production Monitoring** | ❌ Not Started | 2-3 days | Pre-launch | -| **Error Tracking (Sentry)** | ❌ Not Started | 1 day | Pre-launch | +| Feature | Status | Effort | Trigger | +| --------------------------- | -------------- | -------- | ----------- | +| **SDK Improvements** | ✅ Complete | 3-5 days | Post-launch | +| **Dashboard Features** | ✅ Complete | 5-7 days | Post-launch | +| **Webhook Reliability UI** | ✅ Complete | 2-3 days | Post-launch | +| **Documentation** | ✅ Complete | 3-4 days | Pre-launch | +| **E2E Testing** | ✅ Complete | 3-5 days | Pre-launch | +| **CI/CD Pipeline** | ✅ Complete | 2-3 days | Pre-launch | +| **Production Monitoring** | ⚠️ Partial | 2-3 days | Pre-launch | +| **Error Tracking (Sentry)** | ❌ Not Started | 1 day | Pre-launch | +| **Load Testing** | ❌ Not Started | 1-2 days | Pre-launch | +| **Security Audit** | ❌ Not Started | 1 week | Pre-launch | --- @@ -212,8 +228,14 @@ Every feature request is evaluated against: - [x] Marketing website published - [x] Payment/security email alerts implemented +- [x] Docker self-hosting setup complete +- [x] One-line install script tested - [ ] Terms of Service published - [ ] Privacy Policy published +- [x] SDK documentation & examples complete +- [x] API reference documentation published +- [x] E2E tests passing +- [x] CI/CD pipeline configured - [ ] Production monitoring setup (Grafana) - [ ] Error tracking configured (Sentry) - [ ] Load testing completed @@ -230,7 +252,7 @@ Every feature request is evaluated against: ### Post-Launch (First 30 Days) -- [ ] Complete email notification system +- [x] Complete email notification system - [ ] Gather user feedback - [ ] Iterate based on analytics - [ ] Plan Q2 features @@ -249,6 +271,16 @@ Every feature request is evaluated against: ## 📝 Changelog +### v0.5.0 (May 2026) + +- ✅ Docker self-hosting setup (API, Dashboard, Checkout, MongoDB, Redis) +- ✅ One-line install script (`scripts/install.sh`) +- ✅ Marketing website redesign (centered hero, animated terminal demo) +- ✅ Auth routing improvements (`/login` → `/dashboard` redirect) +- ✅ Unified button styling across all marketing pages +- ✅ Pricing cards with bottom-anchored CTAs +- ✅ Production environment template (`.env.production`) + ### v0.4.0 (February 2026) - ✅ Performance optimization (100x faster webhook processing) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..add6745 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,516 @@ +# KnotEngine API Reference + +> **Base URL:** `https://api.yourdomain.com` (or `http://localhost:5050` for local dev) +> **Authentication:** API key via `x-api-key` header +> **Content Type:** `application/json` + +## Authentication + +All authenticated endpoints require an API key passed in the `x-api-key` header: + +```bash +curl -H "x-api-key: knot_sk_live_your_key" https://api.yourdomain.com/v1/invoices +``` + +API keys are prefixed with: + +- `knot_sk_live_` — Production keys +- `knot_sk_test_` — Testnet keys + +Keys are generated in the Dashboard → Developers → API Keys tab. Each key is shown only once upon creation. + +--- + +## Invoices + +### Create Invoice + +``` +POST /v1/invoices +``` + +Create a new payment invoice. Requires authentication. + +**Request Body:** + +| Field | Type | Required | Description | +| ------------- | ------- | -------- | --------------------------------------------------------------------------------------- | +| `amount_usd` | number | Yes | USD amount (minimum $1.00) | +| `currency` | string | Yes | One of: `BTC`, `LTC`, `ETH`, `USDT_ERC20`, `USDT_POLYGON`, `USDC_ERC20`, `USDC_POLYGON` | +| `metadata` | object | No | Custom data (e.g., `{ orderId: "12345" }`) | +| `ttl_minutes` | number | No | Invoice TTL in minutes (15-1440, default: 30) | +| `description` | string | No | Invoice description | +| `is_testnet` | boolean | No | Use testnet for testing (default: false) | + +**Response:** + +```json +{ + "invoice_id": "inv_abc123...", + "amount_usd": 49.99, + "crypto_amount": 0.00075, + "crypto_currency": "BTC", + "pay_address": "bc1q...", + "status": "pending", + "checkout_url": "https://checkout.yourdomain.com/checkout/inv_abc123...", + "expires_at": "2026-05-16T12:00:00Z", + "created_at": "2026-05-16T11:30:00Z", + "is_testnet": false +} +``` + +**Rate Limits:** + +- Starter: 60 req/min +- Professional: 300 req/min +- Enterprise: 600 req/min + +--- + +### Get Invoice + +``` +GET /v1/invoices/:id +``` + +Get invoice status. **Public endpoint** (no authentication required). + +**Response:** Same as Create Invoice response, plus merchant branding info. + +--- + +### List Invoices + +``` +GET /v1/invoices +``` + +List invoices for the authenticated merchant. Requires authentication. + +**Query Parameters:** + +| Parameter | Type | Description | +| ----------------- | ------- | ------------------------------------------------------------- | +| `status` | string | Filter by status: `pending`, `confirmed`, `expired`, `failed` | +| `include_testnet` | boolean | Include testnet invoices | +| `only_testnet` | boolean | Only return testnet invoices | +| `page` | number | Page number (default: 1) | +| `limit` | number | Items per page (default: 20, max: 100) | + +**Response:** + +```json +{ + "invoices": [...], + "total": 150, + "page": 1, + "limit": 20 +} +``` + +--- + +### Cancel Invoice + +``` +POST /v1/invoices/:id/cancel +``` + +Cancel a pending invoice (sets status to "expired"). Requires authentication. + +--- + +### Resolve Invoice + +``` +POST /v1/invoices/:id/resolve +``` + +Manually resolve an invoice to "confirmed" state. Requires authentication. + +--- + +## Merchants + +### Get Profile + +``` +GET /v1/merchants/me +``` + +Get current merchant profile. Requires authentication. + +**Response:** + +```json +{ + "merchant_id": "mid_abc123", + "webhook_url": "https://api.myapp.com/webhooks", + "currencies": ["BTC", "ETH"], + "confirmation_policy": { "BTC": 2, "ETH": 12 }, + "branding": { "name": "My Store", "color": "#ffffff" }, + "fee_responsibility": "merchant", + "created_at": "2026-01-15T10:00:00Z" +} +``` + +--- + +### Update Profile + +``` +PATCH /v1/merchants/me +``` + +Update merchant settings. Requires authentication. + +**Request Body (all fields optional):** + +| Field | Type | Description | +| --------------------- | -------- | --------------------------- | +| `webhook_url` | string | Webhook endpoint URL | +| `xpub` | string | Bitcoin extended public key | +| `currencies` | string[] | Supported currencies | +| `confirmation_policy` | object | Confirmations per currency | +| `branding` | object | Branding settings | +| `fee_responsibility` | string | `"merchant"` or `"client"` | + +--- + +### Get Stats + +``` +GET /v1/merchants/me/stats?period=7d +``` + +Get dashboard statistics. Requires authentication. + +**Query Parameters:** + +- `period`: `24h`, `7d`, or `30d` (default: `7d`) + +**Response:** + +```json +{ + "totalVolume": 15000.00, + "totalInvoices": 150, + "confirmedInvoices": 120, + "pendingInvoices": 15, + "feesAccrued": { "usd": 75.00 }, + "successRate": "80.0%", + "chartData": [...], + "topCurrencies": [...] +} +``` + +--- + +### Rotate API Key + +``` +POST /v1/merchants/me/keys +``` + +Generate new API key (old key is immediately invalidated). Requires authentication. + +**Response:** + +```json +{ + "key_id": "key_abc123", + "key": "knot_sk_live_newkey...", + "created_at": "2026-05-16T12:00:00Z" +} +``` + +⚠️ The key is shown only once. Store it securely. + +--- + +### Rotate Webhook Secret + +``` +POST /v1/merchants/me/keys/webhook +``` + +Generate new webhook secret. Requires authentication. + +--- + +### Send Test Webhook + +``` +POST /v1/merchants/me/webhooks/test +``` + +Send a test webhook to configured endpoint. Requires authentication. + +--- + +### Get Webhook Deliveries + +``` +GET /v1/merchants/me/webhooks/deliveries?page=1&limit=20&status=success +``` + +List webhook delivery logs. Requires authentication. + +**Query Parameters:** + +| Parameter | Type | Description | +| ----------- | ------ | -------------------------------------- | +| `page` | number | Page number (default: 1) | +| `limit` | number | Items per page (default: 20, max: 100) | +| `status` | string | Filter: `pending`, `success`, `failed` | +| `invoiceId` | string | Filter by invoice ID | + +--- + +### Get Webhook Stats + +``` +GET /v1/merchants/me/webhooks/stats +``` + +Get webhook delivery statistics. Requires authentication. + +**Response:** + +```json +{ + "total": 500, + "success": 480, + "failed": 15, + "pending": 5, + "successRate": 96.0 +} +``` + +--- + +### IP Allowlist + +``` +GET /v1/merchants/me/ip-allowlist +POST /v1/merchants/me/ip-allowlist +``` + +Manage IP allowlist for API access. Requires authentication. + +**POST Body:** + +```json +{ + "enabled": true, + "allowedIps": ["192.168.1.1", "10.0.0.0/8"] +} +``` + +--- + +### Additional Endpoints + +| Method | Endpoint | Description | +| -------- | ------------------------------------------ | ---------------------------------------- | +| `DELETE` | `/v1/merchants/me` | Delete merchant profile | +| `POST` | `/v1/merchants/me/plan` | Update subscription plan | +| `POST` | `/v1/merchants/me/topup` | Verify and claim top-up credits | +| `POST` | `/v1/merchants/me/promo/redeem` | Redeem promo code | +| `GET` | `/v1/merchants/me/notifications` | Get notifications | +| `PATCH` | `/v1/merchants/me/notifications/mark-read` | Mark all notifications read | +| `PATCH` | `/v1/merchants/me/notifications/:id` | Mark one notification read | +| `POST` | `/v1/merchants/me/charge-plan` | Charge for plan during grace period | +| `POST` | `/v1/merchants/me/wallet/generate-testnet` | Generate testnet wallet | +| `POST` | `/v1/merchants/me/ip-allowlist/validate` | Validate IP address | +| `POST` | `/v1/merchants/me/keys/generate` | Generate first API key (OAuth merchants) | +| `POST` | `/v1/webhooks/simulate` | Simulate webhook (dev only) | +| `GET` | `/v1/merchants/me/webhooks/deliveries/:id` | Get delivery details | +| `GET` | `/v1/merchants` | List all merchants for current user | + +--- + +## Configuration + +### Get Supported Assets + +``` +GET /v1/config/assets +``` + +Get supported currencies, networks, and assets. **Public endpoint**. + +**Response:** + +```json +{ + "assets": { + "BTC": { + "symbol": "BTC", + "network": "bitcoin", + "type": "hd_wallet", + "confirmations": 2 + }, + "ETH": { + "symbol": "ETH", + "network": "ethereum", + "type": "static", + "confirmations": 12 + } + }, + "networks": ["bitcoin", "litecoin", "ethereum", "polygon"], + "supportedCurrencies": [ + "BTC", + "LTC", + "ETH", + "USDT_ERC20", + "USDT_POLYGON", + "USDC_ERC20", + "USDC_POLYGON" + ] +} +``` + +--- + +## Health Check + +``` +GET /health +``` + +Check API health status. **Public endpoint**. + +**Response:** + +```json +{ + "status": "ok", + "engine": "Knot v0.5.0", + "timestamp": "2026-05-16T12:00:00.000Z", + "uptime": 3600 +} +``` + +--- + +## Error Responses + +All errors follow this format: + +```json +{ + "error": "Error message", + "code": "error_code" +} +``` + +| HTTP Status | Error Code | Description | +| ----------- | ---------------------- | -------------------------- | +| 400 | `validation_error` | Invalid request parameters | +| 401 | `authentication_error` | Invalid or missing API key | +| 404 | `not_found_error` | Resource not found | +| 429 | `rate_limit_error` | Rate limit exceeded | +| 500 | `internal_error` | Server error | + +--- + +## Webhooks + +KnotEngine sends webhook notifications to your configured endpoint when invoice events occur. + +### Headers + +| Header | Description | +| ------------------ | -------------------------------------- | +| `x-knot-signature` | HMAC-SHA256 signature of the raw body | +| `x-knot-event` | Event type (e.g., `invoice.confirmed`) | +| `x-knot-invoice` | Invoice ID | +| `Content-Type` | `application/json` | + +### Payload + +```json +{ + "id": "evt_abc123...", + "event": "invoice.confirmed", + "created": 1700000000, + "invoice_id": "inv_abc123...", + "status": "confirmed", + "amount": { + "usd": 49.99, + "crypto": 0.00075, + "crypto_received": 0.00075, + "currency": "BTC", + "fee_usd": 0.5 + }, + "payment": { + "address": "bc1q...", + "tx_hash": "0x...", + "confirmations": 2, + "paid_at": "2026-05-16T12:00:00Z" + }, + "metadata": { + "orderId": "12345" + } +} +``` + +### Event Types + +| Event | Description | +| -------------------------- | -------------------------------------- | +| `invoice.confirmed` | Invoice reached required confirmations | +| `invoice.mempool_detected` | Transaction seen in mempool (0-conf) | +| `invoice.confirming` | Invoice is confirming | +| `invoice.expired` | Invoice timed out | +| `invoice.failed` | Invoice unpaid | +| `invoice.partially_paid` | Partial payment received | +| `invoice.overpaid` | Overpayment detected | + +### Retry Policy + +- Up to 10 attempts with exponential backoff +- Backoff: 2, 4, 8, 16, 32... minutes +- ~24 hours of total retry time +- First failure triggers a dashboard notification + +--- + +## SDK Usage + +Install the SDK: + +```bash +npm install @qodinger/knot-sdk +``` + +Initialize: + +```typescript +import { KnotClient } from "@qodinger/knot-sdk"; + +const knot = new KnotClient({ + apiKey: "knot_sk_live_your_key", + baseUrl: "https://api.yourdomain.com", + webhookSecret: "knot_wh_your_secret", +}); +``` + +Create invoice: + +```typescript +const invoice = await knot.createInvoice({ + amount_usd: 49.99, + currency: "BTC", + metadata: { orderId: "12345" }, +}); +``` + +Verify webhook: + +```typescript +const isValid = knot.verifyWebhook(rawBody, signature); +``` + +See [SDK README](../packages/sdk/README.md) for full documentation. diff --git a/docs/GMAIL_SETUP.md b/docs/GMAIL_SETUP.md index c1e16a3..ddceadd 100644 --- a/docs/GMAIL_SETUP.md +++ b/docs/GMAIL_SETUP.md @@ -31,7 +31,7 @@ KnotEngine uses Gmail SMTP for sending transactional emails (payment alerts, sec ### Step 3: Add to Environment Variables -Create or update your `.env` file in the `apps/api/` directory: +Create or update your `.env` file in the **project root** directory: ```bash # Gmail SMTP Configuration @@ -150,7 +150,7 @@ INTERNAL_SECRET=your-internal-secret-here ## 🎯 Production vs Development -In the latest v0.3.1 architecture, follows a **hybrid email model**: +In the current architecture, KnotEngine follows a **hybrid email model**: 1. **Local Development**: Use this guide to set up **Gmail SMTP**. It is free, requires no DNS verification, and is perfect for testing onboarding and payment flows. 2. **Production**: Use **Resend**. It provides enterprise-grade deliverability, bounce tracking, and professional custom domains. diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md new file mode 100644 index 0000000..9207971 --- /dev/null +++ b/docs/INTEGRATION_GUIDE.md @@ -0,0 +1,284 @@ +# Integration Guide + +> Learn how to integrate KnotEngine into your application to accept cryptocurrency payments. + +## Quick Start + +### 1. Install the SDK + +```bash +npm install @qodinger/knot-sdk +``` + +### 2. Initialize the Client + +```typescript +import { KnotClient } from "@qodinger/knot-sdk"; + +const knot = new KnotClient({ + apiKey: process.env.KNOT_API_KEY!, + baseUrl: process.env.KNOT_API_URL || "http://localhost:5050", + webhookSecret: process.env.KNOT_WEBHOOK_SECRET, +}); +``` + +### 3. Create an Invoice + +```typescript +// In your checkout handler +app.post("/checkout", async (req, res) => { + const { amount, currency } = req.body; + + const invoice = await knot.createInvoice({ + amount_usd: amount, + currency: currency || "BTC", + metadata: { + orderId: generateOrderId(), + customerId: req.user.id, + }, + }); + + // Redirect to hosted checkout + res.json({ checkout_url: invoice.checkout_url }); +}); +``` + +### 4. Handle Webhooks + +```typescript +// In your webhook endpoint +app.post("/webhooks/knot", async (req, res) => { + const signature = req.headers["x-knot-signature"] as string; + const rawBody = req.rawBody; // Important: use raw body, not parsed JSON + + // Verify signature + if (!knot.verifyWebhook(rawBody, signature)) { + return res.status(401).send("Invalid signature"); + } + + const event = JSON.parse(rawBody); + + switch (event.event) { + case "invoice.confirmed": + await fulfillOrder(event.metadata.orderId); + break; + case "invoice.mempool_detected": + await notifyCustomer(event.invoice_id, "Payment detected"); + break; + case "invoice.expired": + await cancelOrder(event.metadata.orderId); + break; + } + + res.status(200).send("OK"); +}); +``` + +--- + +## Complete Payment Flow + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Your App │ │ KnotEngine │ │ Customer │ +└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + │ POST /v1/invoices│ │ + │──────────────────>│ │ + │ │ │ + │ Invoice Response │ │ + │<──────────────────│ │ + │ {checkout_url} │ │ + │ │ │ + │ Redirect customer│ │ + │───────────────────────────────────────>│ + │ │ │ + │ │ Customer pays │ + │ │<──────────────────│ + │ │ │ + │ │ Blockchain │ + │ │ detects payment │ + │ │ │ + │ POST webhook │ │ + │<──────────────────│ │ + │ {event: │ │ + │ "confirmed"} │ │ + │ │ │ + │ Fulfill order │ │ + │ │ │ +``` + +--- + +## Self-Hosting + +### Docker Compose + +Use the provided `docker-compose.yml` in the repository root: + +```bash +git clone https://github.com/qodinger/knotengine.git +cd knotengine +cp .env.production .env +# Edit .env with your Tatum/Alchemy API keys +docker compose up -d --build +``` + +Or use the one-line install script: + +```bash +curl -fsSL https://raw.githubusercontent.com/qodinger/knotengine/main/scripts/install.sh | bash +``` + +### Environment Variables + +| Variable | Description | Required | +| ----------------- | --------------------------------------- | -------- | +| `MONGO_USER` | MongoDB username | Yes | +| `MONGO_PASSWORD` | MongoDB password | Yes | +| `MONGO_DB` | MongoDB database name | Yes | +| `REDIS_PASSWORD` | Redis password | Yes | +| `TATUM_API_KEY` | Tatum API key for blockchain monitoring | Yes | +| `ALCHEMY_API_KEY` | Alchemy API key (failover) | Yes | +| `BTC_XPUB` | Bitcoin extended public key | Yes | +| `LTC_XPUB` | Litecoin extended public key | Yes | +| `ETH_ADDRESS` | Ethereum static address | Yes | +| `WEBHOOK_SECRET` | HMAC secret for signing webhooks | Yes | +| `INTERNAL_SECRET` | Secret for internal API communication | Yes | +| `JWT_SECRET` | Secret for session signing | Yes | + +```bash +curl -fsSL https://raw.githubusercontent.com/qodinger/knotengine/main/scripts/install.sh | bash +``` + +--- + +## Configuration + +### Supported Currencies + +| Currency | Network | Confirmations | Type | +| ------------ | -------- | ------------- | -------------- | +| BTC | Bitcoin | 2 | HD Wallet | +| LTC | Litecoin | 6 | HD Wallet | +| ETH | Ethereum | 12 | Static Address | +| USDT_ERC20 | Ethereum | 12 | Static Address | +| USDT_POLYGON | Polygon | 30 | Static Address | +| USDC_ERC20 | Ethereum | 12 | Static Address | +| USDC_POLYGON | Polygon | 30 | Static Address | + +--- + +## Security Best Practices + +1. **Never expose API keys** — Only use `knot_sk_live_...` keys server-side +2. **Always verify webhooks** — Use `verifyWebhook()` before processing events +3. **Use IP allowlisting** — Restrict API access to your server IPs +4. **Rotate keys regularly** — Use `rotateApiKey()` periodically +5. **Enable 2FA** — Protect your merchant account with TOTP + +--- + +## Error Handling + +### SDK Errors + +```typescript +import { + KnotClient, + KnotAuthenticationError, + KnotValidationError, + KnotRateLimitError, + KnotNotFoundError, +} from "@qodinger/knot-sdk"; + +try { + const invoice = await knot.createInvoice({ ... }); +} catch (error) { + if (error instanceof KnotAuthenticationError) { + console.error("Invalid API key"); + } else if (error instanceof KnotValidationError) { + console.error("Invalid parameters:", error.details); + } else if (error instanceof KnotRateLimitError) { + console.error(`Rate limited. Retry after ${error.retryAfter}s`); + } else if (error instanceof KnotNotFoundError) { + console.error("Invoice not found"); + } else { + console.error("Unknown error:", error); + } +} +``` + +--- + +## Testing + +### Testnet Mode + +Create testnet invoices to test without real funds: + +```typescript +const invoice = await knot.createInvoice({ + amount_usd: 10.0, + currency: "BTC", + is_testnet: true, // Use testnet +}); +``` + +### Test Webhooks + +Send a test webhook to verify your endpoint: + +```typescript +const result = await knot.sendTestWebhook(); +console.log(result.message); +``` + +--- + +## Troubleshooting + +### Webhook Not Received + +1. Check your webhook URL is accessible from the internet +2. Verify the URL in Dashboard → Developers → Webhooks +3. Check webhook delivery logs in Dashboard → Developers → Webhooks → Delivery Logs +4. Use the "Test" button to send a test webhook + +### Invoice Not Confirming + +1. Check blockchain provider status (Tatum/Alchemy) +2. Verify confirmations required for the currency +3. Check the invoice status via `GET /v1/invoices/:id` + +### Rate Limited + +- Starter: 60 req/min +- Professional: 300 req/min +- Enterprise: 600 req/min + +Implement exponential backoff when rate limited: + +```typescript +async function createWithRetry(data: CreateInvoiceRequest, maxRetries = 3) { + for (let i = 0; i < maxRetries; i++) { + try { + return await knot.createInvoice(data); + } catch (error) { + if (error instanceof KnotRateLimitError && error.retryAfter) { + await sleep(error.retryAfter * 1000); + } else { + throw error; + } + } + } +} +``` + +--- + +## Support + +- **Documentation:** See [API Reference](./API_REFERENCE.md) and [SDK README](../packages/sdk/README.md) +- **GitHub:** https://github.com/qodinger/knotengine +- **SDK:** `@qodinger/knot-sdk` on npm diff --git a/packages/types/README.md b/packages/types/README.md index 11c95dc..baaa166 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -20,9 +20,9 @@ npm install @qodinger/knot-types ## 📖 Usage ```typescript -import { IInvoice, SUPPORTED_CURRENCIES } from "@qodinger/knot-types"; +import { Invoice, SUPPORTED_CURRENCIES } from "@qodinger/knot-types"; -const payment: Partial = { +const payment: Partial = { status: "pending", cryptoCurrency: "BTC", amountUsd: 100, From 280177409c5c27a720e2ff6db85ca9c963d54ff3 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:10:29 +0700 Subject: [PATCH 08/24] build: monorepo env sharing and test infrastructure - Add turbo.json globalEnv for shared environment variables - Add dotenv to dashboard/checkout to load root .env - Delete redundant apps/*/.env.local files - Add smoke-tests.sh for Docker deployment verification - Add e2e.test.ts for API integration tests --- apps/checkout/next.config.ts | 5 + apps/checkout/package.json | 1 + apps/dashboard/next.config.ts | 5 + apps/dashboard/package.json | 1 + scripts/smoke-tests.sh | 89 ++++++++++++++ tests/e2e.test.ts | 225 ++++++++++++++++++++++++++++++++++ turbo.json | 34 +++++ 7 files changed, 360 insertions(+) create mode 100644 scripts/smoke-tests.sh create mode 100644 tests/e2e.test.ts diff --git a/apps/checkout/next.config.ts b/apps/checkout/next.config.ts index 68a6c64..9a20cf2 100644 --- a/apps/checkout/next.config.ts +++ b/apps/checkout/next.config.ts @@ -1,4 +1,9 @@ import type { NextConfig } from "next"; +import dotenv from "dotenv"; +import path from "path"; + +// Load root .env for monorepo development +dotenv.config({ path: path.resolve(__dirname, "../../.env") }); const nextConfig: NextConfig = { output: "standalone", diff --git a/apps/checkout/package.json b/apps/checkout/package.json index 228d116..964c7cc 100644 --- a/apps/checkout/package.json +++ b/apps/checkout/package.json @@ -31,6 +31,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^16.4.7", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 5ac45ba..6fb46b1 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -1,4 +1,9 @@ import type { NextConfig } from "next"; +import dotenv from "dotenv"; +import path from "path"; + +// Load root .env for monorepo development +dotenv.config({ path: path.resolve(__dirname, "../../.env") }); const nextConfig: NextConfig = { output: "standalone", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4e48afb..1c32e10 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -48,6 +48,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^16.4.7", "eslint": "^9", "eslint-config-next": "16.1.6", "shadcn": "^3.8.5", diff --git a/scripts/smoke-tests.sh b/scripts/smoke-tests.sh new file mode 100644 index 0000000..79bf203 --- /dev/null +++ b/scripts/smoke-tests.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Smoke tests for KnotEngine Docker deployment +# Run after docker compose up -d --build + +set -e + +API_URL="${API_URL:-http://localhost:5050}" +DASHBOARD_URL="${DASHBOARD_URL:-http://localhost:5052}" +CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:5051}" + +PASS=0 +FAIL=0 + +check_service() { + local name=$1 + local url=$2 + local expected_status=${3:-200} + + echo -n "Testing $name ($url)... " + + status=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000") + + if [ "$status" = "$expected_status" ]; then + echo "✅ PASS (HTTP $status)" + PASS=$((PASS + 1)) + else + echo "❌ FAIL (Expected $expected_status, got $status)" + FAIL=$((FAIL + 1)) + fi +} + +echo "==========================================" +echo " KnotEngine Smoke Tests" +echo "==========================================" +echo "" + +# API Health +check_service "API Health" "$API_URL/health" 200 + +# API Config +check_service "API Config" "$API_URL/v1/config/assets" 200 + +# API Auth enforcement +echo -n "Testing API Auth enforcement... " +status=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/v1/merchants/me" 2>/dev/null || echo "000") +if [ "$status" = "401" ]; then + echo "✅ PASS (HTTP $status - auth enforced)" + PASS=$((PASS + 1)) +else + echo "❌ FAIL (Expected 401, got $status)" + FAIL=$((FAIL + 1)) +fi + +# Dashboard +check_service "Dashboard" "$DASHBOARD_URL" 200 + +# Checkout +check_service "Checkout" "$CHECKOUT_URL" 200 + +# MongoDB +echo -n "Testing MongoDB connection... " +if docker compose exec -T mongo mongosh --eval "db.runCommand({ping: 1})" >/dev/null 2>&1; then + echo "✅ PASS" + PASS=$((PASS + 1)) +else + echo "❌ FAIL" + FAIL=$((FAIL + 1)) +fi + +# Redis +echo -n "Testing Redis connection... " +if docker compose exec -T redis redis-cli ping 2>/dev/null | grep -q "PONG"; then + echo "✅ PASS" + PASS=$((PASS + 1)) +else + echo "❌ FAIL" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "==========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "==========================================" + +if [ $FAIL -gt 0 ]; then + exit 1 +fi + +echo "All smoke tests passed! ✅" diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts new file mode 100644 index 0000000..046d789 --- /dev/null +++ b/tests/e2e.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + KnotClient, + KnotAuthenticationError, + KnotValidationError, +} from "@qodinger/knot-sdk"; +import * as crypto from "crypto"; + +/** + * E2E Tests for KnotEngine API + * + * These tests require a running API server and MongoDB instance. + * Run with: pnpm test:e2e + * + * Environment variables: + * - KNOT_API_URL: API base URL (default: http://localhost:5050) + * - KNOT_API_KEY: API key for authentication + */ + +const API_URL = process.env.KNOT_API_URL || "http://localhost:5050"; +const API_KEY = process.env.KNOT_API_KEY || "knot_sk_test_e2e"; + +describe("KnotEngine E2E Tests", () => { + let knot: KnotClient; + + beforeAll(() => { + knot = new KnotClient({ + apiKey: API_KEY, + baseUrl: API_URL, + }); + }); + + describe("Health Check", () => { + it("should return health status", async () => { + const response = await fetch(`${API_URL}/health`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.status).toBe("ok"); + expect(data.engine).toContain("Knot"); + }); + }); + + describe("Public Endpoints", () => { + it("should get supported assets", async () => { + const response = await fetch(`${API_URL}/v1/config/assets`); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.currencies).toContain("BTC"); + expect(data.currencies).toContain("ETH"); + expect(data.networks).toContain("bitcoin"); + }); + + it("should require auth for merchants endpoint", async () => { + const response = await fetch(`${API_URL}/v1/merchants/me`); + expect(response.status).toBe(401); + }); + }); + + describe("Invoice Creation", () => { + it("should reject requests without API key", async () => { + const response = await fetch(`${API_URL}/v1/invoices`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amount_usd: 10, + currency: "BTC", + }), + }); + expect(response.status).toBe(401); + }); + + it("should reject invalid amount", async () => { + try { + await knot.createInvoice({ + amount_usd: 0.5, + currency: "BTC", + }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(KnotValidationError); + } + }); + + it("should create a testnet invoice", async () => { + if (API_KEY === "knot_sk_test_e2e") { + console.log("Skipping: No valid API key configured"); + return; + } + + const invoice = await knot.createInvoice({ + amount_usd: 10.0, + currency: "BTC", + is_testnet: true, + metadata: { orderId: "e2e_test_001" }, + }); + + expect(invoice.invoice_id).toBeDefined(); + expect(invoice.invoice_id).toMatch(/^inv_/); + expect(invoice.amount_usd).toBe(10.0); + expect(invoice.crypto_currency).toBe("BTC"); + expect(invoice.pay_address).toBeDefined(); + expect(invoice.checkout_url).toBeDefined(); + expect(invoice.status).toBe("pending"); + expect(invoice.is_testnet).toBe(true); + }); + }); + + describe("Invoice Retrieval", () => { + it("should get invoice by ID", async () => { + if (API_KEY === "knot_sk_test_e2e") { + console.log("Skipping: No valid API key configured"); + return; + } + + const created = await knot.createInvoice({ + amount_usd: 25.0, + currency: "BTC", + is_testnet: true, + }); + + const retrieved = await knot.getInvoice(created.invoice_id); + expect(retrieved.invoice_id).toBe(created.invoice_id); + expect(retrieved.amount_usd).toBe(25.0); + }); + + it("should return 404 for non-existent invoice", async () => { + try { + await knot.getInvoice("inv_nonexistent_123456"); + expect.fail("Should have thrown"); + } catch (error) { + expect((error as Error).message).toContain("not found"); + } + }); + }); + + describe("Invoice Listing", () => { + it("should list invoices", async () => { + if (API_KEY === "knot_sk_test_e2e") { + console.log("Skipping: No valid API key configured"); + return; + } + + const result = await knot.listInvoices({ + page: 1, + limit: 10, + include_testnet: true, + }); + + expect(result.invoices).toBeDefined(); + expect(Array.isArray(result.invoices)).toBe(true); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + }); + + describe("Invoice Cancellation", () => { + it("should cancel a pending invoice", async () => { + if (API_KEY === "knot_sk_test_e2e") { + console.log("Skipping: No valid API key configured"); + return; + } + + const invoice = await knot.createInvoice({ + amount_usd: 15.0, + currency: "BTC", + is_testnet: true, + }); + + const cancelled = await knot.cancelInvoice(invoice.invoice_id); + expect(cancelled.status).toBe("expired"); + }); + }); + + describe("Webhook Verification", () => { + it("should verify valid webhook signature", () => { + const webhookSecret = "whsec_test_secret"; + const client = new KnotClient({ + apiKey: "test", + webhookSecret, + }); + + const payload = JSON.stringify({ + event: "invoice.confirmed", + invoice_id: "inv_test", + }); + + const signature = crypto + .createHmac("sha256", webhookSecret) + .update(payload) + .digest("hex"); + + expect(client.verifyWebhook(payload, signature)).toBe(true); + }); + + it("should reject invalid webhook signature", () => { + const client = new KnotClient({ + apiKey: "test", + webhookSecret: "whsec_test", + }); + + expect(client.verifyWebhook("{}", "invalid_signature")).toBe(false); + }); + }); + + describe("Error Handling", () => { + it("should throw KnotAuthenticationError for invalid key", async () => { + const badClient = new KnotClient({ + apiKey: "knot_sk_invalid_key", + baseUrl: API_URL, + }); + + try { + await badClient.createInvoice({ + amount_usd: 10, + currency: "BTC", + }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(KnotAuthenticationError); + } + }); + }); +}); diff --git a/turbo.json b/turbo.json index 8ac0991..3c228a6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,39 @@ { "$schema": "https://turbo.build/schema.json", + "globalEnv": [ + "DATABASE_URL", + "REDIS_URL", + "NODE_ENV", + "PORT", + "NEXT_PUBLIC_API_URL", + "NEXT_PUBLIC_CHECKOUT_URL", + "NEXT_PUBLIC_DASHBOARD_URL", + "INTERNAL_SECRET", + "JWT_SECRET", + "WEBHOOK_SECRET", + "BITCOIN_NETWORK", + "PUBLIC_URL", + "TATUM_API_KEY", + "TATUM_WEBHOOK_SECRET", + "ALCHEMY_API_KEY", + "ALCHEMY_AUTH_TOKEN", + "ALCHEMY_NOTIFY_WEBHOOK_ID", + "ALCHEMY_WEBHOOK_SIGNING_KEY", + "PLATFORM_FEE_RATE", + "MIN_INVOICE_AMOUNT", + "MIN_FEE_USD", + "WELCOME_CREDIT_AMOUNT", + "AFFILIATE_SIGNUP_BONUS", + "PLATFORM_FEE_WALLET_EVM", + "GMAIL_USER", + "GMAIL_APP_PASSWORD", + "RESEND_API_KEY", + "FROM_EMAIL", + "DASHBOARD_URL", + "CLOUDINARY_CLOUD_NAME", + "CLOUDINARY_API_KEY", + "CLOUDINARY_API_SECRET" + ], "tasks": { "build": { "dependsOn": ["^build"], From 8401d7ad7a248b40202df722ae8d8b1c87745ddb Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:11:18 +0700 Subject: [PATCH 09/24] fix(docker): api build and production deployment fixes fix module resolution to node20 in tsconfig.json fix package.json import attribute syntax add PORT env vars for dashboard/checkout in docker-compose --- apps/api/Dockerfile | 19 +++++++++---------- apps/api/src/main.ts | 2 +- apps/api/tsconfig.json | 2 +- docker-compose.yml | 2 ++ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 1ca152b..a08c648 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -13,15 +13,11 @@ COPY apps/api/package.json ./apps/api/ COPY packages/crypto/package.json ./packages/crypto/ COPY packages/database/package.json ./packages/database/ COPY packages/types/package.json ./packages/types/ -RUN corepack enable && pnpm install --frozen-lockfile --filter api --filter @qodinger/knot-crypto --filter @qodinger/knot-database --filter @qodinger/knot-types +RUN corepack enable && pnpm install --frozen-lockfile # ── Build ── FROM base AS builder -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules -COPY --from=deps /app/packages/crypto/node_modules ./packages/crypto/node_modules -COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules -COPY --from=deps /app/packages/types/node_modules ./packages/types/node_modules +COPY --from=deps /app . COPY . . RUN corepack enable && pnpm --filter api build @@ -30,14 +26,17 @@ FROM node:20-alpine AS runner WORKDIR /app RUN apk add --no-cache dumb-init -COPY --from=builder /app/apps/api/dist ./dist -COPY --from=builder /app/apps/api/package.json ./package.json -COPY --from=builder /app/apps/api/node_modules ./node_modules +# Copy built output and all node_modules +COPY --from=builder /app/apps/api/dist ./apps/api/dist +COPY --from=builder /app/apps/api/package.json ./apps/api/package.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/apps/api/node_modules ./apps/api/node_modules COPY --from=builder /app/packages/crypto ./packages/crypto COPY --from=builder /app/packages/database ./packages/database COPY --from=builder /app/packages/types ./packages/types +WORKDIR /app/apps/api ENV NODE_ENV=production EXPOSE 5050 -CMD ["dumb-init", "--", "node", "dist/main.js"] +CMD ["dumb-init", "--", "node", "dist/src/main.js"] diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5cda91c..5b8779f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -32,7 +32,7 @@ import dotenv from "dotenv"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; -import packageJson from "../package.json"; +import packageJson from "../package.json" with { type: "json" }; // Fix __dirname for ES modules const __filename = fileURLToPath(import.meta.url); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index b50e9b1..59fb04e 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "node16", + "module": "node20", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/docker-compose.yml b/docker-compose.yml index 64928a7..e81c678 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,6 +93,7 @@ services: env_file: .env environment: NODE_ENV: production + PORT: 5052 NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} NEXT_PUBLIC_CHECKOUT_URL: ${CHECKOUT_URL:-http://localhost:5051} depends_on: @@ -119,6 +120,7 @@ services: env_file: .env environment: NODE_ENV: production + PORT: 5051 NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5050} depends_on: - api From adf3fc8e9db756493786efd072ea438d1fdfffde Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:23:38 +0700 Subject: [PATCH 10/24] fix(ci): resolve pnpm version conflict and docker build context --- .github/workflows/ci.yml | 12 +++--------- .github/workflows/docker-publish.yml | 3 ++- .github/workflows/publish.yml | 2 -- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 071abbd..c6ce7be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: @@ -33,8 +31,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: @@ -52,8 +48,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: @@ -78,10 +72,10 @@ jobs: - uses: actions/checkout@v4 - name: Build API Docker image - run: docker build -t knot-api ./apps/api + run: docker build -t knot-api -f apps/api/Dockerfile . - name: Build Dashboard Docker image - run: docker build -t knot-dashboard ./apps/dashboard + run: docker build -t knot-dashboard -f apps/dashboard/Dockerfile . - name: Build Checkout Docker image - run: docker build -t knot-checkout ./apps/checkout + run: docker build -t knot-checkout -f apps/checkout/Dockerfile . diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c4a8335..17b75e7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -42,7 +42,8 @@ jobs: - name: Build and push uses: docker/build-push-action@v5 with: - context: ./apps/${{ matrix.service }} + context: . + file: ./apps/${{ matrix.service }}/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afa6df5..5c82f6c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,8 +17,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v3 - with: - version: 9 - name: Set up Node.js for npm uses: actions/setup-node@v4 From 4fedba0cdfe9ccf6d7b3c827294db27807287763 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:26:58 +0700 Subject: [PATCH 11/24] fix(docker): update lockfile and build workspace dependencies before api --- apps/api/Dockerfile | 2 +- pnpm-lock.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index a08c648..3ea775b 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -19,7 +19,7 @@ RUN corepack enable && pnpm install --frozen-lockfile FROM base AS builder COPY --from=deps /app . COPY . . -RUN corepack enable && pnpm --filter api build +RUN corepack enable && pnpm --filter @qodinger/knot-types build && pnpm --filter @qodinger/knot-crypto build && pnpm --filter @qodinger/knot-database build && pnpm --filter api build # ── Production ── FROM node:20-alpine AS runner diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77ccb50..f8dd77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) @@ -338,6 +341,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) From 120512a896a0db0a24eb89d7d42657c6584dd8ba Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:31:08 +0700 Subject: [PATCH 12/24] fix(ci): escape quotes in terms page and build workspace packages before api tests --- .github/workflows/ci.yml | 3 +++ apps/dashboard/src/app/(marketing)/terms/page.tsx | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ce7be..6d1c1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Run SDK tests run: pnpm --filter @qodinger/knot-sdk test + - name: Build workspace packages + run: pnpm --filter @qodinger/knot-types build && pnpm --filter @qodinger/knot-crypto build && pnpm --filter @qodinger/knot-database build + - name: Run API tests run: pnpm --filter api test diff --git a/apps/dashboard/src/app/(marketing)/terms/page.tsx b/apps/dashboard/src/app/(marketing)/terms/page.tsx index c7b5a79..ffe6bb2 100644 --- a/apps/dashboard/src/app/(marketing)/terms/page.tsx +++ b/apps/dashboard/src/app/(marketing)/terms/page.tsx @@ -28,8 +28,9 @@ export default function TermsPage() { 3. Limitation of Liability

- KnotEngine is provided "as is" without warranty of any kind. We - are not liable for any losses arising from the use of our service. + KnotEngine is provided "as is" without warranty of any + kind. We are not liable for any losses arising from the use of our + service.

From fd750b71e9e0a7210a55f287c2afd87b427f432a Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:55:01 +0700 Subject: [PATCH 13/24] chore(release): v0.5.0 --- apps/api/package.json | 2 +- apps/checkout/package.json | 2 +- apps/dashboard/package.json | 4 ++-- package.json | 2 +- packages/crypto/package.json | 2 +- packages/database/package.json | 2 +- packages/types/package.json | 2 +- pnpm-lock.yaml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index f08ce08..69d0c73 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "tsx watch src/main.ts", diff --git a/apps/checkout/package.json b/apps/checkout/package.json index 964c7cc..0693b3b 100644 --- a/apps/checkout/package.json +++ b/apps/checkout/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev -p 5051", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1c32e10..42ce360 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev -p 5052", @@ -16,7 +16,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", - "@qodinger/knot-types": "workspace:0.4.0", + "@qodinger/knot-types": "workspace:0.5.0", "axios": "^1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/package.json b/package.json index 7f31261..171f5a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "knotengine", "private": true, - "version": "0.4.0", + "version": "0.5.0", "description": "Minimalist, Non-Custodial Crypto Payment Infrastructure", "author": "tyecode", "repository": { diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 4d08dbe..6d14e61 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/database/package.json b/packages/database/package.json index c030f7c..993c878 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/types/package.json b/packages/types/package.json index 4b667f9..05a215f 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/qodinger/knotengine.git" }, "license": "AGPL-3.0", - "version": "0.4.0", + "version": "0.5.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8dd77b..1ad430a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,7 +251,7 @@ importers: specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.3)) '@qodinger/knot-types': - specifier: workspace:0.4.0 + specifier: workspace:0.5.0 version: link:../../packages/types axios: specifier: ^1.13.5 From b3a99742752292b07e230303394f078d984df99c Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:41:02 +0700 Subject: [PATCH 14/24] fix(docker): add buildx setup for cache support --- .github/workflows/docker-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 17b75e7..d308df1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,6 +22,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR uses: docker/login-action@v3 with: From f41fe000c7af22908a4f8a2bb833fe470eecbce8 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 13:43:49 +0700 Subject: [PATCH 15/24] fix(docker): use docker-container driver for buildx --- .github/workflows/docker-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d308df1..55a0160 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,6 +24,8 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + driver: docker-container - name: Log in to GHCR uses: docker/login-action@v3 From 1aaef142f3d8f3d8c76eed017e20e314537de122 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 14:13:04 +0700 Subject: [PATCH 16/24] ci: use conventional commit parser for release notes --- .github/workflows/publish.yml | 11 ++- scripts/generate-release-notes.ts | 110 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-release-notes.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c82f6c..556097b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,10 +47,19 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Generate Release Notes + id: notes + run: | + TAG=${GITHUB_REF#refs/tags/} + NOTES=$(npx tsx scripts/generate-release-notes.ts "$TAG") + echo "NOTES<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - generate_release_notes: true + body: ${{ steps.notes.outputs.NOTES }} draft: false prerelease: ${{ contains(github.ref, '-') }} env: diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts new file mode 100644 index 0000000..407092c --- /dev/null +++ b/scripts/generate-release-notes.ts @@ -0,0 +1,110 @@ +import { execSync } from "child_process"; + +const TAG = process.argv[2]; +if (!TAG) { + console.error("Usage: tsx scripts/generate-release-notes.ts "); + process.exit(1); +} + +function getPreviousTag(tag: string): string { + try { + const result = execSync( + `git tag --sort=-v:refname | grep -E '^v[0-9]+\\.' | awk '/^${tag}$/{found=1; next} found{print; exit}'`, + { encoding: "utf-8" }, + ).trim(); + return result || ""; + } catch { + return ""; + } +} + +function parseCommits(from: string, to: string): string { + const result = execSync( + `git log ${from}..${to} --pretty=format:"%H%n%s%n%b%n---END---"`, + { encoding: "utf-8" }, + ); + + const commits: { hash: string; subject: string; body: string }[] = []; + let currentHash = ""; + let currentSubject = ""; + let currentBody = ""; + + for (const line of result.split("\n")) { + if (line === "---END---") { + if (currentHash) { + commits.push({ + hash: currentHash, + subject: currentSubject, + body: currentBody.trim(), + }); + } + currentHash = ""; + currentSubject = ""; + currentBody = ""; + } else if (!currentHash) { + currentHash = line; + } else if (!currentSubject) { + currentSubject = line; + } else { + currentBody += line + "\n"; + } + } + + const sections: Record = { + feat: [], + fix: [], + docs: [], + chore: [], + ci: [], + build: [], + refactor: [], + perf: [], + test: [], + style: [], + }; + + for (const commit of commits) { + const match = commit.subject.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/); + if (match) { + const [, type, scope, message] = match; + const entry = scope ? `**${scope}:** ${message}` : message; + if (sections[type]) { + sections[type].push(entry); + } + } + } + + const typeLabels: Record = { + feat: "🚀 Features", + fix: "🐛 Bug Fixes", + docs: "📚 Documentation", + chore: "🔧 Maintenance", + ci: "🔄 CI/CD", + build: "📦 Build", + refactor: "♻️ Refactoring", + perf: "⚡ Performance", + test: "🧪 Tests", + style: "💅 Styling", + }; + + let output = ""; + for (const [type, label] of Object.entries(typeLabels)) { + if (sections[type].length > 0) { + output += `## ${label}\n\n`; + for (const entry of sections[type]) { + output += `- ${entry}\n`; + } + output += "\n"; + } + } + + return output; +} + +const previousTag = getPreviousTag(TAG); + +console.log(`# KnotEngine ${TAG}\n`); +console.log(parseCommits(previousTag || "", TAG)); +console.log( + `**Full Changelog**: https://github.com/qodinger/knotengine/compare/${previousTag || "v0.0.0"}...${TAG}`, +); From 567017a51fb4137e1a21f102361fda91963ae22a Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 14:15:01 +0700 Subject: [PATCH 17/24] fix(ci): fetch all tags for release notes generation --- .github/workflows/publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 556097b..d6a62de 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,9 @@ jobs: packages: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Install pnpm uses: pnpm/action-setup@v3 From 284ff8674311db0cf7a22aec58515160c5f25cd1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:11 +0700 Subject: [PATCH 18/24] ci(deps): bump softprops/action-gh-release from 2 to 3 (#2) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d6a62de..8f474ee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,7 +60,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: body: ${{ steps.notes.outputs.NOTES }} draft: false From 0895200e57111d292da6629b7813d26671283840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:15 +0700 Subject: [PATCH 19/24] ci(deps): bump docker/login-action from 3 to 4 (#3) Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 55a0160..0e73e08 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,7 +28,7 @@ jobs: driver: docker-container - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 6d2b6324d1139a9aafabf29f3d0f574ce84fba69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:18 +0700 Subject: [PATCH 20/24] ci(deps): bump docker/metadata-action from 5 to 6 (#4) Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/v5...v6) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0e73e08..81a705c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -36,7 +36,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}-${{ matrix.service }} tags: | From 8011a6043e1156f0f4824bae7c71098e45d1d5ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:21 +0700 Subject: [PATCH 21/24] ci(deps): bump pnpm/action-setup from 3 to 6 (#5) Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 3 to 6. - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v3.0.0...v6) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1c1ad..7ff9299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: @@ -30,7 +30,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: @@ -47,7 +47,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8f474ee..f77f973 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: fetch-tags: true - name: Install pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v6 - name: Set up Node.js for npm uses: actions/setup-node@v4 From c18fba9cf0553f074c427e2bc9ded4a65363ec68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:24 +0700 Subject: [PATCH 22/24] ci(deps): bump actions/setup-node from 4 to 6 (#6) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ff9299..8395a17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "pnpm" @@ -32,7 +32,7 @@ jobs: - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "pnpm" @@ -49,7 +49,7 @@ jobs: - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 20 cache: "pnpm" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f77f973..cdfff7e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ jobs: uses: pnpm/action-setup@v6 - name: Set up Node.js for npm - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" registry-url: "https://registry.npmjs.org" @@ -40,7 +40,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Set up Node.js for GitHub Packages - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" registry-url: "https://npm.pkg.github.com" From 27db376946e30d31704f3839a15dac76f8bfeac0 Mon Sep 17 00:00:00 2001 From: tyecode Date: Sat, 16 May 2026 14:27:36 +0700 Subject: [PATCH 23/24] chore(deps): disable dependabot grouping, change to monthly updates --- .github/dependabot.yml | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c9eb3a4..571fa43 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,24 +3,14 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "weekly" - open-pull-requests-limit: 10 - groups: - production-deps: - patterns: - - "*" - exclude-patterns: - - "@types/*" - - "typescript" - - "vitest" - dev-deps: - patterns: - - "@types/*" - - "typescript" - - "vitest" + interval: "monthly" + open-pull-requests-limit: 5 commit-message: prefix: "chore" include: "scope" + ignore: + - dependency-name: "node" + update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" From 84f960388ced31f02e1fd4a40b07dd31ba89b5d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 07:28:24 +0000 Subject: [PATCH 24/24] ci(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/docker-publish.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8395a17..ef5aa06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -28,7 +28,7 @@ jobs: name: Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -45,7 +45,7 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -72,7 +72,7 @@ jobs: name: Docker Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build API Docker image run: docker build -t knot-api -f apps/api/Dockerfile . diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 81a705c..1e6c9fb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,7 +20,7 @@ jobs: matrix: service: [api, dashboard, checkout] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdfff7e..a104d2b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true