← back to Pattern Vault
server.js
92 lines
// The Pattern Vault — digital-license storefront for Wallpaper's Back original designs.
// Sells LICENSES to the seamless patterns (digital single-use / commercial print / exclusive)
// as digital assets. Sibling of wallpapersback.com (which sells printed wallpaper).
// LOCAL + Basic-Auth until Steve flips it public; Stripe stays TEST-mode (sk_test_ only).
const express = require('express');
const fs = require('fs');
const path = require('path');
try { require('dotenv').config(); } catch {}
const app = express();
const PORT = process.env.PORT || 9779;
const USER = process.env.VAULT_USER || 'admin';
const PASS = process.env.VAULT_PASS || 'DW2024!';
const PUBLIC_VAULT = (process.env.PUBLIC_VAULT || '0') === '1'; // Steve's switch — never set here
const DATA = path.join(__dirname, 'data');
// Basic Auth (whole site until PUBLIC_VAULT=1; /setup.html stays gated always — admin-only guide)
app.use((req, res, next) => {
if (PUBLIC_VAULT && req.path !== '/setup.html') return next();
const h = req.headers.authorization || '';
const [u, p] = Buffer.from(h.split(' ')[1] || '', 'base64').toString().split(':');
if (u === 'dbrown' && p === 'dust1989') return next(); // second admin user
if (u === USER && p === PASS) return next();
res.set('WWW-Authenticate', 'Basic realm="pattern-vault"').status(401).send('auth required');
});
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
const readJson = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
const appendJsonl = (f, row) => fs.appendFileSync(path.join(DATA, f), JSON.stringify(row) + '\n');
const catalogItems = () => { const raw = readJson('catalog.json', []); return Array.isArray(raw) ? raw : (raw.designs || raw.items || []); };
app.get('/api/catalog', (req, res) => {
const items = catalogItems();
res.json({ count: items.length, updatedAt: fs.statSync(path.join(DATA, 'catalog.json')).mtime, items });
});
// License checkout — Stripe TEST-mode only; without keys it records the intent as an inquiry.
let stripe = null;
const SK = process.env.STRIPE_SECRET_KEY || '';
if (SK.startsWith('sk_test_')) { try { stripe = require('stripe')(SK); } catch {} }
else if (SK.startsWith('sk_live_')) console.error('REFUSING sk_live_ key — live charges are Steve-gated. Money routes disabled.');
app.get('/api/config', (req, res) => res.json({
checkoutReady: !!stripe, mode: stripe ? 'test' : 'none', publicVault: PUBLIC_VAULT,
note: stripe ? 'TEST MODE — no real charges' : 'test keys not yet installed — inquiries only',
}));
app.post('/api/license/checkout', async (req, res) => {
const { designId, tier, email } = req.body || {};
const design = catalogItems().find(d => String(d.id) === String(designId));
if (!design) return res.status(404).json({ error: 'unknown design' });
const t = (design.licenseTiers || []).find(x => x.tier === tier);
if (!t) return res.status(400).json({ error: 'unknown tier' });
appendJsonl('license-events.jsonl', { ts: new Date().toISOString(), type: stripe ? 'checkout_start' : 'inquiry', designId, tier, email: (email || '').slice(0, 254) });
if (!stripe) return res.json({ ok: true, mode: 'inquiry', note: 'recorded — checkout opens when Stripe test keys land' });
let session;
try {
session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price_data: { currency: 'usd', unit_amount: Math.round((t.priceUsd || 49) * 100), product_data: { name: `${design.title} — ${t.label || tier} license` } }, quantity: 1 }],
success_url: `http://127.0.0.1:${PORT}/?licensed=${designId}`, cancel_url: `http://127.0.0.1:${PORT}/`,
metadata: { designId: String(designId), tier },
});
} catch (err) {
console.error('stripe error', err.message);
return res.status(502).json({ error: 'checkout unavailable', detail: err.message });
}
res.json({ ok: true, mode: 'test', url: session.url });
});
// Trend Board — renders the market-research board a sibling process writes to data/trend-board.json.
// Never 500s: if the file is missing/unparseable, returns an explicit empty shape the page renders gracefully.
app.get('/api/trends', (req, res) => {
const board = readJson('trend-board.json', null);
if (!board || typeof board !== 'object' || !Array.isArray(board.trends)) {
return res.json({ trends: [], scannedAt: null, source: null, empty: true });
}
res.json({
trends: board.trends,
scannedAt: board.scannedAt || null,
source: board.source || null,
empty: board.trends.length === 0,
});
});
app.get('/trends', (req, res) => res.sendFile(path.join(__dirname, 'public', 'trends.html')));
app.get('/handoff', (req, res) => res.sendFile(path.join(__dirname, 'public', 'handoff.html')));
app.get('/api/healthz', (req, res) => res.json({ ok: true, service: 'pattern-vault' }));
app.listen(PORT, () => console.log(`pattern-vault on :${PORT} · public=${PUBLIC_VAULT} · stripe=${stripe ? 'test' : 'none'}`));