← back to Dw Signup Fulfillment

lib/config.js

193 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(__dirname, '..', '.env'),
  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_APP_CLIENT_ID: process.env.SHOPIFY_APP_CLIENT_ID || '6e55daaad038f1c506cfe84bd5a369f0',
  SHOPIFY_SIGNUP_APP_CLIENT_SECRET: firstEnv('SHOPIFY_SIGNUP_APP_CLIENT_SECRET', SECRETS_ENVS),
  SHOPIFY_WEBHOOK_SECRET: firstEnv('SHOPIFY_WEBHOOK_SECRET', SECRETS_ENVS),
  // Secret for the dedicated dw-free-samples Shopify app. App-managed orders/paid
  // webhooks are signed with this app secret, not the legacy signup webhook secret.
  SHOPIFY_FREE_SAMPLES_APP_CLIENT_SECRET: firstEnv('SHOPIFY_FREE_SAMPLES_APP_CLIENT_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',

  // --- Retail double-opt-in verify -> tag-gated samples (Option C, DTD 2026-08-14) ---
  // WIRED retail path (lib/verify.js): a new/claiming customer confirms their email,
  // and the /verify click appends VERIFIED_TAG to their Shopify customer. The store's
  // tag-gated sample discount (the SAME Regios mechanism that already makes `trade`
  // memos free — scoped to the "Sample" variant, so the $80+ roll is never touched)
  // then shows their samples free at checkout. No gift card, no ledger, no coupon.
  // GO-LIVE: create the Regios rule "samples free for tag <VERIFIED_TAG>" (clone the
  // trade-memo rule) and keep this value === that rule's tag.
  VERIFIED_TAG: process.env.VERIFIED_TAG || 'verified-sample',
  // Signs the stateless email-verify token. From the secrets master (256-bit random),
  // NEVER a documented value. Empty in LIVE -> mint/read fail closed (no token issued,
  // /verify 503) so a missing secret disables the reward rather than trusting a
  // guessable key. In DRY_RUN a dev fallback is used so local testing round-trips.
  VERIFY_SECRET: firstEnv('DW_SIGNUP_VERIFY_SECRET', SECRETS_ENVS),
  // Verify-link lifetime in hours (default 7 days).
  VERIFY_TTL_HOURS: parseInt(process.env.VERIFY_TTL_HOURS || '168', 10),

  // 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. MUST be 'info' (info@designerwallcoverings.com)
  // so the visible From is genuinely info@ — Steve's directive 2026-09-02 (TK-11120).
  // The earlier default 'steve-office' made George send AS steve-office and, because
  // info@ is NOT a verified send-as alias on that mailbox, Gmail rewrote the visible From
  // back to steve@designerwallcoverings.com — so the retail-verify blast appeared to come
  // from Steve personally. Authenticating as the info mailbox fixes the sender identity.
  GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'info',
  GEORGE_FROM: process.env.GEORGE_FROM || 'info@designerwallcoverings.com',
  GEORGE_EXTERNAL_SEND_TOKEN: firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS),
  // Basic-auth credential for George. Resolve from the SAME source the working
  // token-bridge uses (secrets-manager GEORGE_AUTH) if george-gmail/.env doesn't carry
  // GEORGE_BASIC_AUTH, then normalize any form (a "Basic <b64>" header, a bare password,
  // or "user:pass") down to "user:pass" so email.js can base64-encode it correctly. The
  // old 'admin:' default silently produced a 401 — this is the fix for that.
  GEORGE_BASIC_AUTH: (() => {
    let v = firstEnv('GEORGE_BASIC_AUTH', GEORGE_ENVS) || firstEnv('GEORGE_AUTH', SECRETS_ENVS) || 'admin:';
    if (v.startsWith('Basic ')) { try { v = Buffer.from(v.slice(6), 'base64').toString(); } catch (e) {} }
    if (!v.includes(':')) v = 'admin:' + v;
    // Fail LOUD if the password resolved empty — this empty-password fallback is exactly
    // what silently 401'd every George send and hid the DW welcome-email outage. Warn at
    // load so a misconfig surfaces immediately instead of as mysterious 401s in production.
    if (!(v.split(':')[1] || '')) console.warn('[config] WARNING: George Basic-auth resolved to an EMPTY password (no GEORGE_BASIC_AUTH / GEORGE_AUTH found) — external sends will 401. Set GEORGE_AUTH in secrets-manager/.env.');
    return v;
  })(),

  // 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!',

  // --- Auto-approve on signup (Steve, 2026-09-10) ---
  // GUARDED auto-approve: every genuine new /trade/apply is approved instantly with no
  // human review (tags the customer `trade`, assigns a rep, emails them "approved"),
  // EXCEPT (a) blank/invalid emails and (b) an email that already has an approved app
  // (exact-duplicate re-submit) — so a bot/competitor spraying the form can't mint
  // unlimited trade accounts + free samples. Default ON. Flip to '0' to restore the
  // old "park as pending + email a review card" behavior. Still DRY_RUN-safe: in
  // DRY_RUN the approve() call only simulates (no live tag/email), like every flow here.
  TRADE_AUTO_APPROVE: !(String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === '0'
    || String(process.env.TRADE_AUTO_APPROVE || '1').toLowerCase() === 'false'),
  // Volume backstop for auto-approve. Beyond this many auto-approvals in one UTC day,
  // new signups fall back to the legacy review card instead of being granted instantly.
  // 0 disables auto-approve entirely; the ledger is durable across restarts/deploys.
  TRADE_AUTO_APPROVE_DAILY_CAP: parseInt(process.env.TRADE_AUTO_APPROVE_DAILY_CAP || '50', 10),

  // --- 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.
  // With TRADE_AUTO_APPROVE on, a SUCCESSFUL auto-approve sends a lighter FYI instead,
  // and the actionable review card is only sent if an auto-approve FAILS (so a stuck
  // applicant still surfaces to staff).
  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 /trade/apply throttle (TK-11185) ---
  // The intake now server-side creates a Shopify customer per POST (write_customers), so
  // the public endpoint is throttled per IP like the webhook to block customer-table
  // pollution / designer-welcome-email spam. A real designer applies once — 5/hour/IP is
  // generous. Same sliding-window util (lib/rate-limit) the webhook uses.
  TRADE_APPLY_RATE_MAX: parseInt(process.env.TRADE_APPLY_RATE_MAX || '5', 10),
  TRADE_APPLY_RATE_WINDOW_MS: parseInt(process.env.TRADE_APPLY_RATE_WINDOW_MS || String(60 * 60 * 1000), 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;