← back to Dw Signup Fulfillment

lib/config.js

128 lines

'use strict';
// Centralized config + env resolution. Reads the secrets master (never hardcodes
// a token) and exposes DRY_RUN as the single global safety switch.
const fs = require('fs');
const os = require('os');
const path = require('path');

const HOME = os.homedir();

// firstEnv: process env wins, then each candidate .env file (Mac dev + Kamatera).
function envFrom(file, key) {
  try {
    const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
    return m ? m[1].replace(/^["']|["']$/g, '').trim() : '';
  } catch { return ''; }
}
function firstEnv(key, files) {
  if (process.env[key] != null && process.env[key] !== '') return process.env[key];
  for (const f of files) { const v = envFrom(f, key); if (v) return v; }
  return '';
}

const SECRETS_ENVS = [
  path.join(HOME, 'Projects/secrets-manager/.env'),
];
const GEORGE_ENVS = [
  path.join(HOME, 'Projects/george-gmail/.env'), // Mac dev
  path.join(HOME, 'DW-Agents/gmail-agent/.env'),  // Kamatera prod
];

// DRY_RUN defaults ON. Only the literal '0' / 'false' disables it — so a missing
// or malformed value can never accidentally arm live writes.
const DRY_RAW = (process.env.DRY_RUN == null ? '1' : String(process.env.DRY_RUN)).toLowerCase();
const DRY_RUN = !(DRY_RAW === '0' || DRY_RAW === 'false' || DRY_RAW === 'no');

const config = {
  DRY_RUN,
  PORT: parseInt(process.env.PORT || '9856', 10),

  // Shopify — the fulfillment token is separate from the products-only ADMIN token
  // and the read-only CONTENT token. Steve grants write_customers/write_gift_cards/
  // write_discounts/read+write webhooks on this token before go-live.
  SHOP_DOMAIN: process.env.SHOP_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
  SHOPIFY_API_VERSION: process.env.SHOPIFY_API_VERSION || '2024-10',
  SHOPIFY_FULFILLMENT_TOKEN: firstEnv('SHOPIFY_FULFILLMENT_TOKEN', SECRETS_ENVS),
  SHOPIFY_WEBHOOK_SECRET: firstEnv('SHOPIFY_WEBHOOK_SECRET', SECRETS_ENVS),

  // Sample economics — retail "3 free samples". The gift-card face value is
  // FREE_SAMPLE_COUNT × SAMPLE_PRICE (default 3 × 4.25 = 12.75).
  SAMPLE_PRICE: parseFloat(process.env.SAMPLE_PRICE || '4.25'),
  FREE_SAMPLE_COUNT: parseInt(process.env.FREE_SAMPLE_COUNT || '3', 10),
  CURRENCY: process.env.CURRENCY || 'USD',

  // WIRED retail path (lib/retail-code.js): the SHARED discount code created once in
  // Shopify admin against the "DW Free Samples" function, limited to one-use-per-
  // customer. The service just emails this code to every new customer. Must match the
  // admin-created code exactly. (Per-customer unique codes aren't possible from this
  // token — the function is owned by a different app; see retail-code.js.)
  // No default on purpose: must be set explicitly to the exact admin-created code, so
  // a mismatch fails loud (WARN + skipped send) rather than emailing a wrong code.
  RETAIL_SHARED_CODE: process.env.RETAIL_SHARED_CODE || '',

  // The deployed function id — informational now (retail uses the shared admin code,
  // not discountCodeAppCreate). Kept for reference / a possible future owned-app path.
  DISCOUNT_FUNCTION_ID: process.env.DISCOUNT_FUNCTION_ID || '',

  // Used only by the ALTERNATE lib/giftcode-discount.js (not wired) — scopes 100%-off
  // to this Samples collection. Empty = that alternate logs a TODO.
  SAMPLES_COLLECTION_ID: process.env.SAMPLES_COLLECTION_ID || '',

  // George email sender.
  GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
  // Account that authenticates to George (steve-office Gmail). The FROM address is the
  // monitored office inbox so customer/designer replies land there, not Steve's personal
  // box. GO-LIVE: confirm info@designerwallcoverings.com is a verified send-as alias in
  // the steve-office Gmail, else Gmail rewrites the From to the authenticated address.
  GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'steve-office',
  GEORGE_FROM: process.env.GEORGE_FROM || 'info@designerwallcoverings.com',
  GEORGE_EXTERNAL_SEND_TOKEN: firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS),
  GEORGE_BASIC_AUTH: firstEnv('GEORGE_BASIC_AUTH', GEORGE_ENVS) || 'admin:',

  // Admin review surface basic-auth. Resolve order: explicit env → on-host secrets
  // master → the fleet default. Reading from the secrets file lets a strong per-host
  // ADMIN_PASS be provisioned WITHOUT passing the value on a command line (no wire leak)
  // — the public /admin/trade surface must not stay on the documented DW2024! at go-live.
  ADMIN_USER: process.env.ADMIN_USER || firstEnv('ADMIN_USER', SECRETS_ENVS) || 'admin',
  ADMIN_PASS: process.env.ADMIN_PASS || firstEnv('DW_SIGNUP_ADMIN_PASS', SECRETS_ENVS) || 'DW2024!',

  // --- Trade-application notify + one-click email approval ---
  // Every new /trade/apply emails a review card (via George) to this office inbox
  // with Approve/Reject buttons, so Steve approves a designer straight from the inbox.
  TRADE_NOTIFY_TO: process.env.TRADE_NOTIFY_TO || 'info@designerwallcoverings.com',
  // Public base the Approve/Reject buttons point at. Empty → server.js falls back to
  // the local dev port; at go-live set to the Kamatera host (signup.designer…).
  PUBLIC_URL: (process.env.PUBLIC_URL || '').replace(/\/+$/, ''),
  // Secret signing the one-click approve/reject magic-links. MUST come from the secrets
  // master (256-bit random) — NEVER derived from ADMIN_PASS or any documented value, or
  // an attacker who knows the fleet-standard password could forge an approve token from
  // the public /trade/apply id (contrarian critical, 2026-07-28). Empty when unset →
  // verifyActionToken() fails closed and the magic-link routes 503, so a missing secret
  // disables one-click approval rather than silently trusting a guessable key.
  APPROVE_LINK_SECRET: firstEnv('APPROVE_LINK_SECRET', SECRETS_ENVS),
  // Approve/reject magic-links expire after this many hours (contrarian LOW-MED fix) so a
  // forwarded/leaked, not-yet-clicked email can't be redeemed forever. 48h default.
  APPROVE_LINK_TTL_HOURS: parseInt(process.env.APPROVE_LINK_TTL_HOURS || '48', 10),

  // --- Public webhook hardening (the mint endpoint is public + secret-less) ---
  // 1) URL-token auth: register the webhook at /webhooks/customers/create/<token>.
  //    Only Shopify (and whoever set it) knows the token → a caller who doesn't have
  //    it is rejected. This is the secret-less-compatible replacement for HMAC. Set it
  //    to a long random string (openssl rand -hex 24) at go-live. If unset, the service
  //    REFUSES to serve the webhook live (503) — it only runs open in DRY_RUN dev.
  WEBHOOK_URL_TOKEN: firstEnv('WEBHOOK_URL_TOKEN', SECRETS_ENVS),
  // 2) Freshness gate: only gift a customer whose Shopify created_at is within this many
  //    minutes (a real customers/create fires within seconds; blocks minting to the
  //    existing customer base). Generous default tolerates Shopify delivery retries.
  WEBHOOK_FRESHNESS_MIN: parseInt(process.env.WEBHOOK_FRESHNESS_MIN || '1440', 10),
  // 3) Rate limit: max webhook POSTs accepted per IP per minute.
  WEBHOOK_RATE_MAX: parseInt(process.env.WEBHOOK_RATE_MAX || '30', 10),
  // 4) Money backstop: hard cap on gift cards minted per calendar day (UTC). Beyond it
  //    the webhook skips + warns, so a runaway/abuse can't mint unbounded liability.
  MINT_DAILY_CAP: parseInt(process.env.MINT_DAILY_CAP || '200', 10),

  get SAMPLE_GIFT_VALUE() { return +(this.SAMPLE_PRICE * this.FREE_SAMPLE_COUNT).toFixed(2); },
};

module.exports = config;