← back to Wallco Ai
wallco.ai downloads route: /downloads/:slug/:token.zip token-gated bundle delivery (wallco_download_tokens PG table) + mint CLI + tokens minted + Shopify metafields set on 3 drafts
e1b59d851986b421936a53378d9ccb011c4d4c7d · 2026-05-28 13:21:05 -0700 · Steve Abrams
Files touched
A scripts/mint-shopify-download-token.jsM server.js
Diff
commit e1b59d851986b421936a53378d9ccb011c4d4c7d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 13:21:05 2026 -0700
wallco.ai downloads route: /downloads/:slug/:token.zip token-gated bundle delivery (wallco_download_tokens PG table) + mint CLI + tokens minted + Shopify metafields set on 3 drafts
---
scripts/mint-shopify-download-token.js | 85 ++++++++++++++++++++++++++++++++++
server.js | 30 ++++++++++++
2 files changed, 115 insertions(+)
diff --git a/scripts/mint-shopify-download-token.js b/scripts/mint-shopify-download-token.js
new file mode 100644
index 0000000..61dc4f3
--- /dev/null
+++ b/scripts/mint-shopify-download-token.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+/**
+ * mint-shopify-download-token — generates a token-gated URL for a bundle ZIP
+ * hosted at /downloads/:slug/:token.zip on wallco.ai.
+ *
+ * Steve 2026-05-28: Shopify can't host the ZIPs (admin token missing write_files
+ * scope), so wallco.ai serves the downloads gated by tokens stored in PG. This
+ * CLI mints a token, INSERTs it into wallco_download_tokens, and prints the
+ * full URL for pasting into the Shopify product metafield (or for testing).
+ *
+ * Usage:
+ * node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259
+ * node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259 --email customer@example.com --order 12345
+ * node scripts/mint-shopify-download-token.js --slug cactus --date 2026-05-28 --product 7843826270259 --days 90 --max 10
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync } = require('child_process');
+const ROOT = path.resolve(__dirname, '..');
+
+function argv(name, dflt) {
+ const i = process.argv.indexOf('--' + name);
+ if (i === -1) return dflt;
+ if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true;
+ return process.argv[i + 1];
+}
+
+const slug = argv('slug', null);
+const date = argv('date', null);
+const productId = argv('product', null);
+const email = argv('email', null);
+const orderId = argv('order', null);
+const days = parseInt(argv('days', '30'), 10);
+const maxDl = parseInt(argv('max', '5'), 10);
+const baseUrl = argv('base', process.env.WALLCO_BASE_URL || 'https://wallco.ai');
+
+if (!slug || !date || !productId) {
+ console.error('usage: --slug <bundle-slug> --date YYYY-MM-DD --product <shopify-product-id> [--email <e>] [--order <id>] [--days 30] [--max 5]');
+ process.exit(2);
+}
+
+// Resolve SKU from the bundle listing.json
+const listingPath = path.join(ROOT, 'data', 'etsy-exports', date, 'bundles', slug, 'listing.json');
+if (!fs.existsSync(listingPath)) { console.error(`listing not found: ${listingPath}`); process.exit(3); }
+const listing = JSON.parse(fs.readFileSync(listingPath, 'utf8'));
+const sku = listing.sku || `DW-BUNDLE-${slug}`;
+
+// Mint a 32-char hex token (16 bytes of randomness)
+const token = crypto.randomBytes(16).toString('hex');
+
+function pgEsc(s) { return s == null ? 'NULL' : `'${String(s).replace(/'/g, "''")}'`; }
+
+const sql = `INSERT INTO wallco_download_tokens
+ (token, bundle_sku, bundle_slug, bundle_date, shopify_order_id, shopify_product_id, customer_email, max_downloads, expires_at)
+ VALUES (${pgEsc(token)}, ${pgEsc(sku)}, ${pgEsc(slug)}, ${pgEsc(date)},
+ ${orderId ? parseInt(orderId, 10) : 'NULL'},
+ ${productId ? parseInt(productId, 10) : 'NULL'},
+ ${pgEsc(email)},
+ ${maxDl},
+ now() + interval '${days} days')
+ RETURNING token, max_downloads, expires_at::timestamp(0);`;
+
+try {
+ // psql -c with embedded newlines breaks (JSON.stringify escapes \n as backslash-n,
+ // psql then sees a literal backslash). Use stdin instead so newlines stay newlines.
+ const { spawnSync } = require('child_process');
+ const r = spawnSync('psql', ['dw_unified', '-At', '-F|'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(r.stderr || `psql exit ${r.status}`);
+ const out = (r.stdout || '').trim();
+ if (!out) { console.error('INSERT returned no row'); process.exit(4); }
+ const [tk, max, exp] = out.split('|');
+ const url = `${baseUrl}/downloads/${slug}/${tk}.zip`;
+ console.log(`✓ minted token for ${sku}`);
+ console.log(` url: ${url}`);
+ console.log(` max_downloads: ${max}`);
+ console.log(` expires: ${exp}`);
+ if (email) console.log(` customer: ${email}`);
+ console.log();
+ console.log(`Paste into Shopify product metafield (wallco_ai.bundle_download_url, type=url) for product ${productId}.`);
+} catch (e) {
+ console.error('mint failed:', e.message.slice(0, 400));
+ process.exit(5);
+}
diff --git a/server.js b/server.js
index c7a08a7..d55e522 100644
--- a/server.js
+++ b/server.js
@@ -3812,6 +3812,36 @@ app.post('/api/saved', (req, res) => {
}
});
+// ── GET /downloads/:slug/:token.zip — token-gated digital-bundle download.
+//
+// Steve 2026-05-28: Shopify Admin token lacks write_files scope, so we can't
+// host bundle ZIPs on Shopify's CDN. Instead, the Shopify product metafield
+// (wallco_ai.bundle_download_url) points to this route — token gates access,
+// download_count caps password-sharing, expires_at retires old links.
+//
+// Path lookup: ZIPs live at data/etsy-exports/<bundle_date>/bundles/<slug>/<slug>.zip
+// Failures emit opaque 404 so we don't leak token validity to scrapers.
+app.get('/downloads/:slug/:token.zip', (req, res) => {
+ const slug = String(req.params.slug || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
+ const token = String(req.params.token || '').replace(/[^a-f0-9]/g, '').slice(0, 64);
+ if (!slug || !token) return res.status(404).type('html').send('<h1>Not Found</h1>');
+ let row;
+ try {
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT token, bundle_slug, bundle_date, download_count, max_downloads, expires_at FROM wallco_download_tokens WHERE token=${pgEsc(token)} LIMIT 1) t;`);
+ if (!raw || raw.length < 3) return res.status(404).type('html').send('<h1>Not Found</h1>');
+ row = JSON.parse(raw);
+ } catch (e) { return res.status(404).type('html').send('<h1>Not Found</h1>'); }
+ if (row.bundle_slug !== slug) return res.status(404).type('html').send('<h1>Not Found</h1>');
+ if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) return res.status(404).type('html').send('<h1>Link expired</h1>');
+ if (row.max_downloads != null && row.download_count >= row.max_downloads) return res.status(404).type('html').send('<h1>Download limit reached</h1>');
+ const zipPath = path.join(__dirname, 'data', 'etsy-exports', row.bundle_date, 'bundles', slug, `${slug}.zip`);
+ if (!fs.existsSync(zipPath)) return res.status(404).type('html').send('<h1>Not Found</h1>');
+ // Increment + stamp BEFORE sending so a concurrent download still counts.
+ try { psqlExecLocal(`UPDATE wallco_download_tokens SET download_count=download_count+1, last_downloaded_at=now() WHERE token=${pgEsc(token)};`); } catch {}
+ try { psqlExecLocal(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent) VALUES (NULL, FALSE, 'download', 'bundle_download', ${pgEsc(JSON.stringify({ token: token.slice(0,6)+'…', slug, count: (row.download_count||0)+1 }))}::jsonb, ${pgEsc(((req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim()) || '0.0.0.0')}::inet, ${pgEsc((req.headers['user-agent'] || '').toString().slice(0, 500))});`); } catch {}
+ return res.download(zipPath, `${slug}-wallco-ai.zip`);
+});
+
// GET /me/saved — auth-gated page listing all the user's saved designs.
// Uses the same .design-grid CSS class as /designs so it inherits the same
// layout + density-slider system.
← c4b01d5 add tile-period-detect: validate mural-mode room renders
·
back to Wallco Ai
·
luxe-curator: add canonical .when chip + flow created_at thr 7e34229 →