← back to Designer Wallcoverings
mailers/tools/pj-asset-swap.mjs
172 lines
#!/usr/bin/env node
/**
* pj-asset-swap.mjs — TK-10899
*
* Re-hosts the 5 vendor-CDN images in the Phillip Jeffries Fall 2026 mailer onto the
* DW Shopify Files CDN (the house pattern every other DW mailer already uses), then
* rewrites the mailer's <img src> to the new cdn.shopify.com URLs.
*
* WHY: hotlinking a vendor CDN in an email is a standing liability — the vendor can
* rotate or expire the object at any time and every ALREADY-DELIVERED email breaks
* retroactively (there is no way to fix a mail that is already in an inbox). It also
* leaks recipient opens/IPs to the vendor. All 5 local copies are byte-identical
* SHA-256 to the live vendor objects, so this is a lossless 1:1 substitution.
*
* SAFETY MODEL:
* - DRY-RUN BY DEFAULT. Without --apply it performs ZERO writes: it verifies local
* files, re-checks SHA parity against the vendor CDN, and prints the exact swap map.
* - --apply is a Shopify write and is STEVE-GATED. Do not run it without approval.
* - Every --apply writes a restore map to tools/pj-asset-swap-restore.json BEFORE
* touching the HTML, so rollback is mechanical (see --rollback).
* - --rollback restores the original vendor-CDN src values from the restore map and
* prints the fileDelete GIDs (file deletion stays a deliberate, separate action).
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const MAILER = path.join(ROOT, 'phillip-jeffries-fall-2026-launch.html');
const ASSETS = path.join(ROOT, 'assets', 'pj-fall-2026');
const RESTORE = path.join(__dirname, 'pj-asset-swap-restore.json');
// vendor CDN url -> byte-identical local copy
const MAP = [
['https://api.phillipjeffries.com/images/product_lines/WN-SC_395_355x518.jpg', 'pj-coco-weave.jpg'],
['https://api.phillipjeffries.com/images/product_lines/WN-SC_435_355x518.jpg', 'pj-plush-pillars.jpg'],
['https://api.phillipjeffries.com/images/product_lines/WN-SC_454_355x518.jpg', 'pj-cirque-de-soho.jpg'],
['https://api.phillipjeffries.com/images/product_lines/WN-SC_504_355x518.jpg', 'pj-idyllic.jpg'],
['https://cdn2.webdamdb.com/1280_EU2NBNq1Wno66UmS.jpg?1787686038', 'pj-hero-idyllic-room.jpg'],
];
const args = new Set(process.argv.slice(2));
const APPLY = args.has('--apply');
const ROLLBACK = args.has('--rollback');
const SHOP = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_FULL_ACCESS_TOKEN; // narrow ADMIN token lacks write_files
const API = `https://${SHOP}/admin/api/2024-10/graphql.json`;
const sha = b => crypto.createHash('sha256').update(b).digest('hex');
const log = (...a) => console.log(...a);
async function gql(query, variables = {}) {
const r = await fetch(API, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const j = await r.json();
if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors).slice(0, 400));
return j.data;
}
async function rollback() {
if (!fs.existsSync(RESTORE)) return log('No restore map at', RESTORE, '— nothing to roll back.');
const rec = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
let html = fs.readFileSync(MAILER, 'utf8');
let n = 0;
for (const e of rec.swaps) {
if (html.includes(e.newUrl)) { html = html.split(e.newUrl).join(e.originalUrl); n++; }
}
fs.writeFileSync(MAILER, html);
log(`Restored ${n}/${rec.swaps.length} src values to the original vendor CDN URLs.`);
log('Uploaded Shopify file GIDs (delete deliberately, separately if desired):');
rec.swaps.forEach(e => log(' ', e.gid, e.newUrl));
log('Also: `git checkout -- mailers/phillip-jeffries-fall-2026-launch.html` fully reverts the HTML.');
}
async function main() {
if (ROLLBACK) return rollback();
if (!fs.existsSync(MAILER)) throw new Error('Mailer not found: ' + MAILER);
let html = fs.readFileSync(MAILER, 'utf8');
log(`Mailer: ${MAILER}\n bytes=${html.length} sha=${sha(Buffer.from(html)).slice(0,12)}\n`);
// --- Preflight: local file exists, is in the HTML, and matches the vendor object ---
const plan = [];
for (const [url, file] of MAP) {
const p = path.join(ASSETS, file);
if (!fs.existsSync(p)) throw new Error('Missing local asset: ' + p);
const local = fs.readFileSync(p);
if (!html.includes(url)) throw new Error('src not present in mailer: ' + url);
let parity = 'SKIPPED';
try {
const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
const remote = Buffer.from(await r.arrayBuffer());
parity = (sha(remote) === sha(local)) ? 'IDENTICAL' : `MISMATCH(remote ${sha(remote).slice(0,8)})`;
} catch { parity = 'UNREACHABLE(using local)'; }
plan.push({ url, file, p, bytes: local.length, sha: sha(local).slice(0, 12), parity });
log(` ${parity.padEnd(24)} ${file.padEnd(26)} ${local.length} B <- ${url}`);
}
if (plan.some(x => x.parity.startsWith('MISMATCH')))
throw new Error('Aborting: a local copy no longer matches the vendor object. Re-download before swapping.');
if (!APPLY) {
log('\nDRY RUN — no writes performed. 0 network writes, 0 file changes.');
log('This is a Shopify write and is STEVE-GATED. To execute after approval:');
log(' SHOPIFY_FULL_ACCESS_TOKEN=... node mailers/tools/pj-asset-swap.mjs --apply');
log('Rollback after apply: node mailers/tools/pj-asset-swap.mjs --rollback');
return;
}
if (!TOKEN) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not set (the narrow ADMIN token lacks write_files).');
const swaps = [];
for (const item of plan) {
log(`\nUploading ${item.file} ...`);
// 1) staged target
const st = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){
stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ field message } } }`,
{ input: [{ filename: item.file, mimeType: 'image/jpeg', resource: 'FILE', httpMethod: 'POST' }] });
if (st.stagedUploadsCreate.userErrors?.length) throw new Error(JSON.stringify(st.stagedUploadsCreate.userErrors));
const t = st.stagedUploadsCreate.stagedTargets[0];
// 2) POST bytes to the staged target
const form = new FormData();
for (const p of t.parameters) form.append(p.name, p.value);
form.append('file', new Blob([fs.readFileSync(item.p)], { type: 'image/jpeg' }), item.file);
const up = await fetch(t.url, { method: 'POST', body: form });
if (!up.ok) throw new Error(`staged upload failed ${up.status}: ${(await up.text()).slice(0,300)}`);
// 3) register the file
const fc = await gql(`mutation($files:[FileCreateInput!]!){ fileCreate(files:$files){
files{ id fileStatus ... on MediaImage { image { url } } } userErrors{ field message } } }`,
{ files: [{ originalSource: t.resourceUrl, contentType: 'IMAGE', alt: `Phillip Jeffries Fall 2026 — ${item.file}` }] });
if (fc.fileCreate.userErrors?.length) throw new Error(JSON.stringify(fc.fileCreate.userErrors));
const gid = fc.fileCreate.files[0].id;
// 4) fileCreate returns UPLOADED, not READY — the CDN url is only populated after
// processing. Poll, or we would write an empty src="" into the mailer.
let cdn = null;
for (let i = 0; i < 30 && !cdn; i++) {
await new Promise(r => setTimeout(r, 2000));
const q = await gql(`query($id:ID!){ node(id:$id){ ... on MediaImage { fileStatus image { url } } } }`, { id: gid });
if (q.node?.fileStatus === 'READY' && q.node.image?.url) cdn = q.node.image.url;
if (q.node?.fileStatus === 'FAILED') throw new Error('Shopify reported FAILED for ' + item.file);
}
if (!cdn) throw new Error('Timed out waiting for READY: ' + item.file);
// Shopify Files RE-ENCODES uploads at quality=85 (measured PSNR ~30dB vs the original,
// worst on woven/high-frequency textures - i.e. exactly what this line sells). ?quality=100
// serves the pixel-exact original bytes back, which is what makes this a lossless swap.
cdn = cdn + (cdn.includes('?') ? '&' : '?') + 'quality=100';
log(` READY -> ${cdn}`);
swaps.push({ originalUrl: item.url, file: item.file, gid, newUrl: cdn, sha: item.sha });
}
// restore map BEFORE mutating the HTML
fs.writeFileSync(RESTORE, JSON.stringify({ ticket: 'TK-10899', at: new Date().toISOString(), mailer: MAILER, swaps }, null, 2));
log('\nRestore map written ->', RESTORE);
for (const s of swaps) html = html.split(s.originalUrl).join(s.newUrl);
fs.writeFileSync(MAILER, html);
const left = MAP.filter(([u]) => html.includes(u));
log(`Mailer rewritten. Remaining vendor-CDN hotlinks: ${left.length} (expected 0).`);
if (left.length) throw new Error('Swap incomplete: ' + left.map(x => x[0]).join(', '));
log('\nNOTE: this updates the LOCAL mailer only. Pushing the swapped HTML into the staged');
log('Constant Contact draft (54105fd3-...) is a separate, separately-gated step.');
}
main().catch(e => { console.error('\nFAILED:', e.message); process.exit(1); });