From 415c9d165a9784e2acd8621b7b4aa51b9e8f7af0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 13:35:19 +0000 Subject: [PATCH] feat: expand production config and documentation for provider integrations --- .env.example | 24 +++++++++ README.md | 39 +++++++++++--- src/config.js | 44 ++++++++++++++++ test/config.test.js | 109 ++++++++++++++++++++++++++++++++++++++++ test/deployment.test.js | 7 +++ 5 files changed, 217 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index af9cd28..0fbf303 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,35 @@ NODE_ENV=production PORT=3000 STATE_FILE_PATH=/data/state.json LOG_LEVEL=info +APP_BASE_URL=https://xartaudio.example.com # Webhook secrets X_WEBHOOK_SECRET=replace-me POLAR_WEBHOOK_SECRET=replace-me +# X API +X_BEARER_TOKEN=replace-me +X_BOT_USER_ID=replace-me + +# Polar API +POLAR_ACCESS_TOKEN=replace-me +POLAR_SERVER=production +POLAR_PRODUCT_IDS=prod_123 + +# TTS (OpenAI-compatible) +TTS_API_KEY=replace-me +TTS_BASE_URL= +TTS_MODEL=gpt-4o-mini-tts +TTS_VOICE=alloy + +# S3-compatible object storage +S3_BUCKET=replace-me +S3_REGION=us-east-1 +S3_ENDPOINT= +S3_ACCESS_KEY_ID=replace-me +S3_SECRET_ACCESS_KEY=replace-me +S3_SIGNED_URL_TTL_SEC=3600 + # Credit policy BASE_CREDITS=1 INCLUDED_CHARS=25000 diff --git a/README.md b/README.md index add4c5e..1ab0292 100644 --- a/README.md +++ b/README.md @@ -326,14 +326,22 @@ This repository now contains a deployable production-style app (single container - non-owner pay-to-unlock (same credit amount, permanent unlock) 4. Webhook-first ingestion and billing: - `POST /api/webhooks/x` (HMAC verified) -- `POST /api/webhooks/polar` (HMAC verified) -5. Persistent state across restarts: +- `POST /api/webhooks/polar` (supports Polar standard webhook signatures and legacy HMAC fallback) +5. Real integration adapters implemented: +- X API (`twitter-api-v2`) +- Polar SDK checkout/webhook handling (`@polar-sh/sdk`) +- TTS (`openai`) +- Object storage + signed URLs (`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`) +6. Persistent state across restarts: - all wallet/job/asset/access state is snapshotted and stored to `STATE_FILE_PATH` -6. Abuse protection: +7. Abuse protection: - fixed-window rate limiting for webhook, auth, and action routes -7. PWA support: +8. PWA support: - `manifest.webmanifest` - `sw.js` +9. Bun-native quality checks: +- `bun test` +- `bun run lint` ### Authentication model 1. Browser flow uses secure-ish HTTP-only cookie session (`xartaudio_user`) via `/auth/dev-login`. @@ -354,6 +362,8 @@ This repository now contains a deployable production-style app (single container 3. APIs: - `POST /api/webhooks/x` - `POST /api/webhooks/polar` +- `POST /api/payments/create-checkout` +- `GET /api/x/mentions` - `GET /api/me/wallet` - `GET /api/jobs/:id` - `POST /api/audio/:id/unlock` @@ -371,9 +381,26 @@ Use `.env.example` as the source of truth. 1. Runtime: - `PORT` - `STATE_FILE_PATH` +- `LOG_LEVEL` +- `APP_BASE_URL` 2. Secrets: - `X_WEBHOOK_SECRET` - `POLAR_WEBHOOK_SECRET` +- `X_BEARER_TOKEN` +- `X_BOT_USER_ID` +- `POLAR_ACCESS_TOKEN` +- `POLAR_SERVER` +- `POLAR_PRODUCT_IDS` +- `TTS_API_KEY` +- `TTS_BASE_URL` +- `TTS_MODEL` +- `TTS_VOICE` +- `S3_BUCKET` +- `S3_REGION` +- `S3_ENDPOINT` +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` +- `S3_SIGNED_URL_TTL_SEC` 3. Credit model: - `BASE_CREDITS` - `INCLUDED_CHARS` @@ -400,6 +427,6 @@ Use `.env.example` as the source of truth. ## Production Checklist 1. Replace dev-login cookie auth with X OAuth before public launch. -2. Connect real TTS generation worker and object storage (S3/R2/GCS). +2. Populate integration keys in Coolify environment for X, Polar, TTS, and S3. 3. Replace local state file with managed database for multi-replica scaling. -4. Add structured logging, tracing, and external alerting. +4. Add tracing and external alerting. diff --git a/src/config.js b/src/config.js index fb0510b..0eaecf4 100644 --- a/src/config.js +++ b/src/config.js @@ -19,12 +19,40 @@ function strFromEnv(name, fallback) { return raw && String(raw).trim() ? String(raw).trim() : fallback; } +function listFromEnv(name, fallback = []) { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + + return String(raw) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + const parsed = { port: intFromEnv("PORT", 3000), stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"), logLevel: strFromEnv("LOG_LEVEL", "info"), + appBaseUrl: strFromEnv("APP_BASE_URL", "http://localhost:3000"), xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret", + xBearerToken: strFromEnv("X_BEARER_TOKEN", ""), + xBotUserId: strFromEnv("X_BOT_USER_ID", ""), polarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET || "dev-polar-secret", + polarAccessToken: strFromEnv("POLAR_ACCESS_TOKEN", ""), + polarServer: strFromEnv("POLAR_SERVER", "production"), + polarProductIds: listFromEnv("POLAR_PRODUCT_IDS", []), + ttsApiKey: strFromEnv("TTS_API_KEY", ""), + ttsBaseUrl: strFromEnv("TTS_BASE_URL", ""), + ttsModel: strFromEnv("TTS_MODEL", "gpt-4o-mini-tts"), + ttsVoice: strFromEnv("TTS_VOICE", "alloy"), + s3Bucket: strFromEnv("S3_BUCKET", ""), + s3Region: strFromEnv("S3_REGION", ""), + s3Endpoint: strFromEnv("S3_ENDPOINT", ""), + s3AccessKeyId: strFromEnv("S3_ACCESS_KEY_ID", ""), + s3SecretAccessKey: strFromEnv("S3_SECRET_ACCESS_KEY", ""), + s3SignedUrlTtlSec: intFromEnv("S3_SIGNED_URL_TTL_SEC", 3600), rateLimits: { webhookPerMinute: intFromEnv("WEBHOOK_RPM", 120), authPerMinute: intFromEnv("AUTH_RPM", 30), @@ -43,8 +71,24 @@ const ConfigSchema = z.object({ port: z.number().int().positive(), stateFilePath: z.string().min(1), logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), + appBaseUrl: z.string().min(1), xWebhookSecret: z.string().min(1), + xBearerToken: z.string(), + xBotUserId: z.string(), polarWebhookSecret: z.string().min(1), + polarAccessToken: z.string(), + polarServer: z.enum(["production", "sandbox"]), + polarProductIds: z.array(z.string().min(1)), + ttsApiKey: z.string(), + ttsBaseUrl: z.string(), + ttsModel: z.string().min(1), + ttsVoice: z.string().min(1), + s3Bucket: z.string(), + s3Region: z.string(), + s3Endpoint: z.string(), + s3AccessKeyId: z.string(), + s3SecretAccessKey: z.string(), + s3SignedUrlTtlSec: z.number().int().positive(), rateLimits: z.object({ webhookPerMinute: z.number().int().positive(), authPerMinute: z.number().int().positive(), diff --git a/test/config.test.js b/test/config.test.js index 34b3327..ed20b99 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -8,13 +8,25 @@ test("config uses defaults when env is missing", () => { PORT: process.env.PORT, STATE_FILE_PATH: process.env.STATE_FILE_PATH, LOG_LEVEL: process.env.LOG_LEVEL, + APP_BASE_URL: process.env.APP_BASE_URL, + TTS_MODEL: process.env.TTS_MODEL, + S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, + X_BOT_USER_ID: process.env.X_BOT_USER_ID, WEBHOOK_RPM: process.env.WEBHOOK_RPM, + POLAR_SERVER: process.env.POLAR_SERVER, + POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS, }; delete process.env.PORT; delete process.env.STATE_FILE_PATH; delete process.env.LOG_LEVEL; + delete process.env.APP_BASE_URL; + delete process.env.TTS_MODEL; + delete process.env.S3_SIGNED_URL_TTL_SEC; + delete process.env.X_BOT_USER_ID; delete process.env.WEBHOOK_RPM; + delete process.env.POLAR_SERVER; + delete process.env.POLAR_PRODUCT_IDS; delete require.cache[require.resolve("../src/config")]; const { config } = require("../src/config"); @@ -22,6 +34,12 @@ test("config uses defaults when env is missing", () => { assert.equal(config.port, 3000); assert.equal(config.stateFilePath, "./data/state.json"); assert.equal(config.logLevel, "info"); + assert.equal(config.appBaseUrl, "http://localhost:3000"); + assert.equal(config.ttsModel, "gpt-4o-mini-tts"); + assert.equal(config.s3SignedUrlTtlSec, 3600); + assert.equal(config.xBotUserId, ""); + assert.equal(config.polarServer, "production"); + assert.deepEqual(config.polarProductIds, []); assert.equal(config.rateLimits.webhookPerMinute, 120); if (previous.PORT === undefined) { @@ -42,11 +60,47 @@ test("config uses defaults when env is missing", () => { process.env.LOG_LEVEL = previous.LOG_LEVEL; } + if (previous.APP_BASE_URL === undefined) { + delete process.env.APP_BASE_URL; + } else { + process.env.APP_BASE_URL = previous.APP_BASE_URL; + } + + if (previous.TTS_MODEL === undefined) { + delete process.env.TTS_MODEL; + } else { + process.env.TTS_MODEL = previous.TTS_MODEL; + } + + if (previous.S3_SIGNED_URL_TTL_SEC === undefined) { + delete process.env.S3_SIGNED_URL_TTL_SEC; + } else { + process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC; + } + + if (previous.X_BOT_USER_ID === undefined) { + delete process.env.X_BOT_USER_ID; + } else { + process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID; + } + if (previous.WEBHOOK_RPM === undefined) { delete process.env.WEBHOOK_RPM; } else { process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM; } + + if (previous.POLAR_SERVER === undefined) { + delete process.env.POLAR_SERVER; + } else { + process.env.POLAR_SERVER = previous.POLAR_SERVER; + } + + if (previous.POLAR_PRODUCT_IDS === undefined) { + delete process.env.POLAR_PRODUCT_IDS; + } else { + process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS; + } }); test("config reads state path and numeric env overrides", () => { @@ -55,12 +109,24 @@ test("config reads state path and numeric env overrides", () => { STATE_FILE_PATH: process.env.STATE_FILE_PATH, LOG_LEVEL: process.env.LOG_LEVEL, WEBHOOK_RPM: process.env.WEBHOOK_RPM, + APP_BASE_URL: process.env.APP_BASE_URL, + TTS_MODEL: process.env.TTS_MODEL, + S3_SIGNED_URL_TTL_SEC: process.env.S3_SIGNED_URL_TTL_SEC, + X_BOT_USER_ID: process.env.X_BOT_USER_ID, + POLAR_SERVER: process.env.POLAR_SERVER, + POLAR_PRODUCT_IDS: process.env.POLAR_PRODUCT_IDS, }; process.env.PORT = "8080"; process.env.STATE_FILE_PATH = "/data/prod-state.json"; process.env.LOG_LEVEL = "debug"; + process.env.APP_BASE_URL = "https://xartaudio.app"; + process.env.TTS_MODEL = "custom-tts"; + process.env.S3_SIGNED_URL_TTL_SEC = "7200"; + process.env.X_BOT_USER_ID = "bot-user-id"; process.env.WEBHOOK_RPM = "77"; + process.env.POLAR_SERVER = "sandbox"; + process.env.POLAR_PRODUCT_IDS = "prod_1,prod_2"; delete require.cache[require.resolve("../src/config")]; const { config } = require("../src/config"); @@ -68,6 +134,12 @@ test("config reads state path and numeric env overrides", () => { assert.equal(config.port, 8080); assert.equal(config.stateFilePath, "/data/prod-state.json"); assert.equal(config.logLevel, "debug"); + assert.equal(config.appBaseUrl, "https://xartaudio.app"); + assert.equal(config.ttsModel, "custom-tts"); + assert.equal(config.s3SignedUrlTtlSec, 7200); + assert.equal(config.xBotUserId, "bot-user-id"); + assert.equal(config.polarServer, "sandbox"); + assert.deepEqual(config.polarProductIds, ["prod_1", "prod_2"]); assert.equal(config.rateLimits.webhookPerMinute, 77); if (previous.PORT === undefined) { @@ -86,9 +158,46 @@ test("config reads state path and numeric env overrides", () => { } else { process.env.LOG_LEVEL = previous.LOG_LEVEL; } + + if (previous.APP_BASE_URL === undefined) { + delete process.env.APP_BASE_URL; + } else { + process.env.APP_BASE_URL = previous.APP_BASE_URL; + } + + if (previous.TTS_MODEL === undefined) { + delete process.env.TTS_MODEL; + } else { + process.env.TTS_MODEL = previous.TTS_MODEL; + } + + if (previous.S3_SIGNED_URL_TTL_SEC === undefined) { + delete process.env.S3_SIGNED_URL_TTL_SEC; + } else { + process.env.S3_SIGNED_URL_TTL_SEC = previous.S3_SIGNED_URL_TTL_SEC; + } + + if (previous.X_BOT_USER_ID === undefined) { + delete process.env.X_BOT_USER_ID; + } else { + process.env.X_BOT_USER_ID = previous.X_BOT_USER_ID; + } + if (previous.WEBHOOK_RPM === undefined) { delete process.env.WEBHOOK_RPM; } else { process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM; } + + if (previous.POLAR_SERVER === undefined) { + delete process.env.POLAR_SERVER; + } else { + process.env.POLAR_SERVER = previous.POLAR_SERVER; + } + + if (previous.POLAR_PRODUCT_IDS === undefined) { + delete process.env.POLAR_PRODUCT_IDS; + } else { + process.env.POLAR_PRODUCT_IDS = previous.POLAR_PRODUCT_IDS; + } }); diff --git a/test/deployment.test.js b/test/deployment.test.js index 056b6d2..5b1fe51 100644 --- a/test/deployment.test.js +++ b/test/deployment.test.js @@ -17,8 +17,15 @@ test("Dockerfile contains production container essentials", () => { test("env example includes required webhook and credit settings", () => { const envFile = fs.readFileSync(".env.example", "utf8"); assert.match(envFile, /LOG_LEVEL=/); + assert.match(envFile, /APP_BASE_URL=/); assert.match(envFile, /X_WEBHOOK_SECRET=/); + assert.match(envFile, /X_BEARER_TOKEN=/); + assert.match(envFile, /X_BOT_USER_ID=/); assert.match(envFile, /POLAR_WEBHOOK_SECRET=/); + assert.match(envFile, /POLAR_ACCESS_TOKEN=/); + assert.match(envFile, /POLAR_PRODUCT_IDS=/); + assert.match(envFile, /TTS_API_KEY=/); + assert.match(envFile, /S3_BUCKET=/); assert.match(envFile, /INCLUDED_CHARS=/); assert.match(envFile, /WEBHOOK_RPM=/); });