← 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;