feat: expand production config and documentation for provider integrations
This commit is contained in:
24
.env.example
24
.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
|
||||
|
||||
39
README.md
39
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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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=/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user