← 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'}`));