← back to Ticket System
board: per-row status dropdown to reassign status incl. blocked→parked (TK-12076)
0774932ecdfd0728e3985756385f4114941b1814 · 2026-09-23 11:56:06 -0700 · Steve Abrams
Status column becomes a <select> (open/doing/blocked/done/stopped/parked).
Real statuses POST /api/status (mirrors /api/stop+/api/reopen, append-only
reversible status event); parked routes to /api/park -> parked.mjs registry.
Selecting a real status on a parked ticket unparks first. 'done' confirms
(bypasses tk evidence guard). Invalid status -> 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECJyafBrMNNoWMcceN3MXo
Files touched
M board.htmlA data/evidence/TK-11-reverify-2026-09-23.mdM server.js
Diff
commit 0774932ecdfd0728e3985756385f4114941b1814
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 11:56:06 2026 -0700
board: per-row status dropdown to reassign status incl. blocked→parked (TK-12076)
Status column becomes a <select> (open/doing/blocked/done/stopped/parked).
Real statuses POST /api/status (mirrors /api/stop+/api/reopen, append-only
reversible status event); parked routes to /api/park -> parked.mjs registry.
Selecting a real status on a parked ticket unparks first. 'done' confirms
(bypasses tk evidence guard). Invalid status -> 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECJyafBrMNNoWMcceN3MXo
---
board.html | 21 +++++++++++++++-
data/evidence/TK-11-reverify-2026-09-23.md | 34 ++++++++++++++++++++++++++
server.js | 39 ++++++++++++++++++++++++++++++
3 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/board.html b/board.html
index 1c01a272..bf445115 100644
--- a/board.html
+++ b/board.html
@@ -66,6 +66,11 @@
.ticket-runtime.running{color:#8fd49a}
.stpill{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
.s-open{color:var(--open)} .s-doing{color:var(--doing)} .s-blocked{color:var(--blocked)} .s-done{color:var(--done);opacity:.8} .s-stopped{color:var(--stopped);text-decoration:line-through}
+ /* per-row status reassign dropdown (TK-12076) */
+ .stsel{font:inherit;font-size:11px;padding:1px 6px;border-radius:20px;border:1px solid var(--line);background:var(--card);cursor:pointer;max-width:118px;text-decoration:none}
+ .stsel:hover{border-color:var(--acc)} .stsel:disabled{opacity:.5;cursor:progress}
+ .stsel.s-open{color:var(--open)}.stsel.s-doing{color:var(--doing)}.stsel.s-blocked{color:var(--blocked)}.stsel.s-done{color:var(--done)}.stsel.s-stopped{color:var(--stopped);text-decoration:none}.stsel.s-parked{color:#e879b9}
+ .stsel option{color:var(--ink);background:var(--card)}
.vb{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid var(--line)}
.vb.yes{color:#34d399;border-color:#2b6f52} .vb.no{color:#e06c75} .vb.unknown{color:var(--faint)} .vb.none{color:var(--faint);opacity:.6}
.vb small{color:var(--faint)}
@@ -202,7 +207,7 @@ const COLS=[
{k:'ratings',l:'Ratings',g:'Priority',w:150, def:1, cell:t=>barsCell(t), raw:t=>t.priority||0},
{k:'id', l:'Ticket', g:'Core', w:150, def:1, cell:t=>ticketCell(t), raw:t=>t.id},
{k:'about', l:'About · 5 words',g:'Core',w:230,def:1,cell:t=>`<span title="${esc(t.title)}">${esc(fiveWords(t.title))}</span>`,raw:t=>fiveWords(t.title)},
- {k:'status',l:'Status', g:'Core', w:96, def:1, cell:t=>`<span class="stpill s-${t.status}"><span class="dot" style="background:currentColor"></span>${t.status==='stopped'?'TicketStopped':t.status}</span>`, raw:t=>t.status},
+ {k:'status',l:'Status', g:'Core', w:120, def:1, cell:t=>{const cur=t.parked?'parked':t.status;const o=['open','doing','blocked','done','stopped','parked'].map(s=>`<option value="${s}"${s===cur?' selected':''}>${s==='stopped'?'TicketStopped':s==='parked'?'⏸ parked':s}</option>`).join('');return `<select class="stsel s-${cur}" data-id="${t.id}" data-cur="${cur}" title="Reassign status" onpointerdown="event.stopPropagation()" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onchange="reassignStatus(event)">${o}</select>`;}, raw:t=>t.parked?'parked':t.status},
{k:'blocker',l:'Blocker lane',g:'Blocker',w:150,def:1,cell:t=>blockerCell(t),raw:t=>t.blocker?.type||''},
{k:'next_action',l:'Next unblock',g:'Blocker',w:320,def:1,cell:t=>esc(t.blocker?.next_action||'—'),raw:t=>t.blocker?.next_action||''},
{k:'blocker_owner',l:'Blocker owner',g:'Blocker',w:145,def:1,cell:t=>esc(t.blocker?.owner||'—'),raw:t=>t.blocker?.owner||''},
@@ -398,6 +403,20 @@ $('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length){setRunMsg('sel
$('#stopBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Stop ${ids.length} ticket(s) FOREVER (TicketStopped)? Reversible via Reopen.`))return;
try{await post('/api/stop',{ids});}catch(e){setRunMsg('stop failed: '+e.message);}SEL.clear();load();};
$('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){setRunMsg('reopen failed: '+e.message);}SEL.clear();load();};
+// Per-row status reassign (TK-12076). 'parked' isn't a real status — it's the durable PARKED
+// registry — so route it to /api/park; a real status routes to /api/status (unparking first
+// if the ticket was parked). On failure, restore the select so the row never lies.
+async function reassignStatus(e){e.stopPropagation();const sel=e.target,id=sel.dataset.id,to=sel.value,cur=sel.dataset.cur;if(to===cur)return;
+ // 'done' bypasses the tk-CLI evidence guard + clears the blocker — confirm it like Stop Forever (reversible via Reopen).
+ if(to==='done'&&!confirm(`Mark ${id} DONE from the board? This skips the tk evidence guard. Reversible via Reopen.`)){sel.value=cur;return;}
+ sel.disabled=true;setRunMsg(`${id} → ${to}…`);
+ try{
+ if(to==='parked'){const r=await post('/api/park',{ids:[id],park:true});if(r&&r.failed&&r.failed.length)throw new Error(r.failed[0].error||'park failed');}
+ else{if(cur==='parked'){await post('/api/park',{ids:[id],park:false});}const r=await post('/api/status',{ids:[id],status:to});if(r&&r.error)throw new Error(r.error);}
+ setRunMsg(`${id} → ${to==='parked'?'⏸ parked':to}`);
+ }catch(err){setRunMsg('status change failed: '+err.message);sel.value=cur;sel.disabled=false;return;}
+ load();
+}
$('#dtdBtn').onclick=async()=>{const ids=selIds();$('#dtdBtn').disabled=true;
try{const r=await post('/api/dtd',ids.length?{ids}:{});if(r.already)dtdChip('analyzing…');else dtdChip('analyzing '+(ids.length?ids.length+' selected':'recent')+'…');pollDTD();}catch(e){setRunMsg('DTD failed: '+e.message);}finally{setTimeout(()=>{$('#dtdBtn').disabled=false;},1500);}};
$('#yesBtn').onclick=()=>{if(!DTD.verdicts||!DTD.verdicts.tickets){setRunMsg('Run "Analyze w/ DTD" first.');return;}
diff --git a/data/evidence/TK-11-reverify-2026-09-23.md b/data/evidence/TK-11-reverify-2026-09-23.md
new file mode 100644
index 00000000..5c23cb94
--- /dev/null
+++ b/data/evidence/TK-11-reverify-2026-09-23.md
@@ -0,0 +1,34 @@
+# TK-11 re-verify evidence — 2026-09-23 (claude-run-11)
+
+Independent, re-measurable artifacts (not authored by the closer):
+
+## Track 1 — astek → Cloudflare Access (edge)
+- `curl -s -o /dev/null -w '%{http_code} %{redirect_url}' https://astek.designerwallcoverings.com/`
+ → `302 https://silent-base-31e2.cloudflareaccess.com/cdn-cgi/access/login/astek.designerwallcoverings.com?...`
+- Header: `www-authenticate: Cloudflare-Access resource_metadata="https://astek.designerwallcoverings.com/.well-known/cloudflare-access-protected-resource/"`
+- Machine consumer (all-dw crawler :9958, Kamatera) sees astek THROUGH Access:
+ `GET https://all.designerwallcoverings.com/api/microsites` → astek entry `up:true status:200 title:"Astek · Catalog · dw_unified" productCount:6167`
+- Local mode-600 token dir `~/.claude/tk11-astek/` is gone (shredded). Service token lives only in Kamatera :9958 `.env`.
+
+## Track 2 — daily sales → Slack #new-order
+- launchd `com.steve.daily-sales-summary`: loaded, last exit 0, schedule 16:30 daily.
+ Now runs `~/Projects/filemaker-mcp/scripts/sales-summary.mjs` (FileMaker invoice DB source — the Shopify read_orders path is no longer in this job).
+- `/tmp/daily-sales-summary.log` (mtime 2026-09-22 16:30):
+ `✓ Posted to C06D9C2PG1K (ts 1790119809.614249) — 24h 11/$526.93 · MTD 191/$53,670.38`
+- Slack token in use: `SLACK_BOT_TOKEN` last-4 `OlqR` (rotated 2026-09-22 under the tk11786 memo; dw_reports_bot).
+
+## Follow-up (a) — exposed read_orders token shpat_…c1d6
+- Not present in ANY local `.env` (grep across ~/Projects/*/.env, secrets-manager, Desktop, skill envs → 0 hits).
+- `SHOPIFY_ADMIN_TOKEN` slot now `…6755` (Steve minted 2026-09-23, tk11786 memo), `SHOPIFY_ORDERS_TOKEN` `…2042`.
+- REPLACED ≠ REVOKED. Revocation at Shopify (uninstall/re-auth DW-MCP-Read-Orders app 292726538241 in the Dev Dashboard) is a Steve/identity action → handed to vp-security under the tk11786 rotate-exposed-secrets memo.
+
+## Follow-up (b) — origin nginx auth_basic drop: UNSAFE, do NOT do
+- Origin is NOT locked to Cloudflare:
+ `curl -sk -H 'Host: astek.designerwallcoverings.com' https://45.61.58.125/` → `401 www-authenticate: Basic realm="Astek - Designer Wallcoverings (internal)"`
+ (`http://` → 301). Direct-IP requests bypass Access entirely; nginx Basic Auth is the ONLY protection on that path.
+- Dropping auth_basic before an origin lock would expose the internal catalog to anyone who knows the IP. Filed as a separate gated follow-up ticket (origin lock: Cloudflare IP allowlist / Authenticated Origin Pulls / cf-access JWT check at nginx).
+
+## Memo state
+- `260727A-astek-cloudflare-access-migration.md` → `_done/`
+- `astek-pw-rotation-slack-scope.md` → `_ungate-2026-09-10/filed-done/`
+- No open pending-approval memo for TK-11.
diff --git a/server.js b/server.js
index fa29ade8..c139ec73 100644
--- a/server.js
+++ b/server.js
@@ -625,6 +625,45 @@ http.createServer((req, res) => {
json(res, 200, { reopened: ids });
});
}
+ // Reassign status — set any ticket to a valid status (open/doing/blocked/done/stopped) from
+ // the per-row dropdown. Mirrors /api/stop + /api/reopen: a reversible status event on the
+ // internal ticket log (one more append re-assigns it), so it stays in the reversible tier.
+ if (req.method === 'POST' && req.url === '/api/status') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const status = String(body.status || '').toLowerCase();
+ if (!STATUSES.includes(status)) return json(res, 400, { error: 'invalid status: ' + status });
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
+ withLock(() => { for (const id of ids) {
+ append({ ts: new Date().toISOString(), type: 'status', id, status, agent: 'board' });
+ append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: `↔ status → ${status} from the board` });
+ } });
+ invalidateViews();
+ json(res, 200, { updated: ids, status });
+ });
+ }
+ // Park / unpark — the durable PARKED registry (TK-11946) lives OUTSIDE the status enum, so
+ // "blocked → parked" is a registry write, not a status event. parked.mjs appends its own
+ // audit line to the ticket log (which busts the events cache); we also clear the 5s parked
+ // cache so the row reflects the change immediately.
+ if (req.method === 'POST' && req.url === '/api/park') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
+ const verb = body.park === false ? 'unpark' : 'park';
+ const done = [], failed = [];
+ for (const id of ids) {
+ try {
+ execFileSync(process.execPath, [PARKED_MJS, verb, 'ticket', shortTk(id)],
+ { timeout: 8000, env: { ...process.env, TK_AGENT: 'board' } });
+ done.push(id);
+ } catch (e) { failed.push({ id, error: String(e.message || e) }); }
+ }
+ _parkedCache = { at: 0, ids: new Set() };
+ invalidateViews();
+ json(res, (failed.length && !done.length) ? 500 : 200, { [verb + 'ed']: done, failed });
+ });
+ }
// DTD — trigger a batched run-now sweep (POST) / read the latest verdicts + running state (GET).
if (req.method === 'POST' && req.url === '/api/dtd') {
if (execBlockedForRemote(req, res)) return;
← 27c33873 ticketbar: add one-click Compact/Full toggle button (parity
·
back to Ticket System
·
auto-data-snapshot: 2026-09-23T16:24:32 (1 data files) — TK- 58609090 →