[object Object]

← 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

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 →