diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..a9459f45f --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ────────────────────────────────────────────── +# BlackRoad Production Environment Variables +# Copy to .env and fill in real values +# ────────────────────────────────────────────── + +# ── Server ──────────────────────────────────── +NODE_ENV=production +PORT=4000 + +# ── Stripe ──────────────────────────────────── +STRIPE_SECRET_KEY=sk_live_REPLACE_ME +STRIPE_PUBLISHABLE_KEY=pk_live_REPLACE_ME +STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME + +# ── Google Drive ────────────────────────────── +GOOGLE_DRIVE_CLIENT_ID=REPLACE_ME +GOOGLE_DRIVE_CLIENT_SECRET=REPLACE_ME +GOOGLE_DRIVE_REDIRECT_URI=https://blackroad.io/api/drive/callback +GOOGLE_DRIVE_FOLDER_ID=REPLACE_ME + +# ── Database ────────────────────────────────── +DATABASE_URL=postgresql://user:password@localhost:5432/blackroad + +# ── LLM / Lucidia ──────────────────────────── +LLM_BASE_URL=http://127.0.0.1:8083 + +# ── S3 / Object Storage ────────────────────── +S3_BUCKET=blackroad-uploads +S3_REGION=us-east-1 +S3_ACCESS_KEY=REPLACE_ME +S3_SECRET_KEY=REPLACE_ME + +# ── Auth / JWT ──────────────────────────────── +JWT_SECRET=REPLACE_ME +SESSION_SECRET=REPLACE_ME diff --git a/.gitignore b/.gitignore index 1776534a5..770775e51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +package-lock.json .env *.log *.sqlite diff --git a/config/index.js b/config/index.js new file mode 100644 index 000000000..583fcfd7c --- /dev/null +++ b/config/index.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * Centralised configuration loader. + * + * Reads from process.env (populate via .env in development). + * All Stripe and Drive keys are required in production; missing keys are + * surfaced through the /api/config/status health endpoint. + */ + +const REQUIRED_KEYS = [ + 'STRIPE_SECRET_KEY', + 'STRIPE_PUBLISHABLE_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'GOOGLE_DRIVE_CLIENT_ID', + 'GOOGLE_DRIVE_CLIENT_SECRET', + 'GOOGLE_DRIVE_FOLDER_ID', + 'DATABASE_URL', + 'JWT_SECRET' +]; + +function loadConfig() { + return { + port: process.env.PORT || 4000, + nodeEnv: process.env.NODE_ENV || 'development', + + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY || '', + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '' + }, + + drive: { + clientId: process.env.GOOGLE_DRIVE_CLIENT_ID || '', + clientSecret: process.env.GOOGLE_DRIVE_CLIENT_SECRET || '', + redirectUri: process.env.GOOGLE_DRIVE_REDIRECT_URI || 'https://blackroad.io/api/drive/callback', + folderId: process.env.GOOGLE_DRIVE_FOLDER_ID || '' + }, + + database: { + url: process.env.DATABASE_URL || '' + }, + + llm: { + baseUrl: (process.env.LLM_BASE_URL || 'http://127.0.0.1:8083').replace(/\/$/, '') + }, + + s3: { + bucket: process.env.S3_BUCKET || 'blackroad-uploads', + region: process.env.S3_REGION || 'us-east-1', + accessKey: process.env.S3_ACCESS_KEY || '', + secretKey: process.env.S3_SECRET_KEY || '' + }, + + auth: { + jwtSecret: process.env.JWT_SECRET || '', + sessionSecret: process.env.SESSION_SECRET || '' + } + }; +} + +/** + * Returns a list of required environment variables that are missing or empty. + */ +function getMissingKeys() { + return REQUIRED_KEYS.filter(k => !process.env[k]); +} + +module.exports = { loadConfig, getMissingKeys, REQUIRED_KEYS }; diff --git a/config/products.js b/config/products.js new file mode 100644 index 000000000..29636c49a --- /dev/null +++ b/config/products.js @@ -0,0 +1,134 @@ +'use strict'; + +/** + * BlackRoad production product catalog. + * + * Each entry maps to a portal described in PORTALS.md and is ready to be + * synced with Stripe Products via the API. + */ + +const products = [ + { + id: 'roadbook', + name: 'Roadbook', + description: 'Trip journals with photos, itineraries and map routes.', + active: true, + tier: 'free', + stripe_price_id: null, // populated after Stripe sync + metadata: { portal: 'roadbook', stack: 'node-prisma-postgres-s3' } + }, + { + id: 'roadview', + name: 'Roadview', + description: 'Stream user journeys on an interactive map with clustering and filters.', + active: true, + tier: 'pro', + stripe_price_id: null, + metadata: { portal: 'roadview', stack: 'websocket-postgis' } + }, + { + id: 'lucidia', + name: 'Lucidia', + description: 'Knowledge base of travel guides and user tips using MDX articles.', + active: true, + tier: 'free', + stripe_price_id: null, + metadata: { portal: 'lucidia', stack: 'nestjs-search' } + }, + { + id: 'roadcode', + name: 'Roadcode', + description: 'Collaborative coding playground for trip-related tools.', + active: true, + tier: 'pro', + stripe_price_id: null, + metadata: { portal: 'roadcode', stack: 'containers-git' } + }, + { + id: 'radius', + name: 'Radius', + description: 'Local meetup planner highlighting events near a user\'s current position.', + active: true, + tier: 'free', + stripe_price_id: null, + metadata: { portal: 'radius', stack: 'redis-geospatial' } + }, + { + id: 'roadworld', + name: 'Roadworld', + description: 'Social feed aggregating activity from all portals.', + active: true, + tier: 'pro', + stripe_price_id: null, + metadata: { portal: 'roadworld', stack: 'kafka-graphql' } + }, + { + id: 'roadcoin', + name: 'Roadcoin', + description: 'Wallet dashboard showing token balances and staking actions.', + active: true, + tier: 'enterprise', + stripe_price_id: null, + metadata: { portal: 'roadcoin', stack: 'rust-ledger' } + }, + { + id: 'roadchain', + name: 'Roadchain', + description: 'Explorer for on-chain travel proofs and smart-contract interactions.', + active: true, + tier: 'enterprise', + stripe_price_id: null, + metadata: { portal: 'roadchain', stack: 'indexer-chain' } + }, + { + id: 'roadie', + name: 'Roadie', + description: 'Mobile assistant guiding users through trips with voice commands.', + active: true, + tier: 'pro', + stripe_price_id: null, + metadata: { portal: 'roadie', stack: 'stt-pipeline' } + }, + { + id: 'prism', + name: 'Prism Console', + description: 'Master console unifying tools from all portals through customizable tiles and global search.', + active: true, + tier: 'enterprise', + stripe_price_id: null, + metadata: { portal: 'prism', stack: 'orchestrator-sso' } + }, + { + id: 'blackroad-drive', + name: 'BlackRoad Drive', + description: 'Cloud storage for trip documents, photos and itineraries backed by Google Drive and S3.', + active: true, + tier: 'pro', + stripe_price_id: null, + metadata: { portal: 'drive', stack: 'google-drive-s3' } + } +]; + +function getAllProducts() { + return products; +} + +function getActiveProducts() { + return products.filter(p => p.active); +} + +function getProductById(id) { + return products.find(p => p.id === id) || null; +} + +function getProductsByTier(tier) { + return products.filter(p => p.tier === tier); +} + +module.exports = { + products, + getAllProducts, + getActiveProducts, + getProductById, + getProductsByTier +}; diff --git a/package.json b/package.json index b33d85f59..13d242337 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "node server_full.js" }, "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "stripe": "^14.11.0" } } diff --git a/server_full.js b/server_full.js index 1600d17c6..4c8361df8 100644 --- a/server_full.js +++ b/server_full.js @@ -2,9 +2,12 @@ const express = require('express'); const http = require('http'); +const { loadConfig, getMissingKeys } = require('./config'); +const { getAllProducts, getActiveProducts, getProductById, getProductsByTier } = require('./config/products'); const app = express(); const server = http.createServer(app); +const config = loadConfig(); app.use(express.json()); @@ -18,6 +21,91 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'blackroad-api' }); }); +// ── Config status (production readiness check) ────────────── +app.get('/api/config/status', (req, res) => { + const missing = getMissingKeys(); + res.json({ + ready: missing.length === 0, + environment: config.nodeEnv, + missing_keys: missing + }); +}); + +// ── Products ───────────────────────────────────────────────── +app.get('/api/products', (req, res) => { + const { tier, active } = req.query; + let result; + if (tier) { + result = getProductsByTier(tier); + } else if (active === 'true') { + result = getActiveProducts(); + } else { + result = getAllProducts(); + } + res.json({ products: result, count: result.length }); +}); + +app.get('/api/products/:id', (req, res) => { + const product = getProductById(req.params.id); + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + res.json(product); +}); + +// ── Stripe products sync endpoint ──────────────────────────── +app.post('/api/stripe/products/sync', async (req, res) => { + if (!config.stripe.secretKey) { + return res.status(503).json({ error: 'STRIPE_SECRET_KEY not configured' }); + } + try { + const stripe = require('stripe')(config.stripe.secretKey); + const catalog = getActiveProducts(); + const synced = []; + + for (const product of catalog) { + // Check if product already exists in Stripe by blackroad_id metadata + const existing = await stripe.products.search({ + query: `metadata["blackroad_id"]:"${product.id}"` + }); + + let stripeProduct; + if (existing.data.length > 0) { + stripeProduct = await stripe.products.update(existing.data[0].id, { + name: product.name, + description: product.description, + active: product.active, + metadata: { blackroad_id: product.id, tier: product.tier, ...product.metadata } + }); + synced.push({ id: product.id, stripe_id: stripeProduct.id, action: 'updated' }); + } else { + stripeProduct = await stripe.products.create({ + name: product.name, + description: product.description, + active: product.active, + metadata: { blackroad_id: product.id, tier: product.tier, ...product.metadata } + }); + synced.push({ id: product.id, stripe_id: stripeProduct.id, action: 'created' }); + } + } + + res.json({ synced, count: synced.length }); + } catch (err) { + console.error('Stripe sync error:', err); + res.status(500).json({ error: 'Stripe sync failed' }); + } +}); + +// ── Drive status endpoint ──────────────────────────────────── +app.get('/api/drive/status', (req, res) => { + const configured = !!(config.drive.clientId && config.drive.clientSecret); + res.json({ + configured, + folder_id: config.drive.folderId || null, + redirect_uri: config.drive.redirectUri + }); +}); + // Chat bridge app.post('/api/llm/chat', async (req, res) => { try { @@ -25,7 +113,8 @@ app.post('/api/llm/chat', async (req, res) => { if (typeof message !== 'string' || !message.trim()) { return res.status(400).json({ error: 'message (string) required' }); } - const r = await fetch('http://127.0.0.1:8000/chat', { + const llmUrl = config.llm.baseUrl; + const r = await fetch(`${llmUrl}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) @@ -38,7 +127,11 @@ app.post('/api/llm/chat', async (req, res) => { } }); -const PORT = process.env.PORT || 4000; +const PORT = config.port; server.listen(PORT, () => { + const missing = getMissingKeys(); console.log(`BlackRoad API listening on port ${PORT}`); + if (missing.length > 0) { + console.warn(`⚠ Missing env keys: ${missing.join(', ')}`); + } });