← back to New Import Viewer
DTD-C: per-session arm toggle defaults OFF every load; live write now needs .env ceiling AND session-armed (arm:true). .env stays hard ceiling, bulk still stage-only
e4e0cedcfcff99adc7dab54b4af3e94656b031e9 · 2026-06-09 18:45:48 -0700 · SteveStudio2
Files touched
M data/launch-queue.jsonlM public/index.htmlM server.js
Diff
commit e4e0cedcfcff99adc7dab54b4af3e94656b031e9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue Jun 9 18:45:48 2026 -0700
DTD-C: per-session arm toggle defaults OFF every load; live write now needs .env ceiling AND session-armed (arm:true). .env stays hard ceiling, bulk still stage-only
---
data/launch-queue.jsonl | 3 +++
public/index.html | 42 ++++++++++++++++++++++++++++++++----------
server.js | 20 +++++++++++++++-----
3 files changed, 50 insertions(+), 15 deletions(-)
diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
index 46a2081..301d84b 100644
--- a/data/launch-queue.jsonl
+++ b/data/launch-queue.jsonl
@@ -7,3 +7,6 @@
{"batchId":"launch-1-38784-mq7e5ld1","action":"launch","mode":"active","count":1,"skus":1,"at":"2026-06-10T01:29:49.669Z"}
{"batchId":"unpublish-1-676255-mq7eb3am","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:34:06.190Z"}
{"batchId":"unpublish-2-676255-mq7eb400","action":"unpublish","mode":"draft","count":2,"skus":2,"at":"2026-06-10T01:34:07.104Z"}
+{"batchId":"unpublish-1-676255-mq7epm0m","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:45:23.638Z"}
+{"batchId":"unpublish-1-676255-mq7epm1h","action":"unpublish","mode":"draft","count":1,"skus":1,"at":"2026-06-10T01:45:23.669Z"}
+{"batchId":"unpublish-2-676255-mq7epmgn","action":"unpublish","mode":"draft","count":2,"skus":2,"at":"2026-06-10T01:45:24.215Z"}
diff --git a/public/index.html b/public/index.html
index 57cbda4..7cb79ea 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,8 +15,13 @@ select,input[type=text]{background:var(--panel);color:var(--ink);border:1px soli
input[type=text]{min-width:180px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
-.warn{font-size:12px;color:#2a0606;background:var(--arch);border-radius:6px;padding:3px 11px;font-weight:800;letter-spacing:.4px;display:inline-flex;align-items:center;gap:6px;box-shadow:0 0 0 2px #f8717155}
-.warn::before{content:'';width:8px;height:8px;border-radius:50%;background:#2a0606;animation:livepulse 1.6s ease-in-out infinite}
+/* Live-writes arm toggle — defaults OFF every page load (per-session). OFF = safe
+ grey; ARMED = red with pulsing dot. Only shown when the .env ceiling permits. */
+.armtoggle{font-size:12px;font-weight:800;letter-spacing:.3px;border-radius:6px;padding:4px 11px;cursor:pointer;
+ border:1px solid var(--line);background:var(--panel);color:var(--mut);display:inline-flex;align-items:center;gap:6px}
+.armtoggle:hover{border-color:var(--mut)}
+body.armed .armtoggle{background:var(--arch);color:#2a0606;border-color:#fca5a5;box-shadow:0 0 0 2px #f8717155}
+body.armed .armtoggle::before{content:'';width:8px;height:8px;border-radius:50%;background:#2a0606;animation:livepulse 1.6s ease-in-out infinite}
@keyframes livepulse{0%,100%{opacity:1}50%{opacity:.25}}
/* armed top strip — unmissable peripheral signal that live writes are on */
#livestrip{display:none;position:fixed;top:0;left:0;right:0;height:3px;background:var(--arch);z-index:30;box-shadow:0 0 8px #f87171}
@@ -138,7 +143,7 @@ body.armed #livestrip{display:block}
<input type="range" id="density" min="3" max="10" step="1"></div>
<button class="btn" id="view">▤ List view</button>
<button class="btn" id="selall">☑ Select all shown</button>
- <span class="warn" id="livebadge" style="display:none">LIVE WRITES ON</span>
+ <button class="armtoggle" id="armtoggle" style="display:none" onclick="toggleArm()">🔒 Live writes: OFF</button>
</header>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
@@ -164,7 +169,25 @@ const sel=new Set();
const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
// Resulting status after a live action lands (optimistic; live path returns truth).
const ACTION_RESULT={publish:'PUBLISHED',unpublish:'UNPUBLISHED',archive:'ARCHIVED'};
-const isLive=a=>state.liveActions.includes(a);
+// A single-card action fires live ONLY when the .env ceiling allows it (it's in
+// liveActions) AND this session is armed. state.armed defaults false every load.
+const isLive=a=>state.armed&&state.liveActions.includes(a);
+// Per-session arm toggle — defaults OFF on every page load (DTD verdict C).
+function toggleArm(){
+ if(!state.armable)return;
+ if(!state.armed){
+ if(!confirm(`Arm LIVE writes to the REAL store (${state.store}) for THIS session?\n\nWhile armed, single-card ${state.liveActions.join('/')} buttons write to the live storefront (still confirm each). Resets to OFF when you reload.`))return;
+ state.armed=true;
+ }else state.armed=false;
+ renderArm(); load(true); // re-render cards so live styling appears/clears
+}
+function renderArm(){
+ const b=$('#armtoggle'); if(!b)return;
+ if(!state.armable){b.style.display='none';document.body.classList.remove('armed');return;}
+ b.style.display='inline-flex';
+ document.body.classList.toggle('armed',state.armed);
+ b.textContent=state.armed?`🔴 Live writes: ARMED (${state.liveActions.join('/')})`:'🔒 Live writes: OFF';
+}
// "nina_campbell" → "Nina Campbell" (de-shout raw vendor codes)
function fmtVendor(v){return String(v||'').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());}
// Build the action-button row; live-armed actions get the danger treatment.
@@ -258,13 +281,12 @@ function setHeaderCount(counts){
}
async function boot(){
const s=await (await fetch('/api/stats')).json();
- state.live=s.live; state.liveActions=s.liveActions||[]; state.store=s.store||''; const c=s.counts||{};
+ state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false; const c=s.counts||{};
setHeaderCount(c);
[['new','🟠 New to import'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['all','▦ All']].forEach(([k,lbl])=>{
const o=$('#status').querySelector(`option[value="${k}"]`); if(o&&c[k]!=null) o.textContent=`${lbl} (${c[k].toLocaleString()})`;
});
- if(s.live){$('#livebadge').style.display='inline-flex';document.body.classList.add('armed');}
- else document.body.classList.remove('armed');
+ renderArm();
window.__counts=c;
await reloadVendors(); load(true);
}
@@ -288,7 +310,7 @@ async function rowAction(id,spec,btn){
if(!confirm(warn))return;
btn.disabled=true; const msg=$('#msg-'+id); if(msg)msg.textContent=live?'writing to Shopify…':'staging…';
try{
- const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode})});
+ const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode,arm:state.armed})});
const d=await r.json(); if(d.error)throw new Error(d.error);
if(d.live){
const ap=d.applied||{}; const res=(d.results||[])[0]||{};
@@ -302,14 +324,14 @@ async function rowAction(id,spec,btn){
}catch(e){if(msg)msg.textContent='✗ '+e.message;}
btn.disabled=false;
}
-window.rowAction=rowAction; window.togglePick=togglePick; window.toggleCard=toggleCard;
+window.rowAction=rowAction; window.togglePick=togglePick; window.toggleCard=toggleCard; window.toggleArm=toggleArm;
$('#bulkgo').onclick=async()=>{
const ids=[...sel]; if(!ids.length)return;
const [action,mode]=$('#bulkact').value.split(':');
if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nBulk actions ALWAYS stage to the launch queue — bulk never writes live to the store (single-card only).`))return;
$('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
try{
- const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,action,mode,stageOnly:true})});
+ const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,action,mode,stageOnly:true,arm:false})});
const d=await r.json(); if(d.error)throw new Error(d.error);
const vlist=Object.entries(d.byVendor||{}).map(([v,n])=>`${v}:${n}`).join(', ');
$('#bulkmsg').textContent=`✅ staged ${d.staged} ${action} — ${vlist}`;
diff --git a/server.js b/server.js
index 3d7e2d3..a6fd3da 100644
--- a/server.js
+++ b/server.js
@@ -164,11 +164,14 @@ const server = http.createServer((req, res) => {
count(*),
to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
FROM vendor_catalog vc ${SP_JOIN}`)[0] || [];
+ const armable = LIVE && !!SHOP_TOKEN && LIVE_ACTIONS.size > 0;
return send(res, 200, JSON.stringify({
- live: LIVE,
- // Actions that would fire a live single-card write right now (master gate
- // on AND allow-listed AND token present). Bulk never goes live regardless.
- liveActions: LIVE && SHOP_TOKEN ? [...LIVE_ACTIONS] : [],
+ // `armable` = the .env ceiling permits live writes at all. The actual arm
+ // is a per-session UI toggle (defaults OFF each load) the client tracks and
+ // sends as arm:true per action. `live` kept for back-compat = armable.
+ live: armable,
+ armable,
+ liveActions: armable ? [...LIVE_ACTIONS] : [],
store: SHOP_STORE,
counts: { new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0 },
@@ -232,8 +235,13 @@ const server = http.createServer((req, res) => {
// changes only ever happen one card at a time. The fat-finger blast
// radius of a bulk live write isn't worth it. Bulk always stages.
const stageOnly = d.stageOnly === true || ids.length > 1;
+ // SAFETY: the per-session UI arm toggle defaults OFF every page load.
+ // A live write needs BOTH the .env ceiling (isLiveAction) AND the client
+ // to be session-armed (d.arm===true). .env stays the hard ceiling — the
+ // toggle can never arm beyond what SHOPIFY_LIVE_WRITES/LIVE_ACTIONS allow.
+ const sessionArmed = d.arm === true;
- if (isLiveAction(action) && !stageOnly) {
+ if (isLiveAction(action) && !stageOnly && sessionArmed) {
// LIVE PATH — only reachable when SHOPIFY_LIVE_WRITES=1 AND the action
// is in LIVE_ACTIONS AND a token is present AND it's a single-card act.
const rows = q(`SELECT vc.id, vc.shopify_product_id
@@ -258,6 +266,8 @@ const server = http.createServer((req, res) => {
out.note = `LIVE ${action} on ${SHOP_STORE}: ${ok} ok, ${skipped} skipped (not on Shopify), ${failed} failed`;
} else if (isLiveAction(action) && stageOnly) {
out.note = `staged only — bulk/${ids.length}-item ${action} never fires live (single-card only)`;
+ } else if (isLiveAction(action) && !sessionArmed) {
+ out.note = `staged only — session not armed (flip the Live-writes toggle to arm '${action}')`;
} else {
out.note = !LIVE
? 'staged only — live Shopify writes are OFF (SHOPIFY_LIVE_WRITES!=1)'
← 0d6de3d Apply design-agent critique: red danger treatment for live a
·
back to New Import Viewer
·
Panel item A: split details/actions into separate triggers — fe11c1f →