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