security hardening
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Small env-access helpers that fail closed.
|
||||
*
|
||||
* `requireEnv` throws a clear error (without ever printing the secret value)
|
||||
* when a required environment variable is missing or empty. `optionalEnv`
|
||||
* returns the trimmed value or undefined.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the value of `name` from `process.env`, throwing if it is unset or
|
||||
* empty (after trimming). The secret value itself is never included in the
|
||||
* error message.
|
||||
*/
|
||||
export function requireEnv(name: string): string {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
throw new Error(
|
||||
`Missing required environment variable "${name}". ` +
|
||||
`Set it before starting the app (see deploy/.env.dev.example).`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of `name` from `process.env`, or `undefined` if it is
|
||||
* unset or empty (after trimming).
|
||||
*/
|
||||
export function optionalEnv(name: string): string | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") return undefined;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Field-level encryption at rest using AES-256-GCM.
|
||||
*
|
||||
* Output format: `enc:v1:<base64(iv)>:<base64(tag)>:<base64(ciphertext)>`
|
||||
*
|
||||
* `decryptField` is backward-compatible: values that do not carry the
|
||||
* `enc:v1:` prefix are assumed to be legacy plaintext and returned unchanged,
|
||||
* so an existing (dev) database keeps working without a data migration.
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { requireEnv } from "../config/env.server";
|
||||
|
||||
const PREFIX = "enc:v1:";
|
||||
const IV_BYTES = 12;
|
||||
const KEY_BYTES = 32;
|
||||
|
||||
let cachedKey: Buffer | null = null;
|
||||
|
||||
/**
|
||||
* Loads and validates the 32-byte AES key from `DATA_ENCRYPTION_KEY`
|
||||
* (base64-encoded). Cached after first use. Throws if unset or wrong length.
|
||||
*/
|
||||
function getKey(): Buffer {
|
||||
if (cachedKey) return cachedKey;
|
||||
const b64 = requireEnv("DATA_ENCRYPTION_KEY");
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
throw new Error('DATA_ENCRYPTION_KEY must be valid base64 of 32 bytes.');
|
||||
}
|
||||
if (key.length !== KEY_BYTES) {
|
||||
throw new Error(
|
||||
`DATA_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes (got ${key.length}).`,
|
||||
);
|
||||
}
|
||||
cachedKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Encrypts `plaintext` and returns the `enc:v1:...` envelope. */
|
||||
export function encryptField(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return (
|
||||
PREFIX +
|
||||
iv.toString("base64") +
|
||||
":" +
|
||||
tag.toString("base64") +
|
||||
":" +
|
||||
ciphertext.toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an `enc:v1:...` envelope. If `value` is not in that format it is
|
||||
* assumed to be legacy plaintext and returned unchanged.
|
||||
*/
|
||||
export function decryptField(value: string): string {
|
||||
if (!value.startsWith(PREFIX)) return value;
|
||||
const parts = value.slice(PREFIX.length).split(":");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Malformed encrypted field (expected iv:tag:ciphertext).");
|
||||
}
|
||||
const [ivB64, tagB64, dataB64] = parts;
|
||||
const key = getKey();
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const tag = Buffer.from(tagB64, "base64");
|
||||
const ciphertext = Buffer.from(dataB64, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "./emailTemplates";
|
||||
import { STORED_LOGO_SENTINEL } from "./logoCache.constants";
|
||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||
import { decryptField } from "../crypto/fieldCrypto.server";
|
||||
import { optionalEnv } from "../config/env.server";
|
||||
|
||||
export interface SendInvoiceEmailArgs {
|
||||
shopDomain: string;
|
||||
@@ -86,7 +88,7 @@ export async function sendInvoiceEmail(
|
||||
const customSubject =
|
||||
(language === "en" ? settings.emailSubjectEn : settings.emailSubjectDe) ||
|
||||
(language === "en" ? DEFAULT_EMAIL_SUBJECT_EN : DEFAULT_EMAIL_SUBJECT_DE);
|
||||
const subject = renderTemplate(customSubject, vars);
|
||||
const subject = renderTemplate(customSubject, vars, { html: false });
|
||||
|
||||
const customBodyHtml =
|
||||
(language === "en" ? settings.emailBodyHtmlEn : settings.emailBodyHtmlDe) ||
|
||||
@@ -124,10 +126,13 @@ export async function sendInvoiceEmail(
|
||||
}
|
||||
|
||||
try {
|
||||
// Optional archival BCC. Off by default for privacy/GDPR; set INVOICE_BCC
|
||||
// to a comma-separated address list to opt in.
|
||||
const bcc = optionalEnv("INVOICE_BCC");
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to,
|
||||
bcc: "shop@linumiq.com",
|
||||
...(bcc ? { bcc } : {}),
|
||||
replyTo: settings.smtpReplyTo || undefined,
|
||||
subject,
|
||||
text: body.text,
|
||||
@@ -180,7 +185,7 @@ function buildTransport(settings: ShopSettings): Transporter {
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpSecure,
|
||||
auth: settings.smtpUser
|
||||
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
||||
? { user: settings.smtpUser, pass: decryptField(settings.smtpPassword) }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -286,13 +291,49 @@ function buildTemplateVars(args: {
|
||||
|
||||
/**
|
||||
* Substitutes {{token}} placeholders in `template`. Unknown tokens are left
|
||||
* in place so the user notices typos instead of silent blanks. Values are
|
||||
* inserted verbatim — callers are responsible for HTML-escaping if needed.
|
||||
* in place so the user notices typos instead of silent blanks.
|
||||
*
|
||||
* For HTML output (the default), every interpolated value is HTML-escaped to
|
||||
* prevent stored-XSS from merchant- or customer-derived data bleeding into the
|
||||
* email body. URL-valued tokens that land inside `href` attributes are scheme-
|
||||
* validated first: `shopWebsite` must be an `https:` URL and `shopEmail` must
|
||||
* look like a bare email address (rendered after a `mailto:` prefix); anything
|
||||
* else renders empty so a hostile `javascript:`/`data:` value can't be planted.
|
||||
*
|
||||
* Pass `{ html: false }` for plain-text contexts (e.g. the subject line), where
|
||||
* the raw value is substituted without HTML entity encoding.
|
||||
*/
|
||||
function renderTemplate(template: string, vars: TemplateVars): string {
|
||||
const URL_TOKENS = new Set(["shopWebsite"]);
|
||||
const EMAIL_TOKENS = new Set(["shopEmail"]);
|
||||
|
||||
function safeHttpsUrl(value: string): string {
|
||||
const v = value.trim();
|
||||
try {
|
||||
return new URL(v).protocol === "https:" ? v : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeEmailAddress(value: string): string {
|
||||
const v = value.trim();
|
||||
return /^[^\s@<>"'/\\]+@[^\s@<>"'/\\]+\.[^\s@<>"'/\\]+$/.test(v) ? v : "";
|
||||
}
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
vars: TemplateVars,
|
||||
opts: { html?: boolean } = {},
|
||||
): string {
|
||||
const html = opts.html !== false; // HTML-escape by default
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (full, key) => {
|
||||
const v = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
return v === undefined ? full : v;
|
||||
const raw = (vars as unknown as Record<string, string | undefined>)[key];
|
||||
if (raw === undefined) return full;
|
||||
if (!html) return raw;
|
||||
let value = raw;
|
||||
if (URL_TOKENS.has(key)) value = safeHttpsUrl(raw);
|
||||
else if (EMAIL_TOKENS.has(key)) value = safeEmailAddress(raw);
|
||||
return escapeHtml(value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ export interface GiroCodeInput {
|
||||
remittance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces CR/LF in a free-text EPC field with a single space and collapses
|
||||
* runs of whitespace, so the line-delimited payload can't be tampered with by
|
||||
* smuggling newlines into user-supplied text (beneficiary name / remittance).
|
||||
*/
|
||||
function sanitizeEpcField(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function buildGiroCodePayload(input: GiroCodeInput): string {
|
||||
const currency = input.currency || "EUR";
|
||||
if (currency !== "EUR") {
|
||||
@@ -25,12 +34,14 @@ export function buildGiroCodePayload(input: GiroCodeInput): string {
|
||||
console.warn(`GiroCode: non-EUR currency ${currency} is non-standard.`);
|
||||
}
|
||||
|
||||
// Beneficiary name max 70 chars per spec.
|
||||
const name = input.beneficiaryName.slice(0, 70);
|
||||
// Beneficiary name max 70 chars per spec. Strip CR/LF first so injected
|
||||
// newlines can't forge/add EPC fields (the payload is line-delimited).
|
||||
const name = sanitizeEpcField(input.beneficiaryName).slice(0, 70);
|
||||
const iban = input.iban.replace(/\s+/g, "").toUpperCase();
|
||||
const bic = (input.bic || "").replace(/\s+/g, "").toUpperCase();
|
||||
const amount = input.amount.toFixed(2);
|
||||
const remittance = input.remittance.slice(0, 140);
|
||||
// Unstructured remittance max 140 chars; strip CR/LF for the same reason.
|
||||
const remittance = sanitizeEpcField(input.remittance).slice(0, 140);
|
||||
|
||||
// Field order is fixed; trailing fields can be empty.
|
||||
// Service tag SCT = SEPA Credit Transfer.
|
||||
|
||||
@@ -22,6 +22,21 @@ const TEXT_DARK = "#1F2933";
|
||||
const TEXT_MUTED = "#6B7280";
|
||||
const TABLE_BORDER = "#E5E7EB";
|
||||
|
||||
/**
|
||||
* Returns true only for syntactically valid http(s) URLs. Used to gate
|
||||
* carrier/fulfillment-supplied tracking URLs before embedding them as PDF
|
||||
* link annotations, so non-http schemes (javascript:, file:, data:, …) can't
|
||||
* be smuggled into the document.
|
||||
*/
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === "https:" || u.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
@@ -348,7 +363,7 @@ export function InvoiceDocument({ invoice }: DocProps) {
|
||||
{t.trackingLabel}
|
||||
{tr.company ? ` (${tr.company})` : ""}
|
||||
</Text>
|
||||
{tr.url ? (
|
||||
{tr.url && isHttpUrl(tr.url) ? (
|
||||
<Link src={tr.url} style={styles.metaValue}>{tr.number}</Link>
|
||||
) : (
|
||||
<Text style={styles.metaValue}>{tr.number}</Text>
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
* hammering the network when regenerating an invoice multiple times.
|
||||
*/
|
||||
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
||||
const CACHE_MAX_ENTRIES = 200;
|
||||
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
|
||||
const MAX_IMAGES_PER_INVOICE = 100;
|
||||
/** Max concurrent image fetches per invoice. */
|
||||
const IMAGE_FETCH_CONCURRENCY = 6;
|
||||
|
||||
const cache = new Map<string, string>(); // url -> data URL
|
||||
|
||||
@@ -76,17 +81,28 @@ export async function fetchProductImageDataUrl(url: string): Promise<string | un
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves images for every line in parallel, mutating `imageDataUrl` in place.
|
||||
* Failures are swallowed (the row simply renders without an icon).
|
||||
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
|
||||
* run with bounded concurrency and a hard cap on the number of images
|
||||
* embedded per invoice, so a large cart (Shopify allows hundreds of line
|
||||
* items) can't trigger an unbounded fan-out of network requests. Failures are
|
||||
* swallowed (the row simply renders without an icon).
|
||||
*/
|
||||
export async function attachLineItemImages(
|
||||
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
lines.map(async (line) => {
|
||||
if (!line.imageUrl) return;
|
||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl);
|
||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||
}),
|
||||
);
|
||||
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
|
||||
let budget = MAX_IMAGES_PER_INVOICE;
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.imageUrl) continue;
|
||||
if (budget <= 0) break; // cap reached — remaining rows render iconless
|
||||
budget -= 1;
|
||||
tasks.push(
|
||||
limit(async () => {
|
||||
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
|
||||
if (dataUrl) line.imageDataUrl = dataUrl;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Agent as HttpAgent } from "node:http";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
/** Hard cap in bytes; the read aborts as soon as this is exceeded. */
|
||||
@@ -60,43 +61,62 @@ export class SafeFetchError extends Error {
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
function isPrivateIpv4(ip: string): boolean {
|
||||
const parts = ip.split(".").map((n) => parseInt(n, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
|
||||
// Treat malformed addresses as unsafe.
|
||||
return true;
|
||||
/**
|
||||
* Default-deny address classifier backed by the well-vetted `ipaddr.js`
|
||||
* library. An address is considered safe to connect to ONLY if it is a
|
||||
* clearly public, globally-routable unicast address. Everything else —
|
||||
* loopback, private (RFC1918), link-local, unique-local, multicast,
|
||||
* reserved, unspecified, broadcast, carrier-grade NAT, plus the various
|
||||
* IPv4-in-IPv6 tunnelling/transition forms — is rejected.
|
||||
*
|
||||
* This closes IPv6 bypasses that string-prefix checks miss, e.g.:
|
||||
* - `::ffff:7f00:1` (IPv4-mapped HEX form of 127.0.0.1)
|
||||
* - `::7f00:1` (deprecated IPv4-compatible ::127.0.0.1)
|
||||
* - `fe90::` / `fea0::` / `feb0::` (link-local is fe80::/10, not just fe80:)
|
||||
*/
|
||||
function isSafePublicAddress(ip: string): boolean {
|
||||
let addr: ipaddr.IPv4 | ipaddr.IPv6;
|
||||
try {
|
||||
addr = ipaddr.parse(ip);
|
||||
} catch {
|
||||
// Unparseable => treat as unsafe.
|
||||
return false;
|
||||
}
|
||||
const [a, b] = parts;
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 0) return true;
|
||||
if (a === 169 && b === 254) return true; // link-local + AWS metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 192 && b === 0) return true; // 192.0.0.0/24, 192.0.2.0/24
|
||||
if (a === 198 && (b === 18 || b === 19)) return true;
|
||||
if (a >= 224) return true; // multicast / reserved
|
||||
return false;
|
||||
|
||||
if (addr.kind() === "ipv4") {
|
||||
// Only globally-routable unicast IPv4 is allowed. `range()` returns
|
||||
// 'unicast' exclusively for public space; private/loopback/linkLocal/
|
||||
// carrierGradeNat/reserved/broadcast/multicast/unspecified are all denied.
|
||||
return (addr as ipaddr.IPv4).range() === "unicast";
|
||||
}
|
||||
|
||||
const v6 = addr as ipaddr.IPv6;
|
||||
|
||||
// Unwrap IPv4-mapped (::ffff:a.b.c.d, incl. hex form ::ffff:7f00:1) and
|
||||
// validate the embedded IPv4 against the v4 policy.
|
||||
if (v6.isIPv4MappedAddress()) {
|
||||
return v6.toIPv4Address().range() === "unicast";
|
||||
}
|
||||
|
||||
// Deprecated IPv4-compatible addresses live in ::/96 (first 96 bits zero,
|
||||
// e.g. ::7f00:1 == ::127.0.0.1). ipaddr.js classifies these as plain
|
||||
// 'unicast', so unwrap the trailing 32 bits and validate as IPv4. This
|
||||
// also covers :: (unspecified) and ::1 (loopback), which map to
|
||||
// 0.0.0.0 / 0.0.0.1 and are denied by the IPv4 policy.
|
||||
const p = v6.parts;
|
||||
if (p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 0 && p[4] === 0 && p[5] === 0) {
|
||||
const v4 = new ipaddr.IPv4([(p[6] >> 8) & 0xff, p[6] & 0xff, (p[7] >> 8) & 0xff, p[7] & 0xff]);
|
||||
return v4.range() === "unicast";
|
||||
}
|
||||
|
||||
// Everything else: only true global unicast is allowed. This rejects
|
||||
// loopback, linkLocal (fe80::/10), uniqueLocal (fc00::/7), multicast,
|
||||
// reserved, 6to4, teredo, rfc6145/rfc6052 transition ranges, etc.
|
||||
return v6.range() === "unicast";
|
||||
}
|
||||
|
||||
function isPrivateIpv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::1" || lower === "::") return true;
|
||||
if (lower.startsWith("fe80:")) return true; // link-local
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA
|
||||
if (lower.startsWith("ff")) return true; // multicast
|
||||
// IPv4-mapped: ::ffff:a.b.c.d — apply IPv4 rules
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
const v4 = lower.slice(7);
|
||||
if (v4.includes(".")) return isPrivateIpv4(v4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateAddress(ip: string, family: number): boolean {
|
||||
if (family === 4) return isPrivateIpv4(ip);
|
||||
if (family === 6) return isPrivateIpv6(ip);
|
||||
return true;
|
||||
function isPrivateAddress(ip: string): boolean {
|
||||
return !isSafePublicAddress(ip);
|
||||
}
|
||||
|
||||
function hostMatchesAllowlist(hostname: string, allowed: string[] | undefined): boolean {
|
||||
@@ -117,7 +137,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
|
||||
// If the hostname is already an IP literal, validate it directly.
|
||||
if (net.isIP(hostname)) {
|
||||
const family = net.isIPv6(hostname) ? 6 : 4;
|
||||
if (isPrivateAddress(hostname, family)) {
|
||||
if (isPrivateAddress(hostname)) {
|
||||
throw new SafeFetchError("blocked-address", `Refusing to connect to private address ${hostname}`);
|
||||
}
|
||||
return { address: hostname, family };
|
||||
@@ -129,7 +149,7 @@ async function resolveSafeAddress(hostname: string): Promise<{ address: string;
|
||||
throw new SafeFetchError("dns-failed", `DNS lookup failed for ${hostname}: ${(err as Error).message}`);
|
||||
}
|
||||
for (const r of results) {
|
||||
if (isPrivateAddress(r.address, r.family)) {
|
||||
if (isPrivateAddress(r.address)) {
|
||||
throw new SafeFetchError("blocked-address", `${hostname} resolves to private address ${r.address}`);
|
||||
}
|
||||
}
|
||||
@@ -269,3 +289,30 @@ export async function safeFetch(rawUrl: string, opts: SafeFetchOptions = {}): Pr
|
||||
|
||||
/** Common allowlist for Shopify-served assets (CDN + Files). */
|
||||
export const SHOPIFY_CDN_HOSTS = ["cdn.shopify.com", "shopifycdn.com", "shopify.com"];
|
||||
|
||||
/**
|
||||
* Boundary validation for merchant-supplied URLs (e.g. the logo URL saved in
|
||||
* settings). Requires a syntactically valid `https:` URL whose host is a DNS
|
||||
* name rather than an IP literal (v4 or v6). Returns a user-facing error
|
||||
* string when the URL is unacceptable, or `null` when it is fine to store.
|
||||
*
|
||||
* This is a defence-in-depth boundary check; `safeFetch` remains the runtime
|
||||
* backstop that re-validates the resolved address at fetch time.
|
||||
*/
|
||||
export function validateMerchantHttpsUrl(raw: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return "Enter a valid URL including the https:// prefix.";
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
return "Logo URL must use https://.";
|
||||
}
|
||||
// URL.hostname wraps IPv6 literals in brackets; strip them before checking.
|
||||
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
if (net.isIP(host) !== 0) {
|
||||
return "Logo URL must point to a domain name, not an IP address.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const SECRET = process.env.SHOPIFY_API_SECRET || "";
|
||||
import { optionalEnv } from "../config/env.server";
|
||||
|
||||
/**
|
||||
* Resolves the GiroCode URL signing key lazily (per call, not at module load)
|
||||
* so the process can boot even when only the fallback secret is present.
|
||||
*
|
||||
* Prefers the dedicated `GIROCODE_SIGNING_KEY`; falls back to
|
||||
* `SHOPIFY_API_SECRET` ONLY when the dedicated key is unset, so existing
|
||||
* signed URLs and deployments keep working. Throws if neither is set
|
||||
* (fail closed) — an empty key would make signatures forgeable.
|
||||
*/
|
||||
function getSigningKey(): string {
|
||||
const key = optionalEnv("GIROCODE_SIGNING_KEY") ?? optionalEnv("SHOPIFY_API_SECRET");
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"GiroCode signing key missing: set GIROCODE_SIGNING_KEY (preferred) " +
|
||||
"or SHOPIFY_API_SECRET.",
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function hmac(payload: string): string {
|
||||
return crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
|
||||
return crypto.createHmac("sha256", getSigningKey()).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export interface GiroCodeUrlParams {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Thin wrapper around `@shopify/shopify-app-session-storage-prisma` that
|
||||
* encrypts `accessToken` / `refreshToken` at rest using field-level AES-256-GCM.
|
||||
*
|
||||
* Tokens are encrypted before being handed to the underlying storage and
|
||||
* decrypted after they are loaded back out. `decryptField` is backward
|
||||
* compatible, so any legacy plaintext tokens already in the DB keep working.
|
||||
*/
|
||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||
import type { Session } from "@shopify/shopify-api";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { decryptField, encryptField } from "../crypto/fieldCrypto.server";
|
||||
|
||||
type SessionTokenFields = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
function encryptTokens(session: Session): Session {
|
||||
const s = session as Session & SessionTokenFields;
|
||||
if (s.accessToken) s.accessToken = encryptField(s.accessToken);
|
||||
if (s.refreshToken) s.refreshToken = encryptField(s.refreshToken);
|
||||
return session;
|
||||
}
|
||||
|
||||
function decryptTokens(session: Session): Session {
|
||||
const s = session as Session & SessionTokenFields;
|
||||
if (s.accessToken) s.accessToken = decryptField(s.accessToken);
|
||||
if (s.refreshToken) s.refreshToken = decryptField(s.refreshToken);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a Session so we never mutate the caller's instance when encrypting
|
||||
* for storage. The Prisma session storage only reads plain properties, so a
|
||||
* shallow structured copy via the Session class is sufficient.
|
||||
*/
|
||||
function cloneSession(session: Session): Session {
|
||||
return Object.assign(
|
||||
Object.create(Object.getPrototypeOf(session)),
|
||||
session,
|
||||
) as Session;
|
||||
}
|
||||
|
||||
export class EncryptedPrismaSessionStorage extends PrismaSessionStorage<PrismaClient> {
|
||||
async storeSession(session: Session): Promise<boolean> {
|
||||
return super.storeSession(encryptTokens(cloneSession(session)));
|
||||
}
|
||||
|
||||
async loadSession(id: string): Promise<Session | undefined> {
|
||||
const session = await super.loadSession(id);
|
||||
return session ? decryptTokens(session) : session;
|
||||
}
|
||||
|
||||
async findSessionsByShop(shop: string): Promise<Session[]> {
|
||||
const sessions = await super.findSessionsByShop(shop);
|
||||
return sessions.map((s) => decryptTokens(s));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,118 @@
|
||||
import pLimit from "p-limit";
|
||||
import type { WebhookReservation } from "./dedupe.server";
|
||||
|
||||
/**
|
||||
* Fire-and-forget runner for webhook side-effects.
|
||||
* Background runner for webhook side-effects.
|
||||
*
|
||||
* Shopify expects a 200 response within ~5 seconds, otherwise it considers
|
||||
* the delivery failed and retries it. Heavy automation work (PDF render,
|
||||
* Shopify Files upload, SMTP send) routinely exceeded that budget, which
|
||||
* caused duplicate invoice emails before we added the dedupe table.
|
||||
*
|
||||
* Returning the response immediately and letting the work finish in the
|
||||
* background keeps Shopify happy. Combined with the dedupe table this is
|
||||
* defence-in-depth: dedupe ensures *correctness* even if a retry sneaks
|
||||
* through, while async processing makes retries unlikely in the first
|
||||
* place.
|
||||
* Returning the response immediately and finishing the work afterwards keeps
|
||||
* Shopify happy. Two problems with a naive `void work()`:
|
||||
*
|
||||
* Errors are caught and logged \u2014 they cannot reach a dispatcher because
|
||||
* the HTTP response is already gone.
|
||||
* 1. DoS / resource exhaustion — an order burst would spawn unbounded
|
||||
* concurrent PDF renders + SMTP sends. We cap concurrency with a small
|
||||
* in-process queue (`p-limit`); excess tasks queue instead of piling up.
|
||||
* 2. Data loss on restart — `void work()` is invisible to shutdown, so a
|
||||
* container stop (SIGTERM) killed in-flight invoice work mid-send. We
|
||||
* track in-flight tasks and drain them (bounded) on SIGTERM/SIGINT.
|
||||
*
|
||||
* Reserve/commit dedupe (see dedupe.server.ts) is integrated here: on success
|
||||
* we `commit()` the reservation (permanently deduped); on failure we
|
||||
* `release()` it so Shopify's retry re-runs the work instead of being dropped
|
||||
* as a duplicate.
|
||||
*/
|
||||
|
||||
const CONCURRENCY = Math.max(1, Number(process.env.WEBHOOK_CONCURRENCY) || 4);
|
||||
const DRAIN_TIMEOUT_MS = Math.max(
|
||||
1000,
|
||||
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS) || 25_000,
|
||||
);
|
||||
|
||||
const limit = pLimit(CONCURRENCY);
|
||||
const inFlight = new Set<Promise<unknown>>();
|
||||
let draining = false;
|
||||
|
||||
export function runWebhookInBackground(
|
||||
description: string,
|
||||
work: () => Promise<unknown>,
|
||||
reservation?: WebhookReservation | null,
|
||||
): void {
|
||||
// `void` so we don't accidentally `await` the floating promise; the
|
||||
// node event loop keeps the task alive until it settles.
|
||||
void work().catch((err) => {
|
||||
console.error(`background webhook task '${description}' failed:`, err);
|
||||
if (draining) {
|
||||
// The process is shutting down. We still enqueue so the drain awaits this
|
||||
// task — the server has already stopped listening, so this is at most the
|
||||
// tail end of the last accepted request.
|
||||
console.warn(`[webhook-queue] enqueuing task during shutdown drain: ${description}`);
|
||||
}
|
||||
|
||||
const task = limit(async () => {
|
||||
try {
|
||||
await work();
|
||||
await reservation?.commit();
|
||||
} catch (err) {
|
||||
console.error(`background webhook task '${description}' failed:`, err);
|
||||
// Drop the dedupe reservation so Shopify's retry re-runs the work.
|
||||
try {
|
||||
await reservation?.release();
|
||||
} catch (releaseErr) {
|
||||
console.error(
|
||||
`background webhook task '${description}': failed to release dedupe reservation:`,
|
||||
releaseErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inFlight.add(task);
|
||||
void task.finally(() => inFlight.delete(task));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop accepting new work (best-effort) and await in-flight + queued tasks,
|
||||
* bounded by `timeoutMs`, so a container stop drains invoice work instead of
|
||||
* killing it mid-send. Idempotent.
|
||||
*/
|
||||
export async function drainWebhookQueue(timeoutMs = DRAIN_TIMEOUT_MS): Promise<void> {
|
||||
draining = true;
|
||||
if (inFlight.size === 0) return;
|
||||
|
||||
console.log(
|
||||
`[webhook-queue] draining ${inFlight.size} in-flight webhook task(s) (timeout ${timeoutMs}ms)...`,
|
||||
);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
});
|
||||
|
||||
await Promise.race([Promise.allSettled([...inFlight]), timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
if (inFlight.size > 0) {
|
||||
console.warn(
|
||||
`[webhook-queue] drain timed out with ${inFlight.size} task(s) still running`,
|
||||
);
|
||||
} else {
|
||||
console.log("[webhook-queue] drain complete");
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge for the custom server (server.js), which loads only the bundled
|
||||
// build and cannot import this module directly. It awaits this drain before
|
||||
// calling process.exit during graceful shutdown.
|
||||
type DrainGlobal = typeof globalThis & {
|
||||
__linumiqWebhookDrain?: typeof drainWebhookQueue;
|
||||
};
|
||||
(globalThis as DrainGlobal).__linumiqWebhookDrain = drainWebhookQueue;
|
||||
|
||||
// Safety net for runtimes that don't go through server.js (e.g. `shopify app
|
||||
// dev`): stop accepting work and best-effort drain. The custom server awaits
|
||||
// the same (idempotent) drain before exiting.
|
||||
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||
process.once(signal, () => {
|
||||
void drainWebhookQueue();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import db from "../../db.server";
|
||||
|
||||
/**
|
||||
* Periodic TTL cleanup for the `ProcessedWebhook` idempotency table.
|
||||
*
|
||||
* The table grows by one row per Shopify webhook delivery and is never read
|
||||
* after the retry window closes, so without pruning it grows unbounded —
|
||||
* eventually a disk/space DoS. We only need rows for as long as Shopify might
|
||||
* retry a delivery (hours), so a generous retention window of a few days is
|
||||
* ample while keeping the table small.
|
||||
*/
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
|
||||
export interface CleanupDeps {
|
||||
db: {
|
||||
processedWebhook: {
|
||||
deleteMany: (args: {
|
||||
where: { receivedAt: { lt: Date } };
|
||||
}) => Promise<{ count: number }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let scheduled = false;
|
||||
|
||||
async function runCleanup(deps: CleanupDeps): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - RETENTION_MS);
|
||||
const { count } = await deps.db.processedWebhook.deleteMany({
|
||||
where: { receivedAt: { lt: cutoff } },
|
||||
});
|
||||
if (count > 0) {
|
||||
console.log(`webhook-cleanup: removed ${count} ProcessedWebhook row(s) older than 7d`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort housekeeping — never throw into the caller.
|
||||
console.warn("webhook-cleanup: prune failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently schedule the hourly cleanup. Safe to call on every webhook —
|
||||
* the first call starts a single unref'd interval and runs an immediate
|
||||
* sweep; subsequent calls are no-ops.
|
||||
*
|
||||
* Because this is only ever invoked while handling a live webhook request, it
|
||||
* never runs during `prisma generate` / `react-router build` or other CLI
|
||||
* contexts. The interval is `unref`'d so it can never keep the process alive.
|
||||
*/
|
||||
export function ensureWebhookCleanupScheduled(deps: CleanupDeps = { db }): void {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void runCleanup(deps);
|
||||
}, INTERVAL_MS);
|
||||
// Don't let the housekeeping interval keep the event loop alive on shutdown.
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
|
||||
// Kick off an immediate sweep so a long-lived process prunes promptly.
|
||||
void runCleanup(deps);
|
||||
}
|
||||
@@ -1,67 +1,204 @@
|
||||
import db from "../../db.server";
|
||||
import { ensureWebhookCleanupScheduled } from "./cleanup.server";
|
||||
|
||||
/**
|
||||
* Minimal shape of the Prisma client surface we use — declared inline so
|
||||
* the helper can be unit-tested with a tiny stub instead of pulling in a
|
||||
* real database.
|
||||
* How long a `status="processing"` reservation is considered "live" before we
|
||||
* assume the worker that claimed it crashed mid-process. After this window a
|
||||
* stale reservation may be reclaimed and the work retried.
|
||||
*/
|
||||
const STALE_LEASE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface ProcessedRow {
|
||||
webhookId: string;
|
||||
status: string;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape of the Prisma client surface we use — declared inline so the
|
||||
* helper can be unit-tested with a tiny stub instead of a real database.
|
||||
*/
|
||||
export interface DedupeDeps {
|
||||
db: {
|
||||
processedWebhook: {
|
||||
create: (args: {
|
||||
data: { webhookId: string; topic: string; shopDomain: string };
|
||||
data: { webhookId: string; topic: string; shopDomain: string; status: string };
|
||||
}) => Promise<unknown>;
|
||||
findUnique: (args: { where: { webhookId: string } }) => Promise<ProcessedRow | null>;
|
||||
update: (args: {
|
||||
where: { webhookId: string };
|
||||
data: { status?: string; receivedAt?: Date };
|
||||
}) => Promise<unknown>;
|
||||
delete: (args: { where: { webhookId: string } }) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when this Shopify webhook delivery has already been
|
||||
* processed and the caller should short-circuit without doing the work.
|
||||
* A claim on a single Shopify webhook delivery. Obtained from
|
||||
* {@link reserveWebhook}. The caller MUST eventually `commit()` (work
|
||||
* succeeded — the delivery is permanently deduped) or `release()` (work
|
||||
* failed — drop the reservation so Shopify's retry re-runs the work).
|
||||
*
|
||||
* Shopify retries webhook deliveries when it doesn't receive a 200 within
|
||||
* its (~5s) timeout window. Without dedupe this caused us to email an
|
||||
* invoice twice for the same order: the first slow delivery completed its
|
||||
* work but Shopify timed out and re-sent the webhook, which then ran the
|
||||
* automation a second time.
|
||||
*
|
||||
* We key on the `X-Shopify-Webhook-Id` header — Shopify guarantees the same
|
||||
* value for retries of the same delivery, but a new value for genuinely
|
||||
* new events. The insert is the lock: a unique-constraint violation
|
||||
* (Prisma error code `P2002`) means another delivery already claimed this
|
||||
* id.
|
||||
* `commit`/`release` are no-ops for reservations without a webhook id (unit
|
||||
* tests / non-Shopify callers) and for the fail-open path.
|
||||
*/
|
||||
export async function isDuplicateWebhook(
|
||||
export interface WebhookReservation {
|
||||
webhookId: string | null;
|
||||
commit: () => Promise<void>;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
function noopReservation(webhookId: string | null): WebhookReservation {
|
||||
return {
|
||||
webhookId,
|
||||
commit: async () => {},
|
||||
release: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function isP2002(err: unknown): boolean {
|
||||
// Duck-typed so callers can stub the db without pulling in the real
|
||||
// `Prisma` namespace. P2002 = unique-constraint violation.
|
||||
return (err as { code?: string } | null)?.code === "P2002";
|
||||
}
|
||||
|
||||
function makeReservation(
|
||||
webhookId: string,
|
||||
shop: string,
|
||||
topic: string,
|
||||
deps: DedupeDeps,
|
||||
): WebhookReservation {
|
||||
return {
|
||||
webhookId,
|
||||
commit: async () => {
|
||||
try {
|
||||
await deps.db.processedWebhook.update({
|
||||
where: { webhookId },
|
||||
data: { status: "done" },
|
||||
});
|
||||
} catch (err) {
|
||||
// The work already succeeded; a failed commit just risks a later
|
||||
// duplicate (which the side-effect code is expected to tolerate).
|
||||
console.warn(`dedupe: failed to commit webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
}
|
||||
},
|
||||
release: async () => {
|
||||
try {
|
||||
await deps.db.processedWebhook.delete({ where: { webhookId } });
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to release webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve this Shopify webhook delivery for processing.
|
||||
*
|
||||
* Shopify retries a delivery (re-using the same `X-Shopify-Webhook-Id`) when
|
||||
* it doesn't receive a 200 within its ~5s timeout. Naively recording the id as
|
||||
* "processed" *before* doing the work meant that if the heavy background work
|
||||
* later failed (SMTP/GraphQL/PDF error), Shopify's retry was dropped as a
|
||||
* duplicate and the invoice was never sent.
|
||||
*
|
||||
* This uses a two-phase reserve/commit keyed on the webhook id, with the
|
||||
* unique `webhookId` primary key as the concurrency lock:
|
||||
*
|
||||
* - RESERVE: insert a `status="processing"` row. A unique-constraint
|
||||
* violation (`P2002`) means the id is already claimed; we then inspect the
|
||||
* existing row:
|
||||
* - `done` → genuine duplicate → return `null` (skip).
|
||||
* - `processing`, fresh → another delivery is in flight → `null`.
|
||||
* - `processing`, stale → previous worker crashed → reclaim & retry.
|
||||
* - COMMIT (caller, on success) → flip the row to `status="done"`.
|
||||
* - RELEASE (caller, on failure) → delete the row so a retry reprocesses.
|
||||
*
|
||||
* Returns a {@link WebhookReservation} when the caller should process the
|
||||
* delivery, or `null` when it must short-circuit (duplicate / concurrent).
|
||||
*
|
||||
* Fail-open: a dedupe-table error (other than P2002) never silently drops a
|
||||
* webhook — we return a no-op reservation and let the work run.
|
||||
*/
|
||||
export async function reserveWebhook(
|
||||
request: Request,
|
||||
shop: string,
|
||||
topic: string,
|
||||
deps: DedupeDeps = { db },
|
||||
): Promise<boolean> {
|
||||
): Promise<WebhookReservation | null> {
|
||||
// Opportunistically schedule TTL cleanup (runtime-only; never in build/CLI
|
||||
// since this is reached only while handling a live webhook request).
|
||||
ensureWebhookCleanupScheduled();
|
||||
|
||||
const webhookId = request.headers.get("x-shopify-webhook-id");
|
||||
if (!webhookId) {
|
||||
// Defensive: in unit tests / non-Shopify callers there is no id.
|
||||
// Don't dedupe — that would silently drop legitimate calls.
|
||||
return false;
|
||||
// No id (unit tests / non-Shopify callers): process without dedupe.
|
||||
return noopReservation(null);
|
||||
}
|
||||
|
||||
const reservation = makeReservation(webhookId, shop, topic, deps);
|
||||
|
||||
try {
|
||||
await deps.db.processedWebhook.create({
|
||||
data: { webhookId, topic, shopDomain: shop },
|
||||
data: { webhookId, topic, shopDomain: shop, status: "processing" },
|
||||
});
|
||||
return false;
|
||||
return reservation;
|
||||
} catch (err) {
|
||||
// Duck-typed P2002 check so callers can stub the db without pulling
|
||||
// in the real `Prisma` namespace.
|
||||
if ((err as { code?: string } | null)?.code === "P2002") {
|
||||
console.log(
|
||||
`dedupe: skipping duplicate ${topic} delivery for ${shop} (webhookId=${webhookId})`,
|
||||
);
|
||||
return true;
|
||||
if (!isP2002(err)) {
|
||||
// Don't fail (or silently drop) a webhook on a logging-table issue.
|
||||
console.warn(`dedupe: failed to reserve webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
return noopReservation(webhookId);
|
||||
}
|
||||
// Don't fail the webhook on a logging-table issue; just process it.
|
||||
console.warn(
|
||||
`dedupe: failed to record webhook ${webhookId} (${topic}/${shop}):`,
|
||||
err,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// A row already exists. Classify it.
|
||||
let existing: ProcessedRow | null = null;
|
||||
try {
|
||||
existing = await deps.db.processedWebhook.findUnique({ where: { webhookId } });
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to load existing webhook ${webhookId} (${topic}/${shop}):`, err);
|
||||
// Another worker owns the row and we can't classify it — be safe and skip.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
// Raced with a release/delete between create() and findUnique(); reclaim.
|
||||
return reservation;
|
||||
}
|
||||
|
||||
if (existing.status === "done") {
|
||||
console.log(
|
||||
`dedupe: skipping already-processed ${topic} for ${shop} (webhookId=${webhookId})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const age = Date.now() - new Date(existing.receivedAt).getTime();
|
||||
if (age > STALE_LEASE_MS) {
|
||||
// The worker that reserved this crashed mid-process (or left a stale row).
|
||||
// Renew the lease and retry the work.
|
||||
try {
|
||||
await deps.db.processedWebhook.update({
|
||||
where: { webhookId },
|
||||
data: { status: "processing", receivedAt: new Date() },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`dedupe: failed to reclaim stale webhook ${webhookId}:`, err);
|
||||
return null;
|
||||
}
|
||||
console.log(
|
||||
`dedupe: reclaiming stale ${topic} reservation for ${shop} ` +
|
||||
`(webhookId=${webhookId}, age=${Math.round(age / 1000)}s)`,
|
||||
);
|
||||
return reservation;
|
||||
}
|
||||
|
||||
// A fresh "processing" row: another delivery is actively working on it.
|
||||
// Skip this concurrent delivery. Shopify will retry; if the active worker
|
||||
// fails it releases the reservation so a later retry reprocesses.
|
||||
console.log(
|
||||
`dedupe: ${topic} for ${shop} already in-flight (webhookId=${webhookId}); ` +
|
||||
`skipping concurrent delivery`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user