← back to Dw Signup Fulfillment
server.js
520 lines
'use strict';
// DW signup / sample / rep fulfillment service.
//
// Fixes the three broken flows:
// 1. Retail signup -> emailed 3-free-samples gift code (webhook customers/create)
// 2. Trade application -> moderated approval -> `trade` tag + rep assignment
// 3. Rep round-robin with persisted cursor
//
// EVERYTHING runs in DRY_RUN by default (config.DRY_RUN). No live Shopify writes,
// no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
const express = require('express');
const config = require('./lib/config');
const retailWebhook = require('./lib/retail-webhook'); // WIRED: re-fetch auth + idempotent verify-letter send
const verify = require('./lib/verify'); // WIRED default (Option C, DTD 2026-08-14): double opt-in -> VERIFIED_TAG -> tag-gated free samples
const shopify = require('./lib/shopify'); // used by /claim to resolve a customer id from email
const giftcard = require('./lib/giftcard'); // legacy alternate — stored-value gift card (retired path)
const retailCode = require('./lib/retail-code'); // legacy alternate — shared function code
const giftcodeDiscount = require('./lib/giftcode-discount'); // legacy alternate — collection-scoped code (unsafe: samples share a product with the roll)
const trade = require('./lib/trade');
const autoApproveLedger = require('./lib/auto-approve-ledger');
const { createRateLimiter } = require('./lib/rate-limit'); // shared per-IP sliding-window limiter
const reps = require('./lib/reps');
const email = require('./lib/email');
const sampleLedger = require('./lib/sample-ledger');
const shopifyOauth = require('./lib/shopify-oauth');
const app = express();
// --- /healthz FIRST, before any auth/body parsing (Steve's fleet rule) ---
app.get('/healthz', (_req, res) => res.status(200).json({ ok: true, service: 'dw-signup-fulfillment', dry_run: config.DRY_RUN }));
// --- Favicon: 204 so browsers/tools never log a favicon 404. ---
app.get('/favicon.ico', (_req, res) => res.status(204).end());
// --- Root status page (open) — a clean 200 landing so the base URL is never a bare 404. ---
app.get('/', (_req, res) => {
res.status(200).type('html').send(`<!doctype html><html><head><meta charset="utf-8"><title>DW Signup Fulfillment</title>
<style>body{font:15px/1.6 -apple-system,system-ui,sans-serif;margin:40px;color:#1a1a1a;background:#faf9f7}code{background:#eee;padding:1px 5px;border-radius:4px}.p{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}</style>
</head><body>
<h1>DW Signup Fulfillment <span class="p">DRY_RUN: ${config.DRY_RUN ? 'ON' : 'OFF (LIVE)'}</span></h1>
<p>Service is running. Signed-in retail customers receive exactly 3 lifetime complimentary samples; approved designers receive unlimited samples. Also handles account confirmation and trade applications.</p>
<ul>
<li><code>GET /healthz</code> — liveness (open)</li>
<li><code>POST /webhooks/customers/create/<token></code> — Shopify webhook → sends the verify letter (URL-token auth + rate-limit)</li>
<li><code>POST /claim</code> — retail sample claim (email in → verify letter out)</li>
<li><code>GET /verify?token=…</code> — confirm email → apply the free-samples tag</li>
<li><code>POST /trade/apply</code> — trade application intake</li>
<li><code>GET /admin/trade</code> — trade review (basic-auth)</li>
</ul>
</body></html>`);
});
// --- Webhook receiver. Defense-in-depth on a public, mint-capable endpoint:
// (1) URL-TOKEN auth — Shopify posts to /webhooks/customers/create/<WEBHOOK_URL_TOKEN>;
// a caller without the token is rejected. This is the secret-less-compatible
// replacement for HMAC (Steve's choice — we don't hold the app's HMAC secret).
// If the token is unset it runs open ONLY in DRY_RUN dev; live-with-no-token 503s.
// (2) RATE LIMIT — max WEBHOOK_RATE_MAX POSTs per IP per minute.
// (3) The handler then re-fetches the customer (forged ids rejected), gifts the REAL
// on-file email, enforces a created_at freshness gate + a daily mint cap, and is
// idempotent (one gift per customer). ---
// Shared per-IP sliding-window limiters (lib/rate-limit). Webhook = WEBHOOK_RATE_MAX/min;
// public /trade/apply = TRADE_APPLY_RATE_MAX per TRADE_APPLY_RATE_WINDOW_MS (default 5/hr) —
// TK-11185: the intake now server-side creates a Shopify customer per POST (write_customers),
// so throttle it like the webhook to block customer-table pollution / welcome-email spam.
const RATE_WINDOW_MS = 60000; // 1-minute sliding window (webhook)
const webhookLimiter = createRateLimiter({ windowMs: RATE_WINDOW_MS, max: config.WEBHOOK_RATE_MAX });
const tradeApplyLimiter = createRateLimiter({ windowMs: config.TRADE_APPLY_RATE_WINDOW_MS, max: config.TRADE_APPLY_RATE_MAX });
function clientIp(req) { return (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim(); }
function webhookAuth(req, res, next) {
const tok = config.WEBHOOK_URL_TOKEN;
if (tok) {
if (req.params.token !== tok) return res.status(401).json({ ok: false, error: 'bad_webhook_token' });
} else if (!config.DRY_RUN) {
// Live with no URL token configured → refuse rather than run the mint endpoint open.
return res.status(503).json({ ok: false, error: 'webhook_url_token_unset' });
}
const ip = clientIp(req);
if (webhookLimiter(ip)) return res.status(429).json({ ok: false, error: 'rate_limited' });
next();
}
async function webhookHandler(req, res) {
const customer = req.body || {};
res.status(200).json({ ok: true, received: true }); // respond fast; work + log after
try {
console.log(`[webhook] customers/create id=${customer.id}`);
const result = await retailWebhook.handleCustomerCreate(customer);
// Redact the verify URL before logging — it carries a bearer token that would let a
// log reader self-apply the sample tag.
const safe = result && result.started && result.started.verifyUrl
? { ...result, started: { ...result.started, verifyUrl: '***' } }
: result;
console.log('[webhook] retail result:', JSON.stringify(safe));
} catch (e) {
console.error('[webhook] fulfillment error:', e.message);
}
}
// Tokened path (go-live) + bare path (DRY_RUN dev / back-compat) share one handler.
app.post('/webhooks/customers/create/:token', express.json({ type: '*/*', limit: '2mb' }), webhookAuth, webhookHandler);
app.post('/webhooks/customers/create', express.json({ type: '*/*', limit: '2mb' }), webhookAuth, webhookHandler);
// App-managed orders/paid webhook for the exact three-samples-lifetime ledger.
// This route must receive raw bytes so Shopify's HMAC can be verified before JSON parse.
const ledger = new sampleLedger.SampleUsageLedger(async (query, variables) => {
const response = await shopify.graphql(query, variables);
const errors = response?.json?.errors;
if (!response?.ok || errors?.length) throw new Error(JSON.stringify(errors || response?.json || response));
return response.json.data;
});
app.post('/webhooks/orders-paid', express.raw({ type: 'application/json', limit: '2mb' }), async (req, res) => {
if (!sampleLedger.verifyShopifyHmac(req.body, req.get('X-Shopify-Hmac-Sha256'), config.SHOPIFY_SIGNUP_APP_CLIENT_SECRET)) {
return res.status(401).json({ ok: false, error: 'invalid_signature' });
}
let order;
try { order = JSON.parse(req.body.toString('utf8')); }
catch { return res.status(400).json({ ok: false, error: 'invalid_json' }); }
try {
const result = await ledger.process(order);
return res.status(200).json({ ok: true, status: result.status });
} catch (error) {
console.error('[orders-paid] ledger error:', error.message);
return res.status(500).json({ ok: false, error: 'retry' });
}
});
// Global JSON parser for the rest.
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// --- CORS for the public trade-apply endpoint. The modal fetch()es this from the
// Shopify storefront origin (a different host), so allow the DW storefront(s). ---
const TRADE_ALLOWED_ORIGINS = (process.env.TRADE_ALLOWED_ORIGINS ||
'https://www.designerwallcoverings.com,https://designerwallcoverings.com').split(',').map(s => s.trim());
function tradeCors(req, res, next) {
const origin = req.get('Origin');
if (origin && TRADE_ALLOWED_ORIGINS.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') return res.status(204).end();
next();
}
app.options('/trade/apply', tradeCors);
// --- Trade application intake (public) ---
app.post('/trade/apply', tradeCors, async (req, res) => {
const b = req.body || {};
if (!b.email) return res.status(400).json({ ok: false, error: 'email required' });
// GUARDRAIL (auto-approve): reject blank/invalid emails at intake so a malformed address
// never creates a Shopify customer or an auto-approved trade account (same check as /claim).
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(b.email).trim())) {
return res.status(400).json({ ok: false, error: 'valid email required' });
}
// TK-11185: throttle per IP — the intake now creates a real Shopify customer per POST,
// so an unthrottled public endpoint could be sprayed to pollute the customer table /
// spam the designer-welcome email. Same sliding-window util as the webhook.
if (tradeApplyLimiter(clientIp(req))) return res.status(429).json({ ok: false, error: 'rate_limited' });
// GUARDRAIL (auto-approve dedupe): an exact re-submit of an email that ALREADY has an
// approved trade account is a no-op — don't create a duplicate customer/app or re-email.
if (config.TRADE_AUTO_APPROVE && trade.emailAlreadyApproved(String(b.email).trim())) {
return res.json({ ok: true, status: 'approved', already: true });
}
// TK-11185: synchronously find-or-create the Shopify customer + stamp the id, so the
// application is born LINKED and approve() can never hard-fail cannot_resolve_customer.
// DW is on New Customer Accounts (OTP) so the account is minted server-side (Admin API),
// not carried through a register page. Degrades gracefully — if the create fails the app
// is still persisted (unlinked, dead-lettered by the log below), applicant still sees ok.
let created, linkage;
try {
({ app: created, linkage } = await trade.applyAndLink(b));
} catch (e) {
console.error('[trade] applyAndLink error:', e.message);
created = trade.apply(b); // absolute backstop — never a black hole
linkage = { ok: false, error: 'route_exception:' + e.message };
}
if (!linkage.ok) {
console.error(`[trade] application ${created.id} (${created.email}) persisted UNLINKED (${linkage.error}) — review /admin/trade; resolvable in a later recovery pass.`);
}
// AUTO-APPROVE ON SIGNUP (Steve, 2026-09-10, guarded). Fire-and-forget so the applicant's
// POST isn't blocked on Shopify+George; DRY_RUN-safe (approve() only simulates in dev).
// On success approve() emails the applicant "you're approved" + a staff FYI goes to the
// office — so we DON'T also send the separate designer-welcome (avoids a redundant email).
// DAILY CAP (Steve, 2026-09-10): auto-approve grants a real trade account, and dedupe
// only stops SAME-email re-submits — a sprayer with unique emails is otherwise bounded
// only by 5/hr/IP. Past the cap we fall back to the review card rather than refusing the
// applicant, so a burst degrades to "a human looks at it" instead of dropping signups.
// Counted at dispatch (see lib/auto-approve-ledger.js) so a burst can't race past it.
const autoApproveCapped = config.TRADE_AUTO_APPROVE
&& autoApproveLedger.todayCount() >= config.TRADE_AUTO_APPROVE_DAILY_CAP;
if (autoApproveCapped) {
console.error(`[trade] AUTO-APPROVE DAILY CAP HIT (${autoApproveLedger.todayCount()}/${config.TRADE_AUTO_APPROVE_DAILY_CAP}) — ${created.id} (${created.email}) parked for review instead of auto-granting.`);
}
if (config.TRADE_AUTO_APPROVE && !autoApproveCapped) {
autoApproveLedger.record();
autoApproveAndNotify(created).catch(e => console.error('[trade] auto-approve failed:', e.message));
} else {
// Review-card path — reached when auto-approve is OFF (TRADE_AUTO_APPROVE=0) OR the
// daily cap is exhausted: park pending + email the review card + welcome letter.
notifyTradeApplication(created).catch(e => console.error('[trade] notify failed:', e.message));
(async () => {
const first = created.first_name || (created.contact_name ? String(created.contact_name).split(' ')[0] : '') || (created.email ? created.email.split('@')[0] : '');
const { subject, html } = email.designerWelcomeEmail({ firstName: first });
const r = await email.sendEmail({ to: created.email, subject, html, source: 'designer-welcome' });
if (r && r.ok === false) console.error(`[trade] designer-welcome send FAILED for ${created.email}: ${r.error || r.status}`);
})().catch(e => console.error('[trade] designer-welcome error:', e.message));
}
res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at, linked: !!linkage.ok });
});
// --- Retail sample claim (public, double opt-in) — email in, verify letter out. ---
// CORS-shared with /trade/apply so the storefront can POST it from its own origin.
app.options('/claim', tradeCors);
app.post('/claim', tradeCors, async (req, res) => {
const emailAddr = String((req.body || {}).email || '').trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailAddr)) return res.status(400).json({ ok: false, error: 'valid email required' });
// Best-effort: resolve an existing customer id so the tag lands on the right account.
let custId = null;
try { custId = await shopify.findCustomerByEmail(emailAddr); } catch (e) { /* best-effort only */ }
const started = await verify.startVerification({ email: emailAddr, customerId: custId, firstName: (req.body || {}).first_name });
// Never reveal whether the email exists — always answer the same.
res.json({ ok: started.ok !== false, message: 'Check your inbox to confirm your email. Sign in with the same address for your complimentary samples.' });
});
// Tiny branded claim form (handy for testing / embedding on a landing page).
app.get('/claim', (_req, res) => res.type('html').send(claimPage()));
// --- Verify click — validate the signed token, apply the free-samples tag. Idempotent. ---
app.get('/verify', async (req, res) => {
const parsed = verify.readToken(req.query.token);
if (!parsed.ok) {
const msg = parsed.reason === 'expired'
? "This confirmation link has expired. Request a new one and we'll send a fresh link."
: 'This confirmation link is invalid.';
// /verify is a human-facing page (a clicked email link), not an API. An invalid/expired
// token is a rendered outcome, not a malformed request → 200 (consistent with the
// completeVerification-failure page below, and avoids a console error on a customer page).
// Reserve a 5xx only for no_secret, which is a genuine server misconfiguration.
return res.status(parsed.reason === 'no_secret' ? 503 : 200).type('html').send(verifyPage(msg, false));
}
const done = await verify.completeVerification({ email: parsed.email, customerId: parsed.customerId });
// TK-11120 (Option B): show the count the email promised (token-carried), else global default.
const cnt = parsed.count || config.FREE_SAMPLE_COUNT;
if (!done.ok) {
return res.status(200).type('html').send(verifyPage("We couldn't attach the samples to your account. Please make sure you're signed in with this email and try again — or reply to our email and we'll sort it out.", false));
}
// Confirmation letter — ONLY on the first verification, so re-clicking the link doesn't
// send duplicate "samples unlocked" emails (the tag write is idempotent; the email isn't).
if (done.firstTime) {
(async () => {
const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: cnt });
const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
})().catch(e => console.error('[verify] unlocked-email error:', e.message));
} else {
console.log(`[verify] repeat click for customer ${done.customerId} — tag re-applied, confirmation email skipped (already sent).`);
}
console.log(`[verify] tagged customer ${done.customerId} '${done.tag}'${done.dryRun ? ' (DRY_RUN)' : ''}`);
res.type('html').send(verifyPage(`Email confirmed. Sign in with this address and your remaining ${cnt}-sample lifetime allowance applies automatically at checkout.`, true));
});
// Public base the emailed Approve/Reject buttons point at (Kamatera host at go-live).
// FAIL-CLOSED in LIVE (TK-11120): never fall back to the loopback host in a real send —
// a localhost link mailed to a human is dead. Only DRY_RUN/dev may use the loopback base.
function baseUrl() {
if (config.PUBLIC_URL) return config.PUBLIC_URL;
if (config.DRY_RUN) return `http://127.0.0.1:${config.PORT}`;
return '';
}
// Send the trade-application review card to the office inbox via George.
async function notifyTradeApplication(application) {
const base = baseUrl();
const adminUrl = `${base}/admin/trade`;
// Mint one-click magic-links only if a real signing secret is configured; otherwise
// fall back to the login-gated admin panel so the email never ships a dead/forgeable link.
const at = trade.actionToken(application.id, 'approve');
const rt = trade.actionToken(application.id, 'reject');
const approveUrl = at ? `${base}/admin/trade/${application.id}/approve?token=${at}` : adminUrl;
const rejectUrl = rt ? `${base}/admin/trade/${application.id}/reject?token=${rt}` : adminUrl;
const { subject, html } = email.tradeApplicationEmail({ app: application, approveUrl, rejectUrl, adminUrl });
const r = await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject, html, source: 'trade-application' });
// DEAD-LETTER (contrarian #2, 2026-07-28): sendEmail resolves {ok:false} on a George
// outage WITHOUT throwing, so the .catch in the route wouldn't fire — the application
// would silently rot unseen. Record every failed notify so a crashed George over a
// weekend leaves an auditable trail (and a hook for a pending-apps digest) instead of
// a black hole. The application itself is already persisted + visible on /admin/trade.
if (r && r.ok === false) {
try {
require('fs').appendFileSync(require('path').join(__dirname, 'data', 'trade-notify-failures.jsonl'),
JSON.stringify({ at: new Date().toISOString(), id: application.id, email: application.email, to: config.TRADE_NOTIFY_TO, error: r.error || r.status }) + '\n');
} catch (e) { console.error('[trade] dead-letter write failed:', e.message); }
console.error(`[trade] NOTIFY FAILED for ${application.id} (${application.email}) — dead-lettered; review /admin/trade`);
}
return r;
}
// Auto-approve a fresh application (Steve, 2026-09-10, guarded). Approves via the SAME
// trade.approve() path the one-click card uses (idempotent + DRY_RUN-safe: it only
// simulates in dev, returning ok:false+dryRun). On a LIVE success it sends a staff FYI so
// the office still sees every signup; on DRY_RUN or a LIVE approve FAILURE it falls back to
// the actionable review card so a stuck applicant still surfaces to staff.
async function autoApproveAndNotify(application) {
const r = await trade.approve(application.id);
if (r && r.ok) {
const fresh = trade.get(application.id) || application;
const repName = fresh.assigned_rep && fresh.assigned_rep.name;
const { subject, html } = email.tradeAutoApprovedEmail({ app: fresh, repName, adminUrl: `${baseUrl()}/admin/trade` });
await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject, html, source: 'trade-auto-approved' });
return r;
}
// DRY_RUN simulation (r.dryRun) or a genuine LIVE failure (r.ok===false): send the
// review card so staff can approve manually. notifyTradeApplication dead-letters a
// George outage, so the applicant is never a black hole.
await notifyTradeApplication(application);
return r;
}
// Small confirmation page rendered when Approve/Reject is clicked from the email.
function resultPage(msg, ok) {
return `<!doctype html><meta charset="utf-8"><title>DW Trade</title>` +
`<body style="font:16px/1.5 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">` +
`<div style="text-align:center;padding:32px;border:1px solid #e5e2dd;border-radius:12px;background:#fff">` +
`<div style="font-size:42px">${ok ? '✅' : '⚠️'}</div>` +
`<h1 style="font-weight:600;font-size:20px;margin:8px 0">${esc(msg)}</h1>` +
`<p style="color:#6b7280"><a href="/admin/trade">Open the trade admin panel</a></p></div></body>`;
}
// Branded retail claim form (email input) that POSTs to /claim.
function claimPage() {
return `<!doctype html><meta charset="utf-8"><title>DW — 3 Free Samples</title>
<body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
<div style="max-width:420px;width:100%;box-sizing:border-box;padding:32px;border:1px solid #e5e2dd;border-radius:12px;background:#fff;text-align:center">
<div style="font-size:16px;letter-spacing:3px;font-weight:600">DESIGNER WALLCOVERINGS</div>
<h1 style="font-weight:600;font-size:22px;margin:14px 0 6px">3 free samples, on us</h1>
<p style="color:#6b7280;margin:0 0 18px">Enter your account email and we'll send a confirmation link. Your sample allowance applies whenever you're signed in.</p>
<input id="e" type="email" placeholder="you@example.com" style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d6d1c8;border-radius:8px;font-size:15px">
<button onclick="go()" style="margin-top:12px;width:100%;padding:12px;border:0;background:#1a1a1a;color:#fff;border-radius:30px;font-size:15px;cursor:pointer">Send my link</button>
<p id="m" style="margin:14px 0 0;min-height:20px"></p>
</div>
<script>
async function go(){
const email=document.getElementById('e').value.trim();
const m=document.getElementById('m');
// Validate client-side (same rule as the server) BEFORE any network call, so an
// empty/invalid email shows instant inline feedback instead of firing a 400.
if(!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(email)){ m.style.color='#b91c1c'; m.textContent='Please enter a valid email address.'; return; }
m.style.color='#6b7280'; m.textContent='Sending…';
try{ const r=await fetch('/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})});
const j=await r.json(); m.style.color=j.ok?'#16a34a':'#b91c1c'; m.textContent=j.ok?j.message:(j.error||'Something went wrong'); }
catch(e){ m.style.color='#b91c1c'; m.textContent='Network error — try again.'; }
}
</script></body>`;
}
// Branded verify result page (success or failure).
function verifyPage(msg, ok) {
return `<!doctype html><meta charset="utf-8"><title>Designer Wallcoverings</title>` +
`<body style="font:16px/1.6 -apple-system,system-ui,sans-serif;background:#faf9f7;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">` +
`<div style="max-width:440px;text-align:center;padding:34px;border:1px solid #e5e2dd;border-radius:12px;background:#fff">` +
`<div style="font-size:16px;letter-spacing:3px;font-weight:600;color:#1a1a1a">DESIGNER WALLCOVERINGS</div>` +
`<div style="font-size:44px;margin:14px 0 6px">${ok ? '🎁' : '⚠️'}</div>` +
`<h1 style="font-weight:600;font-size:20px;margin:6px 0 10px">${ok ? "You're all set" : 'Hmm.'}</h1>` +
`<p style="color:#4b5563;margin:0 0 18px">${esc(msg)}</p>` +
`<a href="https://designerwallcoverings.com" style="background:#1a1a1a;color:#fff;text-decoration:none;padding:12px 30px;border-radius:30px;font-size:14px;display:inline-block">Explore the Collections</a>` +
`</div></body>`;
}
// --- Basic-auth guard for /admin/* ---
function adminAuth(req, res, next) {
const hdr = req.get('Authorization') || '';
const [scheme, val] = hdr.split(' ');
if (scheme === 'Basic' && val) {
// RFC 7617: userid has no colon, but the password may — split on the FIRST colon only.
const decoded = Buffer.from(val, 'base64').toString('utf8');
const i = decoded.indexOf(':');
if (i >= 0) {
const u = decoded.slice(0, i), p = decoded.slice(i + 1);
if (u === config.ADMIN_USER && p === config.ADMIN_PASS) return next();
}
}
res.set('WWW-Authenticate', 'Basic realm="DW Trade Admin"');
return res.status(401).send('Auth required');
}
// One-time installed-app reauthorization. The start is admin protected; Shopify's
// callback is authenticated by a short-lived signed state and Shopify query HMAC.
app.get('/admin/oauth/start', adminAuth, (_req, res) => {
if (!config.SHOPIFY_SIGNUP_APP_CLIENT_SECRET || !config.PUBLIC_URL) return res.status(503).send('OAuth configuration incomplete');
res.redirect(shopifyOauth.authorizeUrl());
});
app.get('/oauth/callback', async (req, res) => {
if (!shopifyOauth.validCallback(req.query)) return res.status(401).send('Invalid OAuth callback');
try {
const granted = await shopifyOauth.exchangeCode(req.query);
shopifyOauth.persistToken(granted.token);
console.log(`[oauth] fulfillment token refreshed; scopes=${granted.scope}`);
res.type('html').send('<!doctype html><meta charset="utf-8"><title>DW authorization complete</title><p>Authorization complete. The token was stored securely; this window can be closed.</p>');
} catch (error) {
console.error('[oauth] callback failed:', error.message);
res.status(502).send('Authorization exchange failed');
}
});
// --- Admin trade review surface ---
app.get('/admin/trade', adminAuth, (_req, res) => {
const pending = trade.listPending();
res.type('html').send(renderAdmin(pending));
});
app.post('/admin/trade/:id/approve', adminAuth, async (req, res) => {
const result = await trade.approve(req.params.id);
res.status(result.ok ? 200 : 400).json(result);
});
app.post('/admin/trade/:id/reject', adminAuth, async (req, res) => {
const result = await trade.reject(req.params.id);
res.status(result.ok ? 200 : 400).json(result);
});
// --- One-click approve/reject from the notification email (token-authed magic-links,
// no basic-auth so the button just works from the inbox). GET is safe here: the
// token is per-app + per-action, single-transition (already-decided → no-op). ---
app.get('/admin/trade/:id/approve', async (req, res) => {
if (!trade.verifyActionToken(req.params.id, 'approve', req.query.token)) {
return res.status(403).type('html').send(resultPage('This approval link is invalid or has expired.', false));
}
const r = await trade.approve(req.params.id);
res.status(r.ok ? 200 : 400).type('html').send(resultPage(r.ok ? 'Trade account approved.' : `Could not approve: ${r.error}`, r.ok));
});
app.get('/admin/trade/:id/reject', async (req, res) => {
if (!trade.verifyActionToken(req.params.id, 'reject', req.query.token)) {
return res.status(403).type('html').send(resultPage('This link is invalid or has expired.', false));
}
const r = await trade.reject(req.params.id);
res.status(r.ok ? 200 : 400).type('html').send(resultPage(r.ok ? 'Application rejected.' : `Could not reject: ${r.error}`, r.ok));
});
// --- Introspection helpers ---
app.get('/reps/next', adminAuth, (_req, res) => res.json({ assigned: reps.houseAccount() }));
// Manual retail trigger (admin) — handy for go-live smoke test without a real
// webhook. Default mirrors the WIRED gift-card path (FINAL, memo §2). ALTERNATES for
// comparison only: ?mode=sharedcode (function code) and ?mode=discount (collection code).
app.post('/admin/retail/issue', adminAuth, async (req, res) => {
const customer = req.body || {};
if (!customer.email) return res.status(400).json({ ok: false, error: 'email required' });
let mode = req.query.mode || 'verify';
let result;
if (mode === 'giftcard') result = await giftcard.issueRetailGiftCode(customer); // legacy alternate
else if (mode === 'sharedcode') result = await retailCode.issueRetailCode(customer); // legacy alternate
else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // legacy alternate
else { mode = 'verify'; result = await verify.startVerification(customer); } // WIRED default (Option C)
res.json({ ok: true, mode, result });
});
// --- Admin HTML with created date+time chip (Steve's admin-card rule) ---
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} catch { return iso; }
}
function renderAdmin(pending) {
const cards = pending.map(a => `
<div class="card">
<div class="when" title="${esc(a.created_at)}">🕓 ${esc(fmtDate(a.created_at))}</div>
<div class="biz">${esc(a.business_name || '(no business name)')}</div>
<div class="meta">${esc(a.email)}${a.phone ? ' · ' + esc(a.phone) : ''}</div>
<div class="meta">Resale cert: ${esc(a.resale_cert || '—')}</div>
<div class="id">${esc(a.id)}</div>
<div class="actions">
<button onclick="act('${a.id}','approve')">Approve</button>
<button class="rej" onclick="act('${a.id}','reject')">Reject</button>
</div>
</div>`).join('\n');
return `<!doctype html><html><head><meta charset="utf-8"><title>DW Trade Admin</title>
<style>
body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:24px;color:#1a1a1a;background:#faf9f7}
h1{font-weight:600}
.dry{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:16px}
.card{border:1px solid #e5e2dd;border-radius:8px;padding:12px;background:#fff}
.when{font-size:12px;color:#6b7280}
.biz{font-size:16px;font-weight:600;margin:4px 0}
.meta{font-size:13px;color:#374151}
.id{font-size:11px;color:#9ca3af;margin-top:6px;font-family:monospace}
.actions{margin-top:10px;display:flex;gap:8px}
button{padding:6px 12px;border:1px solid #111;background:#111;color:#fff;border-radius:6px;cursor:pointer}
button.rej{background:#fff;color:#b91c1c;border-color:#b91c1c}
.empty{color:#6b7280;margin-top:20px}
</style></head><body>
<h1>Trade applications <span class="dry">DRY_RUN: ${config.DRY_RUN ? 'ON (no live writes)' : 'OFF (LIVE)'}</span></h1>
<div class="grid">${cards || '<p class="empty">No pending applications.</p>'}</div>
<script>
async function act(id, decision){
if(!confirm(decision.toUpperCase()+' '+id+'?')) return;
const r = await fetch('/admin/trade/'+id+'/'+decision, {method:'POST'});
const j = await r.json();
alert(JSON.stringify(j, null, 2));
location.reload();
}
</script></body></html>`;
}
if (require.main === module) {
app.listen(config.PORT, () => {
console.log(`[dw-signup-fulfillment] listening on :${config.PORT} DRY_RUN=${config.DRY_RUN}`);
console.log(` health: http://127.0.0.1:${config.PORT}/healthz`);
console.log(` admin: http://127.0.0.1:${config.PORT}/admin/trade (basic-auth user=${config.ADMIN_USER}, pass in env/config — not logged)`);
console.log(` webhook: POST /webhooks/customers/create/<token> (URL-token auth + rate-limit; token ${config.WEBHOOK_URL_TOKEN ? 'SET' : 'UNSET → 503 when live'})`);
console.log(` claim: POST /claim · verify: GET /verify?token=… (tag=${config.VERIFIED_TAG}; secret ${config.VERIFY_SECRET ? 'SET' : (config.DRY_RUN ? 'dev-fallback (DRY_RUN)' : 'UNSET → /verify 503')})`);
if (config.DRY_RUN) console.log(' ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
});
}
module.exports = app;